Skip to content

Optimized connectivity: Simultaneous face_edges and edge_nodes - #1560

Merged
erogluorhan merged 55 commits into
UXARRAY:mainfrom
cmdupuis3:cmd/merge-OFE
Sep 8, 2026
Merged

erogluorhan merged 55 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
@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
@cmdupuis3
cmdupuis3 requested a review from rajeeja August 21, 2026 21:01
@erogluorhan
erogluorhan self-requested a review August 25, 2026 20:20

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

Please see below a few comments:

Comment thread uxarray/grid/connectivity.py Outdated
Comment thread uxarray/grid/connectivity.py Outdated
Comment thread uxarray/grid/connectivity.py Outdated

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

All of my prior comments have been resolved and no glaring issues popped out at me from a final read-through, so I'm happy to approve!

@rajeeja

rajeeja commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Going through this in pieces, will have a few more comments later today. Good PR overall and happy to be reviewing it.

assert actual == expected

# Remaining slots stay padded
assert np.all(face_edges[face_idx, n_edges:] == INT_FILL_VALUE)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@cmdupuis3 The new ValueError in _populate_edge_node_connectivity can't be reached from any caller, so it's untested and shows as uncovered. This MPAS mesh already loads with edge_node_connectivity in _ds, so it pins the guard without a new fixture.

Suggested change
assert np.all(face_edges[face_idx, n_edges:] == INT_FILL_VALUE)
assert np.all(face_edges[face_idx, n_edges:] == INT_FILL_VALUE)
def test_connectivity_edge_node_refuses_to_overwrite(gridpath):
"""Test that rebuilding edges on a grid that already has them is refused."""
from uxarray.grid.connectivity import _populate_edge_node_connectivity
# This mesh supplies verticesOnEdge, so the grid loads with edges already present
uxgrid = ux.open_grid(gridpath("mpas", "QU", "mesh.QU.1920km.151026.nc"))
assert "edge_node_connectivity" in uxgrid._ds
with pytest.raises(ValueError, match="already has"):
_populate_edge_node_connectivity(uxgrid)

@rajeeja

rajeeja commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Take outCSne30.ug. Renumber its face_edge_connectivity edge ids, put on a fresh grid with
no edge_node_connectivity. Legal UGRID.

  • Touch grid.n_edge. Main: 5400/5400 rows survive. This PR: 5400/5400 overwritten. No error,
    no warning.
  • Cause: the PR builds both connectivities in one pass, so _populate_edge_node_connectivity
    now writes two arrays. The guard at connectivity.py:173 still checks one.
  • Second write at connectivity.py:200 walks past it.
  • The guard's reasoning is right. Constructed edges are lexicographic and won't match file
    order, which is what its own error message says. It just needs to cover both variables the
    function writes now.
  • Main isn't correct here either, it keeps the file's array while the new
    edge_node_connectivity is lexicographic, so the two disagree silently.
  • Untestable today: no mesh under test/meshfiles has face_edge_connectivity without
    edge_node_connectivity. Same hole as test_connectivity.py:176, one variable over.

Two smaller things in utils.py:529-536. The comment on MIN_ADAPTIVE_SORT_SIZE = 16 says a
bucket that size can't exceed the shift budget, which holds only because
MAX_SHIFTS_PER_EDGE = 8: max inversions are size(size-1)/2 against a budget of 8*size, so
size <= 17. That coupling isn't written down or asserted anywhere, and dropping MAX to 4
makes the comment false at size 9.

And test_connectivity_bucket_sort says the 500 bucket falls back to the heap sort, which it
does, but the test only checks the final order and insertion sort alone would produce the
same. Raise MAX_SHIFTS_PER_EDGE and the heap path goes uncovered with the test still green.
_heap_sort_bucket isn't imported by any test.

@cmdupuis3

cmdupuis3 commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

@rajeeja Wait, so if I understand correctly, this issue is more that the guards at call sites don't check both connectivities, so without having if "edge_node_connectivity" in grid._ds or "face_edge_connectivity" in grid._ds:, we still have the possibility of desynching between these two connectivity variables, right? It would just be a different mode of desynch than I was thinking of.

I'll see about consolidating the two sorting parameters into one and moving it to constants.py.

_populate_edge_node_connectivity writes both edge_node_connectivity and
face_edge_connectivity, and numbers both in the constructed (lexicographic)
edge order. The guard only covered edge_node_connectivity, so a grid holding
a file-order face_edge_connectivity and no edge_node_connectivity passed every
caller's check and had the stored variable silently renumbered on the first
access to n_edge or edge_node_connectivity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@rajeeja

rajeeja commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Yes, that covers both writes now, so the n_edge touch fails loud.

@cmdupuis3

Copy link
Copy Markdown
Collaborator Author

@rajeeja As per a convo yesterday, @erogluorhan and I think the edge_node indexing situation is still a bit confusing, so I want to rethink it slightly before we commit to merging.

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

Great!

@erogluorhan
erogluorhan merged commit a1eeb0e into UXARRAY:main Sep 8, 2026
16 checks passed
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

6 participants