Skip to content

Merge coincident nodes when constructing any Grid - #1692

Open
rajeeja wants to merge 38 commits into
mainfrom
rajeeja/coincident-nodes
Open

rajeeja wants to merge 38 commits into
mainfrom
rajeeja/coincident-nodes

Conversation

@rajeeja

@rajeeja rajeeja commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Closes #865

  • Grids with duplicate node indices were rejected outright; the dual is now built after canonicalizing those indices in the face-node connectivity.
  • geoflow-small previously could not produce a dual at all and now yields 3803 faces; the test asserts this and fails on main.
  • _find_duplicate_nodes is vectorized with np.unique instead of a per-node dict.
  • Grid.get_dual(check_duplicate_nodes=...) is now ignored and deprecated rather than removed, so existing callers keep working.
  • UxDataArray.get_dual and UxDataset.get_dual still raise GridInvalidError, since node-centered data cannot be remapped onto a merged node set.
  • Does not cover nodes that are coincident on the sphere but differ in (lon, lat), such as poles and the antimeridian; that is Grid.from_structured does not merge coincident pole and antimeridian nodes #1689 / Merge coincident pole and antimeridian nodes in structured grids #1690.

Match nodes in Cartesian space rather than the lon/lat plane so pole and
antimeridian nodes are recognized as the same point, and store the resulting
polar faces as triangles instead of quads with a repeated corner.
Canonicalize duplicate node indices in the face-node connectivity before
building the dual, so grids with repeated nodes produce a correct dual instead
of being rejected. Also vectorize the duplicate lookup and deprecate the now
redundant check_duplicate_nodes argument.
@rajeeja rajeeja self-assigned this Aug 19, 2026
@rajeeja
rajeeja requested a review from Sevans711 August 19, 2026 22:06

@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 like a good fix! I think it still needs a little bit of extra work, and I left some inline comments accordingly.

