Skip to content

Add a votable parse memory use benchmark - #158

Merged
pllim merged 5 commits into
astropy:mainfrom
jdavies-st:votable-memory-binary
Oct 13, 2026
Merged

pllim merged 5 commits into
astropy:mainfrom
jdavies-st:votable-memory-binary

Conversation

@jdavies-st

Copy link
Copy Markdown
Contributor

This adds a peak memory usage benchmark for votable parse. Spurred by issue astropy/astropy#8946 and PR astropy/astropy#20509

Running it today locally, comparing main to the PR branch:

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

That PR does not touch tabledata_format="tabledata" so this is the expected result. The benchmark shows the improvement I was expecting, and allows us to perhaps improve it further in the future.

@jdavies-st

jdavies-st commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor Author

One thing I notice here is that the peakmem_ tests hide smaller changes when comparing commits.

These benchmarks report the peak memory of the whole process that runs the benchmark. That includes the Python interpreter, astropy and the other packages it imports, the modules in this benchmark suite along with anything kept in memory in setup(), and asv itself. So lots of overhead. And that's the right number for "how much memory does a process need to do this?".

But astropy's github actions benchmark CI asks a different question: did this pull request change memory use? It answers by dividing the PR's result by main's and flagging changes larger than 1.3x. The overhead above is in both numbers, so it pulls the ratio towards 1.

For the VOTable benchmarks in this PR on Linux, the overhead is about 330 MB. A change that halved the memory use of a parse from 200 MB to 100 MB would show as 530 MB against 430 MB, a ratio of 0.81, and CI wouldn't flag it as an improvement, even though it halved memory use. One can go with larger files, but then that slows down the benchmark.

I do notice there are some track_ benchmarks that just report a number. Maybe one can use that for the case where one really wants to know if a PR improved memory usage or not without having to use very large files relative to the overhead of of the python stack? Maybe reseting the peak memory measurement, or doing a before/after?

I noticed this because the ratio above, 0.47, is less for these small files than it was for the very large ~1.2GB file in the PR. For that our improvement was ~38%, and it was larger still for the 2.5GB file from the original issue.

So it really depends on the use case - is this primarily for CI and differences? Or for total memory usage for this benchmark test case?

- Replace peakmem_parse with track_peak_memory_parse, which reports the
  peak memory added by the parse rather than the peak of the whole process
- This leaves out the interpreter, imported modules and asv itself, which
  are the same for both commits and hid smaller changes when comparing them
- Add a peak_added_bytes helper that takes the peak after the call minus
  the memory in use before it, using resource.getrusage on Linux and the
  process's physical footprint on macOS.  Follow asv patterns.
- Skip the benchmark on Windows and other platforms, as I can't test
  locally.
@jdavies-st

jdavies-st commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor Author

OK, so I've updated the benchmark to subtract off the python interpreter (and astropy, numpy, asv stack) using the track_ pattern in asv.

track_peak_memory_parse returns the process's peak memory after the parse minus its memory use just before it. asv runs each benchmark in its own process, and before the parse that process has only imported modules. So when the parse starts, the peak so far is the same as the current use. The table is built and the test VOTable files are written in setup_cache(), which runs in a separate process, so none of that work is counted.

As you can see in the table, each before/after value is ~300MB less.

Results on MacOS for astropy/astropy#20509:

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

Which now shows what I expect. And it shows the same regardless of size of the arrays, i.e. I varied LARGE_SIZE which gives the number of rows in the table, and above 100k rows, the ratios are the same. Below that, the fixed size of the parser is added in, so a 25k row table shows a 0.31 and 0.36 ratio for binary and binary2, respectively. Previously, using peakmem_ which includes the size of the interpreter stack in the numerator and denominator, it washes out the changes much more, even when the input files are very large, and at the 60 second limit of the asv benchmarks themselves. For example 1 million row tables just fail to run because of the 60 sec limit.

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

Thanks!

Comment thread benchmarks/votable.py
return info


def peak_added_bytes(func, *args):

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.

Would this be useful in a more general module outside of votable?

@jdavies-st jdavies-st Oct 5, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Perhaps! I followed the patterns in the asv implementation for the peakmem_ benchmarks. See airspeed-velocity/asv#260 For now, I just put it in this module. peakmem_ benchmarks are not used anywhere in astropy-benchmarks. But it's possible that this fix could go upstream to asv. If the benchmarks are designed to look for memory usage regressions or improvements the way astropy uses them, then they are not working currently as the memory overhead of the python stack trends them towards 1.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That looks interesting!

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.

