Skip to content

ASV better memory benchmarks - #1609

Open
cmdupuis3 wants to merge 4 commits into
UXARRAY:mainfrom
cmdupuis3:cmd/asv-peakmem-benchmark-fix
Open

ASV better memory benchmarks#1609
cmdupuis3 wants to merge 4 commits into
UXARRAY:mainfrom
cmdupuis3:cmd/asv-peakmem-benchmark-fix

Conversation

@cmdupuis3

@cmdupuis3 cmdupuis3 commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Closes #1605

Overview

The current behavior of a few peakmem benchmarks entrains module imports and caching, which dwarf the actual memory usage supposedly being tested. This PR offers benchmarks of low-level memory behavior as well as warm setups to prevent the persistent "every PR improves memory by the same amount" issue in #1605.

Expected Usage

Most of these benchmarks are useful as-is, but there are also a couple of benchmarks of pure uxarray imports to compare with if the illusory memory improvement ever returns.

PR Checklist

General

  • An issue is linked created and linked
  • Add appropriate labels
  • Filled out Overview and Expected Usage (if applicable) sections

Testing

  • Adequate tests are created if there is new functionality
  • Tests cover all possible logical paths in your function
  • Tests are not too basic (such as simply calling a function and nothing else)

Documentation

  • Docstrings have been added to all new functions
  • Docstrings have updated with any function changes

cmdupuis3 and others added 3 commits July 22, 2026 12:52
Three benchmarks reported a near-identical "improvement" on every PR regardless
of what the PR touched:

  578M -> 391M  0.68  face_bounds.FaceBounds.peakmem_face_bounds(geoflow-small)
  708M -> 390M  0.55  face_bounds.FaceBounds.peakmem_face_bounds(quad-hexagon)
  498M -> 384M  0.77  mpas_ocean.Gradient.peakmem_gradient('480km')

They were not measuring the operation under test. asv's peakmem_* records the
max RSS of the whole process, and per asv's docs it "also counts memory usage
during the setup routine". Profiling the face_bounds params:

  grid                    import  +open_grid  +.bounds   attributable to op
  quad-hexagon  ( 24K)     236MB      265MB     301MB     36MB (12%)
  geoflow-small (1.1M)     236MB      263MB     487MB    224MB (46%)
  outCSne8      ( 48K)     235MB      281MB     317MB     35MB (11%)
  oQU480        (4.6M)     236MB      270MB     306MB     36MB (12%)

`import uxarray` alone is ~226 MB and constant to within 1 MB. oQU480 is 190x
larger than quad-hexagon yet both attributed ~36 MB to Grid.bounds -- the
benchmark was nearly insensitive to its own workload. The part that did vary was
numba: uxarray has 81 @njit(cache=True) kernels and both affected paths go
through them (uxarray/grid/bounds.py, uxarray/core/gradient.py). With a cold JIT
cache the quad-hexagon case peaked at 599 MB, with a warm one 298 MB -- a ratio
of 0.50, matching the ratios seen in CI. Which side of an `asv continuous`
comparison paid the compile cost depended on run order, not on the code under
review.

peakmem_* could not be repaired in place, because there was nothing to measure.
Across every operation the five peakmem benchmarks covered, against every mesh
in the suite including the 98 MB / 28,571-face oQU120:

  Grid.bounds        ~1 MB     open_grid   0-10 MB (lazy; mostly allocator noise)
  gradient        0-0.1 MB     integrate       0.0 MB
  open_dataset      0.0 MB

Two better instruments were evaluated and rejected:

  - Sampled peak RSS. Validates cleanly (a known 200 MB allocation measures as
    200.0 MB) and is immune to process history. But the warm-up call needed to
    keep JIT out of the measurement leaves freed pages in the allocator, so RSS
    *growth* undercounts -- every operation measured 0.0-0.1 MB this way.
  - tracemalloc. Measures allocation volume rather than RSS growth, so it would
    sidestep page reuse, but it does not observe numba NRT allocations, which is
    where uxarray's array memory is allocated.

What remains is deterministic size accounting via track_* benchmarks, which also
sidesteps the setup confound entirely -- track_* does not count setup. Verified
byte-identical across cold JIT, warm JIT, and an independent second cold JIT for
all 14 parameter combinations, and it scales correctly with the mesh
(quad-hexagon reports 128 bytes = 4 faces x 32). It does not capture transient
peaks, but the measurements above show those are ~1 MB, far below anything worth
gating on, and no available instrument captures them reliably here.

The rationale is recorded in benchmarks/_memsize.py so the next person does not
reintroduce peakmem_*. The commented-out mem_* stubs in quad_hexagon.py, an
earlier attempt at the same thing, are removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous commit removed peakmem_* because the per-operation memory it
claimed to measure was ~1 MB against a reported 400-700 MB. But the larger
question those benchmarks were reaching for -- "how much memory does it take to
open this grid and compute on it, from a cold start" -- is real, and asv can
answer it with its own peakmem_* once two things are controlled:

  1. Process history. ru_maxrss is a high-water mark that never falls, so
     imports, setup() and earlier work in the same process set a floor under the
     result. asv already spawns a separate process per benchmark *and per
     parameter* (runner._run_benchmark_single_param), so this is handled as long
     as the benchmark classes declare no setup() -- asv counts setup memory
     towards peakmem_*, which is exactly the confound its own docs warn about.

  2. numba JIT cache warmth. Compiling uxarray's 81 @njit(cache=True) kernels
     costs a few hundred MB of transient RSS, so an otherwise identical run
     peaked at 599 MB cold and 298 MB warm. Whichever side of an `asv continuous`
     comparison happened to compile paid that cost.

setup_cache handles (2). asv runs it in its own process
(runner.Spawner.create_setup_cache), so LLVM's footprint never enters the
high-water mark of the processes that do the measuring; they load the compiled
kernels from numba's on-disk cache instead. It runs once per commit, so both
sides of a comparison are warmed symmetrically. Every parameter is warmed, not
just one -- the grids do not all reach the same njit signatures.

Measured via `asv run --python=same --quick -b peakmem`, twice warm and once
after deleting every .nbi/.nbc in the installed uxarray:

                              warm    warm    COLD    spread
  import uxarray              280M    282M    280M      0.7%
  open+bounds quad-hexagon    349M    349M    346M      0.9%
  open+bounds geoflow-small   348M    348M    348M      0.0%
  open+bounds outCSne8        365M    364M    370M      1.6%
  open+bounds oQU480          354M    356M    351M      1.4%
  gradient 480km              358M    355M    354M      1.1%
  gradient 120km              371M    370M    381M      2.9%

The cold column is the condition that used to halve the result; the cache
repopulated from 0 to 33 entries during that run, confirming setup_cache did the
compiling. Everything sits inside 2.9%, against asv's default 10% factor.

peakmem_import_uxarray is included deliberately. ~280 MB of every row above is
just importing uxarray, so tracking it on its own means a heavy new top-level
import shows up as itself instead of silently inflating everything else.

Also moves _memsize into benchmarks/helpers/ and gives it an __init__.py, so the
package structure is explicit rather than relying on namespace packages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cmdupuis3 cmdupuis3 added bug Something isn't working benchmarking Related to benchmarks, memory usage, and/or time profiling run-benchmark Run ASV benchmark workflow labels Jul 22, 2026
@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown

ASV Benchmarking

Benchmark Comparison Results

Benchmarks that have stayed the same:

Change Before [058a991] After [b2fee54] Ratio Benchmark (Parameter)
10.9±0.2μs 10.8±0.06μs 0.99 bench_connectivity.Connectivity.time_edge_face('120km')
11.1±0.2μs 11.1±0.2μs 1.00 bench_connectivity.Connectivity.time_edge_face('480km')
10.9±0.1μs 10.8±0.07μs 0.99 bench_connectivity.Connectivity.time_edge_node('120km')
10.9±0.2μs 11.4±0.3μs 1.04 bench_connectivity.Connectivity.time_edge_node('480km')
10.7±0.1μs 10.5±0.2μs 0.98 bench_connectivity.Connectivity.time_face_edge('120km')
11.1±0.1μs 11.1±0.3μs 1.00 bench_connectivity.Connectivity.time_face_edge('480km')
10.6±0.1μs 10.8±0.08μs 1.02 bench_connectivity.Connectivity.time_face_face('120km')
11.1±0.2μs 11.1±0.08μs 1.00 bench_connectivity.Connectivity.time_face_face('480km')
21.5±0.1μs 22.4±0.6μs 1.04 bench_connectivity.Connectivity.time_face_node('120km')
22.2±0.2μs 22.2±0.2μs 1.00 bench_connectivity.Connectivity.time_face_node('480km')
10.6±0.06μs 10.7±0.1μs 1.01 bench_connectivity.Connectivity.time_node_edge('120km')
11.0±0.1μs 11.0±0.1μs 1.00 bench_connectivity.Connectivity.time_node_edge('480km')
10.8±0.08μs 10.6±0.1μs 0.99 bench_connectivity.Connectivity.time_node_face('120km')
11.0±0.05μs 11.3±0.1μs 1.02 bench_connectivity.Connectivity.time_node_face('480km')
14.6±0.1ms 14.5±0.08ms 1.00 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
3.65±0.06ms 3.72±0.04ms 1.02 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
19.1±0.08ms 18.8±0.06ms 0.98 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
2.16±0.02ms 2.16±0.02ms 1.00 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
57.3k 57.3k 1.00 face_bounds.FaceBounds.track_nbytes_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
12.3k 12.3k 1.00 face_bounds.FaceBounds.track_nbytes_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
123k 123k 1.00 face_bounds.FaceBounds.track_nbytes_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
128 128 1.00 face_bounds.FaceBounds.track_nbytes_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
1.27M 1.27M 1.00 face_bounds.FaceBounds.track_nbytes_grid_with_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
50.1k 50.1k 1.00 face_bounds.FaceBounds.track_nbytes_grid_with_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
1.48M 1.48M 1.00 face_bounds.FaceBounds.track_nbytes_grid_with_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
712 712 1.00 face_bounds.FaceBounds.track_nbytes_grid_with_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
334M 334M 1.00 face_bounds.FaceBoundsPeakMem.peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
365M 363M 0.99 face_bounds.FaceBoundsPeakMem.peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
336M 335M 1.00 face_bounds.FaceBoundsPeakMem.peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
335M 335M 1.00 face_bounds.FaceBoundsPeakMem.peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
234M 234M 1.00 import.Imports.peakmem_import_uxarray
572±10ms 590±8ms 1.03 import.Imports.timeraw_import_uxarray
928±10ns 925±3ns 1.00 mpas_ocean.CheckNorm.time_check_norm('120km')
903±6ns 887±20ns 0.98 mpas_ocean.CheckNorm.time_check_norm('480km')
828±9ms 844±10ms 1.02 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('120km')
54.0±0.4ms 54.9±0.5ms 1.02 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('480km')
662±20μs 684±20μs 1.03 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('120km')
596±10μs 602±9μs 1.01 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('480km')
5.50±0.1ms 5.46±0.03ms 0.99 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('120km')
4.03±0.1ms 3.94±0.02ms 0.98 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('480km')
3.43±0.01s 3.46±0.02s 1.01 mpas_ocean.ConstructFaceLatLon.time_welzl('120km')
226±6ms 222±3ms 0.99 mpas_ocean.ConstructFaceLatLon.time_welzl('480km')
18.2±0.02ms 18.2±0.02ms 1.00 mpas_ocean.ConstructTreeStructures.time_ball_tree('120km')
1.05±0.01ms 1.07±0.01ms 1.02 mpas_ocean.ConstructTreeStructures.time_ball_tree('480km')
10.6±0.02ms 10.6±0.03ms 1.00 mpas_ocean.ConstructTreeStructures.time_kd_tree('120km')
752±9μs 742±6μs 0.99 mpas_ocean.ConstructTreeStructures.time_kd_tree('480km')
713±5ms 724±2ms 1.02 mpas_ocean.CrossSections.time_const_lat('120km', 1)
357±3ms 366±2ms 1.02 mpas_ocean.CrossSections.time_const_lat('120km', 2)
184±0.8ms 189±2ms 1.03 mpas_ocean.CrossSections.time_const_lat('120km', 4)
543±1ms 559±2ms 1.03 mpas_ocean.CrossSections.time_const_lat('480km', 1)
275±0.8ms 281±3ms 1.02 mpas_ocean.CrossSections.time_const_lat('480km', 2)
142±0.5ms 145±2ms 1.02 mpas_ocean.CrossSections.time_const_lat('480km', 4)
24.6±0.09ms 24.6±0.07ms 1.00 mpas_ocean.DualMesh.time_dual_mesh_construction('120km')
3.30±0.06ms 3.23±0.05ms 0.98 mpas_ocean.DualMesh.time_dual_mesh_construction('480km')
959±2ms 949±10ms 0.99 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', False)
53.3±2ms 53.1±2ms 1.00 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', True)
83.7±0.9ms 84.3±0.6ms 1.01 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', False)
5.92±0.04ms 5.96±0.08ms 1.01 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', True)
175±0.8ms 176±0.8ms 1.00 mpas_ocean.Gradient.time_gradient('120km')
13.2±0.03ms 12.7±0.1ms 0.96 mpas_ocean.Gradient.time_gradient('480km')
457k 457k 1.00 mpas_ocean.Gradient.track_nbytes_gradient('120km')
28.7k 28.7k 1.00 mpas_ocean.Gradient.track_nbytes_gradient('480km')
349M 349M 1.00 mpas_ocean.GradientPeakMem.peakmem_gradient('120km')
328M 328M 1.00 mpas_ocean.GradientPeakMem.peakmem_gradient('480km')
227±2μs 228±3μs 1.00 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('120km')
133±1μs 133±0.3μs 1.00 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('480km')
222±3μs 220±3μs 0.99 mpas_ocean.Integrate.time_integrate('120km')
202±2μs 204±1μs 1.01 mpas_ocean.Integrate.time_integrate('480km')
18.4M 18.4M 1.00 mpas_ocean.Integrate.track_nbytes_integrate('120km')
1.2M 1.2M 1.00 mpas_ocean.Integrate.track_nbytes_integrate('480km')
185±2ms 185±0.7ms 1.00 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'exclude')
181±1ms 187±1ms 1.03 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'include')
183±1ms 186±0.8ms 1.02 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'split')
13.6±0.06ms 14.2±0.4ms 1.04 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'exclude')
13.9±0.2ms 14.1±0.3ms 1.01 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'include')
13.8±0.2ms 14.0±0.2ms 1.01 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'split')
352±2μs 360±3μs 1.02 mpas_ocean.PointInPolygon.time_face_search_lonlat('120km')
359±3μs 358±4μs 1.00 mpas_ocean.PointInPolygon.time_face_search_lonlat('480km')
334±2μs 336±2μs 1.01 mpas_ocean.PointInPolygon.time_face_search_xyz('120km')
338±1μs 339±4μs 1.00 mpas_ocean.PointInPolygon.time_face_search_xyz('480km')
248±0.5ms 246±0.5ms 0.99 mpas_ocean.RemapDownsample.time_bilinear_remapping
294±1ms 292±2ms 0.99 mpas_ocean.RemapDownsample.time_inverse_distance_weighted_remapping
1.44±0.01s 1.45±0.01s 1.01 mpas_ocean.RemapUpsample.time_bilinear_remapping
37.3±0.8ms 39.0±0.9ms 1.05 mpas_ocean.RemapUpsample.time_inverse_distance_weighted_remapping
9.65±0.2ms 9.73±0.06ms 1.01 mpas_ocean.RemapUpsample.time_nearest_neighbor_remapping
28.9±0.2ms 29.5±0.2ms 1.02 mpas_ocean.ZonalAverage.time_zonal_average('120km')
6.50±0.02ms 7.17±0.1ms ~1.10 mpas_ocean.ZonalAverage.time_zonal_average('480km')
7.18±0.2ms 7.45±0.4ms 1.04 quad_hexagon.QuadHexagon.time_open_dataset
6.02±0.07ms 5.99±0.05ms 1.00 quad_hexagon.QuadHexagon.time_open_grid
408 408 1.00 quad_hexagon.QuadHexagon.track_nbytes_open_dataset
392 392 1.00 quad_hexagon.QuadHexagon.track_nbytes_open_grid

