Skip to content

Optimized connectivity: Simultaneous face_edges and edge_nodes - #1560

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

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

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

Took a close look at this one just now. It does look like it is getting closer to being ready overall, I have a few small questions and I left some inline comments accordingly.

The main blockers for me would be a couple bigger questions which I do not think are resolved yet. These are: "why does this PR introduce lexicographic ordering?" and "do the changes here actually speed up n_nodes_per_face for large grids?" (See replies to older comment threads for more details).

Comment thread benchmarks/mpas_ocean.py
Comment thread uxarray/grid/connectivity.py
Comment thread uxarray/grid/connectivity.py
Comment thread test/grid/grid/test_connectivity.py
Comment thread uxarray/grid/connectivity.py Outdated
@cmdupuis3

Copy link
Copy Markdown
Collaborator Author

pre-commit.ci autofix

@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
f"edge-centered variables ({', '.join(stale)}). Constructed edges are "
f"numbered in lexicographic node-pair order, which need not match the "
f"numbering those variables were stored with; they may no longer refer "
f"to the same edges."

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.

Can you elaborate on this?

If the input grid has edges defined but no explicit edge-node connectivity, will we never be able to create that connectivity?

Even if that's the case, UXarray should still be able to construct connectivity that respect the edge ordering in the existing edges, and I believe this has been the case so far with our current connectivity construction.

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, what's happening here is that if a grid already comes with edge_node connectivity, that would be loaded. It would presumably be in some order, but we don't know what order that actually is. If _populate_edge_node_connectivity gets called on that Grid, it's going to reconstruct the edge_node connectivity in lexicographic order, which may not match the original order. So the idea is that if you have variables indexed by edge_node, recalculating edge_node connectivity could potentially scramble the index order.

I changed this message to hopefully be clearer about what's happening.

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.

That sounds good, but what is concerning to me is that this if-check will trigger even when there is only edge coords but not edge connectivities present in the grid, e.g. edge_lon, edge_lat. What do you think?

@cmdupuis3 cmdupuis3 Sep 1, 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.

@erogluorhan Maybe if "edge_node_connectivity" in grid._ds would be more accurate?

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.

Yeah, I think that is safer

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.

No, it's not reachable. All three callers check "edge_node_connectivity" not in grid._ds before calling, so a grid that already has edges from a file returns them straight from the property and never gets here.

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.

You're right, callers already handle that check.

@cmdupuis3 if you want to handle this check only here, that's okay, but remove the same check from all the other callers.

Also on a second thought, should we really raise a ValueError or just silently skip? I think the latter.

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 don't think any other _populate... functions have internal logic like "if check: actually, don't populate anything". It would feel strange to me to add that here, it would not follow my expectations that "calling _populate...() should actually populate something". If there is some reason it can't actually run as expected, I would want it to crash (with ValueError if it is fundamentally impossible for the given inputs, or NotImplementedError if it is possible but would just need a cleverer algorithm which doesn't exist yet).

@erogluorhan erogluorhan Sep 3, 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.

We are talking about not populating again when there is already edge_node_connectivity present in the Grid (i.e. in order not to possibly break lexicographic order in this case). It can occur in several cases though, i.e. either when grid.edge_node_connectivity is accessed after the first time, or grid.face_edge_connectivity is being populated but edge_node_connectivity already exists.).

I don't agree with throwing an error, i.e. making code crash in that case doesn't make sense since the user has everything ready, and why should avoidance of re-populating an existing property crash the code, shouldn't it be okay with a warning instead?

If we don't want if-check within _populate...(), remove it since all of the callers are already doing this check and move the above message to where it is called (only for the case of simultaneous construction with face_edges I believe)

@cmdupuis3 cmdupuis3 Sep 3, 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.

@rajeeja Good catch, after looking at the call sites, it looks unreachable to me. On digging deeper though, I found that where Philip left this branch, this block was included but commented out:

    # if (
    #     "edge_node_connectivity" not in grid._ds
    #     or "inverse_indices" not in grid._ds["edge_node_connectivity"].attrs
    # ):
    #     _populate_edge_node_connectivity(grid)

This would have been a case where you'd want to have the check on if "edge_node_connectivity" in grid._ds:. But part of the point of this branch is to remove the need for inverse_indices, so without that, the check becomes degenerate.

I think with that, it makes sense to just remove it.

@erogluorhan To me, it's about keeping the state of Grid consistent. Having edge_node_connectivity in a different state than the rest of the Grid object seems like it would get very confusing. But as Rajeev pointed out, this particular guard is unreachable anyway.

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