Add edge mutation and MutableSet interface for graph nodes#1850
Add edge mutation and MutableSet interface for graph nodes#1850Andy-Jost merged 8 commits intoNVIDIA:mainfrom
Conversation
This comment has been minimized.
This comment has been minimized.
|
Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually. Contributors can view more details about this message here. |
Rename test files to reflect what they actually test: - test_basic -> test_graph_builder (stream capture tests) - test_conditional -> test_graph_builder_conditional - test_advanced -> test_graph_update (moved child_graph and stream_lifetime tests into test_graph_builder) - test_capture_alloc -> test_graph_memory_resource - test_explicit* -> test_graphdef* Made-with: Cursor
- Extend Graph.update() to accept both GraphBuilder and GraphDef sources - Surface CUgraphExecUpdateResultInfo details on update failure instead of a generic CUDA_ERROR_GRAPH_EXEC_UPDATE_FAILURE message - Release the GIL during cuGraphExecUpdate via nogil block - Add parametrized happy-path test covering both GraphBuilder and GraphDef - Add error-case tests: unfinished builder, topology mismatch, wrong type Made-with: Cursor
Replace cached tuple-based pred/succ with mutable AdjacencySet backed by direct CUDA driver calls. Add GraphNode.remove() wrapping cuGraphDestroyNode. Made-with: Cursor
…cencies Enable adding/removing edges between graph nodes via AdjacencySet (a MutableSet proxy on GraphNode.pred/succ), node removal via discard(), and property setters for bulk edge replacement. Includes comprehensive mutation and interface tests. Closes part of NVIDIA#1330 (step 2: edge mutation on GraphDef). Made-with: Cursor
Replace inline skipif version check with requires_module(np, "2.1") from the shared test helpers, consistent with other test files. Made-with: Cursor
4b837a9 to
3aef7e0
Compare
Rename class and file to AdjacencySetProxy to clarify write-through semantics. Add bulk-efficient clear(), __isub__(), __ior__() overrides and remove_edges() on the Cython core. Guard GraphNode.discard() against double-destroy via membership check. Filter duplicates in update(). Add error-path tests for wrong types, cross-graph edges, and self-edges. Made-with: Cursor
3aef7e0 to
8554d30
Compare
|
/ok to test |
…INEL Replace discard() with destroy() which calls cuGraphDestroyNode and then zeroes the CUgraphNode resource in the handle box via invalidate_graph_node_handle. This prevents stale memory access on destroyed nodes. Properties (type, pred, succ, handle) degrade gracefully to None/empty for destroyed nodes. Remove the GRAPH_NODE_SENTINEL (0x1) approach in favor of using NULL for both sentinels and destroyed nodes, which is simpler and avoids the risk of passing 0x1 to driver APIs that treat it as a valid pointer. Made-with: Cursor
| GraphHandle graph_node_get_graph(const GraphNodeHandle& h) noexcept; | ||
|
|
||
| // Zero the CUgraphNode resource inside the handle, marking it invalid. | ||
| void invalidate_graph_node_handle(const GraphNodeHandle& h) noexcept; |
There was a problem hiding this comment.
Could we make the invalidate_graph_node_handle take a mutable reference? Rather than internally mutating through a const& handle?
There was a problem hiding this comment.
I don't think it will improve anything. GraphNodeHandle is a std::shared_ptr, and this "invalidate" function relies on T& std::shared_ptr::operator*() const noexcept to get a non-const reference to the target object (note the function is marked const but the return type is a non-const T&; doc). Aconst qualifier on GraphNodeHandle only refers to whether the pointer can be reset or rebound to a new target through something like assignment/move, not whether the target itself is mutable. The "invalidate" function marks the target as having been invalidated by a call to cuGraphNodeDestroy but does not change the target location, so a const handle is correct.
Removing const from this function would not have the effect you might expect. Consider these three handles to two targets:
GraphNodeHandle h1 = ...; // a graph node handle
const GraphNodeHandle h2 = h1; // a second handle to the same graph node
GraphNodeHandle h3 = ...; // a handle to an unrelated graph node
Marking h2 with const protects the handle, not the GraphNode it points to. In this case, h2 = h3 would be disallowed. If we changed the declaration as you suggest, invalidate_graph_node_handle(h2) would be disallowed; however, invalidate_graph_node_handle(h1) would still be allowed and would invalidate the shared graph node.
In general, handle targets are always marked const because resource handles by design only permit attach, detach, and dereference operations. The GraphNodeHandle specifically and oddly needs invalidation because of the strange (I'd say broken) semantics of cuGraphNodeDestroy.
One way to support invalidation would be removing const from this declaration:
using GraphNodeHandle = std::shared_ptr<const CUgraphNode>
Then one could write *h2 = NULL to invalidate. That's not a good solution because it allows mutation everywhere.
The better approach (what's in this PR) is to keep the current definition, consistent with other handle types, while marking CUgraphNode as mutable in GraphNodeBox and providing an "invalidate" function to invalidate the handle when needed. This keeps the GraphNodeHandle similar to all other handles, while supporting this odd destroy behavior that is needed because of cuGraphNodeDestroy.
There was a problem hiding this comment.
This function should probably be called invalidate_graph_node
| def __contains__(self, x): | ||
| if not isinstance(x, GraphNode): | ||
| return False | ||
| return x in (<_AdjacencySetCore>self._core).query() |
There was a problem hiding this comment.
This may be premature optimization, but is it required to instantiate an entire list that is a copy of the elements on the C++ side just to test for in? We'd have to check that this works in Cython, but having query return an iterator comprehension rather than a list comprehension may be enough.
There was a problem hiding this comment.
Maybe this?
--- a/cuda_core/cuda/core/_graph/_graph_def/_adjacency_set_proxy.pyx
+++ b/cuda_core/cuda/core/_graph/_graph_def/_adjacency_set_proxy.pyx
@@ -68,7 +68,7 @@ class AdjacencySetProxy(MutableSet):
"""Remove all edges in a single driver call."""
members = (<_AdjacencySetCore>self._core).query()
if members:
- (<_AdjacencySetCore>self._core).remove_edges(members)
+ (<_AdjacencySetCore>self._core).remove_edges(list(members))
def __isub__(self, it):
"""Remove edges to all nodes in *it* in a single driver call."""
@@ -139,7 +139,7 @@ cdef class _AdjacencySetCore:
c_from[0] = as_cu(other._h_node)
c_to[0] = as_cu(self._h_node)
- cdef list query(self):
+ cdef query(self):
cdef cydriver.CUgraphNode c_node = as_cu(self._h_node)
if c_node == NULL:
return []
@@ -153,8 +153,8 @@ cdef class _AdjacencySetCore:
with nogil:
HANDLE_RETURN(self._query_fn(
c_node, nodes_vec.data(), &count))
- return [GraphNode._create(self._h_graph, nodes_vec[i])
- for i in range(count)]
+ return (GraphNode._create(self._h_graph, nodes_vec[i])
+ for i in range(count))
cdef Py_ssize_t count(self):
cdef cydriver.CUgraphNode c_node = as_cu(self._h_node)There was a problem hiding this comment.
I was thinking about this, too. Definitely a good idea to see whether query can return a generator. I was thinking of seeing whether I can implement contains() directly on AdjacencySetCore. We have to query the driver in batch, but we don't need to reconstruct the Python objects at all just to check containment.
| nodes.extend(other) | ||
| if not nodes: | ||
| return | ||
| for n in nodes: |
There was a problem hiding this comment.
Could move this check next to the nodes.extend(other) above, since we already know it to be true if isinstance(other, GraphNode) above.
| coeffs = 5, 3 | ||
| b_new = rig.graph_def.launch(rig.config, rig.affine, rig.ptr_b, *coeffs) | ||
| b0, b1 = rig.b | ||
| b0.succ.discard(b1) |
There was a problem hiding this comment.
discard is defined to silently do nothing if the thing being removed doesn't exist. And it definitely does this by experimentation, but it's puzzling to me /why/ it does, since the underlying call to cuGraphRemoveDependencies says:
"Specifying a non-existing dependency will return an error."
I'm probably just missing some step in the logic here that is (correctly) preventing the exception from bubbling up. But in the event that this is the API not doing what it says it would (and therefore might do so in the future), it's probably worth doing this twice to test that removing an already-moved element doesn't fail.
There was a problem hiding this comment.
I had to put this containment check in discard for exactly this reason.
def discard(self, value):
if not isinstance(value, GraphNode):
return
if value not in self: # <<<
returnAlso, the situation you mention is covered in assert_mutable_set_interface:
# discard non-member is a no-op
subject.discard(d)
assert subject == refThere was a problem hiding this comment.
Awesome. That's exactly the thing I missed.
|
Summary
Implements step 2 of #1330 (graph updates): edge mutation on
GraphDef. Graph node adjacencies (pred/succ) are now exposed asAdjacencySetProxy, acollections.abc.MutableSetproxy backed bycuGraphAddDependencies/cuGraphRemoveDependencies. Nodes can be removed viaGraphNode.destroy()(wrapscuGraphDestroyNode), and property setters onpred/succsupport bulk edge replacement.Examples
Changes
AdjacencySetProxy: fullMutableSetimplementation with bulk-efficientclear(),__isub__(),__ior__(),update(), andremove_edges(). Includes_from_iterableoverride for binary ops and duplicate-add guard to avoidCUDA_ERROR_INVALID_VALUE.GraphNode.destroy(): removes a node and its edges from the graph definition viacuGraphDestroyNode, then zeroes the underlying handle to prevent stale memory access. Safe to call on already-destroyed nodes. Properties (type,pred,succ,handle) degrade gracefully toNoneor empty sets.GraphNode.is_valid: returnsFalseafterdestroy()has been calledGraphNode.pred/succsetters: clear existing edges and add new ones in bulkGraphDef.nodes()/edges(): returnsetinstead oftuplefor consistencycompile_parallel_kernels()ingraph_kernels.py,assert_mutable_set_interface()incollection_interface_testers.pyTest Coverage
TestMutateYRigclass: baseline, node destroy (single/multiple), edge insertion, join-node removal with rewiringtest_destroyed_node: verifies handle invalidation, graceful property degradation, edge-add failure on destroyed nodes, and double-destroy safetytest_adjacency_set_interface: exercises everyMutableSetmethod against a referencesettest_adjacency_set_pred_direction: verifies bidirectional edge visibilitytest_adjacency_set_property_setter: verifies bulk edge replacement via setterstest_add_wrong_type,test_cross_graph_edge,test_self_edge: error-path coveragetest_convert_linear_to_fan_in: rewires a sequential graph into parallel fan-in topologyRelated Work
_graph_def/subpackage refactor fromgraph-updatesbranch