Comment thread uxarray/grid/validation.py Outdated
"""Map duplicate node indices to the first index with the same coordinates."""
node_coordinates = np.column_stack((grid.node_lon.values, grid.node_lat.values))
_, first_indices, inverse_indices = np.unique(
node_coordinates, axis=0, return_index=True, return_inverse=True

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 "exact equality" the correct way to go here? My intuition originally was that there should probably be some sort of tolerance here, e.g. if values agree to within 1e-12, they are probably the same node, right?

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.

In fact, looking back at the original issue thread, it looks like you were the person who originally suggested there be a tolerance in the first place! So, I would actually now assert that exact inequality is not the desired implementation, there should be a tolerance, as clarified in thread of #865.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed — matching goes through _coincident_node_canonical_indices, which is a KDTree query within ERROR_TOLERANCE on the sphere, not exact equality.

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.

This fix looks reasonable. One more nitpick here though: your comment on the original thread #865 (comment) suggested you wanted the user to be able to specify the tolerance.

Right now, there is no way to specify the "tolerance for merging duplicate nodes" from any public-facing functions. To specify it as a larger value (e.g. 1e-4) a user would need to call _merge_coincident_grid_ds_nodes directly on grid_ds before passing to Grid.__init__, while there is presumably no way to properly specify it as a smaller value (e.g. 1e-12) because the _merge_coincident_grid_ds_nodes call inside Grid.__init__ would always rerun the process with a tolerance of ERROR_TOLERANCE=1e-8.

Should this option be added somewhere (e.g. something like a duplicates_tolerance parameter in Grid.__init__) or is "allow the user to specify the tolerance" no longer desirable?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Can be a good edition, but not in this PR. I'll open a separate issue for a user-facing tolerance.
Adding this means threading it through open_grid and the from_* constructors..

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.

(Keeping this unresolved as a reminder to open that issue. Feel free to mark as resolved as soon as you opened that issue!)

Comment thread uxarray/grid/validation.py Outdated
np.arange(grid.n_node, dtype=INT_DTYPE) != first_indices[inverse_indices]
)
return {
INT_DTYPE(index): INT_DTYPE(first_indices[inverse_indices[index]])

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.

I would guess that creating a dict here is extremely inefficient… maybe that doesn't matter if there are only ever a tiny number of duplicate nodes. Not necessarily blocking, but have you looked into how long this takes to run for any larger grids containing duplicate nodes? (Or, do you expect a very limited number of duplicate nodes in most cases? I would guess it is probably not worth worrying about if there are less than ~1000 duplicates or so.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The dict only holds duplicates, not every node, so it stays small. The real cost was _check_duplicate_nodes_indices looping over every face in Python; that is now one np.isin over the connectivity array.

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.

I think that makes sense. Added the run-benchmarks label because this PR adds a call to _merge_coincident_grid_ds_nodes into Grid.__init__ so it might affect uxarray performance. I believe my original comment here is resolved, but I am keeping this thread open for now as a reminder to myself to consider the benchmarking results before approving this PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The cache wasn't the whole story - dual mesh is still ~1.6x slower because get_dual builds a new Grid, which re-runs the coincident merge on the dual. Fixing that next, so don't go by the current benchmark numbers.

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.

That fix definitely helped, but it still looks like >40% slowdown in dual_mesh construction. Any insights?

Though, now that the performance improvements are expanding significantly beyond the original lines I flagged here, I think it might make more sense to continue the benchmarking discussion in the main thread below instead. Feel free to mark this as resolved once you read it, I will leave a comment soon during re-review.

Comment thread uxarray/grid/connectivity.py Outdated
Comment thread test/grid/grid/test_core.py Outdated
Comment thread test/grid/grid/test_core.py
@Sevans711

Copy link
Copy Markdown
Collaborator

Actually, apologies for not including this during the original review, comment but one more thought: does this actually fully close the original issue? The issue writeup makes it sound to me like duplicate nodes should be handled immediately upon constructing the grid, not just during one functionality (get_dual). Is there a reason that duplicate nodes should be handled only during get_dual, instead of immediately? (Do all other current/planned functions work properly regardless of whether there are duplicate nodes?)

@Sevans711 Sevans711 added the bug Something isn't working label Aug 21, 2026
rajeeja and others added 11 commits August 21, 2026 11:00
float32 input (e.g. real climate datasets) silently ran the whole
xyz/tolerance pipeline at float32 precision, causing pole/antimeridian
merges to fail or merge only partially.
Match nodes in Cartesian space rather than the lon/lat plane so pole and
antimeridian nodes are recognized as the same point, and store the resulting
polar faces as triangles instead of quads with a repeated corner.
float32 input (e.g. real climate datasets) silently ran the whole
xyz/tolerance pipeline at float32 precision, causing pole/antimeridian
merges to fail or merge only partially.
Extend #865's fix beyond the dual mesh: canonicalize duplicate/coincident
node indices in connectivity for every Grid construction path, not just
construct_dual. Detection is now tolerance-based (unit-sphere chordal
distance) instead of exact lon/lat match, so pole-degenerate duplicates
are also caught. Node coordinate arrays are left untouched by design;
only connectivity is remapped to canonical indices, with any resulting
repeated face corners collapsed.
construct_dual no longer needs its own per-call duplicate detection and
remap, and get_dual() no longer needs to hard-gate on duplicate node
indices, since Grid construction now canonicalizes them structurally
before any of this code runs.
Since duplicate node coordinates are intentionally left unreferenced by
connectivity, a node KDTree/BallTree built over the raw coordinate array
could select an index no face actually points to, silently returning
empty or wrong nearest-neighbor results. Build the "nodes" tree only
over live (referenced) indices and translate query results back to
original index space.
polars' unique() with maintain_order unset does not guarantee row order
across runs, so the node index assigned to a given corner coordinate
could vary between reads of the same file. This is normally harmless,
but it made canonical-node selection for coincident duplicates
(e.g. pole points with differing longitude) flaky from run to run.
test_dual_duplicate: validate() now succeeds since connectivity is fully
canonicalized (duplicate coordinates remain by design, but nothing
references a dead index anymore). test_grid_nn_subset: max valid k for a
node search is now bounded by the live node count, not raw node count.
test_to_geodataframe_preserves_antimeridian_faces: pole-coincident
corners with differing longitude are now also merged, shifting the
antimeridian face count.
@rajeeja
rajeeja requested a review from Sevans711 August 21, 2026 19:58
Merging pole-adjacent duplicate nodes was collapsing each face's own
locally-meaningful longitude at the pole into one arbitrary canonical
value, which corrupted lat/lon bounds and broke zonal weight
computation for cube-sphere grids near the poles.

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

Suggestion: please change the title of this PR to reflect the full scope. Duplicate nodes are now handled directly whenever constructing a Grid, not just when constructing the dual mesh. I was confused about why _check_duplicate_nodes_indices checks were removed despite construct_dual() being unchanged. I think the reason is because all Grid objects are now guaranteed to not have duplicate nodes.

I tried leaving a full review but I kept getting the feeling that something weird was happening, a nagging feeling like "hey I think I've looked at this code before and left an inline comment, why am I reviewing it again?" Then I realized many of the changes here are also in #1690 which hasn't been merged yet. That led to duplicating review work and will probably lead to needing to apply fixes multiple times. I'm not sure the cleanest way forwards at this point, but I might suggest waiting to merge this until after #1690 gets merged. At least, I will want to do another close review of this PR after that PR merges, because I lost track of which things I already looked at closely and which I need to consider again.

Comment thread test/io/test_structured.py Outdated
Comment thread uxarray/grid/validation.py
Comment thread uxarray/io/_structured.py Outdated
…des' into rajeeja/coincident-nodes

# Conflicts:
#	test/io/test_structured.py
#	uxarray/core/dataarray.py
#	uxarray/core/dataset.py
#	uxarray/grid/grid.py
#	uxarray/io/_structured.py
"Coincident nodes" is the domain term used by TempestRemap, MOAB, and our own
validation module; "dedupe" reads like a dataframe operation. Also note in the
docstring that we keep the redundant coordinates and only remap connectivity,
where TempestRemap and MOAB delete and renumber.
_coincident_node_canonical_indices exempts pole nodes from merging so each face
touching a pole keeps its own longitude. The mask used np.isclose(|z|, 1.0,
atol=tolerance), which is wrong twice: tolerance is a chord radius everywhere
else in the function, and np.isclose's default rtol=1e-5 swamped atol entirely.
The carve-out spanned 1 - |z| <= 1.001e-5, a chord of 4.5e-3 or ~28 km on Earth,
so any node within 28 km of a pole was silently exempt from merging.

For a point at colatitude t, 1 - |z| = 1 - cos(t) = chord**2 / 2, so the correct
cap is tolerance**2 / 2 with rtol pinned to zero.
construct_dual reads node_face_connectivity with no duplicate handling, so a
face still referencing a coincident duplicate index yields a degenerate dual
face instead of an error. Merging at construction makes that unreachable today,
so the guard is a no-op safety net; check_duplicate_nodes is now deprecated and
ignored, since the check always runs.
The pole/seam merge in _read_structured_grid changes the ersstv5 grid (16290
nodes to 16200) and turns pole quads into triangles, so the near-pole plots were
stale. Outputs re-executed; the stray OMP stderr line is dropped.
@review-notebook-app

Copy link
Copy Markdown

Check out this pull request on  ReviewNB

See visual diffs & provide feedback on Jupyter Notebooks.


Powered by ReviewNB

The notebook's cell sources are identical to main; only the outputs
differed, and those differences were an xarray coordinate-ordering repr
change and a stray OMP info line. Keeping the re-run added ~1700 lines of
diff with no content change.
Coincident-node merging now runs on every Grid construction, so the cost
is paid by grids that have no duplicates at all. Two points within a
chord of ERROR_TOLERANCE differ by at most that in x, so in x-sorted
order every consecutive gap between them is also within tolerance; a
point whose sorted neighbours are both further away cannot be coincident
with anything. Filtering on that is exact and drops the tree build
entirely for a clean grid: 1.23s -> 0.28s over 2M nodes.
@rajeeja rajeeja changed the title Merge duplicate nodes when constructing the dual mesh Merge coincident nodes when constructing any Grid Sep 8, 2026
@Sevans711 Sevans711 added the run-benchmark Run ASV benchmark workflow label Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

ASV Benchmarking

Benchmark Comparison Results

Benchmarks that have stayed the same:

Change Before [38b33d8] <v2026.09.0> After [6dee854] Ratio Benchmark (Parameter)
5.29±0.08ms 5.39±0.06ms 1.02 bench_connectivity.Connectivity.time_edge_face('120km')
1.72±0.01ms 1.76±0.02ms 1.02 bench_connectivity.Connectivity.time_edge_face('480km')
4.21±0.01ms 4.34±0.2ms 1.03 bench_connectivity.Connectivity.time_edge_node('120km')
1.35±0.01ms 1.40±0.02ms 1.04 bench_connectivity.Connectivity.time_edge_node('480km')
4.16±0.01ms 4.31±0.02ms 1.03 bench_connectivity.Connectivity.time_face_edge('120km')
1.36±0.01ms 1.40±0.02ms 1.03 bench_connectivity.Connectivity.time_face_edge('480km')
6.23±0.04ms 6.31±0.04ms 1.01 bench_connectivity.Connectivity.time_face_face('120km')
2.11±0.02ms 2.15±0.01ms 1.02 bench_connectivity.Connectivity.time_face_face('480km')
57.5±0.4μs 62.2±1μs 1.08 bench_connectivity.Connectivity.time_face_node('480km')
360±8μs 374±7μs 1.04 bench_connectivity.Connectivity.time_n_nodes_per_face('480km')
5.72±0.03ms 5.90±0.1ms 1.03 bench_connectivity.Connectivity.time_node_edge('120km')
1.74±0.01ms 1.79±0.01ms 1.02 bench_connectivity.Connectivity.time_node_edge('480km')
76.1±0.7ms 77.7±2ms 1.02 bench_connectivity.Connectivity.time_node_face('120km')
4.80±0.05ms 4.83±0.04ms 1.01 bench_connectivity.Connectivity.time_node_face('480km')
7.29±0.05ms 7.36±0.06ms 1.01 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
2.52±0.03ms 2.36±0.03ms 0.93 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
10.2±10s 9.97±10ms ~0.00 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
1.93±0.01ms 1.83±0.02ms 0.95 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.99M 2.01M 1.01 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
2M 1.98M 0.99 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
2.16M 2.16M 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
38.3k 37.9k 0.99 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
335M 342M 1.02 face_bounds.FaceBoundsColdStartRss.peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
367M 374M 1.02 face_bounds.FaceBoundsColdStartRss.peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
336M 346M 1.03 face_bounds.FaceBoundsColdStartRss.peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
336M 344M 1.02 face_bounds.FaceBoundsColdStartRss.peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
1.18±0.04μs 1.22±0.02μs 1.04 geometry_kernels.AccucrossKernels.time_accucross
2.53±0.04μs 2.57±0.03μs 1.01 geometry_kernels.AccucrossKernels.time_accucross_pair
401±10ns 386±10ns 0.96 geometry_kernels.EFTPrimitives.time_acc_sqrt_re
425±30ns 405±10ns 0.95 geometry_kernels.EFTPrimitives.time_diff_of_products
340±4ns 350±20ns 1.03 geometry_kernels.EFTPrimitives.time_two_prod
401±40ns 361±9ns ~0.90 geometry_kernels.EFTPrimitives.time_two_sum
631±20ns 646±20ns 1.02 geometry_kernels.GCAConstLatIntersection.time_accux_constlat_kernel
646±10ns 686±20ns 1.06 geometry_kernels.GCAConstLatIntersection.time_gca_const_lat_intersection
736±20ns 727±10ns 0.99 geometry_kernels.GCAConstLatIntersection.time_try_gca_const_lat_intersection
711±10ns 726±20ns 1.02 geometry_kernels.GCAGCAIntersection.time_accux_gca_kernel
791±20ns 836±20ns 1.06 geometry_kernels.GCAGCAIntersection.time_gca_gca_intersection
937±20ns 946±20ns 1.01 geometry_kernels.GCAGCAIntersection.time_try_gca_gca_intersection
37.2±0.3μs 36.6±2μs 0.98 geometry_kernels.OrientPredicates.time_on_minor_arc
656±20ns 661±20ns 1.01 geometry_kernels.OrientPredicates.time_orient3d_on_sphere
3.13±0ms 3.13±0ms 1.00 geometry_samebody.SameBodyConstLat.time_accux_dispatch
1.34±0.01ms 1.33±0.01ms 0.99 geometry_samebody.SameBodyConstLat.time_accux_kernel
2.10±0ms 2.17±0.04ms 1.03 geometry_samebody.SameBodyConstLat.time_fp64_dispatch
155±0.3μs 155±0.7μs 1.00 geometry_samebody.SameBodyConstLat.time_fp64_kernel
27.8±0.02ms 29.3±0.8ms 1.05 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_dispatch
6.81±0.01ms 6.82±0ms 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_kernel
22.2±0.6ms 22.3±0.6ms 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_dispatch
897±3μs 904±0.5μs 1.01 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_kernel
831±3ms 842±7ms 1.01 import.Imports.timeraw_import_uxarray
292M 292M 1.00 import.Imports.track_peakmem_import_uxarray
2.23±0.01ms 2.34±0.02ms 1.05 mpas_ocean.CheckNorm.time_check_norm('120km')
1.84±0.03ms 1.86±0.02ms 1.02 mpas_ocean.CheckNorm.time_check_norm('480km')
1.07±0.01ms 1.10±0.01ms 1.03 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('120km')
540±20μs 535±4μs 0.99 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('480km')
664±10μs 672±10μs 1.01 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('120km')
581±6μs 605±8μs 1.04 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('480km')
5.02±0.03ms 5.07±0.03ms 1.01 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('120km')
3.55±0.07ms 3.56±0.01ms 1.00 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('480km')
101±0.4ms 102±0.7ms 1.01 mpas_ocean.ConstructFaceLatLon.time_welzl('120km')
10.3±0.06ms 10.0±0.3ms 0.98 mpas_ocean.ConstructFaceLatLon.time_welzl('480km')
19.7±0.03ms 19.7±0.02ms 1.00 mpas_ocean.ConstructTreeStructures.time_ball_tree('120km')
1.09±0.02ms 1.12±0.01ms 1.02 mpas_ocean.ConstructTreeStructures.time_ball_tree('480km')
10.6±0.02ms 10.6±0.01ms 1.00 mpas_ocean.ConstructTreeStructures.time_kd_tree('120km')
736±20μs 748±5μs 1.02 mpas_ocean.ConstructTreeStructures.time_kd_tree('480km')
519±6ms 554±2ms 1.07 mpas_ocean.CrossSections.time_const_lat('120km', 1)
261±0.7ms 280±1ms 1.08 mpas_ocean.CrossSections.time_const_lat('120km', 2)
135±0.4ms 146±2ms 1.08 mpas_ocean.CrossSections.time_const_lat('120km', 4)
468±1ms 490±4ms 1.05 mpas_ocean.CrossSections.time_const_lat('480km', 1)
235±0.9ms 243±3ms 1.04 mpas_ocean.CrossSections.time_const_lat('480km', 2)
119±2ms 127±0.7ms 1.07 mpas_ocean.CrossSections.time_const_lat('480km', 4)
355M 363M 1.02 mpas_ocean.CrossSectionsPeakMem.peakmem_const_lat('120km', 1)
355M 364M 1.02 mpas_ocean.CrossSectionsPeakMem.peakmem_const_lat('120km', 2)
355M 363M 1.02 mpas_ocean.CrossSectionsPeakMem.peakmem_const_lat('120km', 4)
338M 346M 1.02 mpas_ocean.CrossSectionsPeakMem.peakmem_const_lat('480km', 1)
338M 346M 1.02 mpas_ocean.CrossSectionsPeakMem.peakmem_const_lat('480km', 2)
338M 346M 1.02 mpas_ocean.CrossSectionsPeakMem.peakmem_const_lat('480km', 4)
13.6±0.09ms 13.7±0.2ms 1.01 mpas_ocean.FaceAreas.time_face_areas('120km')
3.69±0.04ms 3.75±0.05ms 1.02 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')
743k 743k 1.00 mpas_ocean.FaceAreas.track_peakmem_face_areas('480km')
828±3ms 836±2ms 1.01 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', False)
50.5±1ms 50.8±1ms 1.01 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', True)
72.2±0.3ms 72.4±0.5ms 1.00 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', False)
5.55±0.1ms 5.69±0.06ms 1.02 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', True)
14.7±0.2ms 14.6±0.2ms 1.00 mpas_ocean.Gradient.time_gradient('120km')
2.04±0.02ms 2.01±0.02ms 0.98 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.43M 3.43M 1.00 mpas_ocean.Gradient.track_peakmem_gradient('120km')
218k 218k 1.00 mpas_ocean.Gradient.track_peakmem_gradient('480km')
350M 362M 1.03 mpas_ocean.GradientColdStartRss.peakmem_gradient('120km')
330M 337M 1.02 mpas_ocean.GradientColdStartRss.peakmem_gradient('480km')
362±10μs 367±10μs 1.02 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('120km')
190±5μs 209±10μs ~1.10 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('480km')
555±8μs 588±20μs 1.06 mpas_ocean.Integrate.time_integrate('120km')
494±20μs 526±20μs 1.06 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')
186±2ms 186±1ms 1.00 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'exclude')
187±1ms 189±1ms 1.01 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'include')
190±2ms 186±1ms 0.98 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'split')
15.1±1ms 14.2±0.5ms 0.94 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'exclude')
14.0±0.09ms 14.1±0.4ms 1.01 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'include')
13.9±0.2ms 14.3±0.4ms 1.03 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'split')
256±0.2ms 257±0.7ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('120km', 1.0)
1.37±0s 1.37±0s 1.00 mpas_ocean.NeighborhoodBuild.time_build('120km', 15.0)
532±2ms 532±2ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('120km', 5.0)
14.0±0.03ms 14.1±0.05ms 1.01 mpas_ocean.NeighborhoodBuild.time_build('480km', 1.0)
27.1±0.06ms 27.0±0.05ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('480km', 15.0)
17.4±0.03ms 17.5±0.09ms 1.01 mpas_ocean.NeighborhoodBuild.time_build('480km', 5.0)
252±0.2ms 252±0.5ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 1.0)
1.37±0s 1.36±0.01s 0.99 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 15.0)
526±2ms 530±3ms 1.01 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 5.0)
13.7±0.05ms 13.7±0.02ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('480km', 1.0)
26.3±0.08ms 26.2±0.04ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('480km', 15.0)
16.9±0.02ms 17.0±0.03ms 1.00 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)
47.6±0.4ms 47.7±0.6ms 1.00 mpas_ocean.NeighborhoodDask.time_mean('120km', 'grid_chunks')
24.9±0.08ms 25.0±0.02ms 1.00 mpas_ocean.NeighborhoodDask.time_mean('120km', 'numpy')
44.2±2ms 44.7±1ms 1.01 mpas_ocean.NeighborhoodDask.time_mean('120km', 'time_chunks')
10.8±0.1ms 11.0±0.1ms 1.02 mpas_ocean.NeighborhoodDask.time_mean('480km', 'grid_chunks')
550±3μs 547±10μs 1.00 mpas_ocean.NeighborhoodDask.time_mean('480km', 'numpy')
7.91±0.2ms 7.94±0.1ms 1.00 mpas_ocean.NeighborhoodDask.time_mean('480km', 'time_chunks')
5.84M 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')
545k 529k 0.97 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'time_chunks')
13.3±0.02s 13.4±0.03s 1.01 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('120km', 'mean')
14.1±0.01s 14.0±0.01s 0.99 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('120km', 'median')
243±0.6ms 244±0.7ms 1.00 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('480km', 'mean')
251±0.1ms 252±1ms 1.01 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('480km', 'median')
1.41±0s 1.41±0s 1.00 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('120km', 'mean')
1.62±0s 1.62±0s 1.00 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('120km', 'median')
27.4±0.2ms 27.5±0.06ms 1.00 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('480km', 'mean')
28.8±0.09ms 28.8±0.07ms 1.00 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('480km', 'median')
44.8±0.04ms 44.9±0.03ms 1.00 mpas_ocean.NeighborhoodReduce.time_reduce('120km', 'mean')
259±0.3ms 260±0.3ms 1.00 mpas_ocean.NeighborhoodReduce.time_reduce('120km', 'median')
610±10μs 622±10μs 1.02 mpas_ocean.NeighborhoodReduce.time_reduce('480km', 'mean')
2.09±0.01ms 2.11±0.06ms 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.4k 19.2k 0.99 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('480km', 'mean')
19.9k 19.7k 0.99 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('480km', 'median')
374±20μs 352±10μs 0.94 mpas_ocean.PointInPolygon.time_face_search_lonlat('120km')
336±10μs 351±4μs 1.04 mpas_ocean.PointInPolygon.time_face_search_lonlat('480km')
326±10μs 334±8μs 1.02 mpas_ocean.PointInPolygon.time_face_search_xyz('120km')
307±8μs 314±5μs 1.02 mpas_ocean.PointInPolygon.time_face_search_xyz('480km')
200±0.6ms 211±0.6ms 1.05 mpas_ocean.RemapDownsample.time_bilinear_remapping
232±2ms 228±0.7ms 0.99 mpas_ocean.RemapDownsample.time_inverse_distance_weighted_remapping
15.4±0.07ms 15.4±0.06ms 1.00 mpas_ocean.RemapDownsample.time_nearest_neighbor_remapping
1.11±0.01s 1.12±0s 1.01 mpas_ocean.RemapUpsample.time_bilinear_remapping
36.4±2ms 36.2±0.2ms 1.00 mpas_ocean.RemapUpsample.time_inverse_distance_weighted_remapping
11.0±0.5ms 10.9±0.08ms 0.99 mpas_ocean.RemapUpsample.time_nearest_neighbor_remapping
9.41±0.4ms 9.15±0.08ms 0.97 mpas_ocean.ZonalAverage.time_zonal_average('120km')
4.86±0.05ms 4.85±0.05ms 1.00 mpas_ocean.ZonalAverage.time_zonal_average('480km')
357M 365M 1.02 mpas_ocean.ZonalAveragePeakMem.peakmem_zonal_average('120km')
340M 348M 1.02 mpas_ocean.ZonalAveragePeakMem.peakmem_zonal_average('480km')
6.21±0.01ms 6.66±0.09ms 1.07 quad_hexagon.QuadHexagon.time_open_dataset
5.18±0.02ms 5.60±0.07ms 1.08 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.8k 72.6k 0.98 quad_hexagon.QuadHexagon.track_peakmem_open_dataset
73k 72.6k 0.99 quad_hexagon.QuadHexagon.track_peakmem_open_grid

