Skip to content

Optimized connectivity: Simultaneous face_edges and edge_nodes - #1560

Open
cmdupuis3 wants to merge 30 commits into
UXARRAY:mainfrom
cmdupuis3:cmd/merge-OFE
Open

Optimized connectivity: Simultaneous face_edges and edge_nodes#1560
cmdupuis3 wants to merge 30 commits into
UXARRAY:mainfrom
cmdupuis3:cmd/merge-OFE

Conversation

@cmdupuis3

@cmdupuis3 cmdupuis3 commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Would close #1138, #1196

Related to #1180

Supercedes #1195

Overview

This set of changes optimizes face_edge, edge_node, and face_face connectivity. face_edge and edge_node connectivity are combined into one routine, while face_face is optimized stand-alone.

PR Checklist

General

  • An issue is linked created and linked
  • Add appropriate labels
  • Filled out Overview and Expected Usage (if applicable) sections

Testing

  • Adequate tests are created if there is new functionality
  • Tests cover all possible logical paths in your function
  • Tests are not too basic (such as simply calling a function and nothing else)

Documentation

  • Docstrings have been added to all new functions
  • Docstrings have updated with any function changes
  • Internal functions have a preceding underscore (_) and have been added to docs/internal_api/index.rst

@cmdupuis3 cmdupuis3 self-assigned this Jul 10, 2026
@cmdupuis3 cmdupuis3 added the improvement Improvements on existing features or infrastructure label Jul 10, 2026
@cmdupuis3
cmdupuis3 requested a review from hongyuchen1030 July 10, 2026 22:41
@cmdupuis3

Copy link
Copy Markdown
Collaborator Author

I think all the hard parts of the merge are done.

At this point, the only failures seems to be sorting issues.

@cmdupuis3 cmdupuis3 added scalability Related to scalability & performance efforts and removed improvement Improvements on existing features or infrastructure labels Jul 10, 2026
@hongyuchen1030

Copy link
Copy Markdown
Contributor

I think all the hard parts of the merge are done.

At this point, the only failures seems to be sorting issues.

@cmdupuis3 Thanks for your work, I will review it as soon as possible.

And do you have any idea why the CIs are all failing?

@cmdupuis3

Copy link
Copy Markdown
Collaborator Author

The CI is failing because the new algorithm returns the results in a different order.

I was thinking we can add some sorting mechanism, at least for legacy behavior. Phillip was evidently aware of this issue too. It depends on if you need to support that... Personally I'd be okay with just changing it, but I think you would be a better judge of the situation.

Comment thread uxarray/grid/connectivity.py Outdated
pass

