Skip to content

Optimized connectivity: Simultaneous face_edges and edge_nodes - #1560

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

Optimized connectivity: Simultaneous face_edges and edge_nodes#1560
cmdupuis3 wants to merge 20 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).

@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 [b1436fb] After [5fe4bd8] Ratio Benchmark (Parameter)
- 510±8μs 8.92±0.03μs 0.02 bench_connectivity.Connectivity.time_n_nodes_per_face('480km')
- 517M 336M 0.65 face_bounds.FaceBounds.peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
- 625M 335M 0.54 face_bounds.FaceBounds.peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
- 628±10ms 945±30μs 0 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('120km')
- 38.8±1ms 459±20μs 0.01 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('480km')
- 449M 330M 0.73 mpas_ocean.Gradient.peakmem_gradient('480km')

Benchmarks that have stayed the same:

Change Before [b1436fb] After [5fe4bd8] Ratio Benchmark (Parameter)
7.98±0.3μs 7.93±0.09μs 0.99 bench_connectivity.Connectivity.time_edge_face('120km')
8.58±0.1μs 8.82±0.4μs 1.03 bench_connectivity.Connectivity.time_edge_face('480km')
7.96±0.4μs 7.92±0.08μs 1.00 bench_connectivity.Connectivity.time_edge_node('120km')
8.14±0.06μs 8.27±0.07μs 1.01 bench_connectivity.Connectivity.time_edge_node('480km')
7.99±0.2μs 7.92±0.05μs 0.99 bench_connectivity.Connectivity.time_face_edge('120km')
8.37±0.1μs 8.31±0.1μs 0.99 bench_connectivity.Connectivity.time_face_edge('480km')
8.05±0.1μs 8.04±0.3μs 1.00 bench_connectivity.Connectivity.time_face_face('120km')
8.12±0.06μs 8.31±0.1μs 1.02 bench_connectivity.Connectivity.time_face_face('480km')
15.9±0.2μs 16.1±0.5μs 1.01 bench_connectivity.Connectivity.time_face_node('120km')
16.5±0.4μs 17.0±0.3μs 1.03 bench_connectivity.Connectivity.time_face_node('480km')
8.87±0.2μs 9.37±0.2μs 1.06 bench_connectivity.Connectivity.time_n_nodes_per_face('120km')
8.09±0.1μs 7.99±0.1μs 0.99 bench_connectivity.Connectivity.time_node_edge('120km')
8.58±0.4μs 8.21±0.08μs 0.96 bench_connectivity.Connectivity.time_node_edge('480km')
7.82±0.1μs 8.39±0.3μs 1.07 bench_connectivity.Connectivity.time_node_face('120km')
8.14±0.09μs 8.20±0.1μs 1.01 bench_connectivity.Connectivity.time_node_face('480km')
334M 334M 1.00 face_bounds.FaceBounds.peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
365M 363M 1.00 face_bounds.FaceBounds.peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
15.5±0.06μs 16.0±0.1μs 1.03 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
6.96±0.4μs 7.22±0.3μs 1.04 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
9.46±0.6ms 9.63±0.05ms 1.02 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
1.53±0.03ms 1.54±0.03ms 1.01 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
689±3ns 724±60ns 1.05 geometry_kernels.AccucrossKernels.time_accucross
1.83±0.1μs 1.84±0.06μs 1.01 geometry_kernels.AccucrossKernels.time_accucross_pair
271±50ns 216±1ns ~0.80 geometry_kernels.EFTPrimitives.time_acc_sqrt_re
203±0.7ns 207±7ns 1.02 geometry_kernels.EFTPrimitives.time_diff_of_products
177±20ns 173±10ns 0.98 geometry_kernels.EFTPrimitives.time_two_prod
173±5ns 176±2ns 1.01 geometry_kernels.EFTPrimitives.time_two_sum
917±6ns 908±5ns 0.99 geometry_kernels.GCAConstLatIntersection.time_accux_constlat_kernel
616±6ns 616±9ns 1.00 geometry_kernels.GCAConstLatIntersection.time_gca_const_lat_intersection
1.32±0.06μs 1.24±0.1μs 0.94 geometry_kernels.GCAConstLatIntersection.time_try_gca_const_lat_intersection
1.01±0.01μs 1.02±0.03μs 1.01 geometry_kernels.GCAGCAIntersection.time_accux_gca_kernel
856±9ns 884±20ns 1.03 geometry_kernels.GCAGCAIntersection.time_gca_gca_intersection
1.44±0.01μs 1.42±0.01μs 0.99 geometry_kernels.GCAGCAIntersection.time_try_gca_gca_intersection
42.4±0.4μs 41.0±1μs 0.97 geometry_kernels.OrientPredicates.time_on_minor_arc
374±5ns 371±1ns 0.99 geometry_kernels.OrientPredicates.time_orient3d_on_sphere
2.25±0.1ms 2.10±0.08ms 0.94 geometry_samebody.SameBodyConstLat.time_accux_dispatch
661±20μs 657±5μs 0.99 geometry_samebody.SameBodyConstLat.time_accux_kernel
1.41±0.02ms 1.45±0.05ms 1.03 geometry_samebody.SameBodyConstLat.time_fp64_dispatch
105±5μs 103±0.4μs 0.98 geometry_samebody.SameBodyConstLat.time_fp64_kernel
33.2±0.2ms 32.2±0.2ms 0.97 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_dispatch
8.29±0.6ms 7.75±0.2ms 0.94 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_kernel
28.4±0.2ms 29.8±0.5ms 1.05 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_dispatch
4.64±0.1ms 4.57±0.06ms 0.98 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_kernel
645±3ms 647±7ms 1.00 import.Imports.timeraw_import_uxarray
658±10ns 649±4ns 0.99 mpas_ocean.CheckNorm.time_check_norm('120km')
614±10ns 616±2ns 1.00 mpas_ocean.CheckNorm.time_check_norm('480km')
11.2±0.2μs 11.7±0.1μs 1.05 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('120km')
11.4±0.2μs 11.4±0.2μs 1.00 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('480km')
3.46±0.06ms 3.60±0.07ms 1.04 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('120km')
2.59±0.05ms 2.60±0.02ms 1.00 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('480km')
2.89±0.02s 2.87±0.02s 0.99 mpas_ocean.ConstructFaceLatLon.time_welzl('120km')
188±3ms 185±2ms 0.99 mpas_ocean.ConstructFaceLatLon.time_welzl('480km')
12.0±0.2ms 11.3±0.3ms 0.94 mpas_ocean.ConstructTreeStructures.time_ball_tree('120km')
750±50μs 756±50μs 1.01 mpas_ocean.ConstructTreeStructures.time_ball_tree('480km')
8.86±0.4ms 8.67±0.1ms 0.98 mpas_ocean.ConstructTreeStructures.time_kd_tree('120km')
583±20μs 644±20μs ~1.10 mpas_ocean.ConstructTreeStructures.time_kd_tree('480km')
567±4ms 564±1ms 1.00 mpas_ocean.CrossSections.time_const_lat('120km', 1)
288±5ms 284±2ms 0.99 mpas_ocean.CrossSections.time_const_lat('120km', 2)
146±2ms 148±2ms 1.01 mpas_ocean.CrossSections.time_const_lat('120km', 4)
428±4ms 433±9ms 1.01 mpas_ocean.CrossSections.time_const_lat('480km', 1)
216±1ms 218±2ms 1.01 mpas_ocean.CrossSections.time_const_lat('480km', 2)
110±1ms 110±0.6ms 1.00 mpas_ocean.CrossSections.time_const_lat('480km', 4)
23.6±0.07ms 23.7±0.3ms 1.00 mpas_ocean.DualMesh.time_dual_mesh_construction('120km')
2.58±0.06ms 2.56±0.09ms 0.99 mpas_ocean.DualMesh.time_dual_mesh_construction('480km')
702±6ms 704±1ms 1.00 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', False)
41.7±1ms 42.8±0.8ms 1.03 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', True)
58.9±0.3ms 60.7±0.7ms 1.03 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', False)
4.43±0.2ms 4.70±0.2ms 1.06 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', True)
354M 351M 0.99 mpas_ocean.Gradient.peakmem_gradient('120km')
206±5ms 206±3ms 1.00 mpas_ocean.Gradient.time_gradient('120km')
13.7±0.1ms 13.8±0.05ms 1.00 mpas_ocean.Gradient.time_gradient('480km')
248±4μs 251±4μs 1.02 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('120km')
95.3±1μs 96.1±0.8μs 1.01 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('480km')
350M 350M 1.00 mpas_ocean.Integrate.peakmem_integrate('120km')
329M 329M 1.00 mpas_ocean.Integrate.peakmem_integrate('480km')
158±2μs 160±5μs 1.01 mpas_ocean.Integrate.time_integrate('120km')
147±0.6μs 148±2μs 1.01 mpas_ocean.Integrate.time_integrate('480km')
133±3ms 135±3ms 1.02 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'exclude')
136±3ms 134±2ms 0.99 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'include')
137±1ms 136±1ms 0.99 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'split')
10.0±0.07ms 10.4±0.1ms 1.03 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'exclude')
9.96±0.09ms 10.3±0.3ms 1.03 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'include')
10.0±0.1ms 10.4±0.09ms 1.04 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'split')
204±1μs 201±10μs 0.99 mpas_ocean.PointInPolygon.time_face_search_lonlat('120km')
204±4μs 203±1μs 0.99 mpas_ocean.PointInPolygon.time_face_search_lonlat('480km')
195±8μs 194±4μs 0.99 mpas_ocean.PointInPolygon.time_face_search_xyz('120km')
191±0.9μs 195±2μs 1.02 mpas_ocean.PointInPolygon.time_face_search_xyz('480km')
180±0.02ms 175±0.7ms 0.97 mpas_ocean.RemapDownsample.time_bilinear_remapping
172±1ms 172±2ms 1.00 mpas_ocean.RemapDownsample.time_inverse_distance_weighted_remapping
3.58±0.03ms 3.55±0.02ms 0.99 mpas_ocean.RemapDownsample.time_nearest_neighbor_remapping
959±2ms 967±7ms 1.01 mpas_ocean.RemapUpsample.time_bilinear_remapping
30.2±0.8ms 30.5±0.5ms 1.01 mpas_ocean.RemapUpsample.time_inverse_distance_weighted_remapping
7.78±0.2ms 7.99±0.2ms 1.03 mpas_ocean.RemapUpsample.time_nearest_neighbor_remapping
21.8±1ms 21.5±0.4ms 0.98 mpas_ocean.ZonalAverage.time_zonal_average('120km')
4.21±0.08ms 4.30±0.1ms 1.02 mpas_ocean.ZonalAverage.time_zonal_average('480km')
325M 326M 1.00 quad_hexagon.QuadHexagon.peakmem_open_dataset
324M 324M 1.00 quad_hexagon.QuadHexagon.peakmem_open_grid
6.38±0.3ms 5.78±0.2ms ~0.91 quad_hexagon.QuadHexagon.time_open_dataset
4.77±0.1ms 5.56±0.6ms ~1.17 quad_hexagon.QuadHexagon.time_open_grid

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

("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!

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


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?

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

@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

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