Skip to content

Reduce memory needed to read VOTable BINARY and BINARY2 streams - #20509

Merged
pllim merged 4 commits into
astropy:mainfrom
jdavies-st:votable-iterparse-memory
Oct 16, 2026
Merged

pllim merged 4 commits into
astropy:mainfrom
jdavies-st:votable-iterparse-memory

Conversation

@jdavies-st

@jdavies-st jdavies-st commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Description

Reading a VOTable with an embedded BINARY or BINARY2 stream no longer holds several copies of the stream in memory simultaneously. See issue #8946. In this PR:

  • The XML tokenizer no longer copies an element's text into the end events of the elements around it. Before, </BINARY2>, </DATA> and </TABLE> each held a full copy of the base64 stream.
  • The tokenizer frees its text buffer after handing over more than 1 MB of text, instead of holding it until parsing ends.
  • The stream is decoded with binascii.a2b_base64 directly, which skips the full copy made by .encode("ascii").

VOTables are read exactly as before. Two small visible differences:

  • In get_xml_iterator, the end event of an element that contains other elements now has empty text, instead of repeating the text of the last element inside it. Nothing in astropy reads that text; one test that recorded the old text is updated. This is where the main memory savings lays.
  • A non-ASCII character in a stream raises ValueError now instead of UnicodeEncodeError (a ValueError subclass). Base64 is always ASCII, so only malformed files are affected. This isn't caught here or anywhere downstream (pyvo or astroquery).

Reading the first 1.5 million rows of the file from #8946 (1.26 GB, 96 columns) on macOS:

main this PR
Peak memory 8.6 GB 3.3 GB
Time 66 s 61 s

Peak memory footprint measured via:

/usr/bin/time -l python -W ignore -c "from astropy.io.votable import parse_single_table; parse_single_table('half.vot')"

The full 2.5 GB table previously needed about 17 GB at its peak, and now needs about 6.8 GB, or 5.1 GB when reading only some of its columns (as described in the issue). The full file parse takes 125 sec on my Mac laptop.

The pure-Python parser _debug_python_based_parser=True has about the same speed for this file, but uses more memory. But the improvement is still good for the half-size file - from 8.9 GB down to 5.2 GB peak memory footprint. So an improvement, but not as much as on the C side with the default parser. It is still holding a copy somewhere. But since it is really not used, not worth spending more time on.

Downstream pyvo and astroquery unit tests pass based on this branch. So no downstream changes expected.

Resolves #8946

AI Disclosure

Claude Opus 5.5 was used to explore the existing parser code, find redundant memory usage, demonstrate fixes, verify nothing broke, and write new tests. I have carefully reviewed, modified where needed and tested the changes.

  • I certify that I am human and that I take full responsibility for this pull request including all interactions with reviewers.

Merge method

  • By checking this box, the PR author has requested that maintainers do NOT use the "Squash and Merge" button. Maintainers should respect this when possible; however, the final decision is at the discretion of the maintainer that merges the PR.

@github-actions

Copy link
Copy Markdown
Contributor

Thank you for your contribution to Astropy! 🌌 This checklist is meant to remind the package maintainers who will review this pull request of some common things to look for.

  • Do the proposed changes actually accomplish desired goals?
  • Do the proposed changes follow the Astropy coding guidelines?
  • Are tests added/updated as required? If so, do they follow the Astropy testing guidelines?
  • Are docs added/updated as required? If so, do they follow the Astropy documentation guidelines?
  • Is rebase and/or squash necessary? If so, please provide the author with appropriate instructions. Also see instructions for rebase and squash.
  • Did the CI pass? If no, are the failures related? If you need to run daily and weekly cron jobs as part of the PR, please apply the "Extra CI" label. Codestyle issues can be fixed by the bot.
  • Is a change log needed? If yes, did the change log check pass? If no, add the "no-changelog-entry-needed" label. If this is a manual backport, use the "skip-changelog-checks" label unless special changelog handling is necessary.
  • Is this a big PR that makes a "What's new?" entry worthwhile and if so, is (1) a "what's new" entry included in this PR and (2) the "whatsnew-needed" label applied?
  • At the time of adding the milestone, if the milestone set requires a backport to release branch(es), apply the appropriate "backport-X.Y.x" label(s) before merge.

@jdavies-st
jdavies-st force-pushed the votable-iterparse-memory branch from 89c4508 to 8d79923 Compare September 30, 2026 22:24
@pllim pllim added this to the v8.1.0 milestone Oct 1, 2026
@pllim

pllim commented Oct 1, 2026

Copy link
Copy Markdown
Member

Addresses #8946

I don't think "addresses" does anything magical, you might want to change it to "fixes" or "closes" instead (singular works too).

@pllim
pllim requested a review from stvoutsin October 1, 2026 01:47
@pllim

pllim commented Oct 1, 2026

Copy link
Copy Markdown
Member

I recall stvoutsin did some recent work on BINARY2, so I pinged him for review. Thanks, all!

@pllim pllim added the benchmark Run benchmarks for a PR label Oct 1, 2026
@pllim

This comment was marked as resolved.

@jdavies-st

jdavies-st commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor Author

I don't think "addresses" does anything magical, you might want to change it to "fixes" or "closes" instead (singular works too).

Well, it doesn't quite fix everything in the issue, though it's a good start. There will be at least 1 further PR. Maybe I can put "Fixes" in that one. 😅

Actually, you're right. It fixes the main issue - memory use. While doing the fix though I found something else. I will file a separate issue and keep things simple. There's probably some more gains to be had for memory use, but those can be follow-ups and the issue closed.

@pllim pllim added the Extra CI Run cron CI as part of PR label Oct 1, 2026
@pllim

pllim commented Oct 1, 2026

Copy link
Copy Markdown
Member

Looks like our benchmarks not catching this improvement. Ah, well.

@pllim
pllim requested a review from tomdonaldson October 1, 2026 19:19
@jdavies-st

Copy link
Copy Markdown
Contributor Author

Looks like our benchmarks not catching this improvement. Ah, well.

Would it be good to add a benchmark? Can you point me in the right direction?

@pllim

pllim commented Oct 2, 2026

Copy link
Copy Markdown
Member

@jdavies-st

Copy link
Copy Markdown
Contributor Author

Looking at the votable benchmarks, they are all time_* benchmarks, and since this branch doesn't affect time except for speeding up by a second or two, they do not pass the 1.3 (27% speedup) threshold to be reported.

I see there are memory benchmarks for some modules, just none for votable. I can add one and see if it shows the difference!

@jdavies-st

Copy link
Copy Markdown
Contributor Author

Ok, so I added a peak memory usage benchmark for votable parse. See

astropy/astropy-benchmarks#158

And when I run locally:

$ python -m asv continuous --config asv.ci.conf.json --python=3.14 --split --show-stderr --factor 1.3 --bench PeakMem upstream/main 8d7992300b
...
· Running 3 total benchmarks (2 commits * 1 environments * 1 benchmarks)
...
| Change   | Before [073180b4] <upstream-main>   | After [8d799230] <votable-iterparse-memory>   |   Ratio | Benchmark (Parameter)                                 |
|----------|-------------------------------------|-----------------------------------------------|---------|-------------------------------------------------------|
| -        | 1.34G                               | 630M                                          |    0.47 | votable.PeakMemVOTableArrays.peakmem_parse('binary')  |
| -        | 1.34G                               | 630M                                          |    0.47 | votable.PeakMemVOTableArrays.peakmem_parse('binary2') |

SOME BENCHMARKS HAVE CHANGED SIGNIFICANTLY.
PERFORMANCE INCREASED.

🎉

@stvoutsin

Copy link
Copy Markdown
Contributor

Thanks for this, the memory drop looks great!

I compared main and this branch on all the VOTables in io/votable/tests/data that I could read, and the tables and metadata come out identical.

The one difference I found is a side effect of clearing the text buffer at each end event. The position reported for a parent's end event now points near its own closing tag instead of reusing the position of the last child's text.
See:
python -c "from astropy.io.votable import parse; parse('astropy/io/votable/tests/data/names.xml', verify='warn')"
The W18 warning is reported at 206:10 on main and 208:0 here.

I think the new position is actually more accurate, and it only shows up in warnings and validation output, but is it perhaps worth adding a quick line in the changelog about it?

Also since get_xml_iterator is public and both its text and positions change now, should the utils entry be an api one rather than bugfix? I can see it going either way, maybe @pllim has feedback on that.

Also a very very minor thing, the numbers in 20509.perf.rst (16 -> 6.5 GB) don't match the ones in the PR description (17 -> 6.8 GB).

@jdavies-st

jdavies-st commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks for this, the memory drop looks great!

It's even better than the 47% above, as both the peak memory measurements include the python interpreter, astropy, numpy asv, etc overhead in both the numerator and denominator. See the astropy-benchmarks PR and my comments there. Think the improvement is probably closer to ~30% of the memory it was using. That said, I still haven't figured out how to do the measurement better.

The one difference I found is a side effect of clearing the text buffer at each end event. The position reported for a parent's end event now points near its own closing tag instead of reusing the position of the last child's text. See: python -c "from astropy.io.votable import parse; parse('astropy/io/votable/tests/data/names.xml', verify='warn')" The W18 warning is reported at 206:10 on main and 208:0 here.

A nice side effect! Though I note that it really just puts it on the line of the closing tag. "TABLE specified nrows=18949, but table contains 1 rows". And it works here because the table is nicely formatted.

205        <TD/>
206        <TD>AA</TD>
207       </TR>
208      </TABLEDATA>
209     </DATA>

Whereas if the input file had

<TD>AA</TD></TR></TABLEDATA>

it would still point to AA, as on main. In the case of this warning, you'd probably prefer it to point to the <TABLE nrows="18949"> which is the culprit, but I think that's outside the scope of this PR.

I think the new position is actually more accurate, and it only shows up in warnings and validation output, but is it perhaps worth adding a quick line in the changelog about it?

I added a blurb there, though I tried to be non-committal. 😅

Also since get_xml_iterator is public and both its text and positions change now, should the utils entry be an api one rather than bugfix? I can see it going either way, maybe @pllim has feedback on that.

Agree. I can change from bugfix to api if you think this function is user-facing enough. I'll leave that up to those more knowledgable than myself. And not just end-user-facing, as I know pyvo is built on top of our parser.

Also a very very minor thing, the numbers in 20509.perf.rst (16 -> 6.5 GB) don't match the ones in the PR description (17 -> 6.8 GB).

Yes, that's a relic of how I was recording the memory usage depending on how it was measured. dividing by 1024 or 1000. The PR message (divide by 1,000,000,000 to get GB) and the perf changelog are now consistent. Though I'm not terribly happy with the changelog, as it's weirdly specific given the file that spawned that issue and this PR. I would much rather just give a bland "parser uses 47% (or is it 38%) of the memory it used to".

@jdavies-st

jdavies-st commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor Author

So I updated the benchmark to measure the memory usage of just parse(), which is the thing we're interested in here. And the results are very good:

$ python -m asv continuous --config asv_scan.conf.json --split --show-stderr --factor 1.3 --bench TrackMem upstream/main 8d7992300b
...
· Running 2 total benchmarks (2 commits * 1 environments * 1 benchmarks)
...
| Change   | Before [073180b4]    | After [8d799230] <xml-decode-base64-while-parsing>   |   Ratio | Benchmark (Parameter)                                            |
|----------|----------------------|------------------------------------------------------|---------|------------------------------------------------------------------|
| -        | 995M                 | 237M                                                 |    0.24 | votable.TrackMemVOTableArrays.track_peak_memory_parse('binary')  |
| -        | 991M                 | 240M                                                 |    0.24 | votable.TrackMemVOTableArrays.track_peak_memory_parse('binary2') |

SOME BENCHMARKS HAVE CHANGED SIGNIFICANTLY.
PERFORMANCE INCREASED.

No longer is the whole python interpreter stack included in the numerator and denominator. So for medium to large tables, this PR uses 25 to 40% of the memory as previously, depending on columns. I will updated the perf changelog to reflect this, as this is a more general (and more accurate) assessment of the changes here.

@jdavies-st
jdavies-st force-pushed the votable-iterparse-memory branch from 19d405e to f608151 Compare October 5, 2026 12:16
@pllim

pllim commented Oct 5, 2026

Copy link
Copy Markdown
Member

Re: bugfix vs api -- The position change in validation output is unlikely to fail any downstream test, right? But one more api fragment won't hurt. Go for it. Thanks!

@jdavies-st

Copy link
Copy Markdown
Contributor Author

I've added an API changes snippet. 🚀

@stvoutsin stvoutsin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes all look solid to me and those benchmark results are very promising.
Maybe we re-run the benchmark job so that it runs the new memory-related tests and if that passes it seems good to go @pllim ?

@pllim

pllim commented Oct 13, 2026

Copy link
Copy Markdown
Member

Yeah, a rebase would rerun it. And I agree we should. Thanks!

@jdavies-st
jdavies-st force-pushed the votable-iterparse-memory branch from df838ff to d315587 Compare October 14, 2026 06:29
@jdavies-st

Copy link
Copy Markdown
Contributor Author

Rebased. Looks like the benchmark picked it up

https://github.com/astropy/astropy/actions/runs/38522478972/job/115715134737?pr=20509#step:7:1842

| Change   | Before [2767e754]    | After [758784df]    |   Ratio | Benchmark (Parameter)                                                   |
|----------|----------------------|---------------------|---------|-------------------------------------------------------------------------|
| -        | 254M                 | 79.8M               |    0.31 | votable.TrackMemoryTimeVOTableArrays.track_peak_memory_parse('binary')  |
| -        | 255M                 | 80.2M               |    0.31 | votable.TrackMemoryTimeVOTableArrays.track_peak_memory_parse('binary2') |

SOME BENCHMARKS HAVE CHANGED SIGNIFICANTLY.
PERFORMANCE INCREASED.

🚀

@jdavies-st

Copy link
Copy Markdown
Contributor Author

So, I ran the downstream tests via tox of astroquery and pyvo for this branch:

pyvo: py314-test-devdeps-alldeps
astroquery: py314-test-alldeps-devdeps

This branch does break one doctest downstream in pyvo.

________________________________________ [doctest] uws.rst ________________________________________
230 
231 Working with JobInfo
232 ====================
233 
234 JobInfo provides access to service-specific metadata about job execution.
235 
236 Basic JobInfo Access
237 --------------------
238 
239 >>> if job.jobinfo:
Expected:
    Query returned 1,234 rows in 133.3 seconds
    Final queue position: 0
    Executed on: compute-node-03
Got nothing

/Users/jdavies/dev/astropy/.tox/downstream-pyvo/tmp/src/docs/io/uws.rst:239: DocTestFailure
263    access (``job.jobinfo['element']``) for expected elements, as this provides
264    clearer error handling with KeyError exceptions. Use ``.get()`` with default
265    values for optional elements. See the jobInfo examples for detailed patterns.
266 
267 Understanding .value vs .text
268 -----------------------------
269 
270 JobInfo elements provide two ways to access their content:
271 
272 >>> if job.jobinfo:
Expected:
    Raw text: '120'
    Converted value: 120
    Type: <class 'int'>
    Node ID text: compute-node-03
    Node ID value: compute-node-03
Got nothing

/Users/jdavies/dev/astropy/.tox/downstream-pyvo/tmp/src/docs/io/uws.rst:272: DocTestFailure
314 ...     if 'nodeId' in job.jobinfo:
315 ...         node = job.jobinfo['nodeId']
316 ...         print(f"Executed on node: {node.text}")
317 ...
318 ...     # Pattern 4: Iterate through all available elements
319 ...     print("All jobInfo elements:")
320 ...     for key in job.jobinfo.keys():
321 ...         element = job.jobinfo[key]
322 ...         print(f"  {key}: {element.text}")
323 >>> safe_jobinfo_access(job)
Expected:
    Processed 1,234 rows in 133.3 seconds
    Executed on node: compute-node-03
    All jobInfo elements:
      rowsReturned: 1234
      executionTime: 133.333
      cpuTime: 98.765
      queuePosition: 0
      estimatedDuration: 120
      nodeId: compute-node-03
      serviceVersion: 1.1
Got:
    No jobInfo available

/Users/jdavies/dev/astropy/.tox/downstream-pyvo/tmp/src/docs/io/uws.rst:323: DocTestFailure
333   serviceVersion: 1.1
334 
335 Job Status Monitoring
336 =====================
337 
338 Checking Completion Status
339 --------------------------
340 
341 >>> # Check if job completed successfully
342 >>> if job.phase == 'COMPLETED':
Expected:
    Job completed successfully!
    Total job runtime: 2.3 minutes
    Total rows returned: 1,234