edge_nodes, face_edges = _build_edge_node_connectivity(
grid.face_node_connectivity.values, grid.n_nodes_per_face.values

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please think about if you can get rid of .values calls to be chunked-array compatible.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

On it, I think Philip was trying to scalarize here but it introduced this regression.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

So the best I could come up with is

    face_nodes, n_nodes_per_face = dask.compute(
        grid.face_node_connectivity.data, grid.n_nodes_per_face.data
    )

    edge_nodes, face_edges = _build_edge_node_connectivity(
        face_nodes, n_nodes_per_face, grid.n_node
    )

...but I'm unclear on if we're committing to dask or not. Other parts of the repo imply that dask is still sort of an optional load, and doing this would pretty much require a dask import for most grids, unless we have some kind of numpy/dask conditional.

@cmdupuis3 cmdupuis3 Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Alright, I hacked it a bit to get this:

    computed = xr.Dataset(
        {
            "face_nodes": grid.face_node_connectivity.variable,
            "n_nodes_per_face": grid.n_nodes_per_face.variable,
        }
    ).compute()

    edge_nodes, face_edges = _build_edge_node_connectivity(
        computed.face_nodes.data, computed.n_nodes_per_face.data, grid.n_node
    )

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.

Question for @erogluorhan, does uxarray have any plans to support chunked grids? I was under the impression that data variables can be chunked, but that it is always safe to assume the grid itself can be fully loaded into memory. Is that incorrect?

That said, using more dask-compatible syntax like this doesn't seem like it would have a downside (no need to block merging on this question).

@erogluorhan erogluorhan Aug 1, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Per @cmdupuis3 's latest comment:

This looks great, and it is more beneficial than just "hacking" I believe (please document it accordingly wherever possible in your code):

  • Using xr.Dataset.compute() guarantees two major advantages:

    • Bundled instead of separate .compute() calls on a couple DataArrays - uses same upstream dask graph and gets rid of duplicate overhead
    • Doesn't break optional Dask - If given chunked arrays, uses Dask; otherwise, skips compute under the xarray's hood and respects numpy arrays

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Per @Sevans711's latest comment:

The intent with Grid's I/O design was already to have it support chunked features; however; full end-to-end "Grid supports chunkedness" is probably impossible - there are global-topology and spatial-index operations like what we see here in this PR that could probably never avoid materializing the whole array.

  1. Grid I/O is already made chunked - there might be things we can further fix though
  2. Some of the operations can be made chunk-compatible

I think a smart way forward would be to (1) keep (or make) the chunk-local geometric operations lazy, and (2) leave ( and explicitly document) the global-topology/KDTree/validation operations as materialized (whole array computations) rather than attempting to "fix" every .values or .compute() - this PR shows there is still room for optimization for even those though

@cmdupuis3

Copy link
Copy Markdown
Collaborator Author

Altering njit and .values here runs the risk of conflicting with #1583. I'm thinking we can scope it so that .values cleanup in connectivity.py is allowed on this PR and everywhere except connectivity.py belongs to #1583.

cmdupuis3 and others added 4 commits July 22, 2026 21:46
The optimized edge builder deduped half edges with a numba hash map, which
numbered edges in first-encounter order. Edges had previously been numbered
lexicographically by their (min_node, max_node) pair, as a side effect of the
np.unique(..., axis=0) the hash map replaced.

Global edge index is a public identity: it indexes edge_lon/edge_lat, edge
centered data variables, and edge_node_distances, so renumbering silently
re-pairs user data with different physical edges. It also broke the five
TestQuadHexagon connectivity tests, which assert on edge_node, face_edge,
node_edge, edge_face and face_face -- all the same renumbering cascading
through the derived connectivities.

Sort as the dedup mechanism instead of hashing. Node indices are dense
integers in [0, n_node), so a counting sort buckets the half edges by their
first node without any comparisons, and sorting each bucket by its second node
leaves the duplicates adjacent -- the dedup then falls out of the same walk.
Buckets hold one entry per edge incident to a node, so on a real mesh they are
tiny (node degree, typically under ten) and an insertion sort finishes them.
A bucket above MAX_INSERTION_SORT_SIZE is heap sorted so that a degenerate
mesh cannot degrade the build quadratically; np.argsort is deliberately not
used there, as numba's implementation degrades badly on structured input.

Half edges are identified throughout by their flat face_node_connectivity
index, which is also the face_edge_connectivity slot they are written back to,
so the sort needs a single permutation array and no mapping back.

This is faster and leaner than the hash map it replaces. On a synthetic one
million face quad mesh, measured by peak RSS rather than tracemalloc, which
does not observe numba's typed dict allocations:

    dict build     397.3 ms    239.5 MB
    bucket sort     85.1 ms     91.6 MB

The five legacy tests now pass unchanged. Adds order invariant coverage for
the canonical ordering and the face_edge positional contract, plus high degree
nodes either side of the insertion sort threshold.

Also casts n_nodes_per_face back to INT_DTYPE, so that the builder is not
compiled a second time for int64, and restores a blank line dropped between
two top level functions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Sevans711 Sevans711 mentioned this pull request Jul 29, 2026
3 tasks
@cmdupuis3 cmdupuis3 added the run-benchmark Run ASV benchmark workflow label Jul 29, 2026
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

ASV Benchmarking

Benchmark Comparison Results

Benchmarks that have improved:

Change Before [fa82d7b] After [faecf3c] Ratio Benchmark (Parameter)
- 233±0.7ms 6.24±0.02ms 0.03 bench_connectivity.Connectivity.time_edge_face('120km')
- 13.5±0.2ms 2.02±0.02ms 0.15 bench_connectivity.Connectivity.time_edge_face('480km')
- 234±2ms 5.11±0.02ms 0.02 bench_connectivity.Connectivity.time_edge_node('120km')
- 13.2±0.06ms 1.64±0.01ms 0.12 bench_connectivity.Connectivity.time_edge_node('480km')
- 232±0.9ms 5.13±0.03ms 0.02 bench_connectivity.Connectivity.time_face_edge('120km')
- 12.9±0.05ms 1.62±0.01ms 0.13 bench_connectivity.Connectivity.time_face_edge('480km')
- 826±4ms 7.19±0.07ms 0.01 bench_connectivity.Connectivity.time_face_face('120km')
- 50.7±0.4ms 2.39±0.02ms 0.05 bench_connectivity.Connectivity.time_face_face('480km')
- 236±4ms 6.58±0.02ms 0.03 bench_connectivity.Connectivity.time_node_edge('120km')
- 12.9±0.03ms 2.05±0.01ms 0.16 bench_connectivity.Connectivity.time_node_edge('480km')
- 521M 336M 0.64 face_bounds.FaceBounds.peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
- 632M 336M 0.53 face_bounds.FaceBounds.peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
- 711±3ms 1.07±0.01ms 0 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('120km')
- 44.8±0.3ms 588±5μs 0.01 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('480km')
- 435M 331M 0.76 mpas_ocean.FaceAreas.peakmem_compute_face_areas('480km')
- 432M 330M 0.76 mpas_ocean.Gradient.peakmem_gradient('480km')

Benchmarks that have stayed the same:

Change Before [fa82d7b] After [faecf3c] Ratio Benchmark (Parameter)
62.6±2μs 62.8±1μs 1 bench_connectivity.Connectivity.time_face_node('120km')
60.7±1μs 62.1±1μs 1.02 bench_connectivity.Connectivity.time_face_node('480km')
79.4±0.7ms 77.9±1ms 0.98 bench_connectivity.Connectivity.time_node_face('120km')
4.91±0.04ms 4.94±0.04ms 1.01 bench_connectivity.Connectivity.time_node_face('480km')
334M 334M 1 face_bounds.FaceBounds.peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
363M 366M 1.01 face_bounds.FaceBounds.peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
7.38±0.04ms 7.65±0.05ms 1.04 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
9.51±0.02ms 9.74±0.01ms 1.02 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
1.94±0.03ms 1.95±0.01ms 1.01 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
1.16±0.05μs 1.18±0.04μs 1.02 geometry_kernels.AccucrossKernels.time_accucross
2.57±0.04μs 2.60±0.01μs 1.01 geometry_kernels.AccucrossKernels.time_accucross_pair
431±30ns 425±10ns 0.99 geometry_kernels.EFTPrimitives.time_acc_sqrt_re
410±10ns 401±7ns 0.98 geometry_kernels.EFTPrimitives.time_diff_of_products
380±9ns 350±4ns 0.92 geometry_kernels.EFTPrimitives.time_two_prod
1.41±0.02μs 1.40±0.01μs 0.99 geometry_kernels.GCAConstLatIntersection.time_accux_constlat_kernel
1.04±0.02μs 1.03±0.03μs 0.99 geometry_kernels.GCAConstLatIntersection.time_gca_const_lat_intersection
1.75±0.04μs 1.78±0.03μs 1.02 geometry_kernels.GCAConstLatIntersection.time_try_gca_const_lat_intersection
1.56±0.02μs 1.55±0.01μs 0.99 geometry_kernels.GCAGCAIntersection.time_accux_gca_kernel
1.25±0.02μs 1.28±0.02μs 1.02 geometry_kernels.GCAGCAIntersection.time_gca_gca_intersection
1.98±0.01μs 2.03±0.03μs 1.02 geometry_kernels.GCAGCAIntersection.time_try_gca_gca_intersection
38.5±0.5μs 37.2±0.4μs 0.97 geometry_kernels.OrientPredicates.time_on_minor_arc
661±20ns 686±30ns 1.04 geometry_kernels.OrientPredicates.time_orient3d_on_sphere
2.91±0.1ms 2.81±0.01ms 0.96 geometry_samebody.SameBodyConstLat.time_accux_dispatch
1.33±0.01ms 1.33±0ms 1.01 geometry_samebody.SameBodyConstLat.time_accux_kernel
1.81±0.01ms 1.83±0.02ms 1.01 geometry_samebody.SameBodyConstLat.time_fp64_dispatch
153±0.7μs 153±0.8μs 1 geometry_samebody.SameBodyConstLat.time_fp64_kernel
33.3±0.4ms 32.5±0.01ms 0.98 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_dispatch
10.7±0.01ms 10.7±0.01ms 1 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_kernel
27.3±0.06ms 27.8±0.1ms 1.02 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_dispatch
4.54±0.05ms 4.47±0.02ms 0.98 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_kernel
789±5ms 789±4ms 1 import.Imports.timeraw_import_uxarray
2.27±0.03ms 2.28±0.02ms 1.01 mpas_ocean.CheckNorm.time_check_norm('120km')
1.82±0.01ms 1.83±0.01ms 1.01 mpas_ocean.CheckNorm.time_check_norm('480km')
3.51±0.01ms 3.83±0.02ms 1.09 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('480km')
3.32±0.1s 3.28±0.02s 0.99 mpas_ocean.ConstructFaceLatLon.time_welzl('120km')
216±2ms 213±1ms 0.99 mpas_ocean.ConstructFaceLatLon.time_welzl('480km')
19.7±0.01ms 19.7±0.01ms 1 mpas_ocean.ConstructTreeStructures.time_ball_tree('120km')
1.13±0.01ms 1.13±0.01ms 1 mpas_ocean.ConstructTreeStructures.time_ball_tree('480km')
10.6±0.02ms 10.6±0.02ms 1 mpas_ocean.ConstructTreeStructures.time_kd_tree('120km')
764±4μs 758±10μs 0.99 mpas_ocean.ConstructTreeStructures.time_kd_tree('480km')
654±1ms 659±5ms 1.01 mpas_ocean.CrossSections.time_const_lat('120km', 1)
331±1ms 331±1ms 1 mpas_ocean.CrossSections.time_const_lat('120km', 2)
171±0.2ms 171±0.3ms 1 mpas_ocean.CrossSections.time_const_lat('120km', 4)
484±2ms 486±3ms 1 mpas_ocean.CrossSections.time_const_lat('480km', 1)
247±1ms 246±2ms 1 mpas_ocean.CrossSections.time_const_lat('480km', 2)
127±0.6ms 128±0.8ms 1.01 mpas_ocean.CrossSections.time_const_lat('480km', 4)
22.5±0.3ms 22.1±0.04ms 0.98 mpas_ocean.DualMesh.time_dual_mesh_construction('120km')
2.57±0.03ms 2.60±0.01ms 1.01 mpas_ocean.DualMesh.time_dual_mesh_construction('480km')
349M 349M 1 mpas_ocean.FaceAreas.peakmem_compute_face_areas('120km')
57.5±0.09ms 58.3±0.05ms 1.01 mpas_ocean.FaceAreas.time_compute_face_areas('120km')
5.96±0.04ms 6.27±0.04ms 1.05 mpas_ocean.FaceAreas.time_compute_face_areas('480km')
841±5ms 833±3ms 0.99 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', False)
50.2±1ms 50.8±0.7ms 1.01 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', True)
72.6±0.5ms 72.9±0.4ms 1 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', False)
5.55±0.05ms 5.97±0.1ms 1.08 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', True)
350M 350M 1 mpas_ocean.Gradient.peakmem_gradient('120km')
165±0.2ms 166±3ms 1.01 mpas_ocean.Gradient.time_gradient('120km')
11.4±0.03ms 11.4±0.02ms 1 mpas_ocean.Gradient.time_gradient('480km')
373±4μs 370±8μs 0.99 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('120km')
204±9μs 201±3μs 0.98 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('480km')
350M 350M 1 mpas_ocean.Integrate.peakmem_integrate('120km')
328M 329M 1 mpas_ocean.Integrate.peakmem_integrate('480km')
554±9μs 562±10μs 1.01 mpas_ocean.Integrate.time_integrate('120km')
483±7μs 476±8μs 0.99 mpas_ocean.Integrate.time_integrate('480km')
189±0.8ms 191±0.6ms 1.01 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'exclude')
189±1ms 188±1ms 0.99 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'include')
188±0.9ms 190±2ms 1.01 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'split')
14.0±0.05ms 14.4±0.1ms 1.03 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'exclude')
13.9±0.1ms 14.5±0.06ms 1.04 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'include')
13.9±0.06ms 14.6±0.1ms 1.05 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'split')
369±6μs 364±10μs 0.99 mpas_ocean.PointInPolygon.time_face_search_lonlat('120km')
372±5μs 361±10μs 0.97 mpas_ocean.PointInPolygon.time_face_search_lonlat('480km')
342±10μs 333±6μs 0.97 mpas_ocean.PointInPolygon.time_face_search_xyz('120km')
331±10μs 322±4μs 0.97 mpas_ocean.PointInPolygon.time_face_search_xyz('480km')
214±1ms 213±1ms 0.99 mpas_ocean.RemapDownsample.time_bilinear_remapping
230±3ms 228±0.6ms 0.99 mpas_ocean.RemapDownsample.time_inverse_distance_weighted_remapping
15.6±0.06ms 15.5±0.04ms 1 mpas_ocean.RemapDownsample.time_nearest_neighbor_remapping
1.18±0.01s 1.19±0s 1.01 mpas_ocean.RemapUpsample.time_bilinear_remapping
35.8±0.2ms 36.3±0.4ms 1.02 mpas_ocean.RemapUpsample.time_inverse_distance_weighted_remapping
11.2±0.2ms 11.3±0.2ms 1.01 mpas_ocean.RemapUpsample.time_nearest_neighbor_remapping
27.6±0.8ms 26.1±0.09ms 0.94 mpas_ocean.ZonalAverage.time_zonal_average('120km')
5.39±0.1ms 5.40±0.01ms 1 mpas_ocean.ZonalAverage.time_zonal_average('480km')
324M 326M 1.01 quad_hexagon.QuadHexagon.peakmem_open_dataset
326M 324M 0.99 quad_hexagon.QuadHexagon.peakmem_open_grid
6.52±0.1ms 6.55±0.1ms 1 quad_hexagon.QuadHexagon.time_open_dataset
5.57±0.09ms 5.52±0.1ms 0.99 quad_hexagon.QuadHexagon.time_open_grid