Benchmarks that have got worse:

Change Before [058a991] After [b2fee54] Ratio Benchmark (Parameter)
+ 4.36±0.01ms 4.98±0.1ms 1.14 mpas_ocean.RemapDownsample.time_nearest_neighbor_remapping

@Sevans711

Copy link
Copy Markdown
Collaborator

Thank you for working on this and opening up this PR! I have a few quick thoughts before reviewing:

  1. I wonder if nbytes should be added directly as a property of Grid objects, instead of needing a custom grid_nbytes function? It might be nice to be able to do uxgrid.nbytes, since Grid is basically just an xr.Dataset with some extra functionality attached to it, and it seems nice to be able to quickly check how large of a grid you are working with.
  2. What is the purpose of benchmarks that measure the size of the loaded Grid objects or arrays? My initial understanding is that the answer should always be the same, and it isn't really measuring runtime efficiency nor total memory usage. Would these work better as unit tests? E.g. "test_face_bounds_array_size" which asserts the size of face_bounds from oQU480.231010.nc is 57.3 kB, with some small tolerance for rounding errors. I might also be not fully understanding the purpose of benchmarks. Also wondering if @erogluorhan or @rajeeja have thoughts on this question in particular?

@cmdupuis3 cmdupuis3 self-assigned this Jul 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

benchmarking Related to benchmarks, memory usage, and/or time profiling bug Something isn't working run-benchmark Run ASV benchmark workflow

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Some ASV peakmem benchmarks always show the same "improvement"

3 participants