Got:
    Job completed successfully!
    Total job runtime: 2.3 minutes

/Users/jdavies/dev/astropy/.tox/downstream-pyvo/tmp/src/docs/io/uws.rst:342: DocTestFailure

And that is specifically because pyvo currently depends on the closing tag values that is fixed up here in this PR. But a fix on the pyvo side will work with both this branch and main (and previous astropy releases). Essentially pyvo relies on our buggy implementation of the parser, but it doesn't need to.

This also argues strongly for a downstream-{pyvo,astroquery} tox job that could be run on votable-related PRs. Are we open to this? I have already created this tox job locally, but is it worth having in this repo? Further is it worth having as an optional opt-in CI? Perhaps based on the PR label?

Here's the CI cost when I run them locally:

  • astroquery, passed: 2211 tests, about 3 minutes.
  • pyvo, 1 test failed, 510 passed, about 2 minutes)

@pllim

pllim commented Oct 14, 2026

Copy link
Copy Markdown
Member

Oh hmm... should we wait till downstream is patched first before merging this?

bsipocz pushed a commit to astropy/pyvo that referenced this pull request Oct 15, 2026
- bool(job.jobinfo) depended on the text astropy reports at the jobInfo
  end tag, which was the text of the last nested element
- astropy/astropy#20509 stops repeating that text, so a jobInfo holding
  only child elements became false and `if job.jobinfo:` skipped it
- Add __bool__, true when the element has child elements or text
- Add tests for a jobInfo with only child elements and an empty jobInfo
@jdavies-st

Copy link
Copy Markdown
Contributor Author

The pyvo regression above has been fixed and merged. astropy/pyvo#789

🚀

- In get_xml_iterator, the end event of an element that contains other
  elements now has empty text. Before, it repeated the text of the last
  element inside it. This applies to both the C and pure-Python parsers.
  This dramatically reduces memory use for reading large XML files.
- For a VOTable BINARY or BINARY2 table, the end events of BINARY2, DATA
  and TABLE elements no longer each hold a full copy of the base64
  data stream.
- The C parser frees a text buffer that has grown past 1 MB once its
  text is handed over, instead of keeping it until parsing ends.
- VOTable reading decodes the stream with binascii.a2b_base64 directly,
  which skips a full copy of the stream text. Base64 text is always
  ASCII, so valid files decode exactly as before. Only the error for a
  malformed stream with a non-ASCII character changes: it is now
  ValueError ("string argument should contain only ASCII characters")
  instead of UnicodeEncodeError, which is a ValueError subclass.
- Reading the 2.5 GB file from issue astropy#8946 now needs about 6.8 GB of
  memory at its peak, down from about 17 GB.
- Update the one test that expected the repeated text, which was just
  a coverage test pinning current behavior from PR astropy#19922.
- Add tests for both parsers and for elements holding more than 1 MB
  of text.
- Give the memory figures in decimal GB, matching the PR description
- Note that end events now report a position near their own closing tag,
  so VOTable warnings and validation output point at the right line.
@jdavies-st
jdavies-st force-pushed the votable-iterparse-memory branch from d315587 to 3c9155d Compare October 16, 2026 13:06

@pllim pllim left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving by proxy. Thanks!

@pllim

pllim commented Oct 16, 2026

Copy link
Copy Markdown
Member

aarch64 job should not have taken that long, lemme restart to see if it was transient.

@pllim
pllim enabled auto-merge (squash) October 16, 2026 16:06
@pllim
pllim merged commit 28615b3 into astropy:main Oct 16, 2026
74 of 77 checks passed
@jdavies-st
jdavies-st deleted the votable-iterparse-memory branch October 30, 2026 08:56
antheng pushed a commit to antheng/pyvo that referenced this pull request Nov 8, 2026
- bool(job.jobinfo) depended on the text astropy reports at the jobInfo
  end tag, which was the text of the last nested element
- astropy/astropy#20509 stops repeating that text, so a jobInfo holding
  only child elements became false and `if job.jobinfo:` skipped it
- Add __bool__, true when the element has child elements or text
- Add tests for a jobInfo with only child elements and an empty jobInfo
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

benchmark Run benchmarks for a PR Extra CI Run cron CI as part of PR io.votable Performance utils

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Large memory footprint in astropy.io.votable.parse_single_table

3 participants