Benchmarks that have got worse:

Change Before [fa82d7b] After [faecf3c] Ratio Benchmark (Parameter)
+ 342±3μs 1.23±0.01ms 3.59 bench_connectivity.Connectivity.time_n_nodes_per_face('120km')
+ 267±3μs 568±10μs 2.13 bench_connectivity.Connectivity.time_n_nodes_per_face('480km')
+ 2.49±0.01ms 2.86±0.02ms 1.15 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
+ 351±9ns 410±20ns 1.17 geometry_kernels.EFTPrimitives.time_two_sum
+ 624±9μs 1.55±0.01ms 2.48 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('120km')
+ 552±5μs 889±10μs 1.61 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('480km')
+ 5.07±0.06ms 5.97±0.03ms 1.18 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('120km')

@cmdupuis3
cmdupuis3 marked this pull request as ready for review July 29, 2026 19:44

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

These changes look clean overall! It is great to see the possibility for things that previously took hundreds of seconds to maybe be reduced to a few seconds or less (quoting those numbers from #1195).

I left some inline comments and also have a few bigger questions below:

  • (1) Seeing some benchmarks improvements here is encouraging. Though, in order to get better confirmation these changes really solve the linked issues, would you be able to run it on at least one large grid on an HPC system and share the results? Both linked issues referred to large grids.

(You don't necessarily need to reproduce the same exact results or use the same exact grids from the original issues, just looking for at least one large grid showing what the timing looks like on main right now and what it looks like after these changes.)

  • (2) Are the increases to memory usage in large grids acceptable here? E.g., if edge_node_connectivity is requested but user has no plans to utilize face_edge_connectivity, this PR leads to increased memory cost.

On grid=ux.tutorial.open_grid("outCSne30-vortex"), I checked grid._ds.nbytes after a few commands. Upon loading, it is 259236 bytes. On main after grid.edge_node_connectivity it becomes 432036 bytes, and after grid.face_edge_connectivity it becomes 604836 bytes. On this PR, just doing grid=ux.tutorial.open_grid("outCSne30-vortex"); grid.edge_node_connectivity leads to 648036 bytes. (The extra ~40000 bytes are from n_nodes_per_face, though that is basically just a rounding error when compared to the ~200000 extra bytes from storing face_edge_connectivity.) Of course, the numbers here are very small; this is a tiny example. But, they demonstrate that the concern about memory costs may be somewhat plausible, at least. (Unless everybody who asks for edge_node_connectivity also wants face_edge_connectivity?)

centroid_x = grid.node_x.values[edge_nodes].mean(axis=1)
centroid_y = grid.node_y.values[edge_nodes].mean(axis=1)
centroid_z = grid.node_z.values[edge_nodes].mean(axis=1)
centroid_x, centroid_y, centroid_z = _normalize_xyz(centroid_x, centroid_y, centroid_z)

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 seems like a substantive change to the test. Can you clarify why this change was needed? Reply here is fine, not trying to say it is wrong, just not understanding yet why it was changed here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, so this test was basically passing by coincidence, and whether it passes depends on which edge is at index 0. It also skips normalization. I only found this because prior to implementing sorting, this test was failing, which flagged it as something for Claude to look at, and it just happened to be wrong for another reason. With the new sorting algorithm, the original test would be passing though.

("ugrid", "quad-hexagon", "grid.nc"),
("ugrid", "geoflow-small", "grid.nc")])
def test_connectivity_edge_node_canonical_order(gridpath, grid_parts):
"""Test that constructed edges are numbered in lexicographic node order."""

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 didn't realize lexicographic order was supposed to be guaranteed for constructed edges. I see now that the corresponding docstring for _build_edge_node_connectivity has been updated accordingly. Is that change being introduced intentionally by this PR?

If yes, replying with yes here is sufficient and the change looks good to me. In the future if you can mention changes like this too somewhere in the PR overview or as comments in thread, that would have helped with reducing time it takes to review!

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The sorting was implemented to support compatibility with legacy behavior. If we decide to deprecate that behavior we could throw out all the sorting and revert this a little.

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.

Can you clarify a bit further? If you can provide at least one example of a legacy behavior this change was implemented to support, that should make it much easier for me to understand.

Comment thread uxarray/grid/connectivity.py Outdated
# Check edge coordinates already exist, if they do this might cause issues

if "n_edge" in grid.sizes:
# TODO: raise a warning or exception?

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 TODO item should be completed before merging, to avoid introducing a new way for the code to fail silently. I would prefer if an exception is raised, not just a warning. Or (more work but maybe cleaner long-term?) figure out what could actually cause this, and debug it?

Minor style suggestion: change to "n_edge" in grid.dims instead, to make it clearer that the actual sizes of the dimensions are not relevant here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done, I think there could be some downstream effects in _ugrid though, but we should fix it there anyway.

Comment thread uxarray/grid/connectivity.py Outdated

edge_node_attrs = ugrid.EDGE_NODE_CONNECTIVITY_ATTRS
edge_node_attrs["inverse_indices"] = inverse_indices
# HACK: this is lieu of an xarray equivalent to `da.compute(a, b)`

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.

Clarifying: what is the purpose of this hack - why not just use something like:

computed_face_nodes = grid.face_node_connectivity.variable.compute()
computed_n_nodes_per_face = grid.n_nodes_per_face.variable.compute()

I suppose there could theoretically be a speedup from doing a single compute() call instead of two compute calls?

Or, maybe it is some other reason. Could you add a comment in the code to clarify?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Because xarray/dask would end up walking the entire structure twice if you compute them separately. Dask has the multi-arg dask.compute(a, b) which allows it to walk the structure once and iterate all the vars it needs simultaneously, but fsr xarray doesn't have native support for that. I opted to hack this structure in to backdoor in the dask behavior while staying dask-agnostic here.

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.

Noting that @erogluorhan clarified the reasoning above, see #1560 (comment)

Keeping my comment here as "unresolved" for now as a reminder to update comment within the code itself to clarify

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Updated

n_nodes_per_face = _build_n_nodes_per_face(
grid.face_node_connectivity.values, grid.n_face, grid.n_max_face_nodes
n_nodes_per_face = (
(grid.face_node_connectivity != INT_FILL_VALUE).sum(axis=1).astype(INT_DTYPE)

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.

Briefly calling attention to this change, it looks like a huge speed up in the ASV benchmarking suite, which is awesome! It wasn't mentioned in the PR overview / comment thread so I was surprised to see it.

EDIT: actually, many of the n_nodes_per_face tests in the benchmarking suite seem unaffected by this change or even slightly slower because of it. Is this expected behavior?

Confirming: does it also speed things up for large grids on HPC clusters? (Testing with one large grid would be sufficient.)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Removing .values keeps it lazy, and also helps with reducing the graph traversals on line 173

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 agree removing .values is a good idea, but this does more than that: it switches from using the numba _build_n_nodes_per_face to a numpy sum. How does this actually affect time and/or memory usage for large grids?

Also, I noticed a potential accuracy error here: should refer to axis by name, not number, because xarray arrays should support any axis order, and face_node_connectivity might be user-supplied, so it might be transposed compared to the order you expected here.

@cmdupuis3

Copy link
Copy Markdown
Collaborator Author

On this PR, just doing grid=ux.tutorial.open_grid("outCSne30-vortex"); grid.edge_node_connectivity leads to 648036 bytes. (The extra ~40000 bytes are from n_nodes_per_face, though that is basically just a rounding error when compared to the ~200000 extra bytes from storing face_edge_connectivity.) Of course, the numbers here are very small; this is a tiny example. But, they demonstrate that the concern about memory costs may be somewhat plausible, at least. (Unless everybody who asks for edge_node_connectivity also wants face_edge_connectivity?)

Yeah, so I was wondering about that too, which is why I proposed having a dispatcher for combined connectivity routines #1559. However, in practice, I think the most likely situation with the grids we support is that both face/edge and edge/node connectivity are the missing connectivities, which is why they were interested in doing this. There could definitely be a discussion on the design, but I think that's out of scope for this PR.

@Sevans711

Copy link
Copy Markdown
Collaborator

On this PR, just doing grid=ux.tutorial.open_grid("outCSne30-vortex"); grid.edge_node_connectivity leads to 648036 bytes. (The extra ~40000 bytes are from n_nodes_per_face, though that is basically just a rounding error when compared to the ~200000 extra bytes from storing face_edge_connectivity.) Of course, the numbers here are very small; this is a tiny example. But, they demonstrate that the concern about memory costs may be somewhat plausible, at least. (Unless everybody who asks for edge_node_connectivity also wants face_edge_connectivity?)

Yeah, so I was wondering about that too, which is why I proposed having a dispatcher for combined connectivity routines #1559. However, in practice, I think the most likely situation with the grids we support is that both face/edge and edge/node connectivity are the missing connectivities, which is why they were interested in doing this. There could definitely be a discussion on the design, but I think that's out of scope for this PR.

Ah, yes that makes sense, and seems like a good solution. I also agree a full design like that should probably be scoped to a different PR. Before this PR merges I would still be interested to hear from other uxarray developers/users to confirm whether the increased memory cost is an acceptable tradeoff for the possible huge speedup in the meantime. E.g., @erogluorhan, @rajeeja, @rljacob, if you get a chance to look into this, what are your thoughts?

@cmdupuis3 cmdupuis3 linked an issue Jul 30, 2026 that may be closed by this pull request
@cmdupuis3

cmdupuis3 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

The speedups are very real. These are from the new connectivity benchmarks in PR #1633, so this is serial performance. I can add chunked performance to the benchmarks as well. I didn't notice any major memory degradation in glancing at top, if anything it seemed better with this branch. Maybe I can add peakmem benchmarks as well.

Resolution merge-OFE time main time
480km 1.27±0ms 10.1±0ms
120km 3.99±0ms 220±0ms
30km 100±0ms 10.00±0s
15km 434±0ms 44.2±0s
7.5km 1.97±0s 1.58±0m
3.75km 9.92±0s failed

@cmdupuis3

cmdupuis3 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

@erogluorhan I'm working on some peakmem benchmarks, here's the results for this PR.

ASV main versus merge-OFE branch---

face_face:

Resolution merge-OFE Peakmem main Peakmem
480km 101k 886k
120km 1.6M 14.7M
30km 57.7M 351M
15km 231M 1.41G
7.5km 587M failed
3.75km 2.35G failed

edge_node:

Resolution merge-OFE Peakmem main Peakmem
480km 416k 980k
120km 6.47M 15.5M
30km 168M 577M
15km 671M 2.31G
7.5km 2.35G 5.68G
3.75km 9.4G failed

@cmdupuis3

Copy link
Copy Markdown
Collaborator Author

pre-commit.ci autofix

@cmdupuis3

Copy link
Copy Markdown
Collaborator Author

pre-commit.ci autofix

@erogluorhan
erogluorhan requested a review from Sevans711 August 7, 2026 15:26
@erogluorhan

erogluorhan commented Aug 7, 2026

Copy link
Copy Markdown
Member

On this PR, just doing grid=ux.tutorial.open_grid("outCSne30-vortex"); grid.edge_node_connectivity leads to 648036 bytes. (The extra ~40000 bytes are from n_nodes_per_face, though that is basically just a rounding error when compared to the ~200000 extra bytes from storing face_edge_connectivity.) Of course, the numbers here are very small; this is a tiny example. But, they demonstrate that the concern about memory costs may be somewhat plausible, at least. (Unless everybody who asks for edge_node_connectivity also wants face_edge_connectivity?)

Yeah, so I was wondering about that too, which is why I proposed having a dispatcher for combined connectivity routines #1559. However, in practice, I think the most likely situation with the grids we support is that both face/edge and edge/node connectivity are the missing connectivities, which is why they were interested in doing this. There could definitely be a discussion on the design, but I think that's out of scope for this PR.

Ah, yes that makes sense, and seems like a good solution. I also agree a full design like that should probably be scoped to a different PR. Before this PR merges I would still be interested to hear from other uxarray developers/users to confirm whether the increased memory cost is an acceptable tradeoff for the possible huge speedup in the meantime. E.g., @erogluorhan, @rajeeja, @rljacob, if you get a chance to look into this, what are your thoughts?

I realize I never responded to this.

  • Dispatcher: Agreed. Out of scope for this PR but we could look into it separately (Dispatcher for optimized connectivity routines #1559).
  • Memory usage:
    • The extra bytes in the static output: They seem acceptable to me in this context where we have extreme performance optimization, and the models we support seem to have these two connectivities either going together or lacking together (per @cmdupuis3 's observation) make this combined approavh even more logical.
    • Also, the peak-mem results @cmdupuis3 provided seem like the approach here even settles peak memory usage. The extra bytes are measured for stored output size, but the peak memory during combined construction seems significantly improved.

All that said, there seems like a big win on both performance and memory utilization in this PR.

@erogluorhan erogluorhan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@cmdupuis3 a quick observation:

  • I believe this PR is not documenting this new way of combined constructions, so please mention it in both face_edge and edge_node docstrings for the user's reference.

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

5 participants