# Source code for tensorplay.cuda.graphs Source: https://www.tensorplay.cn/docs/_modules/tensorplay/cuda/graphs.html ``` # mypy: allow-untyped-defs r"""CUDA graph capture and replay helpers. Backed by the native :class:`tensorplay._C.CUDAGraph` class (capture on a dedicated side stream or a user-supplied one, graph-private allocator pools shareable across graphs, eager instantiation at ``capture_end``, replay of the cached executable, graph-safe RNG refresh per replay). g = tensorplay.cuda.CUDAGraph() static_input = x.clone() # warm up on the capture stream so lazy state lands outside the capture with tensorplay.cuda.graph(g): static_output = model(static_input) # ...later, refresh inputs and re-run without host-side launch overhead: static_input.copy_(new_batch) g.replay() For bulk replays where every input is a fresh CUDA tensor, :meth:`CUDAGraph.stage_and_launch` stages all inputs with raw async copies (dispatcher bypass) and launches in one call - measurably less host overhead than per-tensor ``copy_`` plus ``replay``. Static input/output tensors must stay alive for as long as the graph; their addresses are baked into the executable. Random ops captured inside the graph read their (seed, offset) from a graph-owned device buffer that every :meth:`CUDAGraph.replay` refreshes from the generator, so each replay draws a """ import contextlib __all__ = [ "CUDAGraph", "graph", "graph_pool_handle", "is_current_stream_capturing", "make_graphed_callables", "make_graphed_autograd_function", "export_dot", ] def _native(): try: from .. import _C except Exception as exc: # pragma: no cover - import failure diagnostics raise RuntimeError( "CUDA graphs require tensorplay._C; import failed: " f"{exc!r}. Was TensorPlay built with CUDA support?" ) from exc if not hasattr(_C, "CUDAGraph"): raise RuntimeError( "CUDA graphs are not supported by this TensorPlay build " "(tensorplay._C exposes no CUDAGraph class)" ) return _C [docs] def graph_pool_handle(): r"""Return an opaque token representing the id of a graph memory pool. Pass it as the ``pool=`` argument of :func:`graph` so several graphs capture into (and reuse memory from) one shared private pool. """ return _native().graph_pool_handle() def _resolve_pool(pool): if pool is None: return 0 if isinstance(pool, CUDAGraph): return pool.pool_id if isinstance(pool, int): return pool raise TypeError( "pool must be None, an id from graph_pool_handle(), or another " f"CUDAGraph; got {type(pool).__name__}" ) [docs] class CUDAGraph: def __init__(self): self._c = _native().CUDAGraph() [docs] def capture_begin(self, pool=None, capture_error_mode="global", stream=None): """Begin capture. Args: pool: ``None`` captures into a fresh private pool; an id from :func:`graph_pool_handle`, another graph's ``pool_id``, or another :class:`CUDAGraph` shares that pool instead. capture_error_mode (str): ``"global"`` fails the capture if any unsafe CUDA call happens anywhere in the process; ``"thread_local"`` only watches this thread; ``"relaxed"`` does not guard against unsafe calls. stream: custom capture stream (a ``tensorplay.cuda.Stream``). Defaults to the runtime's dedicated per-device side stream; the legacy default stream cannot participate in capture. """ if stream is not None: from .streams import Stream if not isinstance(stream, Stream): raise TypeError( f"stream must be a tensorplay.cuda.Stream; got " f"{type(stream).__name__}" ) native_stream = stream._stream else: native_stream = None self._c.capture_begin( _resolve_pool(pool), capture_error_mode, native_stream ) [docs] def capture_end(self): """End capture and compile the executable (paid here, not on first replay).""" self._c.capture_end() [docs] def instantiate(self): """No-op once instantiated; kept for late callers.""" self._c.instantiate() [docs] def replay(self, stream=None): """Run the graph: launch the cached executable on the current stream. Args: stream (Stream, optional): launch on this explicit stream instead of querying the current one - shaves a TLS lookup off hot loops pinned to a single stream. """ if stream is not None: from .streams import Stream if not isinstance(stream, Stream): raise TypeError( f"stream must be a tensorplay.cuda.Stream; got " f"{type(stream).__name__}" ) self._c.replay(stream._stream) else: self._c.replay() [docs] def stage_and_launch(self, static_inputs, inputs): r"""Stage every input onto its static buffer and replay in one call. Args: static_inputs: buffers captured by the graph (kept alive by the caller). inputs: fresh tensors whose contents overwrite the matching static buffer this iteration. Contiguous same-dtype/ same-device pairs take a raw async device-to-device copy; anything else falls back to full copy semantics. This is the low-overhead bulk entry used by :mod:`tensorplay.compiler.backends.cudagraphs`: one Python-to-native crossing for the whole replay instead of one dispatcher round trip per input. """ self._c.stage_and_launch(list(static_inputs), list(inputs)) [docs] def reset(self): """Destroy the executable and release the pool reference. All tensors allocated during the capture must be released first. """ self._c.reset() @property def pool_id(self): """Allocator pool id this graph captured against.""" return self._c.pool_id() def enable_debug_mode(self): self._c.enable_debug_mode() [docs] def debug_dump(self, path): r"""Write a DOT rendering of the captured graph to ``path``. Call :meth:`enable_debug_mode` before capturing for a dump that includes full node attributes. """ self._c.debug_dump(str(path)) # --- conditional nodes (CUDA >= 12.4) ----------------------------------- def _require_conditional_support(self): from .. import _C probe = getattr(_C, "conditional_nodes_supported", None) if probe is not None and not probe(): raise RuntimeError( "CUDA graphs conditional nodes require CUDA >= 12.4" ) [docs] def begin_capture_to_if_node(self, scalar_pred): r"""Inside an open capture, gate the following work on an ``if`` node. ``scalar_pred`` must be a single-element CUDA Bool tensor; at replay time the driver samples it and runs the body captured between this call and :meth:`end_capture_to_conditional_node` only when true. """ self._require_conditional_support() self._c.begin_capture_to_if_node(scalar_pred) [docs] def begin_capture_to_while_node(self, scalar_pred): r"""Like :meth:`begin_capture_to_if_node`, but the body loops while the predicate stays true (driver-level while node).""" self._require_conditional_support() self._c.begin_capture_to_while_node(scalar_pred) [docs] def set_conditional_handle_for_current_node(self, scalar_pred): r"""Refresh the predicate consumed by the innermost open conditional node (used for nested conditionals).""" self._c.set_conditional_handle_for_current_node(scalar_pred) [docs] def end_capture_to_conditional_node(self): r"""Close the open conditional body; subsequent capture returns to the parent stream.""" self._c.end_capture_to_conditional_node() def __del__(self): try: self.reset() except Exception: pass # The most recently completed capture, for module-level DOT export. _last_captured_graph = None [docs] @contextlib.contextmanager def graph(cuda_graph, pool=None, stream=None, capture_error_mode="global"): r"""Context-manager that captures CUDA work into a ``tensorplay.cuda.CUDAGraph``. Args: cuda_graph (CUDAGraph): the graph object to capture into. pool: ``None`` gives the graph its own private memory pool; an id from :func:`graph_pool_handle` (or another graph / its ``pool_id`` property) shares that pool so allocations from both captures can recycle each other's space. stream (Stream, optional): custom capture stream; defaults to the runtime's dedicated per-device side stream (the legacy default stream cannot capture). capture_error_mode (str, optional): see :meth:`CUDAGraph.capture_begin`. """ global _last_captured_graph if not isinstance(cuda_graph, CUDAGraph): raise TypeError( "cuda_graph must be a tensorplay.cuda.CUDAGraph; got " f"{type(cuda_graph).__name__}" ) cuda_graph.capture_begin( pool=pool, capture_error_mode=capture_error_mode, stream=stream ) entered = True try: yield cuda_graph finally: # Only close the window when capture_begin succeeded, so a failed # begin propagates its own error instead of a confusing secondary # "no live capture" from capture_end. if entered: cuda_graph.capture_end() _last_captured_graph = cuda_graph [docs] def is_current_stream_capturing(): r"""Return True if CUDA graph capture is underway on the current thread.""" from . import is_initialized if not is_initialized(): return False try: from .. import _C except ImportError: return False probe = getattr(_C, "cuda_stream_is_capturing", None) if probe is not None: return bool(probe()) process_wide = getattr(_C, "cuda_is_capturing", None) return bool(process_wide()) if process_wide is not None else False [docs] def export_dot(file_path: str) -> str: r"""Export the most recently completed capture to a DOT file. Returns the path written. For richer node attributes call :meth:`CUDAGraph.enable_debug_mode` before that capture. """ if _last_captured_graph is None: raise RuntimeError("no CUDA graph has been captured yet") file_path = str(file_path) _last_captured_graph.debug_dump(file_path) return file_path # --- minimal pytree (tree_flatten/tree_unflatten compatibility) ------------- # # small structural flatten covers the tensor/tuple/list/dict shapes that # make_graphed_callables deals with. _Spec = tuple # ("seq", type, (specs...)) / ("dict", keys, (specs...)) / None=leaf def _flatten(obj) -> tuple: if isinstance(obj, (tuple, list)): leaves, specs = [], [] for item in obj: sub_leaves, sub_spec = _flatten(item) leaves.extend(sub_leaves) specs.append(sub_spec) return leaves, ("seq", type(obj), tuple(specs)) if isinstance(obj, dict): leaves, specs, keys = [], [], [] for key, item in obj.items(): sub_leaves, sub_spec = _flatten(item) leaves.extend(sub_leaves) specs.append(sub_spec) keys.append(key) return leaves, ("dict", tuple(keys), tuple(specs)) return [obj], None def _unflatten(leaves, index, spec): if spec is None: return leaves[index[0]], index[0] + 1 kind = spec[0] if kind == "dict": out = {} for key, sub_spec in zip(spec[1], spec[2]): out[key], index[0] = _unflatten(leaves, index, sub_spec) return out, index[0] items = [] for sub_spec in spec[2]: item, index[0] = _unflatten(leaves, index, sub_spec) items.append(item) return (spec[1](items)), index[0] [docs] def make_graphed_callables( callables, sample_args, num_warmup_iters=3, allow_unused_input=False, pool=None, capture_error_mode="global", ): r"""Callables that run per-iteration with CUDA graph capture. forward (and backward, via :func:`tensorplay.autograd.grad`) into CUDA graphs sharing one private memory pool, then wraps them in autograd Functions whose forward/backward are graph replays. Per-iteration host overhead drops to two graph launches. carried over verbatim: ``sample_args`` must contain only Tensors whose ``requires_grad`` matches the live workload; modules may not carry hooks or trainable buffers; arguments must keep their order and shapes. Args: callables: function or ``tensorplay.nn.Module``, or a tuple of them in live-workload order. sample_args: matching tuple of argument-tuples of CUDA Tensors. num_warmup_iters: warmup iterations run on the capture stream before capturing (flushes lazy cuDNN/cuBLAS state). allow_unused_input: passed through to :func:`tensorplay.autograd.grad`. pool: share an existing graph pool instead of allocating one. """ import tensorplay as tp from ..autograd import grad as _grad if tp.is_autocast_enabled() and tp.is_autocast_cache_enabled(): raise RuntimeError( "make_graphed_callables does not support the autocast caching. " "Please set cache_enabled=False." ) just_one_callable = False if not isinstance(callables, tuple): just_one_callable = True callables = (callables,) _sample_args = (sample_args,) else: _sample_args = tuple(sample_args) flatten_sample_args = [] for c, args in zip(callables, _sample_args): if hasattr(c, "_backward_hooks") or isinstance(c, tp.nn.Module): hooks = ( len(getattr(c, "_backward_hooks", ()) or ()) + len(getattr(c, "_forward_hooks", ()) or ()) + len(getattr(c, "_forward_pre_hooks", ()) or ()) ) if hooks: raise AssertionError( "Modules must not have hooks registered at the time they " "are passed to make_graphed_callables." ) if any(b.requires_grad for b in c.buffers()): raise AssertionError( "In any nn.Module passed to make_graphed_callables, only " "parameters may be trainable; all buffers must have " "requires_grad=False." ) flat, _ = _flatten(args) flatten_sample_args.append(tuple(flat)) for arg in flat: if not isinstance(arg, tp.Tensor): raise AssertionError( "In this API, sample_args for each callable must contain " "only Tensors; got " + type(arg).__name__ ) per_callable_len_user_args = [len(args) for args in flatten_sample_args] per_callable_module_params = [ tuple(c.parameters()) if isinstance(c, tp.nn.Module) else () for c in callables ] per_callable_static_input_surfaces = [ flatten_sample_args[i] + per_callable_module_params[i] for i in range(len(callables)) ] fwd_graphs = [CUDAGraph() for _ in callables] bwd_graphs = [CUDAGraph() for _ in callables] mempool = graph_pool_handle() if pool is None else _resolve_pool(pool) from .streams import Stream from . import stream as _stream_ctx tp.cuda.synchronize() stream = Stream() with _stream_ctx(stream): # Warmup: flushes lazy-init work so it cannot land mid-capture. for func, args, surface in zip( callables, _sample_args, per_callable_static_input_surfaces ): grad_inputs = None for _ in range(num_warmup_iters): leaves, _spec = _flatten(func(*args)) outputs_grad = tuple(o for o in leaves if o.requires_grad) if len(outputs_grad) > 0: grad_inputs = _grad( outputs=outputs_grad, inputs=tuple(i for i in surface if i.requires_grad), grad_outputs=tuple( tp.empty_like(o) for o in leaves if o.requires_grad ), allow_unused=allow_unused_input, ) del grad_inputs tp.cuda.synchronize() # Captures share one mempool, so capture in live order: per_callable_static_outputs = [] per_callable_output_unflatten_spec = [] for func, args, fwd_graph in zip(callables, _sample_args, fwd_graphs): with graph( fwd_graph, pool=mempool, stream=stream, capture_error_mode=capture_error_mode, ): func_outputs = func(*args) leaves, spec = _flatten(func_outputs) per_callable_static_outputs.append(tuple(leaves)) per_callable_output_unflatten_spec.append(spec) per_callable_static_grad_outputs = [] per_callable_static_grad_inputs = [] for surface, static_outputs, bwd_graph in zip( reversed(per_callable_static_input_surfaces), reversed(per_callable_static_outputs), reversed(bwd_graphs), ): static_grad_outputs = tuple( tp.empty_like(o) if o.requires_grad else None for o in static_outputs ) outputs_grad = tuple(o for o in static_outputs if o.requires_grad) grad_inputs = None if len(outputs_grad) > 0: with graph( bwd_graph, pool=mempool, stream=stream, capture_error_mode=capture_error_mode, ): grad_inputs = _grad( outputs=outputs_grad, inputs=tuple(i for i in surface if i.requires_grad), grad_outputs=tuple( o for o in static_grad_outputs if o is not None ), allow_unused=allow_unused_input, ) static_grad_inputs = [] grad_idx = 0 for arg in surface: if arg.requires_grad and grad_inputs is not None: static_grad_inputs.append(grad_inputs[grad_idx]) grad_idx += 1 else: static_grad_inputs.append(None) per_callable_static_grad_outputs.append(tuple(static_grad_outputs)) per_callable_static_grad_inputs.append(tuple(static_grad_inputs)) per_callable_static_grad_outputs.reverse() per_callable_static_grad_inputs.reverse() ret = [] for i, func in enumerate(callables): graphed = make_graphed_autograd_function( fwd_graphs[i], bwd_graphs[i], per_callable_module_params[i], per_callable_len_user_args[i], per_callable_output_unflatten_spec[i], per_callable_static_input_surfaces[i], per_callable_static_outputs[i], per_callable_static_grad_outputs[i], per_callable_static_grad_inputs[i], ) if isinstance(func, tp.nn.Module): original_forward = func.forward graph_training_state = func.training def new_fwd(*user_args, _func=func, _graphed=graphed, _training=graph_training_state, _orig=original_forward): if _func.training == _training: return _graphed(*user_args) return _orig(*user_args) func.forward = new_fwd ret.append(func) else: ret.append(graphed) return ret[0] if just_one_callable else tuple(ret) def make_graphed_autograd_function( fwd_graph, bwd_graph, module_params, len_user_args, output_unflatten_spec, static_input_surface, static_outputs, static_grad_outputs, static_grad_inputs, ): r"""Wrap captured forward/backward graphs in one autograd Function. whose forward stages fresh user args onto the captured static inputs and replays the forward graph; its backward (once-differentiable) stages incoming grads and replays the backward graph. The callable's full input surface is ``user_args + module_params``; parameters are assumed unchanged since capture. """ from ..autograd.function import Function, once_differentiable class Graphed(Function): @staticmethod def forward(ctx, *inputs): for i in range(len_user_args): if static_input_surface[i].data_ptr() != inputs[i].data_ptr(): static_input_surface[i].copy_(inputs[i]) fwd_graph.replay() return tuple(o.detach() for o in static_outputs) @staticmethod @once_differentiable def backward(ctx, *grads): # The engine may append trailing None placeholders beyond the # per-output gradients; only the leading per-output entries are # meaningful here. if len(grads) < len(static_grad_outputs): raise AssertionError( f"len(grads)={len(grads)} < " f"{len(static_grad_outputs)} static_grad_outputs" ) grads = grads[: len(static_grad_outputs)] for g, incoming in zip(static_grad_outputs, grads): if g is not None: if g.data_ptr() != incoming.data_ptr(): g.copy_(incoming) bwd_graph.replay() return tuple(b.detach() if b is not None else b for b in static_grad_inputs) def functionalized(*user_args): flat_user_args, _ = _flatten(user_args) leaves_out = Graphed.apply(*(tuple(flat_user_args) + tuple(module_params))) index = [0] out, _ = _unflatten(leaves_out, index, output_unflatten_spec) return out return functionalized ```