Skip to content

ASV better memory benchmarks - #1609

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

ASV better memory benchmarks#1609
cmdupuis3 wants to merge 10 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 [ab023b9] After [cfd087c] Ratio Benchmark (Parameter)
200±0.6ms 202±0.9ms 1.01 bench_connectivity.Connectivity.time_edge_face('120km')
12.1±0.1ms 12.5±0.4ms 1.03 bench_connectivity.Connectivity.time_edge_face('480km')
11.1±0.1ms 11.1±0.05ms 1.00 bench_connectivity.Connectivity.time_edge_node('480km')
199±2ms 200±0.5ms 1.01 bench_connectivity.Connectivity.time_face_edge('120km')
11.5±0.08ms 11.5±0.07ms 1.00 bench_connectivity.Connectivity.time_face_edge('480km')
882±9ms 878±10ms 1.00 bench_connectivity.Connectivity.time_face_face('120km')
56.5±0.9ms 56.0±1ms 0.99 bench_connectivity.Connectivity.time_face_face('480km')
68.2±2μs 67.6±3μs 0.99 bench_connectivity.Connectivity.time_face_node('120km')
67.0±3μs 66.6±3μs 0.99 bench_connectivity.Connectivity.time_face_node('480km')
404±6μs 409±10μs 1.01 bench_connectivity.Connectivity.time_n_nodes_per_face('120km')
369±10μs 341±10μs 0.93 bench_connectivity.Connectivity.time_n_nodes_per_face('480km')
199±0.8ms 219±20ms ~1.10 bench_connectivity.Connectivity.time_node_edge('120km')
11.4±0.06ms 11.5±0.09ms 1.00 bench_connectivity.Connectivity.time_node_edge('480km')
81.3±6ms 81.4±3ms 1.00 bench_connectivity.Connectivity.time_node_face('120km')
5.10±0.06ms 5.28±0.2ms 1.04 bench_connectivity.Connectivity.time_node_face('480km')
8.27±0.04ms 8.45±0.08ms 1.02 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
2.68±4ms 2.67±0.05ms 1.00 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
10.1±0.02ms 10.2±0.05ms 1.01 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
2.10±0ms 2.16±0.02ms 1.03 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'))
366M 366M 1.00 face_bounds.FaceBoundsPeakMem.peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
336M 336M 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'))
1.20±0.02μs 1.20±0.03μs 1.00 geometry_kernels.AccucrossKernels.time_accucross
2.74±0.02μs 2.72±0.03μs 0.99 geometry_kernels.AccucrossKernels.time_accucross_pair
455±20ns 451±9ns 0.99 geometry_kernels.EFTPrimitives.time_acc_sqrt_re
441±4ns 436±20ns 0.99 geometry_kernels.EFTPrimitives.time_diff_of_products
376±5ns 385±9ns 1.03 geometry_kernels.EFTPrimitives.time_two_prod
381±10ns 395±9ns 1.04 geometry_kernels.EFTPrimitives.time_two_sum
1.55±0.04μs 1.52±0.03μs 0.98 geometry_kernels.GCAConstLatIntersection.time_accux_constlat_kernel
1.12±0.02μs 1.15±0.02μs 1.03 geometry_kernels.GCAConstLatIntersection.time_gca_const_lat_intersection
1.98±0.05μs 1.91±0.04μs 0.96 geometry_kernels.GCAConstLatIntersection.time_try_gca_const_lat_intersection
1.68±0.05μs 1.64±0.03μs 0.98 geometry_kernels.GCAGCAIntersection.time_accux_gca_kernel
1.38±0.02μs 1.42±0.02μs 1.03 geometry_kernels.GCAGCAIntersection.time_gca_gca_intersection
2.19±0.03μs 2.14±0.02μs 0.97 geometry_kernels.GCAGCAIntersection.time_try_gca_gca_intersection
52.4±0.6μs 51.1±1μs 0.98 geometry_kernels.OrientPredicates.time_on_minor_arc
1.07±0.03μs 1.06±0.03μs 0.99 geometry_kernels.OrientPredicates.time_orient3d_on_sphere
2.73±0.1ms 2.59±0.01ms 0.95 geometry_samebody.SameBodyConstLat.time_accux_dispatch
1.17±0.01ms 1.17±0.01ms 1.00 geometry_samebody.SameBodyConstLat.time_accux_kernel
1.72±0.01ms 1.74±0.01ms 1.01 geometry_samebody.SameBodyConstLat.time_fp64_dispatch
147±0.8μs 147±0.5μs 1.00 geometry_samebody.SameBodyConstLat.time_fp64_kernel
32.2±0.07ms 32.5±0.09ms 1.01 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_dispatch
10.2±0.01ms 10.2±0ms 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_kernel
26.4±0.02ms 26.4±0.03ms 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_dispatch
4.85±0.01ms 4.85±0.03ms 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_kernel
293M 293M 1.00 import.Imports.peakmem_import_uxarray
794±3ms 795±3ms 1.00 import.Imports.timeraw_import_uxarray
2.61±0.01ms 2.63±0.02ms 1.01 mpas_ocean.CheckNorm.time_check_norm('120km')
2.16±0.01ms 2.17±0.03ms 1.00 mpas_ocean.CheckNorm.time_check_norm('480km')
811±10ms 815±3ms 1.00 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('120km')
52.7±0.5ms 53.9±0.4ms 1.02 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('480km')
643±10μs 644±10μs 1.00 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('120km')
572±9μs 584±10μs 1.02 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('480km')
5.35±0.02ms 5.29±0.05ms 0.99 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('120km')
3.87±0.03ms 3.98±0.05ms 1.03 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('480km')
3.43±0.03s 3.46±0.02s 1.01 mpas_ocean.ConstructFaceLatLon.time_welzl('120km')
222±0.5ms 223±2ms 1.00 mpas_ocean.ConstructFaceLatLon.time_welzl('480km')
18.1±0.04ms 18.2±0.02ms 1.00 mpas_ocean.ConstructTreeStructures.time_ball_tree('120km')
993±10μs 1.02±0.02ms 1.02 mpas_ocean.ConstructTreeStructures.time_ball_tree('480km')
10.5±0.02ms 10.5±0.01ms 1.00 mpas_ocean.ConstructTreeStructures.time_kd_tree('120km')
691±20μs 676±10μs 0.98 mpas_ocean.ConstructTreeStructures.time_kd_tree('480km')
689±2ms 694±2ms 1.01 mpas_ocean.CrossSections.time_const_lat('120km', 1)
347±3ms 354±5ms 1.02 mpas_ocean.CrossSections.time_const_lat('120km', 2)
180±0.5ms 181±1ms 1.01 mpas_ocean.CrossSections.time_const_lat('120km', 4)
539±1ms 551±7ms 1.02 mpas_ocean.CrossSections.time_const_lat('480km', 1)
274±0.3ms 273±1ms 1.00 mpas_ocean.CrossSections.time_const_lat('480km', 2)
140±0.4ms 141±0.8ms 1.01 mpas_ocean.CrossSections.time_const_lat('480km', 4)
24.3±0.2ms 24.0±0.04ms 0.99 mpas_ocean.DualMesh.time_dual_mesh_construction('120km')
3.02±0.1ms 2.97±0.06ms 0.98 mpas_ocean.DualMesh.time_dual_mesh_construction('480km')
60.7±0.05ms 60.9±0.1ms 1.00 mpas_ocean.FaceAreas.time_face_areas('120km')
7.36±0.05ms 7.43±0.03ms 1.01 mpas_ocean.FaceAreas.time_face_areas('480km')
229k 229k 1.00 mpas_ocean.FaceAreas.track_nbytes_face_areas('120km')
14.3k 14.3k 1.00 mpas_ocean.FaceAreas.track_nbytes_face_areas('480km')
944±5ms 949±5ms 1.01 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', False)
51.3±0.5ms 54.9±0.9ms 1.07 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', True)
81.9±0.6ms 83.0±0.3ms 1.01 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', False)
5.43±0.1ms 5.32±0.09ms 0.98 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', True)
175±0.4ms 173±0.9ms 0.99 mpas_ocean.Gradient.time_gradient('120km')
12.2±0.06ms 12.2±0.06ms 1.00 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')
350M 352M 1.01 mpas_ocean.GradientPeakMem.peakmem_gradient('120km')
330M 330M 1.00 mpas_ocean.GradientPeakMem.peakmem_gradient('480km')
355±9μs 365±10μs 1.03 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('120km')
197±10μs 209±10μs 1.06 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('480km')
519±9μs 534±7μs 1.03 mpas_ocean.Integrate.time_integrate('120km')
446±10μs 490±20μs 1.10 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')
178±2ms 181±1ms 1.02 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'exclude')
180±1ms 179±2ms 0.99 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'include')
180±1ms 180±0.4ms 1.00 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'split')
13.1±0.05ms 13.3±0.3ms 1.02 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'exclude')
13.2±0.1ms 13.4±0.2ms 1.02 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'include')
13.1±0.1ms 13.2±0.1ms 1.01 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'split')
385±6μs 393±10μs 1.02 mpas_ocean.PointInPolygon.time_face_search_lonlat('120km')
387±10μs 401±9μs 1.04 mpas_ocean.PointInPolygon.time_face_search_lonlat('480km')
362±8μs 383±10μs 1.06 mpas_ocean.PointInPolygon.time_face_search_xyz('120km')
356±10μs 358±9μs 1.01 mpas_ocean.PointInPolygon.time_face_search_xyz('480km')
240±0.4ms 241±2ms 1.01 mpas_ocean.RemapDownsample.time_bilinear_remapping
287±0.7ms 285±1ms 0.99 mpas_ocean.RemapDownsample.time_inverse_distance_weighted_remapping
15.3±0.1ms 15.5±0.08ms 1.01 mpas_ocean.RemapDownsample.time_nearest_neighbor_remapping
1.43±0.01s 1.44±0.01s 1.01 mpas_ocean.RemapUpsample.time_bilinear_remapping
36.6±0.2ms 37.6±0.7ms 1.03 mpas_ocean.RemapUpsample.time_inverse_distance_weighted_remapping
12.0±0.3ms 12.2±0.3ms 1.02 mpas_ocean.RemapUpsample.time_nearest_neighbor_remapping
25.8±0.3ms 26.7±0.3ms 1.03 mpas_ocean.ZonalAverage.time_zonal_average('120km')
5.75±0.06ms 6.09±0.1ms 1.06 mpas_ocean.ZonalAverage.time_zonal_average('480km')
6.86±0.08ms 7.09±0.2ms 1.03 quad_hexagon.QuadHexagon.time_open_dataset
5.83±0.07ms 6.07±0.1ms 1.04 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 [ab023b9] After [cfd087c] Ratio Benchmark (Parameter)
+ 197±0.4ms 228±8ms 1.16 bench_connectivity.Connectivity.time_edge_node('120km')

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

Copy link
Copy Markdown
Collaborator Author

@Sevans711 as per convo, 1 is really a question for @erogluorhan or @rajeeja. On 2, these are included because the peakmem is not necessarily the same as the total memory usage in a chunked situation. This particular batch of benchmarks is a little verbose, but it basically serves as a regression test against the kinds of spurious improvements in other PRs, so that we have a better idea of which things are bloating the peakmem metrics.

@cmdupuis3
cmdupuis3 requested a review from rajeeja August 10, 2026 15:25
@Sevans711

Copy link
Copy Markdown
Collaborator

Just noticed, the mpas_ocean.FaceAreas.peakmem_compute_face_areas('480km') is now having a similar problem; would you be able to take a quick look and see if it is easy to fix here in the same way?

@erogluorhan

Copy link
Copy Markdown
Member

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?
  1. I agree with this but don't have a strong preference for it to be in this PR (just to be precise though, Grid is not a xr.Dataset, it just has a composition, i.e. "has a" xr.Dataset).
  2. Great question! With the benchmarks, you want to know the baseline and then both (1) avoid regression with additions to Grid in this case (but can be taken as general) and (2) aim at improving the baseline of the Grid object. For instance, for (1), when you're touching Grid, maybe adding some calculations or attributes, you may want to at least keep the memory used the same as before. For (2), if Grid is not doing a great job with memory utilization with all its cached variables, connectivities, etc. you may want to see if some of them can be dropped, made optional, etc. and benchmarks help you see the improvements if you have any with your PR. Does this help?

In regards to peak-mem vs static object size (nbytes) though, I think each of them has its own value (the former gives the peak memory used throughout an operation while the latter gives you the static memory size needed for an object, and they should not be considered alternative to each other.

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

This is looking great to me; just a few comments below.

Comment thread benchmarks/mpas_ocean.py Outdated

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.

Can you please clarify why this is not converted to the new way? No setup_cache needed for it (since no njit)?

Comment thread benchmarks/mpas_ocean.py
def time_integrate(self, resolution):
self.uxds[data_var].integrate()

def peakmem_integrate(self, resolution):

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.

Why no IntegratePeakMem class defined like others?

@Sevans711

Copy link
Copy Markdown
Collaborator

@erogluorhan Thank you for clarifying these points, that helps, though I do still have a few follow-up thoughts below:

For my question (1), that makes sense, it also sounds good to me to have it go into a different PR. Flagging #1647 as the relevant feature request (created after I left my comment above) for things like Grid.nbytes.

In regards to peak-mem vs static object size (nbytes) though, I think each of them has its own value (the former gives the peak memory used throughout an operation while the latter gives you the static memory size needed for an object, and they should not be considered alternative to each other.

Indeed, it makes sense to me that peak memory is a very different measurement from static memory size needed for an object.

My understanding is that no other benchmarks in the benchmarking suite were measuring static memory size, and that the original issue reported here doesn't really require any static memory size checks in order to solve it. That's the main reason I was surprised these were added here, and wanted to know more about their purpose.

  1. Great question! With the benchmarks, you want to know the baseline and then both (1) avoid regression with additions to Grid in this case (but can be taken as general) and (2) aim at improving the baseline of the Grid object. For instance, for (1), when you're touching Grid, maybe adding some calculations or attributes, you may want to at least keep the memory used the same as before. For (2), if Grid is not doing a great job with memory utilization with all its cached variables, connectivities, etc. you may want to see if some of them can be dropped, made optional, etc. and benchmarks help you see the improvements if you have any with your PR. Does this help?

I believe I understand what you mean. Is this rephrasing correct?:

  • Benchmarking static memory size of objects is now desired, and the main benefits would be:
  • (1) help avoid regression where Grid code changes cause object sizes to increase.
  • (2) help see improvements if Grid code changes cause object sizes to decrease.

If that is correct, my suspicion here is still that adding to the pytest test/ suite might be a better fit. For (1), pytest tests would likely be more reliable in preventing regression, since they are always run, instead of only run when you suspect they might be relevant. It would also be simple and easy to check object size in the pytest test suite, unlike runtime and peakmem which both depend on machine/setup and can produce noisy results. For (2), I suspect any improvements to Grid size would also be seen clearly (and may be even be more visible?) if the static size was in the pytest test suite, because then a line of code would need to get "permanently" changed in response to the improvement.

Assuming I understand correctly, those are my last reservations about it. If the overall preference here is still to include these as benchmarks instead of putting them in the test suite, I will get on board with that decision and review with that in mind!

@cmdupuis3

Copy link
Copy Markdown
Collaborator Author

@Sevans711 I think I'm seeing what you're getting at. These benchmarks don't really track how a chunked peakmem metric would actually behave, they basically just measure the total memory consumption. For now though, I think it's probably okay, because we're kind of accepting that the whole grid is going to be materialized elsewhere anyway, and until we have a strong incentive to get an end-to-end chunked grid implementation, the stakes here are low.

That said, I think we might be able to get something like a true peakmem metric with something like this:

import tracemalloc
__all__ = ["peak_allocated"]


def peak_allocated(build):

    tracemalloc.start()
    try:
        tracemalloc.reset_peak()
        build()
        _, peak = tracemalloc.get_traced_memory()
    finally:
        tracemalloc.stop()
    return peak
    
...

class FaceAreas(GridBenchmark):
    def track_peakmem_face_areas(self, resolution):
        return peak_allocated(lambda: self.uxgrid.face_areas)

@erogluorhan

Copy link
Copy Markdown
Member

I believe I understand what you mean. Is this rephrasing correct?:

  • Benchmarking static memory size of objects is now desired, and the main benefits would be:

Let @cmdupuis3 answer all your questions about if static memory measurements needed as part of this PR's work or whether now desired in benchmarking, or if they should into pytest, etc. but I'll just share my thoughts on a few things below:

  • (1) help avoid regression where Grid code changes cause object sizes to increase.
  • (2) help see improvements if Grid code changes cause object sizes to decrease.

If that is correct, my suspicion here is still that adding to the pytest test/ suite might be a better fit. For (1), pytest tests would likely be more reliable in preventing regression, since they are always run, instead of only run when you suspect they might be relevant. It would also be simple and easy to check object size in the pytest test suite, unlike runtime and peakmem which both depend on machine/setup and can produce noisy results.
For (2), I suspect any improvements to Grid size would also be seen clearly (and may be even be more visible?) if the static size was in the pytest test suite, because then a line of code would need to get "permanently" changed in response to the improvement.

Yeah, pytest could be used for that, too, but we picked ASV to handle benchmarks for a number of reasons. A few to exemplify: When unit tests fail, it means "the code is wrong and needs fixed."; it doesn't/shouldn't apply to performance regression, since there is more than just boolean logic. Performance vs memory tradeoffs are part of benchmarking efforts a lot of times, and looking into them does not necessarily call for a deterministic and binary outcome always, though pytest works that way and could create false negatives (e.g. if Grid is bigger than this, fail etc). Also, ASV can give us history of benchmarks, i.e. the trend. Benchmarking is, by nature, slower than running unit tests, and we wouldn't want to have it as part of the pytest cycles.

Also, we had ASV always run with PRs until some time when we decided not every PR needed it (and also the above speed cost IIRC).

For the time being, drawing a line between performance and unit testing, and leaving the former to ASV and mostly @cmdupuis3 's efforts while leaving the latter to pytest and everyone's care still makes more sense to me.

We can always decide to get ASV to always run again if that's needed and if the speed cost is worth it.

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