Optimized connectivity: Simultaneous face_edges and edge_nodes - #1560
Optimized connectivity: Simultaneous face_edges and edge_nodes#1560cmdupuis3 wants to merge 39 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. |
| pass | ||
|
|
||
| edge_nodes, face_edges = _build_edge_node_connectivity( | ||
| grid.face_node_connectivity.values, grid.n_nodes_per_face.values |
There was a problem hiding this comment.
Please think about if you can get rid of .values calls to be chunked-array compatible.
There was a problem hiding this comment.
On it, I think Philip was trying to scalarize here but it introduced this regression.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
)There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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
- Bundled instead of separate
There was a problem hiding this comment.
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.
- Grid I/O is already made chunked - there might be things we can further fix though
- 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
There was a problem hiding this comment.
Added this comment:
# This is in lieu of an xarray equivalent to `da.compute(a, b)`. We traverse the grid once to gather both variables, possibly as chunks if dask is enabled
Is this bit resolved?
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>
|
pre-commit.ci autofix |
|
pre-commit.ci autofix |
I realize I never responded to this.
All that said, there seems like a big win on both performance and memory utilization in this PR. |
erogluorhan
left a comment
There was a problem hiding this comment.
@cmdupuis3 a quick observation:
- I believe this PR is not documenting this new way of combined constructions, so please mention it in both
face_edgeandedge_nodedocstrings for the user's reference.
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).
| 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) |
There was a problem hiding this comment.
(keeping this comment unresolved until at least one check shows faster n_nodes_per_face performance on a large grid)
|
pre-commit.ci autofix |
for more information, see https://pre-commit.ci
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