Skip to content

Dask-native SCRIP corner dedup when opened with chunks= - #1775

Merged
cmdupuis3 merged 12 commits into
mainfrom
rajeeja/scrip-dask-lazy-dedup-2
Sep 25, 2026
Merged

cmdupuis3 merged 12 commits into
mainfrom
rajeeja/scrip-dask-lazy-dedup-2

Conversation

@rajeeja

@rajeeja rajeeja commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1774

Overview

A SCRIP file stores one (lon, lat) pair per face-corner, so the corner table is n_face × n_corners rows and shared vertices are restated once per touching face. _to_ugrid deduplicated that with Polars, which needs the whole table resident — 53.6 GiB for a 300M-face np4 grid — and never dispatched on chunks=, so large files could not be opened at all.

Each path now gets the dedup that suits it:

path trigger dedup why
eager no chunks= np.lexsort table fits; bounded footprint beats better asymptotics
dask any chunks= shuffle table does not fit; never holds it whole

Measured

polars this PR
eager, 4M faces / 48M corners 13.9 GiB, 5.9 s 3.6 GiB, 12.1 s
dask, 300M faces / 3.6B corners worker killed 119 GiB, 306 s

The 63 GB file now opens: 299,999,162 faces, 500,001,289 unique nodes.

Sorting was also tried on the dask path and is worse there — 191 GiB against the shuffle's 118, because lexsort must materialize what the shuffle streams. The two paths want opposite algorithms for the same reason.

Benchmarks

No ASV regression. The only SCRIP grid in ASV is outCSne8 at 384 faces, where the sort is faster (4.2 ms vs 6.6 ms) because Polars' fixed setup dominates at that size, and open_grid runs in untimed setup(). Crossover is near 10k faces; above it the trade is roughly 1:1, time for memory.

Correctness

Node numbering differs — sorted rather than first-appearance — so tests compare the unique node set and each face's corners in winding order, since winding determines area sign. Identical on every SCRIP mesh in the suite, and chunk-size independent.

Two findings from measurement: the dask shuffle defaults to an in-memory method without a distributed client (pinning it to disk saved 32%), and Polars does not guarantee join order, so maintain_order="left" is requested rather than assumed.

A SCRIP file stores one (lon, lat) pair per face-corner, so the corner
table is n_face * n_corners rows even though adjacent faces share almost
every vertex. The reader deduped that table with Polars, which needs the
whole thing resident: 53.6 GiB of float64 for a 300M-element np4 grid,
before the join copy. That is why large np4 SCRIP grids fail to open.

When the grid is opened with chunks=, dedup with a dask shuffle instead,
which spills partition-by-partition, and build the inverse index lazily
with map_blocks against the (small) unique-node table. The eager path is
unchanged.
Without a distributed client dask picks an in-memory shuffle. On the
226M-face ne2048np4 grid that peaked at 17.9 GiB against 12.1 GiB for the
disk shuffle, for the same answer. Pin it, but only when the caller has
not chosen a method and no client is running, since p2p is the better
choice on a real cluster.

Also drop the intermediate DataFrame once its two columns are out. The
unique-node table is 3.4 GiB at this size, so holding it past its last
use is not free. Together these take the full open from ~31.0 to
~29.3 GiB peak (3 runs each).
The block lookup relied on a sort-after-join to keep each block aligned
with its inputs. Polars' own docs say not to rely on an observed join
order without requesting one, so request it with maintain_order="left"
and check the row count survived. Misalignment here would not raise --
it would build every face from the wrong corners.

Lift the closure to module level so that contract is testable, and widen
the tests: parametrize over chunks= spellings including "auto" (what
callers actually pass), compare corners in winding order rather than
sorted, cover the radians path, and assert the connectivity is still
lazy after open so a future .compute() in the reader fails here rather
than only on a multi-GB file.
The eager reader deduplicated corners with a Polars hash-join. Hashing is
the faster algorithm and that is why it was written that way; what it costs
is space, because the join holds the corner table, a hash table keyed on
every row of it, and the joined result at the same time.

np.lexsort trades the better asymptotic time for a bounded footprint -- an
argsort and two gathers over index arrays. On a 4M-face, 48M-corner SCRIP
grid the whole open goes from 13.9 GiB to 3.6 GiB at roughly twice the wall
time. That is the right trade for a format whose corner table is the largest
thing in the file and the reason large meshes fail to open at all.

The dask path keeps its hash-based shuffle. Measured on a 3.6-billion-row
corner table, sorting there peaked at 191 GiB against the shuffle's 118 --
lexsort must materialize what the shuffle deliberately streams, so the two
paths want opposite algorithms for the same reason.

No benchmark regression: the only SCRIP grid in ASV is outCSne8 at 384
faces, where the sort is faster than the join (4.2 ms against 6.6 ms)
because Polars' fixed setup dominates at that size, and open_grid happens in
setup() which ASV does not time. The crossover is near 10k faces.

Geometry is unchanged. Node numbering differs -- sorted rather than
first-appearance -- so the tests compare the unique node set and each face's
corners in winding order, both identical on every SCRIP mesh in the suite.
@Sevans711 Sevans711 added run-benchmark Run ASV benchmark workflow scalability Related to scalability & performance efforts labels Sep 23, 2026
@Sevans711

Copy link
Copy Markdown
Collaborator

A few quick questions, before I take a closer look:

  1. You reported roughly 2x slowdown in the eager case (4M faces). Of course, the memory usage is almost 4x smaller, which is great. Is this an acceptable tradeoff? Also, can you comment on why these changes improve memory but not speed (is there some fundamental reason why it should be slower, in order to use less memory)?
    • Actually, I saw that this is clarified via comments in your changes to _dedup_scrip_nodes_eager. No need to clarify further here. Keeping my original thoughts here though because it wasn't clear to me just from reading the initial PR message.
  2. Node numbering on main currently differs every time the grid is loaded, even if it is the same exact grid file (see Scrip edge and node order changes each time the grid is loaded #1738). Does this PR happen to fix that issue as well? (This also means there is no need to check during review whether node ordering here matches any "canonical" node ordering, because main does not guarantee any particular order.)
  3. It feels a bit strange to me to have node order depend on the chunks parameter. I generally would expect chunks=True to affect efficiency, but to still produce the same exact answer if I call compute() at the end (assuming it can fit into memory). There's currently no guaranteed order at all so this could be fine, but what are your thoughts on this?

@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

ASV Benchmarking

Benchmark Comparison Results

Benchmarks that have improved:

Change Before [e01d71e] After [ac7a665] Ratio Benchmark (Parameter)
- 350M 317M 0.9 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
- 359M 318M 0.89 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
- 350M 318M 0.91 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
- 350M 275M 0.78 import.Imports.track_peakmem_import_uxarray
- 350M 312M 0.89 mpas_ocean.GradientColdStartRss.track_peakmem_gradient('480km')

Benchmarks that have stayed the same:

Change Before [e01d71e] After [ac7a665] Ratio Benchmark (Parameter)
5.24±0.06ms 5.21±0.03ms 0.99 bench_connectivity.Connectivity.time_edge_face('120km')
1.98±0.01ms 1.98±0.02ms 1.00 bench_connectivity.Connectivity.time_edge_face('480km')
4.33±0.08ms 4.36±0.04ms 1.01 bench_connectivity.Connectivity.time_edge_node('120km')
1.56±0.01ms 1.57±0.02ms 1.01 bench_connectivity.Connectivity.time_edge_node('480km')
4.32±0.02ms 4.34±0.04ms 1.01 bench_connectivity.Connectivity.time_face_edge('120km')
1.57±0.01ms 1.56±0.02ms 0.99 bench_connectivity.Connectivity.time_face_edge('480km')
6.13±0.02ms 6.21±0.03ms 1.01 bench_connectivity.Connectivity.time_face_face('120km')
2.35±0.02ms 2.33±0.01ms 0.99 bench_connectivity.Connectivity.time_face_face('480km')
54.0±1μs 52.7±0.4μs 0.97 bench_connectivity.Connectivity.time_face_node('120km')
51.9±0.9μs 52.2±0.6μs 1.01 bench_connectivity.Connectivity.time_face_node('480km')
423±10μs 422±10μs 1.00 bench_connectivity.Connectivity.time_n_nodes_per_face('120km')
360±9μs 366±10μs 1.02 bench_connectivity.Connectivity.time_n_nodes_per_face('480km')
5.70±0.03ms 5.67±0.04ms 0.99 bench_connectivity.Connectivity.time_node_edge('120km')
2.01±0.02ms 2.02±0.02ms 1.00 bench_connectivity.Connectivity.time_node_edge('480km')
74.9±0.2ms 74.2±0.3ms 0.99 bench_connectivity.Connectivity.time_node_face('120km')
5.11±0.01ms 5.06±0.05ms 0.99 bench_connectivity.Connectivity.time_node_face('480km')
8.37±0.1ms 8.60±0.07ms 1.03 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
2.71±0.04ms 2.73±0.05ms 1.01 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
6.94±7s 10.1±10ms ~0.00 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
1.53±0.02ms 1.55±0.04ms 1.02 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.98M 1.98M 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
1.97M 1.97M 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
2.13M 2.13M 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
35.5k 35.5k 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
350M 319M 0.91 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
1.17±0.02μs 1.14±0.01μs 0.97 geometry_kernels.AccucrossKernels.time_accucross
2.64±0.03μs 2.62±0.02μs 0.99 geometry_kernels.AccucrossKernels.time_accucross_pair
445±9ns 456±20ns 1.02 geometry_kernels.EFTPrimitives.time_acc_sqrt_re
411±5ns 416±10ns 1.01 geometry_kernels.EFTPrimitives.time_diff_of_products
376±20ns 401±30ns 1.07 geometry_kernels.EFTPrimitives.time_two_prod
391±20ns 371±10ns 0.95 geometry_kernels.EFTPrimitives.time_two_sum
656±20ns 691±9ns 1.05 geometry_kernels.GCAConstLatIntersection.time_accux_constlat_kernel
721±10ns 727±20ns 1.01 geometry_kernels.GCAConstLatIntersection.time_gca_const_lat_intersection
816±20ns 787±20ns 0.96 geometry_kernels.GCAConstLatIntersection.time_try_gca_const_lat_intersection
822±9ns 812±20ns 0.99 geometry_kernels.GCAGCAIntersection.time_accux_gca_kernel
886±20ns 922±30ns 1.04 geometry_kernels.GCAGCAIntersection.time_gca_gca_intersection
1.05±0.01μs 1.02±0.04μs 0.97 geometry_kernels.GCAGCAIntersection.time_try_gca_gca_intersection
52.0±0.6μs 49.3±0.6μs 0.95 geometry_kernels.OrientPredicates.time_on_minor_arc
51.3±0.3μs 50.2±0.4μs 0.98 geometry_kernels.OrientPredicates.time_orient3d_on_sphere
3.06±0ms 3.07±0.03ms 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±5μs 148±0.5μs 1.00 geometry_samebody.SameBodyConstLat.time_fp64_kernel
29.6±0.09ms 29.5±0.04ms 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_dispatch
6.29±0.03ms 6.28±0.01ms 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_kernel
23.6±0.06ms 23.6±0.09ms 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_dispatch
860±10μs 870±10μs 1.01 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_kernel
892±4ms 894±6ms 1.00 import.Imports.timeraw_import_uxarray
2.43±0.01ms 2.45±0.01ms 1.01 mpas_ocean.CheckNorm.time_check_norm('120km')
2.00±0.02ms 2.01±0.01ms 1.00 mpas_ocean.CheckNorm.time_check_norm('480km')
1.17±0.01ms 1.15±0.02ms 0.99 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('120km')
558±10μs 565±10μs 1.01 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('480km')
655±20μs 650±8μs 0.99 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('120km')
596±10μs 595±7μs 1.00 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('480km')
5.28±0.03ms 5.24±0.01ms 0.99 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('120km')
3.84±0.01ms 3.82±0.01ms 1.00 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('480km')
96.4±0.06ms 96.3±0.4ms 1.00 mpas_ocean.ConstructFaceLatLon.time_welzl('120km')
10.2±0.2ms 9.65±0.4ms 0.95 mpas_ocean.ConstructFaceLatLon.time_welzl('480km')
18.8±0.09ms 19.0±0.2ms 1.01 mpas_ocean.ConstructTreeStructures.time_ball_tree('120km')
968±10μs 971±10μs 1.00 mpas_ocean.ConstructTreeStructures.time_ball_tree('480km')
9.93±0.03ms 10.00±0.06ms 1.01 mpas_ocean.ConstructTreeStructures.time_kd_tree('120km')
632±20μs 598±20μs 0.95 mpas_ocean.ConstructTreeStructures.time_kd_tree('480km')
591±3ms 577±2ms 0.98 mpas_ocean.CrossSections.time_const_lat('120km', 1)
300±5ms 297±6ms 0.99 mpas_ocean.CrossSections.time_const_lat('120km', 2)
152±2ms 149±0.9ms 0.98 mpas_ocean.CrossSections.time_const_lat('120km', 4)
541±5ms 540±8ms 1.00 mpas_ocean.CrossSections.time_const_lat('480km', 1)
275±5ms 275±5ms 1.00 mpas_ocean.CrossSections.time_const_lat('480km', 2)
138±0.2ms 139±1ms 1.01 mpas_ocean.CrossSections.time_const_lat('480km', 4)
350M 337M 0.96 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 1)
350M 337M 0.96 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 2)
350M 337M 0.96 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 4)
350M 320M 0.91 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 1)
350M 321M 0.91 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 2)
350M 320M 0.91 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 4)
25.8±0.1ms 26.0±0.1ms 1.01 mpas_ocean.DualMesh.time_dual_mesh_construction('120km')
2.99±0.2ms 2.92±0.02ms 0.98 mpas_ocean.DualMesh.time_dual_mesh_construction('480km')
14.3±0.5ms 14.4±0.4ms 1.01 mpas_ocean.FaceAreas.time_face_areas('120km')
4.50±0.3ms 4.22±0.2ms 0.94 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')
714k 715k 1.00 mpas_ocean.FaceAreas.track_peakmem_face_areas('480km')
906±3ms 911±10ms 1.01 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', False)
49.1±0.3ms 49.3±1ms 1.00 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', True)
81.6±2ms 81.1±0.4ms 0.99 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', False)
4.97±0.3ms 4.88±0.09ms 0.98 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', True)
13.2±0.07ms 13.0±0.04ms 0.99 mpas_ocean.Gradient.time_gradient('120km')
1.80±0.01ms 1.80±0.01ms 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')
3.2M 3.2M 1.00 mpas_ocean.Gradient.track_peakmem_gradient('120km')
204k 204k 1.00 mpas_ocean.Gradient.track_peakmem_gradient('480km')
350M 332M 0.95 mpas_ocean.GradientColdStartRss.track_peakmem_gradient('120km')
272±8μs 277±7μs 1.02 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('120km')
155±10μs 149±9μs 0.96 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('480km')
202±2μs 199±3μs 0.99 mpas_ocean.Integrate.time_integrate('120km')
185±3μs 192±20μs 1.04 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')
182±0.9ms 178±1ms 0.98 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'exclude')
180±2ms 178±1ms 0.99 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'include')
184±0.7ms 179±2ms 0.97 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'split')
13.1±0.1ms 12.9±0.1ms 0.99 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'exclude')
13.1±0.04ms 12.8±0.09ms 0.98 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'include')
13.0±0.1ms 12.8±0.09ms 0.98 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'split')
244±0.4ms 245±0.4ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('120km', 1.0)
1.31±0s 1.30±0s 1.00 mpas_ocean.NeighborhoodBuild.time_build('120km', 15.0)
503±0.8ms 507±3ms 1.01 mpas_ocean.NeighborhoodBuild.time_build('120km', 5.0)
13.2±0.02ms 13.4±0.1ms 1.01 mpas_ocean.NeighborhoodBuild.time_build('480km', 1.0)
25.2±0.2ms 25.0±0.06ms 0.99 mpas_ocean.NeighborhoodBuild.time_build('480km', 15.0)
16.3±0.02ms 16.5±0.1ms 1.01 mpas_ocean.NeighborhoodBuild.time_build('480km', 5.0)
240±2ms 242±2ms 1.01 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)
499±1ms 499±2ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 5.0)
12.9±0.01ms 12.9±0.02ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('480km', 1.0)
24.8±0.1ms 25.0±0.2ms 1.01 mpas_ocean.NeighborhoodBuild.time_query_radius('480km', 15.0)
16.1±0.2ms 16.2±0.04ms 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)
42.9±0.3ms 43.0±0.4ms 1.00 mpas_ocean.NeighborhoodDask.time_mean('120km', 'grid_chunks')
22.5±0.03ms 22.5±0.06ms 1.00 mpas_ocean.NeighborhoodDask.time_mean('120km', 'numpy')
39.5±0.3ms 39.9±0.7ms 1.01 mpas_ocean.NeighborhoodDask.time_mean('120km', 'time_chunks')
11.2±0.1ms 11.4±0.2ms 1.02 mpas_ocean.NeighborhoodDask.time_mean('480km', 'grid_chunks')
680±10μs 663±9μs 0.98 mpas_ocean.NeighborhoodDask.time_mean('480km', 'numpy')
8.34±0.2ms 8.21±0.08ms 0.98 mpas_ocean.NeighborhoodDask.time_mean('480km', 'time_chunks')
5.84M 5.84M 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('120km', 'grid_chunks')
2.75M 2.75M 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('120km', 'numpy')
5.69M 5.68M 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('120km', 'time_chunks')
685k 676k 0.99 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'grid_chunks')
177k 177k 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'numpy')
546k 542k 0.99 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'time_chunks')
12.6±0.01s 12.6±0.01s 1.00 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('120km', 'mean')
13.2±0.01s 13.3±0s 1.00 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('120km', 'median')
226±2ms 224±1ms 0.99 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('480km', 'mean')
230±2ms 229±0.6ms 1.00 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('480km', 'median')
1.34±0s 1.35±0.01s 1.01 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('120km', 'mean')
1.54±0.01s 1.54±0s 1.00 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('120km', 'median')
25.7±0.2ms 25.6±0.2ms 0.99 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('480km', 'mean')
26.8±0.04ms 26.8±0.06ms 1.00 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('480km', 'median')
38.0±0.09ms 37.9±0.05ms 1.00 mpas_ocean.NeighborhoodReduce.time_reduce('120km', 'mean')
232±0.8ms 232±0.4ms 1.00 mpas_ocean.NeighborhoodReduce.time_reduce('120km', 'median')
427±8μs 431±8μs 1.01 mpas_ocean.NeighborhoodReduce.time_reduce('480km', 'mean')
1.63±0ms 1.63±0.01ms 1.00 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.4k 19.4k 1.00 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('480km', 'mean')
19.9k 19.9k 1.00 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('480km', 'median')
400±8μs 417±10μs 1.04 mpas_ocean.PointInPolygon.time_face_search_lonlat('120km')
397±10μs 412±10μs 1.04 mpas_ocean.PointInPolygon.time_face_search_lonlat('480km')
373±10μs 382±10μs 1.02 mpas_ocean.PointInPolygon.time_face_search_xyz('120km')
373±10μs 394±9μs 1.06 mpas_ocean.PointInPolygon.time_face_search_xyz('480km')
134±0.5ms 129±0.4ms 0.96 mpas_ocean.RemapDownsample.time_bilinear_remapping
16.9±0.09ms 16.9±0.09ms 1.00 mpas_ocean.RemapDownsample.time_inverse_distance_weighted_remapping
15.5±0.6ms 15.3±0.05ms 0.99 mpas_ocean.RemapDownsample.time_nearest_neighbor_remapping
1.42±0.01s 1.42±0s 1.00 mpas_ocean.RemapUpsample.time_bilinear_remapping
26.6±0.5ms 25.8±0.7ms 0.97 mpas_ocean.RemapUpsample.time_inverse_distance_weighted_remapping
11.5±0.05ms 11.9±0.2ms 1.03 mpas_ocean.RemapUpsample.time_nearest_neighbor_remapping
8.07±0.1ms 7.91±0.2ms 0.98 mpas_ocean.ZonalAverage.time_zonal_average('120km')
5.10±0.09ms 5.13±0.09ms 1.00 mpas_ocean.ZonalAverage.time_zonal_average('480km')
350M 339M 0.97 mpas_ocean.ZonalAveragePeakMem.track_peakmem_zonal_average('120km')
350M 322M 0.92 mpas_ocean.ZonalAveragePeakMem.track_peakmem_zonal_average('480km')
1.0301074284266338 1.0236438603366842 0.99 nogil_scaling.GILScaling.track_gil_scaling
7.26±0.1ms 7.06±0.03ms 0.97 quad_hexagon.QuadHexagon.time_open_dataset
6.03±0.05ms 6.09±0.05ms 1.01 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
73.2k 73.6k 1.01 quad_hexagon.QuadHexagon.track_peakmem_open_dataset
72.4k 72.7k 1.01 quad_hexagon.QuadHexagon.track_peakmem_open_grid

@rajeeja

rajeeja commented Sep 23, 2026

Copy link
Copy Markdown
Contributor Author

Fair point—I thought about this while working on the dedup paths. The eager path should now be deterministic because it uses a lexicographic sort, so it likely fixes #1738 for non-chunked opens. The Dask path may produce a different node order because of the shuffle.

I don’t think eager and chunked loading necessarily need identical node numbering as long as both produce the same mesh with correctly remapped connectivity. That said, consistent ordering would be nice. I’ll measure the memory and runtime overhead of sorting the Dask-generated unique-node table; if it’s reasonably small, I can make both paths produce the same ordering here. Otherwise, I’d prefer to keep this PR focused on bounded-memory loading and handle canonical ordering separately in #1738.

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

Mostly looks good, just some small inline comments!

The inconsistent ordering of nodes/edges between the chunks=... and non-dask and small/large numpy cases doesn't need to block this PR, but can be reassessed/addressed while looking into #1738.

Comment thread uxarray/io/_scrip.py Outdated
Comment thread test/io/test_scrip.py Outdated
Comment thread test/io/test_scrip.py Outdated
Comment thread test/io/test_scrip.py Outdated
Comment thread test/io/test_scrip.py Outdated
@rajeeja

rajeeja commented Sep 23, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, fixed those in 085b255. Let's let ASV finish.

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

Previous comments have been resolved, and there is no longer any performance degradation in the ASV benchmarks suite. Approving!

rajeeja and others added 4 commits September 24, 2026 17:52
- Replace the per-block polars join with a numba binary search over the
  unique table, sorted once; the join rehashed all unique nodes per block.
- Pass shuffle_method to drop_duplicates; dask swapped a config "disk"
  default for "tasks", so the pin never applied.
- Free the sorted copies before building ids on the eager path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NaN != NaN, so the lexsort dedup made every NaN corner (a decoded
_FillValue) its own node, while the dask path merged them. Compare NaN as
equal, only when a NaN is present, so both paths build the same mesh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Give an in-memory corner array the other's chunks before the dask
  dedup; map_blocks passed it whole to every block, pairing corners with
  the wrong latitudes.
- Return empty arrays from the eager dedup when there are no corners
  instead of indexing a zero-length array.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cmdupuis3

cmdupuis3 commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

@rajeeja I added two crash fixes, a faster lookup algorithm, and better NaN handling. I'd be curious to see what the new performance numbers are on the big meshes.

Also, the binary search algorithm included here could potentially be optimized with a galloping search. I was seeing ~1.7x over the existing lexsort/binary search on oQU120, but it depends heavily on how relatively-sorted the grid is. On a huge grid, it's probably a better option. I was thinking that could be a further PR if we wanted to try it. The sorting/searching may not take long enough to bother though.

The disk pin was justified by a 17.9 vs 12.1 GiB measurement that compared
dask's default against an explicit method, not disk against tasks: the pin
was set through dask.config, which drop_duplicates overrides, so its "disk"
arm never ran a disk shuffle. Passing the method as an argument made the
pin effective and so made it measurable, and disk then loses on both axes --
315.5 s/86.8 GiB against tasks' 249.1 s/85.4 GiB on the 300M-face CONUS RRM
grid for byte-identical connectivity, and 2.71 s/7.01 GiB against
2.25 s/6.54 GiB at 12M faces. Dask's own default matches tasks.

Drop the pin and forward only a method the caller configured. This retires
_distributed_client_active, which existed to decide whether to pin, and
corrects the test that asserted the pinned default.

Also record the locality the binary-search lookup depends on, measured as
the normalized mean jump between consecutive lookups (1/3 being random):
0.0001 on the CONUS RRM grid, 0.0003 on a 74M-corner ESMF mesh, 0.0007-0.0028
on regular lat-lon, 0.02-0.08 on MPAS SFC and FESOM.
@rajeeja

rajeeja commented Sep 25, 2026

Copy link
Copy Markdown
Contributor Author

@cmdupuis3 This is great work, thank you. The lookup change is what makes this PR actually work at the scale it was written for, and both crash fixes are real bugs. I ran all three commits against the CONUS RRM grid on Chrysalis plus a locality survey, and your work turned up something that led me to change one of my own decisions.

I reproduced each defect against its own parent before reviewing the fix, and all three hold up:

  • The NaN merge (8158d421) is easy to see: six corners containing two NaN pairs give 5 unique nodes on the parent and 4 with your fix. The eager and dask paths were genuinely building different meshes, and you caught it.
  • The mixed lazy/eager case (eb5ff00a) is a nice find because it hides on small files. I couldn't trigger it through open_grid on our test meshes since a single chunk masks it, but calling _dedup_scrip_nodes_dask directly with a multi-chunk lon and an in-memory lat raises GridInvalidError on the parent.
  • That one fails loudly rather than silently producing a wrong mesh, which is the _lookup_node_ids guard doing its job, but it's still a crash on a perfectly legitimate input.

Full-scale numbers on the 63 GB CONUS RRM grid — 299,999,162 faces, 3,599,989,944 corners, 500,001,289 unique nodes, on 253 GB nodes:

version shuffle open_grid full connectivity peak RSS outcome
3e5f4b20 (before your commits) tasks 125.6 s — 123.8 GiB killed
eb5ff00a disk 207.1 s 315.5 s 86.8 GiB passed
eb5ff00a tasks 141.2 s 249.1 s 85.4 GiB passed
  • Both passing runs gave byte-identical connectivity: shape (299999162, 12), sum 900012117135581213, SHA-256 6a5142cd9bcdbcb09a1694d6ac8aa252b9337aab06d5fcee65af8cc2cdd0cf4b.
  • The mesh went from "cannot be computed at all" to "computes in about four minutes." That's your commit.
  • At 4M production faces it's the same story smaller: 1.78 s / 2.17 GiB on your head against 3.57 s / 11.28 GiB before.
  • Your binary search is about 1.6x slower than the hash join in isolation, which is what you'd expect, but the join rebuilds a hash table over every unique node for each block, so the complete open comes out 2x faster and 5.2x smaller with the search.
  • I looked at whether a size threshold was worth adding and it isn't, since the small-mesh case is neutral.

Your commit message caught a real problem:

  • You noted the config pin never applied, and you were right. Checking the graph layers, the config-only pin emits ['group','split','taskshuffle'] — identical to explicit tasks, different from explicit disk (['diskshuffle','shuffle']).
  • So the 17.9 vs 12.1 GiB figure behind pinning disk had been comparing dask's default against an explicit method rather than the two methods against each other.
  • Your fix is what made it measurable, and with the method actually taking effect, disk loses on both axes: 315.5 s / 86.8 GiB against 249.1 s / 85.4 GiB at full scale, and 2.71 s / 7.01 GiB against 2.25 s / 6.54 GiB at 12M faces locally, with dask's own default at 2.29 s / 6.62 GiB.
  • I've dropped the pin and left the choice to dask, keeping pass-through when a caller configures one. That also retires _distributed_client_active, which only existed to decide whether to pin, and updates the test that asserted requested == ["disk", "tasks"].

On your locality question — whether most grids store faces and nodes close together or whether some end up basically random. Good question to ask, since the binary search depends on the answer, so I measured it. Mean jump between consecutive lookups over the sorted unique-node table, normalized by table length, where 1/3 is the random baseline:

mesh corners native random
CONUS RRM (5 offsets, 63 GB file) 12M each 0.0000–0.0001 0.3333
ESMF esmf_mesh 74.6M 0.0003 0.3322
lat-lon 0.25° row-major 4.1M 0.0007 0.3333
lat-lon 1° row/col-major 259k 0.0014–0.0028 0.332
SCRIP ne30pg2 86.4k 0.0059 0.3312
ESMF ne30pg3 194k 0.0068 0.3340
MPAS QU480 / oQU480 (SFC) 10.7k 0.0226 0.3315
FESOM mesh.diag 17.5k 0.0746 0.3347
  • Nothing came within two orders of magnitude of random. Your instinct was right.
  • It looks structural rather than lucky: a SCRIP corner table is written in face order, and mesh generators number faces by locality — row-major for lat-lon, panel order for cubed-sphere, space-filling curves for MPAS — so consecutive faces share corners by construction.
  • I tried to break it with a row-major lat-lon grid against a (lon,lat)-sorted table, thinking the ordering mismatch would hurt, and it came out at 0.0007.
  • It isn't uniform across families though: MPAS SFC is roughly 100x less local than the RRM grid, still nowhere near random but clearly the least favorable case, which happens to be where your oQU120 numbers came from.
  • Randomizing the corners costs the binary search 3.0x versus 2.1x for the hash join, so even in the worst case the join is only about 2.1x ahead per lookup, which the whole-open numbers swamp anyway.

On galloping:

  • Agreed it's a separate PR. Your 1.7x was measured on the least-local grid we have, so I'd expect a thinner margin on RRM-shaped meshes.
  • At full scale it isn't obvious the lookup is the bottleneck, so worth profiling first to see whether it's worth the complexity.

Where this leaves us:

  • I think this is close to mergeable. Sam approved on 2026-09-23 and his inline comments are addressed; the shuffle change is the only new code since.
  • The one thing still open is his third point about node ordering depending on chunks=. The eager path is deterministic now via the lexsort, but the dask path orders by the shuffle.
  • I'd rather handle canonical ordering in Scrip edge and node order changes each time the grid is loaded #1738 than widen this PR, given nothing on main guarantees an order today.

@cmdupuis3
cmdupuis3 merged commit 379c895 into main Sep 25, 2026
17 checks passed
@Sevans711

Copy link
Copy Markdown
Collaborator

@rajeeja Can you briefly summarize your understanding of what was changed after I approved? I'm trying to understand your message but there are a lot of bullet points and it is difficult for me to summarize and/or determine what is most important.

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.

open_grid on large SCRIP grids OOMs: corner dedup ignores chunks=

3 participants