Optimized connectivity: Simultaneous face_edges and edge_nodes - #1560
Optimized connectivity: Simultaneous face_edges and edge_nodes#1560cmdupuis3 wants to merge 52 commits into
Conversation
|
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? |
|
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. |
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
left a comment
There was a problem hiding this comment.
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).
|
pre-commit.ci autofix |
for more information, see https://pre-commit.ci
erogluorhan
left a comment
There was a problem hiding this comment.
Please see below a few comments:
| 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." |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
@erogluorhan Maybe if "edge_node_connectivity" in grid._ds would be more accurate?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
@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.
Sevans711
left a comment
There was a problem hiding this comment.
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!
|
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) |
There was a problem hiding this comment.
@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.
| 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) |
|
Take outCSne30.ug. Renumber its face_edge_connectivity edge ids, put on a fresh grid with
Two smaller things in utils.py:529-536. The comment on MIN_ADAPTIVE_SORT_SIZE = 16 says a And test_connectivity_bucket_sort says the 500 bucket falls back to the heap sort, which it |
|
@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 I'll see about consolidating the two sorting parameters into one and moving it to |
_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>
545000d to
c1fae0e
Compare
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
Testing
Documentation
_) and have been added todocs/internal_api/index.rst