I am just wondering if instead of building off something that is barely maintained, we should do the memory stuff using something more mainstream. I think memray is the thing to use nowadays (please correct me if I am mistaken).

For the wider discussion of replacing everything, please see:

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.

But if you prefer to get this in for now to support astropy/astropy#20509 , we can do that too, and punt the wider discussion to a new issue. Lemme know. Thanks!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agree that this is sort-of-a one-off type test, but that's just because none of the other tests measure memory usage by a process. As I said, there are no peakmem_ tests in this repo, even though asv supports that, but the asv implementation doesn't do what it advertises, so hence this implementation.

Agree that using something more standard would be good, but I think whether pytest-memray is the correct tool or not should be part of the larger discussion in #120. I don't know enough to have an opinion yet.

If that decision is made in the future, or someone shows how to get pytest plugins to work with asv, or replace asv, then that would be cool! Given we depend on asv here, I think asv's track_ pattern is fine, and my small helper here to track peak memory (before and after) is a reasonable use of it.

If someone else want's to reuse this helper for memory tracking somewhere else in astropy, we can always move the helpers up a level, out of votable and reuse them. I just didn't want to assume to do that too early.

@jdavies-st jdavies-st changed the title Add a votable tabledata/binary/binary2 peakmem_parse benchmark Add a votable parse memory use benchmark Oct 6, 2026

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

Thanks for this, very useful benchmark to have!

Comment thread benchmarks/votable.py Outdated
raise NotImplementedError('peak_added_bytes needs Linux or macOS')


class TrackMemVOTableArrays:

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.

One small thing I noticed while testing locally, is that setup_cache took about 50s on my machine (astropy 8.0.1), with most of that going into writing the tabledata file (~35s).

It doesn't look as if we override the 60s default (https://asv.readthedocs.io/en/stable/benchmarks.html#benchmark-attributes) so we may run the risk of timing out on a slow runner.

Should we bump the timeout here to something like 180?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks! I'm seeing a ~13 sec setup_cache time here on my laptop, which is a bit shorter, but point taken on runners being slower. So I benchmarked my benchmark (yo dog):

class TimeVOTableArraySetupCache:
    """Time generation of the large array-column VOTable cache."""

    def time_setup_cache(self):
        TrackMemVOTableArrays().setup_cache()

If I reduce table size from 200k rows to 50k rows and use views of the module-level ra/dec that are created as module globals on votable.py benchmark import, then it reduces my new benchmark's setup time by a factor of 4:

[ 0.00%] ·· Benchmarking virtualenv-py3.12
[50.00%] ··· Running (votable.TimeVOTableArraySetupCache.time_setup_cache--).
[100.00%] ··· votable.TimeVOTableArraySetupCache.time_setup_cache               3.22±0.02s

And it still shows a similar memory use improvement.

I decided to add a time benchmark for the same table parse (runs in ~3 seconds), so as to flag any time speedups or slowdowns for the same table. I already did some work on speedups in the parser, to be completed in a future astropy.io.votable PR

An aside - the ugly helpers I had added here to get the memory measurements on macOS were workarounds for the fact that I was seeing differences between Linux and macOS improvements when using psutil. Linux (in a docker container) was generally giving better improvement numbers than macOS. And that is because macOS compresses memory and might give inconsistent measurements back when I was using asv's built-in peakmem_ pattern. But now that I'm doing an improved before/after and subtracting off the size of the stack, I always see the improvement difference on macOS, it's just less than on Linux. So for consistency, I think it is fine to do something simpler - just use

resource.getrusage(resource.RUSAGE_SELF).ru_maxrss

before and after. import resource is in the python standard library for Linux and macOS. This is correct on Linux, and will slightly underestimate the improvement on macOS because of the compression. But this is fine.

Our benchmarks actually run on Linux. And this greatly simplifies the helper. If we wanted this to work on Windows, it would need psutil, but again, astropy-benchmarks is not a python package and can't have dependencies. And astropy doesn't have psutil as a dependency except when it pulls in ipython, so I just put a guard there for Windows.

The results from the current state of the benchmark on macOS:

$ python -m asv continuous --config asv.ci.conf.json --split --show-stderr --bench TrackMemoryTimeVOTableArrays --no-only-changed upstream/main 8d7992300b
...
Benchmarks that have improved:

| Change   | Before [b9c7d5a5] <votable-iterparse-memory~4>   | After [8d799230] <xml-decode-base64-while-parsing>   |   Ratio | Benchmark (Parameter)                                                   |
|----------|--------------------------------------------------|------------------------------------------------------|---------|-------------------------------------------------------------------------|
| -        | 267M                                             | 103M                                                 |    0.39 | votable.TrackMemoryTimeVOTableArrays.track_peak_memory_parse('binary')  |
| -        | 267M                                             | 101M                                                 |    0.38 | votable.TrackMemoryTimeVOTableArrays.track_peak_memory_parse('binary2') |

Benchmarks that have stayed the same:

| Change   | Before [b9c7d5a5] <votable-iterparse-memory~4>   | After [8d799230] <xml-decode-base64-while-parsing>   |   Ratio | Benchmark (Parameter)                                                     |
|----------|--------------------------------------------------|------------------------------------------------------|---------|---------------------------------------------------------------------------|
|          | 79M                                              | 78.9M                                                |    1    | votable.TrackMemoryTimeVOTableArrays.track_peak_memory_parse('tabledata') |
|          | 2.54±0.03s                                       | 2.57±0.06s                                           |    1.01 | votable.TrackMemoryTimeVOTableArrays.time_parse_tabledata('tabledata')    |
|          | 448±3ms                                          | 438±5ms                                              |    0.98 | votable.TrackMemoryTimeVOTableArrays.time_parse_tabledata('binary')       |
|          | 540±5ms                                          | 535±7ms                                              |    0.99 | votable.TrackMemoryTimeVOTableArrays.time_parse_tabledata('binary2')      |

SOME BENCHMARKS HAVE CHANGED SIGNIFICANTLY.
PERFORMANCE INCREASED.

Comment thread benchmarks/votable.py Resolved
- Reduce size of table by 4x so initial writing of the 3
  serializations is speeds up.
- Simplify the macOS memory before/after measurement by using
  same method as Linux.  This may under-report total memory before
  and/or after, as macOS uses memory compression.
- Add a time_parse_tabledata benchmark for the same table, to track
  any parsing time improvements.

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

Smaller table sort out the timeout for me thanks!

One thing I was wondering, do we know the peak isn't already above current usage when we take before?
The import time temp lists (short_names, long_descriptions) made me wonder, especially for tabledata now that the table is smaller.

Comment thread benchmarks/votable.py Resolved
Comment thread benchmarks/votable.py Resolved
@jdavies-st

jdavies-st commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor Author

One thing I was wondering, do we know the peak isn't already above current usage when we take before?

If one uses setup_cache() for any setup, then it should not be a problem, as asv runs each benchmark in its own process. I added a note about that to the docstring of the helper function. Peaking before the benchmark ran would only be a problem (I think) if importing the module allocated lots of memory and then released it. Which it does not do, as far as I can tell.

If setup() was used instead of setup_cache(), and used a bunch of memory and then released it on exit, then I think your concern would be justified. So I don't do that, and the docstring specifies to use setup_cache().

It is quite difficult to figure out this memory stuff. There's no perfect measurement, as far as I can tell. Everything is a bit of a compromise.

@jdavies-st

jdavies-st commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor Author

Anything else that needs to happen in this PR?

Summary

  • adds 2 new benchmarks measuring memory use and parse time for a votable read from disk, parametrized across the 3 supported serialization formats - TABLEDATA, BINARY and BINARY2.
  • benchmarks add an extra ~10 to 20 seconds of benchmark runtime.
  • these are the only benchmarks that measure parsing files from disk for votable currently. The existing ones only use bytesIO in-memory objects.

@pllim

pllim commented Oct 13, 2026

Copy link
Copy Markdown
Member

Just waiting for an approval from @stvoutsin :)

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

Thanks for your work on this, this all looks great to me!

@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. LGTM at a glance and I trust y'all. Thanks!

@pllim
pllim merged commit a0cdbd1 into astropy:main Oct 13, 2026
5 checks passed
@jdavies-st
jdavies-st deleted the votable-memory-binary branch October 13, 2026 19:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants