Skip to content

Make longitude range lazy for lazy Grid constructors - #1791

Open
cmdupuis3 wants to merge 9 commits into
UXARRAY:mainfrom
cmdupuis3:cmd/lazy-lon-range
Open

cmdupuis3 wants to merge 9 commits into
UXARRAY:mainfrom
cmdupuis3:cmd/lazy-lon-range

Conversation

@cmdupuis3

@cmdupuis3 cmdupuis3 commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #1792

Overview

_set_desired_longitude_range does if da.max() > 180 on a lazy reduction, three times (node_lon, edge_lon, face_lon). Re-fires on every Grid construction, so every isel and every copy() pays it again.

The basic idea is to use a memoized xr.where so that we don't need to materialize the whole dimension to view the values that are available per chunk.

PR Checklist

General

  • An issue is created and linked
  • Added appropriate labels (if your uxarray repo permissions allow it)
  • Filled out Overview and Expected Usage (if applicable) sections

Testing & Benchmarking

  • There is adequate test coverage of changes from this PR (add new tests if needed)
  • If this PR could affect performance, ran ASV benchmarks and confirmed they show expected behavior (add a new benchmark if necessary)

Documentation and Examples

  • Docstrings updated with any function changes, and included in all new functions
  • User (public) functions added to docs/api.rst; internal (private) function names start with an underscore (_)

AI Disclosure

AI Usage: Claude Opus 5.5

  • I have tested and take responsibility for all AI-generated content in my PR.

cmdupuis3 and others added 3 commits September 25, 2026 18:46
_set_desired_longitude_range decided whether to wrap by asking
lon.max() > 180. On a dask-backed coordinate that reduction is a compute,
and Grid.__init__ calls it -- so opening a chunked grid read and reduced
every longitude array before the caller had asked for anything, and did it
again on every isel and every copy(), each of which builds a new Grid.

The wrap is now xr.where((lon > 180) | (lon < -180), (lon + 180) % 360 - 180,
lon): elementwise, lazy, chunk-parallel, no reduction.

Measured on a synthetic 4M-node UGRID file, chunked at 500k nodes, best of 5:

                       dask computes    wall      tracemalloc peak
    open_grid (before)             3    23.3ms          12.2 MB
    open_grid (after)              2    16.2ms           1.0 MB
    isel      (before)             2
    isel      (after)              1

The computes that remain are a separate site on connectivity rather than
coordinates: _standardize_connectivity's conn.isnull().any() in io/_ugrid.py,
reached twice on the read path, and _slice_face_indices in grid/slice.py
materializing the connectivity it slices by. Neither is touched here.

Three behavioural differences, all from doing this per element rather than
per array.

  * In-range longitudes are now left exactly alone. (lon + 180) - 180 does
    not round-trip, so wrapping the whole array perturbed values that were
    already in range by up to 3e-14 degrees -- in outCSne30, 2.1182935e-14
    became 2.8421709e-14. On the elements that do need wrapping the two forms
    are bit-identical.

  * Longitudes below -180 are normalized. The old test was on the maximum
    alone, so it reached the negative tail only when the same array also held
    a value above 180.

  * Both endpoints are kept, so the interval is the closed [-180, 180].
    Folding 180.0 to -180.0 would match _xyz_to_lonlat_deg, which wraps
    unconditionally into the half-open interval, but it breaks
    antimeridian_face_indices: that reads a face as crossing from the span of
    its longitudes, and a face with one vertex at 180 and the rest near -170
    goes from a span of 350 to a span of 10 and disappears. Caught by
    test_antimeridian_point_on and
    test_to_geodataframe_preserves_antimeridian_faces.

Each variable is wrapped at most once, keyed on the xr.Variable object.
Without that, edge_lat -- which calls this on every property access,
outside its populate guard -- would stack a where layer onto the graph per
access. Keying on the Variable makes the memo
self-invalidating: assigning into _ds replaces that object, so a repopulated
or user-assigned coordinate is wrapped again.

The two Exodus round-trip tests compared a grid against its own
lon -> xyz -> lon reload with assert_allclose(rtol=1e-8), and passed only
because the old whole-array wrap applied to the original the identical
perturbation the reload applies. With the original left alone, the reload's
own error is exposed, and rtol is the wrong instrument for it twice over: a
longitude near zero has no magnitude for a relative tolerance to measure
against (outCSne30 nodes 4372, 4749, 7e-15 degrees apart), and longitude is
periodic, so a node on or one ulp short of the antimeridian reads 180.0 on
the original and -180.0 on the reload -- the same meridian, scored as a
360-degree error (179 nodes of outRLL1deg; outCSne30 nodes 3966, 5155).
Those two assertions now compare the difference modulo 360 to an absolute
tolerance, still ERROR_TOLERANCE. The helper still catches a 1e-7 shift and
still rejects an antipode.

Tier 0.2 of the chunked refactor plan.

Test suite: 962 passed, 1 skipped. test_plot_with_features fails identically
before and after (matplotlib figure size, unrelated).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The elementwise wrap is the right shape for a dask-backed coordinate and the
wrong one for an array already in memory. There was never a compute to defer
on that path, only a scan, and elementwise costs a pass plus ~18 bytes per
node of temporaries -- two bool masks, the arithmetic, the result -- where a
reduction costs a pass and allocates nothing.

That matters because every open_grid/open_dataset call in the benchmark suite
is eager; none pass chunks=. So the previous commit, measured there, was a
regression and nothing else.

Measured on a synthetic 4M-node UGRID file, best of 5. The wrap in isolation:

                              in range         0..360
    old reduction               4.6ms  0MB    36.8ms  64MB
    elementwise only           20.9ms 72MB    38.1ms  72MB
    elementwise + guard         9.3ms  0MB    42.7ms  72MB

and through eager open_grid, where the file read dominates and the peak does
not move at all (180.0 MB in every arm):

                              in range         0..360
    base (cmd/nogil)           145.5ms         181.9ms
    elementwise only           164.4ms         186.1ms
    elementwise + guard        147.9ms         185.9ms

_lon_within_range is a guard, not a decision: when it is true the wrap is the
identity on every element, so skipping it cannot change a value.
test_eager_fast_path_agrees_with_the_where_element_for_element asserts that
directly against the unguarded expression rather than assuming it, over five
inputs including both endpoints, the negative tail and NaN.

The reductions are the thing that made the old code compute, so they are
allowed only where there is nothing to defer -- da.chunks is None. A
dask-backed array skips the guard entirely, which
test_guard_is_skipped_for_dask_backed_arrays pins by asserting the guard does
compute when handed one. The chunked numbers are unchanged: 2 graph
executions, 16.0ms, 1.0 MB peak.

`and` short-circuits, so an array that does need wrapping usually pays a
single max -- the same reduction the old code paid -- before falling through.
The 0..360 column above is that extra max: ~4.6ms on a 190ms open_grid.

Test suite: 973 passed, 1 skipped. test_plot_with_features fails identically
before and after (matplotlib figure size, unrelated).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every other open_grid in the suite is eager, so none of it can see what
Grid construction does to a dask-backed grid -- which is the path the lazy
longitude wrap changed, and the path the rest of the chunked refactor will
keep changing. OpenGridChunked adds time_open_grid and
track_peakmem_open_grid, parametrized over the oQU 480km and 120km meshes
already registered in helpers/_fixtures.py.

Chunks are held at N_CHUNKS=4 per grid dimension, so the graph is the same
shape at both resolutions and only the data under it grows. The file is read
directly rather than through CachedFixtures, because reading it is the
subject.

On MPAS this is where the lazy wrap matters most. node_lon, edge_lon and
face_lon all exist at construction, so the old max() > 180 check ran three
computes per open; the branch runs none. Measured with this benchmark's own
setup, best of 15, against HEAD's tree with coordinates.py taken from
cmd/nogil:

                      time               tracemalloc peak
                  base    branch        base    branch
    480km       60.5ms    56.5ms      3.27MB    3.27MB
    120km       42.3ms    38.0ms      3.53MB    4.02MB

The 120km peak reads higher on the branch, and it is not data. With gc
disabled, building the where graph allocates ~1.9 MB of transient objects,
nearly all in inspect.signature via dask/xarray op dispatch -- the same at
both resolutions and with a single chunk, so it does not scale with the
grid. At 480km it sits under the HDF5 read's own high-water mark; at 120km
the netCDF3 read is cheap enough that it becomes the peak. After a
gc.collect() the branch retains ~30 kB more than base, which is the extra
graph layers. Worth knowing for later steps: this benchmark sees
graph-construction overhead, not only bytes read.

Three things the numbers above depend on:

  * Compare each resolution to its own history, not to the other. The two
    files are different formats -- oQU480.grid.nc is netCDF4/HDF5,
    oQU120.grid.nc is netCDF3 -- and the HDF5 open costs more, so 480km reads
    slower than 120km despite a sixteenth of the data.

  * Each open gets a fresh copy of the chunks dict. match_chunks_to_ugrid
    (core/utils.py) writes the source-format dimension names into the
    dict it is handed, so a reused one gives every sample after the first a
    different argument.

  * The warning "The specified chunks separate the stored chunks" is
    filtered. oQU480 stores layerThickness, ssh and zMid as one chunk of all
    1791 cells, so any n_face chunking splits them and xarray warns once per
    open -- about data variables the grid reader drops.

Checked by calling the class the way asv does (setup(param), then the
time_/track_ methods) under -W error::UserWarning. Not run through asv
itself: its discovery subprocess cannot import uxarray from the uxarray
conda env, where the package is not installed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cmdupuis3 cmdupuis3 self-assigned this Sep 25, 2026
@cmdupuis3 cmdupuis3 added scalability Related to scalability & performance efforts run-benchmark Run ASV benchmark workflow labels Sep 25, 2026
@github-actions

github-actions Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

ASV Benchmarking

Benchmark Comparison Results

Benchmarks that have improved:

Change Before [0287eb0] After [083f528] Ratio Benchmark (Parameter)
- 346M 275M 0.79 import.Imports.track_peakmem_import_uxarray
- 2 1 0.5 lazy_grid_construction.LazyGridConstruction.track_computes_isel
- 3 2 0.67 lazy_grid_construction.LazyGridConstruction.track_computes_open_grid_chunked
- 108±2ms 95.7±0.8ms 0.89 lazy_grid_construction.OpenGridChunked.time_open_grid('120km')
- 2.82M 2.13M 0.75 lazy_grid_construction.OpenGridChunked.track_peakmem_open_grid('120km')
- 346M 312M 0.9 mpas_ocean.GradientColdStartRss.track_peakmem_gradient('480km')

Benchmarks that have stayed the same:

Change Before [0287eb0] After [083f528] Ratio Benchmark (Parameter)
1.00±0.03ms 994±20μs 0.99 connectivity.Connectivity.time_edge_face('120km')
474±10μs 482±20μs 1.02 connectivity.Connectivity.time_edge_face('480km')
4.00±0.04ms 3.92±0.03ms 0.98 connectivity.Connectivity.time_edge_node('120km')
1.29±0.02ms 1.29±0.02ms 1.00 connectivity.Connectivity.time_edge_node('480km')
74.6±8μs 70.2±9μs 0.94 connectivity.Connectivity.time_face_edge('120km')
71.1±5μs 68.8±1μs 0.97 connectivity.Connectivity.time_face_edge('480km')
990±20μs 1.01±0.03ms 1.02 connectivity.Connectivity.time_face_face('120km')
423±20μs 412±10μs 0.97 connectivity.Connectivity.time_face_face('480km')
64.2±2μs 63.5±3μs 0.99 connectivity.Connectivity.time_face_node('120km')
65.1±2μs 62.9±8μs 0.97 connectivity.Connectivity.time_face_node('480km')
475±7μs 475±10μs 1.00 connectivity.Connectivity.time_n_nodes_per_face('120km')
418±20μs 418±30μs 1.00 connectivity.Connectivity.time_n_nodes_per_face('480km')
1.38±0.03ms 1.36±0.05ms 0.99 connectivity.Connectivity.time_node_edge('120km')
488±10μs 491±8μs 1.01 connectivity.Connectivity.time_node_edge('480km')
80.4±2ms 79.4±2ms 0.99 connectivity.Connectivity.time_node_face('120km')
5.29±0.2ms 5.13±0.03ms 0.97 connectivity.Connectivity.time_node_face('480km')
1.42M 1.42M 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_edge_face('120km')
106k 106k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_edge_face('480km')
6.48M 6.48M 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_edge_node('120km')
420k 420k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_edge_node('480km')
2.42k 2.42k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_face_edge('120km')
2.42k 2.42k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_face_edge('480km')
1.6M 1.6M 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_face_face('120km')
101k 101k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_face_face('480km')
2.54k 2.54k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_face_node('120km')
2.54k 2.54k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_face_node('480km')
240k 240k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_n_nodes_per_face('120km')
26k 26k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_n_nodes_per_face('480km')
1.9M 1.9M 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_node_edge('120km')
127k 127k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_node_edge('480km')
11.9M 11.9M 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_node_face('120km')
747k 747k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_node_face('480km')
8.46±0.08ms 8.68±0.4ms 1.03 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
2.74±0.05ms 2.72±0.02ms 0.99 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
6.84±7s 10.2±9ms ~0.00 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
1.52±0.05ms 1.58±0.03ms 1.04 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'))
1.85M 1.85M 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
1.85M 1.84M 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
2.01M 2.01M 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
35.6k 35.6k 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
346M 317M 0.92 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
346M 317M 0.92 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
346M 318M 0.92 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
346M 318M 0.92 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
1.15±0.01μs 1.17±0.04μs 1.02 geometry_kernels.AccucrossKernels.time_accucross
2.60±0.02μs 2.57±0.02μs 0.99 geometry_kernels.AccucrossKernels.time_accucross_pair
456±30ns 431±20ns 0.95 geometry_kernels.EFTPrimitives.time_acc_sqrt_re
440±5ns 405±6ns 0.92 geometry_kernels.EFTPrimitives.time_diff_of_products
416±30ns 371±10ns ~0.89 geometry_kernels.EFTPrimitives.time_two_prod
406±40ns 380±20ns 0.94 geometry_kernels.EFTPrimitives.time_two_sum
691±20ns 676±10ns 0.98 geometry_kernels.GCAConstLatIntersection.time_accux_constlat_kernel
741±10ns 732±20ns 0.99 geometry_kernels.GCAConstLatIntersection.time_gca_const_lat_intersection
862±30ns 776±20ns ~0.90 geometry_kernels.GCAConstLatIntersection.time_try_gca_const_lat_intersection
812±20ns 806±50ns 0.99 geometry_kernels.GCAGCAIntersection.time_accux_gca_kernel
902±10ns 902±30ns 1.00 geometry_kernels.GCAGCAIntersection.time_gca_gca_intersection
1.07±0.03μs 1.06±0.04μs 0.99 geometry_kernels.GCAGCAIntersection.time_try_gca_gca_intersection
51.3±0.4μs 51.1±0.8μs 1.00 geometry_kernels.OrientPredicates.time_on_minor_arc
50.9±0.3μs 49.5±0.2μs 0.97 geometry_kernels.OrientPredicates.time_orient3d_on_sphere
3.06±0ms 3.06±0ms 1.00 geometry_samebody.SameBodyConstLat.time_accux_dispatch
1.16±0ms 1.16±0ms 1.00 geometry_samebody.SameBodyConstLat.time_accux_kernel
2.28±0ms 2.29±0.01ms 1.01 geometry_samebody.SameBodyConstLat.time_fp64_dispatch
148±2μs 146±0.4μs 0.99 geometry_samebody.SameBodyConstLat.time_fp64_kernel
29.6±0.02ms 29.6±0.05ms 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_dispatch
6.29±0.01ms 6.30±0.02ms 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_kernel
23.6±0.03ms 23.8±0.1ms 1.01 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_dispatch
859±2μs 860±1μs 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_kernel
906±5ms 912±8ms 1.01 import.Imports.timeraw_import_uxarray
140±2ms 132±0.9ms 0.94 lazy_grid_construction.OpenGridChunked.time_open_grid('480km')
2.06M 2.19M 1.06 lazy_grid_construction.OpenGridChunked.track_peakmem_open_grid('480km')
2.48±0.03ms 2.59±0.06ms 1.04 mpas_ocean.CheckNorm.time_check_norm('120km')
2.01±0.01ms 2.02±0.03ms 1.00 mpas_ocean.CheckNorm.time_check_norm('480km')
1.16±0.01ms 1.15±0.01ms 0.99 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('120km')
554±3μs 558±7μs 1.01 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('480km')
654±10μs 658±10μs 1.01 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('120km')
600±10μs 596±10μs 0.99 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('480km')
5.40±0.02ms 5.91±0.02ms 1.10 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('120km')
3.86±0.04ms 4.24±0.03ms 1.10 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('480km')
97.6±0.4ms 96.9±0.2ms 0.99 mpas_ocean.ConstructFaceLatLon.time_welzl('120km')
10.1±0.1ms 9.75±0.3ms 0.97 mpas_ocean.ConstructFaceLatLon.time_welzl('480km')
18.6±0.02ms 18.8±0.1ms 1.01 mpas_ocean.ConstructTreeStructures.time_ball_tree('120km')
958±20μs 990±30μs 1.03 mpas_ocean.ConstructTreeStructures.time_ball_tree('480km')
9.95±0.03ms 9.96±0.05ms 1.00 mpas_ocean.ConstructTreeStructures.time_kd_tree('120km')
624±10μs 621±20μs 1.00 mpas_ocean.ConstructTreeStructures.time_kd_tree('480km')
590±7ms 640±5ms 1.09 mpas_ocean.CrossSections.time_const_lat('120km', 1)
301±9ms 321±5ms 1.07 mpas_ocean.CrossSections.time_const_lat('120km', 2)
153±1ms 168±3ms 1.10 mpas_ocean.CrossSections.time_const_lat('120km', 4)
542±8ms 584±3ms 1.08 mpas_ocean.CrossSections.time_const_lat('480km', 1)
273±7ms 299±6ms 1.10 mpas_ocean.CrossSections.time_const_lat('480km', 2)
138±0.8ms 153±2ms ~1.11 mpas_ocean.CrossSections.time_const_lat('480km', 4)
346M 336M 0.97 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 1)
346M 336M 0.97 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 2)
346M 337M 0.97 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 4)
346M 320M 0.92 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 1)
346M 320M 0.92 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 2)
346M 320M 0.92 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 4)
26.0±0.04ms 26.0±0.1ms 1.00 mpas_ocean.DualMesh.time_dual_mesh_construction('120km')
2.97±0.06ms 3.11±0.04ms 1.05 mpas_ocean.DualMesh.time_dual_mesh_construction('480km')
14.1±0.5ms 14.4±0.6ms 1.03 mpas_ocean.FaceAreas.time_face_areas('120km')
4.37±0.3ms 4.31±0.3ms 0.99 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')
2.12M 2.12M 1.00 mpas_ocean.FaceAreas.track_peakmem_face_areas('120km')
718k 718k 1.00 mpas_ocean.FaceAreas.track_peakmem_face_areas('480km')
907±10ms 921±9ms 1.02 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', False)
54.3±2ms 52.5±1ms 0.97 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', True)
80.6±0.5ms 82.2±1ms 1.02 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', False)
5.02±0.3ms 5.29±0.2ms 1.05 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', True)
13.2±0.03ms 13.1±0.07ms 0.99 mpas_ocean.Gradient.time_gradient('120km')
1.83±0.01ms 1.81±0.02ms 0.99 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')
3.2M 3.2M 1.00 mpas_ocean.Gradient.track_peakmem_gradient('120km')
204k 204k 1.00 mpas_ocean.Gradient.track_peakmem_gradient('480km')
346M 333M 0.96 mpas_ocean.GradientColdStartRss.track_peakmem_gradient('120km')
273±4μs 301±10μs ~1.10 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('120km')
151±4μs 152±10μs 1.00 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('480km')
204±1μs 201±1μs 0.99 mpas_ocean.Integrate.time_integrate('120km')
188±10μs 188±8μs 1.00 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')
188±1ms 188±4ms 1.00 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'exclude')
188±2ms 185±3ms 0.98 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'include')
186±0.8ms 184±2ms 0.99 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'split')
13.2±0.08ms 12.9±0.1ms 0.98 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'exclude')
13.0±0.3ms 12.7±0.06ms 0.98 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'include')
13.0±0.1ms 12.9±0.2ms 0.99 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'split')
246±0.4ms 246±0.2ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('120km', 1.0)
1.31±0s 1.32±0s 1.00 mpas_ocean.NeighborhoodBuild.time_build('120km', 15.0)
510±2ms 507±2ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('120km', 5.0)
13.3±0.01ms 13.5±0.08ms 1.01 mpas_ocean.NeighborhoodBuild.time_build('480km', 1.0)
25.6±0.03ms 25.4±0.1ms 0.99 mpas_ocean.NeighborhoodBuild.time_build('480km', 15.0)
16.5±0.06ms 16.5±0.04ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('480km', 5.0)
241±0.8ms 241±2ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 1.0)
1.28±0s 1.28±0s 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 15.0)
500±2ms 504±1ms 1.01 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 5.0)
12.9±0.02ms 13.0±0.1ms 1.01 mpas_ocean.NeighborhoodBuild.time_query_radius('480km', 1.0)
24.9±0.2ms 24.9±0.1ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('480km', 15.0)
16.0±0.03ms 16.2±0.07ms 1.01 mpas_ocean.NeighborhoodBuild.time_query_radius('480km', 5.0)
1.19 1.19 1.00 mpas_ocean.NeighborhoodBuild.track_mean_neighbors('120km', 1.0)
612.76 612.76 1.00 mpas_ocean.NeighborhoodBuild.track_mean_neighbors('120km', 15.0)
74.17 74.17 1.00 mpas_ocean.NeighborhoodBuild.track_mean_neighbors('120km', 5.0)
1.0 1.0 1.00 mpas_ocean.NeighborhoodBuild.track_mean_neighbors('480km', 1.0)
37.29 37.29 1.00 mpas_ocean.NeighborhoodBuild.track_mean_neighbors('480km', 15.0)
6.57 6.57 1.00 mpas_ocean.NeighborhoodBuild.track_mean_neighbors('480km', 5.0)
728k 728k 1.00 mpas_ocean.NeighborhoodBuild.track_nbytes_neighbors('120km', 1.0)
141M 141M 1.00 mpas_ocean.NeighborhoodBuild.track_nbytes_neighbors('120km', 15.0)
17.4M 17.4M 1.00 mpas_ocean.NeighborhoodBuild.track_nbytes_neighbors('120km', 5.0)
43k 43k 1.00 mpas_ocean.NeighborhoodBuild.track_nbytes_neighbors('480km', 1.0)
563k 563k 1.00 mpas_ocean.NeighborhoodBuild.track_nbytes_neighbors('480km', 15.0)
123k 123k 1.00 mpas_ocean.NeighborhoodBuild.track_nbytes_neighbors('480km', 5.0)
5.72M 5.72M 1.00 mpas_ocean.NeighborhoodBuild.track_peakmem_build('120km', 1.0)
145M 145M 1.00 mpas_ocean.NeighborhoodBuild.track_peakmem_build('120km', 15.0)
21.5M 21.5M 1.00 mpas_ocean.NeighborhoodBuild.track_peakmem_build('120km', 5.0)
362k 362k 1.00 mpas_ocean.NeighborhoodBuild.track_peakmem_build('480km', 1.0)
825k 825k 1.00 mpas_ocean.NeighborhoodBuild.track_peakmem_build('480km', 15.0)
384k 384k 1.00 mpas_ocean.NeighborhoodBuild.track_peakmem_build('480km', 5.0)
39.8±0.8ms 40.3±0.7ms 1.01 mpas_ocean.NeighborhoodDask.time_mean('120km', 'grid_chunks')
18.2±0.2ms 18.3±0.06ms 1.01 mpas_ocean.NeighborhoodDask.time_mean('120km', 'numpy')
35.2±0.6ms 35.9±0.3ms 1.02 mpas_ocean.NeighborhoodDask.time_mean('120km', 'time_chunks')
10.9±0.05ms 10.7±0.1ms 0.98 mpas_ocean.NeighborhoodDask.time_mean('480km', 'grid_chunks')
775±10μs 761±3μs 0.98 mpas_ocean.NeighborhoodDask.time_mean('480km', 'numpy')
7.34±0.1ms 7.49±0.1ms 1.02 mpas_ocean.NeighborhoodDask.time_mean('480km', 'time_chunks')
5.83M 5.76M 0.99 mpas_ocean.NeighborhoodDask.track_peakmem_mean('120km', 'grid_chunks')
2.75M 2.75M 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('120km', 'numpy')
5.68M 5.68M 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('120km', 'time_chunks')
177k 177k 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'numpy')
546k 539k 0.99 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'time_chunks')
12.4±0.02s 12.5±0.01s 1.01 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('120km', 'mean')
13.2±0s 13.2±0.01s 1.00 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('120km', 'median')
227±0.9ms 226±3ms 1.00 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('480km', 'mean')
231±2ms 231±2ms 1.00 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('480km', 'median')
1.34±0s 1.34±0s 1.00 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('120km', 'mean')
1.54±0s 1.54±0.01s 1.00 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('120km', 'median')
26.0±0.3ms 26.1±0.2ms 1.00 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('480km', 'mean')
27.0±0.08ms 27.3±0.1ms 1.01 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('480km', 'median')
31.0±0.1ms 31.1±0.2ms 1.00 mpas_ocean.NeighborhoodReduce.time_reduce('120km', 'mean')
228±0.4ms 229±1ms 1.01 mpas_ocean.NeighborhoodReduce.time_reduce('120km', 'median')
446±20μs 464±30μs 1.04 mpas_ocean.NeighborhoodReduce.time_reduce('480km', 'mean')
1.71±0.03ms 1.73±0.05ms 1.01 mpas_ocean.NeighborhoodReduce.time_reduce('480km', 'median')
239k 239k 1.00 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('120km', 'mean')
245k 245k 1.00 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('120km', 'median')
19.5k 19.5k 1.00 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('480km', 'mean')
19.9k 19.9k 1.00 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('480km', 'median')
337±20μs 387±20μs ~1.15 mpas_ocean.PointInPolygon.time_face_search_lonlat('120km')
328±40μs 376±30μs ~1.15 mpas_ocean.PointInPolygon.time_face_search_lonlat('480km')
369±20μs 373±10μs 1.01 mpas_ocean.PointInPolygon.time_face_search_xyz('120km')
331±20μs 327±30μs 0.99 mpas_ocean.PointInPolygon.time_face_search_xyz('480km')
109±0.6ms 109±1ms 1.00 mpas_ocean.RemapDownsample.time_bilinear_remapping
17.0±0.06ms 17.2±0.3ms 1.01 mpas_ocean.RemapDownsample.time_inverse_distance_weighted_remapping
15.5±0.07ms 15.5±0.3ms 1.00 mpas_ocean.RemapDownsample.time_nearest_neighbor_remapping
1.09±0.02s 1.08±0.01s 0.98 mpas_ocean.RemapUpsample.time_bilinear_remapping
26.0±0.2ms 26.1±0.07ms 1.00 mpas_ocean.RemapUpsample.time_inverse_distance_weighted_remapping
11.5±0.09ms 11.9±0.1ms 1.03 mpas_ocean.RemapUpsample.time_nearest_neighbor_remapping
8.45±0.2ms 8.25±0.09ms 0.98 mpas_ocean.ZonalAverage.time_zonal_average('120km')
5.20±0.06ms 5.06±0.05ms 0.97 mpas_ocean.ZonalAverage.time_zonal_average('480km')
346M 338M 0.98 mpas_ocean.ZonalAveragePeakMem.track_peakmem_zonal_average('120km')
346M 321M 0.93 mpas_ocean.ZonalAveragePeakMem.track_peakmem_zonal_average('480km')
1.029593037723677 1.0322925241357217 1.00 nogil_scaling.GILScaling.track_gil_scaling
7.27±0.03ms 7.81±0.2ms 1.07 quad_hexagon.QuadHexagon.time_open_dataset
6.22±0.05ms 6.48±0.04ms 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
72.9k 72.4k 0.99 quad_hexagon.QuadHexagon.track_peakmem_open_dataset
71.5k 71.9k 1.01 quad_hexagon.QuadHexagon.track_peakmem_open_grid
1.12±0.01s 1.10±0s 0.99 to_raster.ToRaster.time_to_raster((10000.0, 10.0))
2.69±0.02s 2.69±0.04s 1.00 to_raster.ToRaster.time_to_raster((10000.0, 100.0))
3.09±0.01s 3.07±0.02s 0.99 to_raster.ToRaster.time_to_raster((100000.0, 100.0))
271M 271M 1.00 to_raster.ToRaster.track_peakmem((10000.0, 10.0))
684M 684M 1.00 to_raster.ToRaster.track_peakmem((10000.0, 100.0))
768M 768M 1.00 to_raster.ToRaster.track_peakmem((100000.0, 100.0))

Benchmarks that have got worse:

Change Before [0287eb0] After [083f528] Ratio Benchmark (Parameter)
+ 677k 757k 1.12 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'grid_chunks')

@cmdupuis3
cmdupuis3 marked this pull request as ready for review September 28, 2026 21:02
@dylannelson

Copy link
Copy Markdown
Member

While hunting for where it may perform differently from main other than performance I think I found an example and a slowdown

  1. Subsetting seems like it can potentially result in lost faces (or at least the value stored in n_faces?)
  2. edge_lat may be slower now when ran many times, but if this isn't commonly looped the slowdown may be negligible (up to you)

Notebooks side by side (left is this branch, right is main):
image

Code to try if you want to see how it works on your end/env/os:

grid = ux.open_grid(MESH + r"\exodus\outCSne8\outCSne8.g")

section = grid.subset.constant_longitude(90.0)
box = grid.subset.bounding_box(lon_bounds=(-50, 50), lat_bounds=(-85, 85))

print("faces on the section at lon = 90:", section.n_face)
print("faces in the box lon -50..50:    ", box.n_face)

and

grid = ux.open_grid(MESH + r"\mpas\QU\oQU480.231010.nc", chunks={"n_node": 1000})

for step in range(100):
    grid.edge_lat

start = time.perf_counter()
grid.node_lon.values
elapsed = time.perf_counter() - start

print("dask graph layers behind node_lon:", len(grid.node_lon.data.dask.layers))
print(f"time to load node_lon: {1000 * elapsed:.0f} ms")

@Sevans711 Sevans711 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The benchmarks seem reasonable and show improvements which is a good sign that this is working as intended. (I see that the lazy_grid_construction.OpenGridChunked.time_open_grid('120km') benchmark shows ratio of 0.91, even though it wasn't listed directly in the "improved" section.)

Looks reasonable overall, just left some minor comments in-line!

Comment on lines +144 to +151
if normalize:
x, y, z = _normalize_xyz(x, y, z)

lon = (lon + 180) % 360 - 180
return lon, lat
_, lat_rad = _xyz_to_lonlat_rad(x, y, z, normalize=False)
# arctan2 is already in [-180, 180]; shifting through [0, 360) loses precision
lon = np.rad2deg(np.arctan2(y, x))
lon = np.where(np.abs(z) > 1.0 - ERROR_TOLERANCE, 0.0, lon)
return lon, np.rad2deg(lat_rad)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

If touching these lines is needed for this PR:

I would suggest to either write a new helper like _xyz_to_lonlat_rad_minus_pi_to_pi or repeat the other relevant internal logic from _xyz_to_lonlat_rad here. The current implementation computes relevant longitude pieces, like arctan2(y, x), multiple times (once here and once in _xyz_to_lonlat_rad).

Either way, please add to the docstring of both _xyz_to_lonlat_rad and _xyz_to_lonlat_deg a clarification about the range of outputs. It took me a while of looking at these to understand that rad and deg methods are not simply np.rad2deg apart, but rather the former returns in [0, 2*pi) while the latter returns in [-180, 180)!

Comment on lines +761 to +764
memo = getattr(uxgrid, "_wrapped_lon_vars", None)
if memo is None:
memo = uxgrid._wrapped_lon_vars = {}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Minor style preference: either assume this exists (just write memo = uxgrid._wrapped_lon_vars) or don't include it inside Grid.__init__ at all.

The former matches existing style (e.g. Grid.__init__ defines self._kdtrees = {} then later there is grid_obj._kdtrees without checking whether "_kdtrees" attribute exists), while the latter keeps this implementation detail fully self-contained within this piece of code so it should be easier to maintain long-term.

Comment on lines +738 to +739
Wraps elementwise with ``xr.where``, so dask-backed coordinates stay lazy and
in-range values pass through bit-for-bit.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Is xr.where actually the thing that is making dask-backed coordinates stay lazy? I would think that they would still stay lazy even if, instead of using xr.where below, you just used (da + 180) % 360 - 180.

I actually would guess that using xr.where like this is simply less efficient, because you eventually (whenever the dask graph resolves) compute the entire (da + 180) % 360 - 180 as part of it (the whole array gets computed, then passed into xr.where), while also needing to allocate an extra boolean array (out_of_range), producing a result which should be equivalent to (da + 180) % 360 - 180 anyway.

However, the point about in-range values passing through bit-by-bit is true, and may be worth the extra cost here. I think my suggestion here is just to update the docstring to clarify, the reason dask-backed coordinates stay lazy is just that .max() is being avoided in the dask case; there's nothing special about xr.where() in terms of keeping dask-backed data lazy.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-benchmark Run ASV benchmark workflow scalability Related to scalability & performance efforts

Projects

None yet

Development

Successfully merging this pull request may close these issues.

_set_desired_longitude_range forces materialization in open_grid

3 participants