Skip to content

Add lazy parsing of FITS headers #5593

Closed

Description

@embray

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):

  1. The Header.fromstring classmethod 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 method Header._from_blocks which 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.

  2. The 2880 byte header blocks are joined into a single string and passed to the classmethod Header.fromstring. So in other words fromfile is just responsible for finding the bytes of a header in an actual file and returning that header as a string. Then fromstring is 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.

  3. Header.fromstring reads 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 a Card object in stows its binary data away in that Card object's "._image" attribute. No parsing of the bytes themselves are done yet.

  4. The Header instance itself is created by being passed a list of Card objects. This is normally how a Header is instantiated (though in practice it's common to see just Header() where it is instantiated empty). Incidentally, the list doesn't have to strictly contain Card objects--it may also contain (keyword, value) or (keyword, value, comment) tuples from which Card objects can be instantiated.

  5. This is the part that probably contributes the most overhead: When instantiated with a Card list, Header.__init__ then loops over that list and calls self.append(card) one card at a time. This is slow for a number of reasons: the Header object 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 a Header from a bulk existing list of Cards that were parsed right out of a file, rather than calling self.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

  1. embray commented on Mar 30, 2017

    @embray
    MemberAuthor

    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).

  2. saimn commented on Sep 7, 2018

    @saimn
    Contributor

    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 Card objects, but I have hit a snag: to identify the HDU classes, with match_header, there are lookups for the GROUPS keyword, and this causes the loading of all keywords when GROUPS is not there...
    https://github.com/saimn/astropy/commits/fits-perf

  3. embray commented on Sep 18, 2018

    @embray
    MemberAuthor

    I'd have to see what you tried, but I'm pretty sure the GROUPS keyword 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 Header class, 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.

  4. embray commented on Sep 18, 2018

    @embray
    MemberAuthor

    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.

  5. saimn commented on Sep 18, 2018

    @saimn
    Contributor

    I'm pretty sure the GROUPS keyword still must be in the first header block if at all for a valid file.

    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 ?

  6. embray commented on Sep 19, 2018

    @embray
    MemberAuthor

    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).

  7. embray commented on Sep 19, 2018

    @embray
    MemberAuthor

    Regarding PCOUNT, GCOUNT, and GROUPS it 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 the END keyword. This could still be done in a minimal fashion much faster than the current header parsing though.

  8. drdavella commented on Sep 19, 2018

    @drdavella
    Contributor

    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_binary really need to be called every time that Header.fromfile is called? Or do we really need to redefine the block_iter function for every call of Header.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.

  9. drdavella commented on Sep 19, 2018

    @drdavella
    Contributor

    Also, I wonder if using something other than regex would be useful when searching for the END card. Or at least doing a first-pass check using in and/or .find, before applying the regex to a more limited string. A quick glance at _find_end_card suggests there might be some room for improvement there.

  10. embray commented on Sep 26, 2018

    @embray
    MemberAuthor

    Also, I wonder whether there is some other low-hanging fruit.

    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).

    Also, I wonder if using something other than regex would be useful when searching for the END card.

    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).

  11. saimn commented on May 24, 2019

    @saimn
    Contributor

    I think that we can considered this one closed, with #8428 and #8502. 🎉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions