Repository navigation
Make it possible to use Table.read on FITS files with no copying - #6821
Conversation
|
Hi there @astrofrog 👋 - thanks for the pull request! I'm just a friendly 🤖 that checks for issues related to the changelog and making sure that this pull request is milestoned and labeled correctly. This is mainly intended for the maintainers, so if you are not a maintainer you can ignore this, and a maintainer will let you know if any action is required on your part 😃. Everything looks good from my point of view! 👍 If there are any issues with this message, please report them here. |
| # We use [:] but not copy() here so that the new | ||
| # col.array is a view of the same region in memory | ||
| # but a differe Python object. | ||
| col.array = col.array[:] |
There was a problem hiding this comment.
Unfortunately this doesn't work in the sense that it re-introduces the memory leak :( I'll need to investigate more.
|
@astrofrog - Is there a way you can check where the large peak in memory usage happens in the Overall, I fear this may need a different look at the reference cycle that #4539 tried to address... |
|
@mhvk - ah good point I can check |
|
@mhvk - it turns out the memory increase is indeed in |
|
Basically it looks like the 2Gb memory appears in this line in self._coldefs = ColDefs(self)I'm going to have to put this on pause for a few days or more. If anyone has any time to investigate this further, please feel free to! |
|
I have some possible fixes coming... |
1350ed6 to
18d556a
Compare
| t = Table(table.data, masked=masked) | ||
| t = Table(masked=masked) | ||
|
|
||
| for col in table.data._coldefs: |
There was a problem hiding this comment.
It should be somewhat more efficient to make a list of Columns and create t from that.
| If `True`, image data is not scaled using BSCALE/BZERO values | ||
| when read. | ||
|
|
||
| - **return_bytes** : bool |
There was a problem hiding this comment.
I wonder about the name return_bytes. It might not be immediately obvious to code readers what that implies. So maybe string_as_bytes or character_as_bytes (bool) or character_type = ('bytes'|'unicode').
There was a problem hiding this comment.
Can we go a bit beyond that and follow numpy in adding an encoding argument (numpy/numpy#4208)? This especially since table columns can now handle at least encoding utf-8 (U would correspond to utf-32, which would be the default; bytes and utf-8 would do the same thing - ideally we change Column to have encoding as an option...)
There was a problem hiding this comment.
Maybe try_decode_bytes or try_decode_ascii ? (Only ascii columns are decoded)
Not sure about adding an encoding argument, it can be misleading.
There was a problem hiding this comment.
Can FITS columns actually include non-ASCII data though? (regarding specifying an encoding)
There was a problem hiding this comment.
I went with character_as_bytes since that is the most intuitive in my opinion
| raise ValueError("Column {0} does not exist".format(name)) | ||
|
|
||
| def add_column(self, col, index=None, name=None, rename_duplicate=False): | ||
| def add_column(self, col, index=None, name=None, copy=True, rename_duplicate=False): |
There was a problem hiding this comment.
Better to maintain API by putting copy at the end.
|
@astrofrog - this looks amazing! The scope of changes within FITS are about what I imagined from the last time I looked at this and looks reasonable. The other changes to Time and Table seem sensible as well. In real life you need to actually do something with the data. Can you compare timing for these use cases:
By "access" I mean any cheap operation that demands getting the actual value. |
|
@astrofrog - also I would suggest using a test data file that is at least 2.2 Gb in size. My first attempt at chunked ASCII table reading used mmap (via the fast reader) and it turns out this blew up for files bigger than 2 Gb (which is the 32-bit limit). I was doing this on a modern 64-bit mac and could not find anything 32-bit-ish in my setup, and this problem was the end of that implementation of chunked reading. Also (sorry) this should be tested on Windows as well with a big file. |
|
@taldcroft - thanks! I'll do all this :) Would you be able to take a look at the test failures to see if they are related to corner cases in the unicode sandwich comparison stuff? |
| # [+/-C]CCYY-MM-DD[Thh:mm:ss[.s...]] where the number of characters | ||
| # from index 20 to the end of string represents the precision | ||
| precision = max(int(col.info.dtype.str[2:]) - 20, 0) | ||
| if col.info.dtype.kind == 'S': |
There was a problem hiding this comment.
I'd not be against solving this on the Time side - @taldcroft - what do you think? It would not be difficult (but definitely in another PR).
There was a problem hiding this comment.
Actually, col here is an astropy Column, which should sandwich the bytes already, so I think that this doesn't work automatically is a buglet.
There was a problem hiding this comment.
See #6823 for a fix. I think it is fairly trivial, so perhaps we can merge it before this PR, so that this change no longer is necessary.
There was a problem hiding this comment.
@taldcroft @mhvk - interestingly I still need these lines otherwise the tests fail, so there must still be some subtleties. Maybe one of you could try and run the tests for fitstime on this branch locally and see what might be missing for Time? In the mean time, I'll leave as-is.
There was a problem hiding this comment.
It is a bit strange - it has to do that the sample columns put in that file have different length:
col
# <Column name='datetime' dtype='bytes29' length=2>
# 1999-01-01T00:00:00.123456789
# 2010-01-01T00:00:00
Time(col[0], format='fits', precision=9)
# <Time object: scale='utc' format='fits' value=1999-01-01T00:00:00.123456789(UTC)>
Time(col[1], format='fits', precision=9)
# <Time object: scale='utc' format='fits' value=2010-01-01T00:00:00.000000000(UTC)>
Time(col, format='fits', precision=9)
# *** ValueError: Input values did not match the format class fits
There was a problem hiding this comment.
the example is not good (col translates to string for items), but the issue is there...
There was a problem hiding this comment.
Don't forget to remove this stanza!
|
Main comment: I wonder if we cannot just use bytes everywhere, since Beyond being not sure about the name and exact interpretation, this looks great! |
|
@mhvk - you mean |
|
I guess what I'm trying to say is that the moment you translate a fits column to a table |
| t = Table(table.data, masked=masked) | ||
| t = Table(masked=masked) | ||
|
|
||
| for col in table.data._coldefs: |
There was a problem hiding this comment.
Using table.data.columns should be the same here, and it avoids using a private attribute.
There was a problem hiding this comment.
Actually even better: table.columns
There was a problem hiding this comment.
@saimn - this causes issues I think because of the @lazyproperty on columns. Let me come up with a simple example and open a separate bug report.
There was a problem hiding this comment.
I can't reproduce this in a minimal script, but give it a try with this PR: change this to table.columns - in my case this goes from 'reading' the 2Gb table in 40ms to 10 seconds. It seems that in that case FITS_rec.__del__ gets called and there are remaining references to the columns, which then get copied.
There was a problem hiding this comment.
(this could be related to the @lazyproperty on columns)
There was a problem hiding this comment.
Not completely sure about table.columns (I will check with your branch), but at least table.data.columns is just a property that returns the _coldefs.
There was a problem hiding this comment.
I think I managed to get things to work, but it turns out accessing col.array sometimes returns scaled data and sometimes not, so I had to get data[col.name] instead to get around this (see #6837 for an example of this)
| else: | ||
| fsize = np.dtype(format.recformat).itemsize | ||
| return chararray.array(array, itemsize=fsize) | ||
| return chararray.array(array, itemsize=fsize, copy=False) |
There was a problem hiding this comment.
So this is the only real change in io.fits when creating the BinTableHDU object (not taking into account the bytes decoding which happens only when accessing a bytes column). I'm trying to do the same but in my case it seems that the data is still loaded in memory whereas your memory usage report shows that you fully use the memmap. Any idea why, or am I missing something ?
There was a problem hiding this comment.
Did you explicitly set return_bytes=True when opening the file? Or you mean creating a new HDU object from scratch? copy=False isn't guaranteed to never copy, it might copy if the item size changes for example - can you share your specific example?
There was a problem hiding this comment.
I was trying just reading a file directly with io.fits and looking at the memory usage, independently from Table. And I found the culprit, it's because of a boolean column: just below the chararray block, for the 'L' format the array is read to check if False or 0 values must be converted.
There was a problem hiding this comment.
Perhaps worthy of raising as a separate issue; no reason this couldn't be done when truly needed.
|
@mhvk - ah ok, I had misunderstood. This PR actually adds |
|
@astrofrog - ah, yes, now I see that it was already what I thought made most sense! For the name, since what we're doing is just skipping ascii decoding, maybe just call the argument |
|
@taldcroft - are the tests I added in dbba587 sufficient? |
| ``fits.open``, a new keyword argument ``character_as_bytes`` can be passed - | ||
| when set to `True`, character columns are returned as Numpy byte arrays | ||
| (Numpy type S) while when set to `False`, the same columns are decoded to | ||
| Unicode strings (Numpy type U) which uses more memory. [#6821] |
There was a problem hiding this comment.
Maybe split in 3 items (performance improvement, memmap, and character_as_bytes ?
| Whether to use memory mapping, which accesses data on disk as needed. If | ||
| you are only accessing part of the data, this is often more efficient. | ||
| If you want to access all the values in the table, and you are able to | ||
| fit the table in memory, you may be better off leaving memory mapping |
There was a problem hiding this comment.
I'm not sure about this one. I'm asking because I'm curious about the answer, and because the FITS FAQ tells a different story (http://docs.astropy.org/en/latest/io/fits/appendix/faq.html#what-performance-differences-are-there-between-astropy-io-fits-and-fitsio):
In the case of reading over all the data in the file the performance difference between using mmap versus reading the entire data into physical memory at once can vary widely between systems, hardware, and depending on what else is happening on the system at the moment, but mmap almost always going to be better.
There was a problem hiding this comment.
If you need all the data, then not using mmap is faster, both with images and tables, for sure. If you need only a small part of the data then mmap is definitely better. In between, it's a gray area. I think that FAQ item is misleading because for cases like reading a table and doing Table.info('stats') then you definitely get better performance without mmap.
There was a problem hiding this comment.
From my tests (on datacubes, not tables), when loading all the data, it is faster with memmap. It may depend on the OS and other things, I don't know.
|
The gain is truly amazing, but I would just like to see how it is specific to the type of data that is in your table. With three columns, floats, ints, and 12-character strings, I think the gain here comes mostly from the string decoding ? (And from the copy that was done when creating the Table). So, the PR looks fine, I'm just trying to understand better the implications, and to see if it would useful to mention that the gain will be influenced by the data types. |
|
@saimn - just to be clear, as mentioned higher up, memory mapping is actually off by default here, so the gains are purely due to avoiding unnecessary copies for most datatypes. Indeed by far the biggest gain is due to string columns, which at least for me is a very common use case (e.g. source name). If a column had a boolean type then we might require a copy of that array but that shouldn't slow things down too much since boolean columns don't take up much memory. The primary goal of this PR was to get rid of the huge bottleneck that comes from string columns, hence my choice of test data. In a separate PR I plan to investigate whether it is beneficial to turn memory mapping on by default and will do more extensive tests as that is trickier. So just to be clear, with this PR there should be no circumstances under which things will be slower than before. But it doesn't mean there aren't other optimizations that can be made - in fact I have a follow-up PR to improve the writing of FITS tables with string columns for example. In future it would indeed be good to have more settings to control things like this. |
|
@saimn - see the latest commit which clarifies that the performance improvement is in particular when there are string columns. |
Indeed, sorry for the confusion about this part. And no worries, I agree that it will always be better, I just try to understand better 😉. |
There was a problem hiding this comment.
Just a minor comment about the changelog, otherwise this is really great, thanks @astrofrog !
|
|
||
| - The performance for reading FITS tables has been significantly improved, in | ||
| - The performance for reading FITS tables has been significantly improved | ||
| for cases where the table contains one or more string columns, in |
There was a problem hiding this comment.
I think it's useful to mention that the best improvement is for string columns, thanks, but it's not only for string columns. What about "... significantly improved in particular for cases where ..." or something similar ?
|
Ok, merging now, thanks for the great improvement and looking forward to the next optimizations! |
|
@astrofrog - Thank you for working on FITS Table performance! I just wanted to mention that this type change of column My understanding is that this is an intentional change in Astropy here for the type of I'm OK with that, and we can update our code in Gammapy. I just wanted to comment here to make sure the change is intentional. Would doing the conversion of |
|
Hmm, @cdeil is right, this is a breaking change. I completely missed that, but the Table unicode sandwich does not decode the data if accessed directly with Should we use |
|
I think that uses that depend on Column.data returning bytes rather than unicode are going to be rare and constrained to more advanced users. I would argue the scale of the performance gain is worth it for the average user. |
|
Hmm I don't know, I really like performance improvements, but on the other hand:
Just thinking out loud, would it be stupid to have |
|
@saimn - don't forget tables are used by a lot of people who do not read fits files - changing the meaning of The one other option that crossed my mind is to use the sandwich only by default for Overall, my sense would be simply to warn about this prominently and see where the pieces fall... |
|
@mhvk - Yes sure, it was just a thought, but I'm trying to weight the pros and cons ;) |
Motivation
The intent of this PR was to address (at least from the FITS point of view) the issue described in #6491 - namely that when using
Table.readwith a FITS file, the data is currently copied, which can lead to poor performance.Bottom line
With this PR, I can read a 1.8Gb FITS table with some string columns using:
in 2 seconds with 40Mb memory usage:
instead of 50+ seconds and 5Gb of memory:
In fact, the 2 seconds/40Mb are dominated almost entirely by the import statement, and the read statement takes 40ms. This is a speedup of almost ~1000x! Of course data will then be read as needed from disk, but this means that for accessing specific columns/rows/cells from a table, users could see significant speedup.
FITS changes
This turned out to be more complicated than I thought. The issue is the following - we can simply add
copy=Falseinsideastropy/io/fits/connect.pyto deal with preventing a copy for non-string columns. However, astropy.io.fits is inefficient when it comes to string columns in that it automatically convertsAcolumns (which are fundamentally ASCII as far as I understand) to Numpy U(nicode) types. This was described by @taldcroft in: #6122 - this has two implications: memory mapping is not respected and the whole string columns are read in, and the memory usage is 4x what it needs to be because of the conversion to unicode.So this PR adds the ability to control whether string columns are converted to unicode in astropy.io.fits with the
return_bytesoption tofits.open(which should be useful in itself), and then makes it so that we use that option for Table.read.Table changes
I also had to make it possible for
add_columnto take acopyargument - @taldcroft: was there a reason for not having that originally?Time changes
Finally I noticed that there was an issue in the Time parsing from FITS files - passing a Numpy array with S type to Time doesn't actually work so I needed to decode the values. This is inefficient but fixes what I think was a real bug.
Issues
A few tests are now failing, which are I think related to comparisons with the unicode sandwich comparisons. @taldcroft, could you take a look at the failures since you are the most familiar with that code?
Testing
A number of changes here require their own unit tests, which I can add if we decide to go ahead with this PR, along with changelog entries.
Review
This needs a careful look by the FITS maintainers: @saimn @MSeifert04 @drdavella, as well as Table maintainers @taldcroft and @mhvk, and @AustereCuriosity because of the FITS/Time change.
Also @adrn - please test this out (since your comment on Slack prompted me to work on this!)
(this description was edited compared to its original version)
Closes #6122