Repository navigation
Description
As a followup to #5065 and related issues, there's another lazy-loading enhancement that could greatly speed up reading of FITS files:
When seeking through a FITS file, in order to determine how to find the next HDU the header needs to be read and parsed. Depending on the size and complexity of the header, this is currently an expensive operation in Astropy, even considering that there's already some laziness--for example the values of header cards are not actually parsed until that value is needed (or running verify()).
However, when seeking through a FITS file, really only a few standard keywords, typically appearing (in a valid header) within the first 8 or so cards of the header, are needed. These include: BITPIX, NAXIS, the NAXISn keywords (of which there may be up to 99 but are often not more than 2 or 3), and in some cases PCOUNT and GCOUNT, as well as the extension type.
Lots of efficiency can be gained by reading only that first part of the header when seeking through the file (as well as searching for the END card of the header, which is unfortunately necessary), and deferring loading of the full header until later.
One might approach this in a couple different ways. One might be to use some kind of "minimal" header parser that only has as much functionality as one needs to parse those first few header cards. A lot of code that's already written in the Card class can be used for this--the idea would be to parse one Card at a time, without bothering to initialize a full Header object (an exception would be if the required keywords are not found in the positions they're suppose to be in--this of course makes the header malformed, but it's still nice to be able to read such malformed files if possible--in this case we would switch to reading the full Header).
Another possibility would be to add some more lazy-loading capabilities to the Header class itself, somewhat similar to #5065, though I think it can be done more simply in the case of headers. First I should explain how headers are currently read from a file (this is somewhat different from creating headers from scratch):
-
The
Header.fromstringclassmethod is responsible for reading the actual bytes of the header, from the first card through to the end card, as well as any trailing padding. This in turn calls the worker methodHeader._from_blockswhich figured out the extend of the 2880 byte blocks that comprise a header, and is responsible for locating the end card. It looks complicated but really isn't--most of the barnacles in it have one of two sources:a) The same code is also used to read headers from a text file containing newlines. This adds a bit of complication, but most of the logic for understanding the extents of a header are otherwise the same.
b) Fallbacks for handling slightly invalid / corrupted FITS files of the sort that have appeared in the wild in the past, such as headers that are missing an END card.
-
The 2880 byte header blocks are joined into a single string and passed to the classmethod
Header.fromstring. So in other wordsfromfileis just responsible for finding the bytes of a header in an actual file and returning that header as a string. Thenfromstringis responsible for parsing a header given the contents of the header as a string. Here already there's room for improvement: While most FITS files don't have massive headers in the first place, most of the keywords we need are going to only be in the first 2880 byte header block. The only cases where they wouldn't be is if there is some huge number of axes, or in pathological cases. So one might, say, implement a shortcut where only the first header block is returned and parsed at first, and only look at later blocks if necessary. -
Header.fromstringreads through the string, extracting each 80-byte header "card". It also has hard-coded logic for handling "CONTINUE cards" which is a single keyword/value pair split over multiple cards. For each logical card, it then creates aCardobject in stows its binary data away in thatCardobject's "._image" attribute. No parsing of the bytes themselves are done yet. -
The
Headerinstance itself is created by being passed a list ofCardobjects. This is normally how aHeaderis instantiated (though in practice it's common to see justHeader()where it is instantiated empty). Incidentally, the list doesn't have to strictly containCardobjects--it may also contain(keyword, value)or(keyword, value, comment)tuples from whichCardobjects can be instantiated. -
This is the part that probably contributes the most overhead: When instantiated with a
Cardlist,Header.__init__then loops over that list and callsself.append(card)one card at a time. This is slow for a number of reasons: theHeaderobject is fairly complex and contains a lot of book-keeping and state that needs to be updated each time a card is added to the header. One could probably implement a faster method for instantiating aHeaderfrom a bulk existing list ofCardsthat were parsed right out of a file, rather than callingself.append()one at a time.
Another problem with Header.append() (or any method that adds a new card to a Header is that it necessarily must parse the card's keyword, since the keyword is an important part of the book-keeping (the value and comment do not need to be parsed at this time, as mentioned previously). Parsing the card keyword is actually surprisingly expensive. Normal FITS keywords are just up to the first 8 bytes of the card and can be extracted with ease. However, the generic path that the code currently goes through also supports special kinds of keywords from FITS extensions, such as ESO "HIERARCH" keywords, and the rare but not non-existent "record-valued keywords". Of course, when it comes to just seeking through a FITS file we only need to read simple, standard keywords. So for this case there could be a mode for temporarily ignoring non-standard cards.
Finally, one other possible optimization is to make Header.__init__ lazy--rather than calling self.append() on all the cards it is passed in (or adding them in a bulk operation as suggested above), it could just store off that list internally, and only look through it when a specific keyword is asked for. This is similar to what #5065 does. This again would be most efficient for the case of seeking through a FITS file, because in most cases it would only need to process the first 7 or 8 cards, and leave the rest until the full header is needed.
Activity
embray commentedon Mar 30, 2017
Upon re-reading this I realize it makes a number of different suggestions that could each be tackled more or less independently.
Out of all of them, I think the one that could have the greatest immediate impact is 2), in the numbered list above. In particular, when reading headers start by only parsing the first 2880 byte block of the header (you could probably avoid even storing the other blocks in memory at this point). As long as all the required keywords are found in the first block (BITPIX, NAXISn, PCOUNT, GCOUNT) the rest of the blocks can be ignored for the initial scan through the file, and not parsed until that header is explicitly accessed. The only other thing that would have to be done is a scan for the END card, but that can also be done without fully parsing the other blocks (I believe the code already does look for the END card before doing any further parsing).
saimn commentedon Sep 7, 2018
I tried a few things from the list above, but it is difficult to make real progress.
I was quite happy with a solution of delaying the creation of
Cardobjects, but I have hit a snag: to identify the HDU classes, withmatch_header, there are lookups for theGROUPSkeyword, and this causes the loading of all keywords whenGROUPSis not there...https://github.com/saimn/astropy/commits/fits-perf
embray commentedon Sep 18, 2018
I'd have to see what you tried, but I'm pretty sure the
GROUPSkeyword still must be in the first header block if at all for a valid file.Another thing I've been thinking about is that it might be useful to have a "fast" header parser that doesn't use the full
Headerclass, but maybe just some regular expressions to parse out known keywords of importance and leave the rest for later. There's a lot of simplification that can be made when you just need to search a presumed-valid FITS file for basic headers defined by the FITS specification.In the rare cases where that fails then one can fall back on full header parsing.
embray commentedon Sep 18, 2018
I've also thought of using CFITSIO for the "fast" header parsing, or at least benchmarking to see how it does by comparison. In fact I've thought of trying to integrate more CFITSIO in general. It has its issues, and there are things pyfits does that it doesn't, but at least for a first pass it might be useful, then the big heavy Python data structures can be lazy-loaded later once we have basic information, like the offsets of each HDU.
saimn commentedon Sep 18, 2018
That was my hope too, but I didn't find such constraint in the standard, sadly.
Instead of relying on CFITSIO, or adding another fast layer, I wonder if we could do something with Cython just to read the Header blocks, find the END card, and parse the keywords ?
embray commentedon Sep 19, 2018
I mean, "do something with Cython" would just be another approach to an alternate fast reader. But supporting all FITS header features is necessarily slow. That's why I think there needs to be a fast header parser that only supports the bare minimum needed to figure out the overall structure of the file (namely, where the HDUs and their header begin and end).
embray commentedon Sep 19, 2018
Regarding
PCOUNT,GCOUNT, andGROUPSit is annoying that the standard doesn't specify an order for them and that they can appear in principle anywhere in the header. So I guess the "faster" header parsing would need to still read the full header at least until it has found some minimal set of keywords (e.g. all the mandatory keywords) ending with theENDkeyword. This could still be done in a minimal fashion much faster than the current header parsing though.drdavella commentedon Sep 19, 2018
Just curious: is there a specific benchmark you have in mind that we can use to evaluate the performance impact of these suggestions?
Also, I wonder whether there is some other low-hanging fruit. For example, does
fileobj_is_binaryreally need to be called every time thatHeader.fromfileis called? Or do we really need to redefine theblock_iterfunction for every call ofHeader.fromfile? I'm not sure whether either of these individually make a significant difference, but there are potentially a lot of other cases like this if we look closer.drdavella commentedon Sep 19, 2018
Also, I wonder if using something other than regex would be useful when searching for the
ENDcard. Or at least doing a first-pass check usinginand/or.find, before applying the regex to a more limited string. A quick glance at_find_end_cardsuggests there might be some room for improvement there.embray commentedon Sep 26, 2018
I'm sure there are several micro-optimizations like that you could make, but I don't think it would have much overall impact. The main benchmark I have in mind is mainly how long
pyfits.info()takes, especially on files with many extensions or long headers (or both).I think most of the performance problems are on the order of the number of heard "cards", rather than on the order of number of headers (which is why I think optimizations to code that is called only once per
Header.from_<whatever>) are less important).That might be useful. Again, I think to get significant improvements it would have to be written in C or Cython. And while you're searching for the END card you might as well search for other critical keywords like NAXIS(n), BITPIX, PCOUNT, and GCOUNT at a minimum, for determining the size of the data portion.
One can pretty easily write a very fast C routine to do this assuming a valid file. If the C routine fails one can fall back on the slower method which has more flexibility (and then failing that of course blow up as necessary).
saimn commentedon May 24, 2019
I think that we can considered this one closed, with #8428 and #8502. 🎉