Skip to content

ASV better memory benchmarks#1609

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

ASV better memory benchmarks#1609
cmdupuis3 wants to merge 3 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

Copy link
Copy Markdown

ASV Benchmarking

Benchmark Comparison Results

Benchmarks that have stayed the same:

Change Before [71d6e37] After [91c8fbc] Ratio Benchmark (Parameter)
10.6±0.05μs 10.6±0.1μs 1 bench_connectivity.Connectivity.time_edge_face('120km')
11.1±0.09μs 11.1±0.09μs 0.99 bench_connectivity.Connectivity.time_edge_face('480km')
10.6±0.07μs 10.8±0.2μs 1.02 bench_connectivity.Connectivity.time_edge_node('120km')
11.2±0.2μs 11.0±0.1μs 0.98 bench_connectivity.Connectivity.time_edge_node('480km')
10.6±0.2μs 10.8±0.1μs 1.01 bench_connectivity.Connectivity.time_face_edge('120km')
11.3±0.2μs 11.1±0.1μs 0.99 bench_connectivity.Connectivity.time_face_edge('480km')
10.7±0.1μs 10.7±0.07μs 1 bench_connectivity.Connectivity.time_face_face('120km')
11.1±0.09μs 11.1±0.08μs 0.99 bench_connectivity.Connectivity.time_face_face('480km')
21.5±0.1μs 21.2±0.2μs 0.99 bench_connectivity.Connectivity.time_face_node('120km')
21.9±0.2μs 22.4±0.3μs 1.02 bench_connectivity.Connectivity.time_face_node('480km')
10.7±0.07μs 10.6±0.07μs 1 bench_connectivity.Connectivity.time_node_edge('120km')
11.3±0.3μs 11.1±0.2μs 0.98 bench_connectivity.Connectivity.time_node_edge('480km')
11.0±0.09μs 10.8±0.2μs 0.98 bench_connectivity.Connectivity.time_node_face('120km')
11.1±0.1μs 10.9±0.1μs 0.98 bench_connectivity.Connectivity.time_node_face('480km')
14.8±0.07ms 14.3±0.04ms 0.97 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
3.72±0.04ms 3.72±0.02ms 1 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
19.1±0.03ms 18.9±0.1ms 0.99 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
2.24±0.05ms 2.20±0.02ms 0.98 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
57.3k 57.3k 1 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 face_bounds.FaceBounds.track_nbytes_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
123k 123k 1 face_bounds.FaceBounds.track_nbytes_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
128 128 1 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 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 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 face_bounds.FaceBounds.track_nbytes_grid_with_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
712 712 1 face_bounds.FaceBounds.track_nbytes_grid_with_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
389M 389M 1 face_bounds.FaceBoundsPeakMem.peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
419M 418M 1 face_bounds.FaceBoundsPeakMem.peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
391M 391M 1 face_bounds.FaceBoundsPeakMem.peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
390M 390M 1 face_bounds.FaceBoundsPeakMem.peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
295M 295M 1 import.Imports.peakmem_import_uxarray
1.06±0.02s 1.02±0.01s 0.97 import.Imports.timeraw_import_uxarray
920±3ns 924±4ns 1 mpas_ocean.CheckNorm.time_check_norm('120km')
905±3ns 903±0.9ns 1 mpas_ocean.CheckNorm.time_check_norm('480km')
843±9ms 830±10ms 0.99 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('120km')
54.9±0.9ms 52.4±0.4ms 0.95 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('480km')
665±6μs 669±20μs 1.01 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('120km')
604±10μs 597±10μs 0.99 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('480km')
5.39±0.05ms 5.48±0.04ms 1.02 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('120km')
4.06±0.1ms 3.94±0.01ms 0.97 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('480km')
3.44±0.01s 3.46±0.01s 1.01 mpas_ocean.ConstructFaceLatLon.time_welzl('120km')
225±2ms 223±1ms 0.99 mpas_ocean.ConstructFaceLatLon.time_welzl('480km')
18.2±0.04ms 18.1±0.02ms 1 mpas_ocean.ConstructTreeStructures.time_ball_tree('120km')
1.07±0.05ms 1.04±0.02ms 0.97 mpas_ocean.ConstructTreeStructures.time_ball_tree('480km')
10.6±0.02ms 10.6±0.02ms 0.99 mpas_ocean.ConstructTreeStructures.time_kd_tree('120km')
731±30μs 691±20μs 0.95 mpas_ocean.ConstructTreeStructures.time_kd_tree('480km')
707±10ms 701±6ms 0.99 mpas_ocean.CrossSections.time_const_lat('120km', 1)
360±6ms 355±1ms 0.99 mpas_ocean.CrossSections.time_const_lat('120km', 2)
188±2ms 181±0.3ms 0.96 mpas_ocean.CrossSections.time_const_lat('120km', 4)
548±5ms 548±4ms 1 mpas_ocean.CrossSections.time_const_lat('480km', 1)
277±6ms 276±0.5ms 1 mpas_ocean.CrossSections.time_const_lat('480km', 2)
143±2ms 141±0.6ms 0.99 mpas_ocean.CrossSections.time_const_lat('480km', 4)
24.9±0.2ms 24.7±0.06ms 0.99 mpas_ocean.DualMesh.time_dual_mesh_construction('120km')
3.36±0.06ms 3.20±0.03ms 0.95 mpas_ocean.DualMesh.time_dual_mesh_construction('480km')
956±7ms 954±8ms 1 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', False)
51.2±0.7ms 50.6±0.7ms 0.99 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', True)
85.6±0.6ms 82.8±0.4ms 0.97 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', False)
5.70±0.3ms 5.68±0.1ms 1 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', True)
173±1ms 176±0.8ms 1.02 mpas_ocean.Gradient.time_gradient('120km')
13.3±0.07ms 12.5±0.2ms 0.94 mpas_ocean.Gradient.time_gradient('480km')
457k 457k 1 mpas_ocean.Gradient.track_nbytes_gradient('120km')
28.7k 28.7k 1 mpas_ocean.Gradient.track_nbytes_gradient('480km')
404M 404M 1 mpas_ocean.GradientPeakMem.peakmem_gradient('120km')
384M 386M 1.01 mpas_ocean.GradientPeakMem.peakmem_gradient('480km')
222±0.6μs 226±2μs 1.01 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('120km')
132±1μs 132±1μs 1 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('480km')
218±1μs 222±1μs 1.01 mpas_ocean.Integrate.time_integrate('120km')
203±0.3μs 202±3μs 0.99 mpas_ocean.Integrate.time_integrate('480km')
18.4M 18.4M 1 mpas_ocean.Integrate.track_nbytes_integrate('120km')
1.2M 1.2M 1 mpas_ocean.Integrate.track_nbytes_integrate('480km')
185±1ms 181±0.7ms 0.98 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'exclude')
183±1ms 182±1ms 1 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'include')
185±3ms 181±1ms 0.98 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'split')
13.5±0.2ms 13.5±0.2ms 1 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'exclude')
13.3±0.2ms 13.2±0.07ms 1 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'include')
13.3±0.2ms 13.6±0.4ms 1.03 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'split')
354±2μs 355±2μs 1 mpas_ocean.PointInPolygon.time_face_search_lonlat('120km')
354±2μs 358±3μs 1.01 mpas_ocean.PointInPolygon.time_face_search_lonlat('480km')
342±3μs 334±1μs 0.98 mpas_ocean.PointInPolygon.time_face_search_xyz('120km')
337±4μs 337±1μs 1 mpas_ocean.PointInPolygon.time_face_search_xyz('480km')
242±0.5ms 248±1ms 1.02 mpas_ocean.RemapDownsample.time_bilinear_remapping
300±3ms 289±7ms 0.96 mpas_ocean.RemapDownsample.time_inverse_distance_weighted_remapping
4.29±0.05ms 4.29±0.01ms 1 mpas_ocean.RemapDownsample.time_nearest_neighbor_remapping
1.43±0s 1.43±0.01s 1 mpas_ocean.RemapUpsample.time_bilinear_remapping
36.6±0.4ms 36.8±0.4ms 1.01 mpas_ocean.RemapUpsample.time_inverse_distance_weighted_remapping
9.50±0.1ms 9.78±0.3ms 1.03 mpas_ocean.RemapUpsample.time_nearest_neighbor_remapping
28.9±0.3ms 29.5±0.4ms 1.02 mpas_ocean.ZonalAverage.time_zonal_average('120km')
6.98±0.2ms 6.75±0.2ms 0.97 mpas_ocean.ZonalAverage.time_zonal_average('480km')
7.03±0.04ms 7.00±0.1ms 1 quad_hexagon.QuadHexagon.time_open_dataset
6.08±0.1ms 5.92±0.08ms 0.97 quad_hexagon.QuadHexagon.time_open_grid
408 408 1 quad_hexagon.QuadHexagon.track_nbytes_open_dataset
392 392 1 quad_hexagon.QuadHexagon.track_nbytes_open_grid

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

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"

2 participants