Benchmarks that have got worse:

Change Before [38b33d8] <v2026.09.0> After [6dee854] Ratio Benchmark (Parameter)
+ 59.6±0.8μs 85.2±5μs 1.43 bench_connectivity.Connectivity.time_face_node('120km')
+ 446±10μs 535±7μs 1.2 bench_connectivity.Connectivity.time_n_nodes_per_face('120km')
+ 22.5±0.4ms 32.5±0.1ms 1.45 mpas_ocean.DualMesh.time_dual_mesh_construction('120km')
+ 2.56±0.05ms 3.43±0.03ms 1.34 mpas_ocean.DualMesh.time_dual_mesh_construction('480km')
+ 633k 764k 1.21 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'grid_chunks')

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

Making good progress! Still a few inline comments to resolve, see below and also I left some follow-up comments on previous inline comment threads.

Also, looking at ASV benchmark results, it looks like quite a few benchmarks have slowed down significantly, especially dual mesh construction being ~50% slower. Why would dual mesh construction slow down so much, if the search for duplicates primarily occurs when building the original grid? Tagging @cmdupuis3 to maybe comment on the benchmarks if you have additional insights. See also #1710 as a sanity check that the benchmark changes represent real changes to performance, rather than quirks of the benchmarking suite.

Comment thread test/grid/grid/test_core.py Outdated
Comment thread test/grid/grid/test_core.py
Comment thread uxarray/grid/connectivity.py
Comment thread uxarray/grid/connectivity.py
@rajeeja

rajeeja commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Making good progress! Still a few inline comments to resolve, see below and also I left some follow-up comments on previous inline comment threads.

Also, looking at ASV benchmark results, it looks like quite a few benchmarks have slowed down significantly, especially dual mesh construction being ~50% slower. Why would dual mesh construction slow down so much, if the search for duplicates primarily occurs when building the original grid? Tagging @cmdupuis3 to maybe comment on the benchmarks if you have additional insights. See also #1710 as a sanity check that the benchmark changes represent real changes to performance, rather than quirks of the benchmarking suite.

Thanks, the dual mesh slowdown was the duplicate check running on every get_dual call and rescanning every node each time. It's cached on the grid now.

@rajeeja
rajeeja requested a review from Sevans711 September 11, 2026 16:36
get_dual constructs a new Grid from this grid's face centers, which re-ran
the coincident node search on every call and made dual mesh construction
~1.6x slower. The dual's connectivity is already canonical, so the merge is
skipped there via a new internal Grid flag.

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

One minor inline comment. Aside from that, my only remaining concern is about performance.

Thanks, the dual mesh slowdown was the duplicate check running on every get_dual call and rescanning every node each time. It's cached on the grid now.

This seems to have helped, but the latest benchmarks still shows a >40% slowdown. Additionally, the slowdown is worse for the larger grid (120km resolution reported 45% slowdown, 480km resolution reported 34% slowdown), which suggests the slowdown might be much worse for larger, production-scale grids.

This led me to trying a larger grid as a sanity check. I have a 15km MPAS grid on my local machine (it's the dyamond 15km grid mentioned in the benchmarks suite) so I used that. Running ux.open_grid() a few times, I'm seeing it takes roughly 600 ms on main, and roughly 3.6 s on this branch. That's a 6x slowdown! Considering the possibility for nonlinear scaling, and that people often use even better resolution than 15km (e.g. 7.5km or 3.75km), this may be a significant performance hit for workflows with large grids.

Any ideas for how to improve this? I suspect that merging a 6x slowdown into open_grid() might not be worth always enforcing this extra validation, as many grids do not have any duplicates at all. (If there's really no way to improve the performance, maybe this check during Grid.__init__ should be disabled by default, with a flag to enable it?)

cc: @cmdupuis3

for index in np.flatnonzero(canonical != np.arange(n_node, dtype=INT_DTYPE))
}
if not duplicate_node_map:
return grid_ds

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 nit: could you indent the subsequent lines to make it clear that the logic here looks like this:

if no duplicates exist:
    no need to handle any duplicates
else:
    handle duplicates

Alternatively, could swap the order if you want:

if not duplicate_node_map:
    handle duplicates
else:
    return grid_ds

With the current code, I had to scroll around a bit to find this line and understand that is what is happening.

Alternatively, if you added a small comment in this function's docstring to emphasize "if there are no duplicates, returns grid_ds, unchanged", that would be sufficient. (Or, could add a small comment below, or above when defining _NODE_INDEX_CONNECTIVITY_TO_REMAP and _DERIVED_CONNECTIVITY_TO_INVALIDATE, to clarify that these things only get applied if actually detected any duplicates.)

@cmdupuis3 cmdupuis3 Sep 15, 2026

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.

To add, is there a way to say that some file formats will be guaranteed to not have duplicate nodes? If we know when we can skip this completely, that would be a nice thing to have.

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

Labels

bug Something isn't working run-benchmark Run ASV benchmark workflow

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Handling Duplicate Node Indices

3 participants