Skip to content

Commit 5be658a

Browse files
cuda.core: add GraphBuilder.graph_definition property (NVIDIA#2026)
* cuda.core: add GraphBuilder.graph_definition property Completes step 3 of NVIDIA#1330 by exposing the captured graph as an explicit `GraphDefinition` view that shares ownership of the underlying `CUgraph`. The handle-layer plumbing landed in PR NVIDIA#2008; this commit wires up the user-facing surface and locks in the state-guard rules. State semantics: - PRIMARY builder: only valid after `end_building()`. Before `begin_building()` no graph exists; during capture the driver is the sole writer, so explicit access is unsafe. - CONDITIONAL_BODY builder: valid both before `begin_building()` (the body graph is allocated at conditional-node creation time) and after `end_building()`. This enables a hybrid flow where a conditional body is populated entirely via the explicit API, with no capture at all. - FORKED builder: never valid. Forked builders share the primary's graph; access through the primary instead. Tests cover the happy path, both hybrid flows on conditional bodies (populate-via-explicit-API and capture-then-augment), the three error states (forked, capturing, primary pre-capture), and the shared-ownership guarantee (the `GraphDefinition` survives the builder's `close()`). Co-authored-by: Cursor <cursoragent@cursor.com> * cuda.core: guard graph_definition after close and document it Address review feedback on PR NVIDIA#2026: - Reject GraphBuilder.graph_definition access after close() via GB_check_open, so it fails at the builder boundary instead of returning a GraphDefinition wrapping a null CUgraph. A view obtained before close() still works (shared ownership). - Add ".. versionadded:: 1.1.0" to the property (.pyx and .pyi) and a release-note bullet in 1.1.0-notes.rst. - Add pytest authorship markers to the new graph_definition tests and a test for the post-close rejection path. --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 3ae6dcc commit 5be658a

4 files changed

Lines changed: 338 additions & 3 deletions

File tree

cuda_core/cuda/core/graph/_graph_builder.pyi

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,53 @@ class GraphBuilder:
129129
def is_join_required(self) -> bool:
130130
"""Returns True if this graph builder must be joined before building is ended."""
131131

132+
@property
133+
def graph_definition(self) -> GraphDefinition:
134+
"""The captured graph as an explicit :class:`~graph.GraphDefinition`.
135+
136+
.. versionadded:: 1.1.0
137+
138+
The returned :class:`~graph.GraphDefinition` is a view of the same
139+
graph this builder is producing: nodes added through it appear in
140+
subsequent :meth:`complete` and :meth:`debug_dot_print` calls, and
141+
the view stays valid even after the builder is closed.
142+
143+
This lets you mix the capture and explicit APIs on a single graph,
144+
for example to inspect what was captured, augment it with extra
145+
nodes, or build a conditional body entirely with the explicit API.
146+
147+
Availability:
148+
149+
- **Primary builders** (created by :meth:`Device.create_graph_builder`
150+
or :meth:`Stream.create_graph_builder`): only after
151+
:meth:`end_building`.
152+
153+
- **Conditional-body builders** (returned by :meth:`if_then`,
154+
:meth:`if_else`, :meth:`while_loop`, :meth:`switch`): both before
155+
:meth:`begin_building` and after :meth:`end_building`. The body
156+
graph already exists when the conditional is created, so you may
157+
populate it through this view without ever calling
158+
:meth:`begin_building` on the body builder.
159+
160+
- **Forked builders** (returned by :meth:`split`): never. Forked
161+
builders share the primary builder's graph; access it through the
162+
primary instead.
163+
164+
Returns
165+
-------
166+
GraphDefinition
167+
A view of the graph being built.
168+
169+
Raises
170+
------
171+
RuntimeError
172+
If the builder is closed, forked, currently building, or (for
173+
primary builders) has not started building yet. A
174+
:class:`~graph.GraphDefinition` obtained before :meth:`close`
175+
keeps working; only fresh access through this property is
176+
rejected once the builder is closed.
177+
"""
178+
132179
def begin_building(self, mode: str | None='relaxed') -> GraphBuilder:
133180
"""Begins the building process.
134181

cuda_core/cuda/core/graph/_graph_builder.pyx

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ from libc.stdint cimport intptr_t
99

1010
from cuda.bindings cimport cydriver
1111

12-
from cuda.core.graph._graph_definition cimport GraphCondition
12+
from cuda.core.graph._graph_definition cimport GraphCondition, GraphDefinition
1313
from cuda.core.graph._utils cimport _attach_host_callback_to_graph
1414
from cuda.core._resource_handles cimport (
1515
GraphHandle,
@@ -282,6 +282,70 @@ cdef class GraphBuilder:
282282
"""Returns True if this graph builder must be joined before building is ended."""
283283
return self._kind == FORKED
284284

285+
@property
286+
def graph_definition(self) -> GraphDefinition:
287+
"""The captured graph as an explicit :class:`~graph.GraphDefinition`.
288+
289+
.. versionadded:: 1.1.0
290+
291+
The returned :class:`~graph.GraphDefinition` is a view of the same
292+
graph this builder is producing: nodes added through it appear in
293+
subsequent :meth:`complete` and :meth:`debug_dot_print` calls, and
294+
the view stays valid even after the builder is closed.
295+
296+
This lets you mix the capture and explicit APIs on a single graph,
297+
for example to inspect what was captured, augment it with extra
298+
nodes, or build a conditional body entirely with the explicit API.
299+
300+
Availability:
301+
302+
- **Primary builders** (created by :meth:`Device.create_graph_builder`
303+
or :meth:`Stream.create_graph_builder`): only after
304+
:meth:`end_building`.
305+
306+
- **Conditional-body builders** (returned by :meth:`if_then`,
307+
:meth:`if_else`, :meth:`while_loop`, :meth:`switch`): both before
308+
:meth:`begin_building` and after :meth:`end_building`. The body
309+
graph already exists when the conditional is created, so you may
310+
populate it through this view without ever calling
311+
:meth:`begin_building` on the body builder.
312+
313+
- **Forked builders** (returned by :meth:`split`): never. Forked
314+
builders share the primary builder's graph; access it through the
315+
primary instead.
316+
317+
Returns
318+
-------
319+
GraphDefinition
320+
A view of the graph being built.
321+
322+
Raises
323+
------
324+
RuntimeError
325+
If the builder is closed, forked, currently building, or (for
326+
primary builders) has not started building yet. A
327+
:class:`~graph.GraphDefinition` obtained before :meth:`close`
328+
keeps working; only fresh access through this property is
329+
rejected once the builder is closed.
330+
"""
331+
GB_check_open(self)
332+
if self._kind == FORKED:
333+
raise RuntimeError(
334+
"graph_definition is unavailable on forked graph builders; "
335+
"access it through the primary builder instead."
336+
)
337+
if self._state == CAPTURING:
338+
raise RuntimeError(
339+
"graph_definition is unavailable while capture is in "
340+
"progress; call end_building() first."
341+
)
342+
if self._kind == PRIMARY and self._state == CAPTURE_NOT_STARTED:
343+
raise RuntimeError(
344+
"graph_definition is unavailable before begin_building() on "
345+
"a primary builder; no graph has been created yet."
346+
)
347+
return GraphDefinition._from_handle(self._h_graph)
348+
285349
def begin_building(self, mode: str | None = "relaxed") -> GraphBuilder:
286350
"""Begins the building process.
287351

cuda_core/docs/source/release/1.1.0-notes.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ New features
4949
These APIs avoid relying on the static NVML ``NVML_NVLINK_MAX_LINKS`` macro
5050
when querying the links available on a particular device.
5151

52+
- Added the :attr:`graph.GraphBuilder.graph_definition` property, which
53+
exposes a captured graph as an explicit :class:`graph.GraphDefinition`
54+
view sharing ownership of the same underlying graph. This enables hybrid
55+
flows that mix the capture and explicit graph-building APIs, such as
56+
inspecting or augmenting a captured graph, or populating a conditional
57+
body entirely through the explicit API.
58+
5259
Bug fixes
5360
---------
5461

cuda_core/tests/graph/test_graph_builder.py

Lines changed: 219 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@
55

66
import numpy as np
77
import pytest
8-
from helpers.graph_kernels import compile_common_kernels
8+
from helpers.graph_kernels import compile_common_kernels, compile_conditional_kernels
99
from helpers.marks import requires_module
10+
from helpers.misc import try_create_condition
1011

1112
from cuda.core import Device, LaunchConfig, LegacyPinnedMemoryResource, launch
12-
from cuda.core.graph import GraphBuilder
13+
from cuda.core.graph import GraphBuilder, GraphDefinition
1314

1415

1516
def test_graph_is_building(init_cuda):
@@ -384,3 +385,219 @@ def test_graph_stream_lifetime(init_cuda):
384385

385386
# Destroy the stream
386387
stream.close()
388+
389+
390+
# ---------------------------------------------------------------------------
391+
# GraphBuilder.graph_definition
392+
# ---------------------------------------------------------------------------
393+
394+
395+
@pytest.mark.agent_authored(model="claude-opus-4.8")
396+
def test_graph_definition_returns_graph_definition_after_end_building(init_cuda):
397+
"""Primary builder exposes its captured graph as a GraphDefinition after end_building()."""
398+
mod = compile_common_kernels()
399+
empty_kernel = mod.get_kernel("empty_kernel")
400+
401+
gb = Device().create_graph_builder().begin_building()
402+
launch(gb, LaunchConfig(grid=1, block=1), empty_kernel)
403+
launch(gb, LaunchConfig(grid=1, block=1), empty_kernel)
404+
gb.end_building()
405+
406+
gd = gb.graph_definition
407+
assert isinstance(gd, GraphDefinition)
408+
# The captured graph must contain the launched kernels.
409+
assert len(gd.nodes()) == 2
410+
411+
412+
@pytest.mark.agent_authored(model="claude-opus-4.8")
413+
def test_graph_definition_raises_before_begin_building(init_cuda):
414+
"""Primary builder has no graph allocated before begin_building()."""
415+
gb = Device().create_graph_builder()
416+
with pytest.raises(RuntimeError, match="before begin_building"):
417+
_ = gb.graph_definition
418+
419+
420+
@pytest.mark.agent_authored(model="claude-opus-4.8")
421+
def test_graph_definition_raises_during_capture(init_cuda):
422+
"""graph_definition is unsafe while the driver is actively capturing."""
423+
gb = Device().create_graph_builder().begin_building()
424+
try:
425+
with pytest.raises(RuntimeError, match="capture is in"):
426+
_ = gb.graph_definition
427+
finally:
428+
gb.end_building()
429+
430+
431+
@pytest.mark.agent_authored(model="claude-opus-4.8")
432+
def test_graph_definition_raises_for_forked(init_cuda):
433+
"""Forked builders share the primary's graph; their property must raise."""
434+
mod = compile_common_kernels()
435+
empty_kernel = mod.get_kernel("empty_kernel")
436+
437+
gb = Device().create_graph_builder().begin_building()
438+
launch(gb, LaunchConfig(grid=1, block=1), empty_kernel)
439+
primary, sibling = gb.split(2)
440+
try:
441+
with pytest.raises(RuntimeError, match="forked"):
442+
_ = sibling.graph_definition
443+
finally:
444+
sibling = GraphBuilder.join(primary, sibling)
445+
sibling.end_building()
446+
447+
448+
@pytest.mark.agent_authored(model="claude-opus-4.8")
449+
def test_graph_definition_shares_ownership(init_cuda):
450+
"""Closing the builder must not invalidate a held GraphDefinition."""
451+
mod = compile_common_kernels()
452+
empty_kernel = mod.get_kernel("empty_kernel")
453+
454+
gb = Device().create_graph_builder().begin_building()
455+
launch(gb, LaunchConfig(grid=1, block=1), empty_kernel)
456+
gb.end_building()
457+
458+
gd = gb.graph_definition
459+
gb.close()
460+
# The shared CUgraph keeps the graph alive.
461+
assert len(gd.nodes()) == 1
462+
463+
464+
@pytest.mark.agent_authored(model="claude-opus-4.8")
465+
def test_graph_definition_raises_after_close(init_cuda):
466+
"""Accessing the property after close() must fail at the builder boundary.
467+
468+
close() resets the graph handle, so returning a view here would wrap a
469+
null CUgraph and defer the failure to a later CUDA call. Reject fresh
470+
access instead, matching complete() and debug_dot_print().
471+
"""
472+
mod = compile_common_kernels()
473+
empty_kernel = mod.get_kernel("empty_kernel")
474+
475+
gb = Device().create_graph_builder().begin_building()
476+
launch(gb, LaunchConfig(grid=1, block=1), empty_kernel)
477+
gb.end_building()
478+
gb.close()
479+
480+
with pytest.raises(RuntimeError, match="closed"):
481+
_ = gb.graph_definition
482+
483+
484+
@pytest.mark.agent_authored(model="claude-opus-4.8")
485+
def test_graph_definition_round_trips_through_explicit_api(init_cuda):
486+
"""Mutating via the explicit API survives complete() and runs correctly."""
487+
mod = compile_common_kernels()
488+
add_one = mod.get_kernel("add_one")
489+
490+
launch_stream = Device().create_stream()
491+
mr = LegacyPinnedMemoryResource()
492+
b = mr.allocate(4)
493+
arr = np.from_dlpack(b).view(np.int32)
494+
arr[0] = 0
495+
496+
gb = launch_stream.create_graph_builder().begin_building()
497+
launch(gb, LaunchConfig(grid=1, block=1), add_one, arr.ctypes.data)
498+
gb.end_building()
499+
500+
# Add a second add_one through the explicit GraphDefinition view.
501+
gd = gb.graph_definition
502+
captured_node = next(iter(gd.nodes()))
503+
captured_node.launch(LaunchConfig(grid=1, block=1), add_one, arr.ctypes.data)
504+
assert len(gd.nodes()) == 2
505+
506+
graph = gb.complete()
507+
graph.launch(launch_stream)
508+
launch_stream.sync()
509+
assert arr[0] == 2
510+
511+
b.close()
512+
513+
514+
@pytest.mark.agent_authored(model="claude-opus-4.8")
515+
@requires_module(np, "2.1")
516+
def test_graph_definition_hybrid_conditional_body(init_cuda):
517+
"""Populate a conditional body entirely through the explicit API.
518+
519+
This is the headline hybrid flow enabled by the new property:
520+
``if_then`` returns a ``GraphBuilder`` for the body, but instead of
521+
calling ``begin_building`` and capturing into it, we reach for
522+
``graph_definition`` and add nodes through the explicit API.
523+
"""
524+
mod = compile_conditional_kernels(int)
525+
add_one = mod.get_kernel("add_one")
526+
set_handle = mod.get_kernel("set_handle")
527+
528+
launch_stream = Device().create_stream()
529+
mr = LegacyPinnedMemoryResource()
530+
b = mr.allocate(4)
531+
arr = np.from_dlpack(b).view(np.int32)
532+
arr[0] = 0
533+
534+
gb = Device().create_graph_builder().begin_building()
535+
condition = try_create_condition(gb)
536+
launch(gb, LaunchConfig(grid=1, block=1), set_handle, condition, 1)
537+
body_gb = gb.if_then(condition)
538+
539+
# Skip body_gb.begin_building() entirely -- the body graph already
540+
# exists at conditional-node creation time and is exposed here.
541+
body_def = body_gb.graph_definition
542+
assert isinstance(body_def, GraphDefinition)
543+
assert len(body_def.nodes()) == 0
544+
body_def.launch(LaunchConfig(grid=1, block=1), add_one, arr.ctypes.data)
545+
546+
graph = gb.end_building().complete()
547+
graph.launch(launch_stream)
548+
launch_stream.sync()
549+
assert arr[0] == 1
550+
551+
b.close()
552+
553+
554+
@pytest.mark.agent_authored(model="claude-opus-4.8")
555+
@requires_module(np, "2.1")
556+
def test_graph_definition_conditional_body_after_capture(init_cuda):
557+
"""Capture into a conditional body, then augment it via the explicit API."""
558+
mod = compile_conditional_kernels(int)
559+
add_one = mod.get_kernel("add_one")
560+
set_handle = mod.get_kernel("set_handle")
561+
562+
launch_stream = Device().create_stream()
563+
mr = LegacyPinnedMemoryResource()
564+
b = mr.allocate(4)
565+
arr = np.from_dlpack(b).view(np.int32)
566+
arr[0] = 0
567+
568+
gb = Device().create_graph_builder().begin_building()
569+
condition = try_create_condition(gb)
570+
launch(gb, LaunchConfig(grid=1, block=1), set_handle, condition, 1)
571+
body_gb = gb.if_then(condition).begin_building()
572+
573+
# Capture one increment into the body.
574+
launch(body_gb, LaunchConfig(grid=1, block=1), add_one, arr.ctypes.data)
575+
body_gb.end_building()
576+
577+
# Add a second increment via the explicit API on the same body graph.
578+
body_def = body_gb.graph_definition
579+
captured_node = next(iter(body_def.nodes()))
580+
captured_node.launch(LaunchConfig(grid=1, block=1), add_one, arr.ctypes.data)
581+
assert len(body_def.nodes()) == 2
582+
583+
graph = gb.end_building().complete()
584+
graph.launch(launch_stream)
585+
launch_stream.sync()
586+
assert arr[0] == 2
587+
588+
b.close()
589+
590+
591+
@pytest.mark.agent_authored(model="claude-opus-4.8")
592+
@requires_module(np, "2.1")
593+
def test_graph_definition_conditional_body_during_capture_raises(init_cuda):
594+
"""The CAPTURING-state guard fires for conditional bodies too."""
595+
gb = Device().create_graph_builder().begin_building()
596+
condition = try_create_condition(gb)
597+
body_gb = gb.if_then(condition).begin_building()
598+
try:
599+
with pytest.raises(RuntimeError, match="capture is in"):
600+
_ = body_gb.graph_definition
601+
finally:
602+
body_gb.end_building()
603+
gb.end_building()

0 commit comments

Comments
 (0)