burn_tensor/tensor/api/graph.rs
1use crate::Device;
2use burn_backend::{Backend, BackendGraph};
3use burn_dispatch::Dispatch;
4
5/// A captured computation.
6///
7/// [`capture`] records a closure once; [`replay`](Graph::replay) then re-runs it
8/// as a single dispatch on backends with hardware graph support (CUDA/HIP —
9/// collapsing hundreds of kernel launches into one), or simply re-executes the
10/// closure everywhere else. The API is identical either way; only the speed
11/// differs.
12///
13/// The recorded graph replays against the exact device buffers used during
14/// capture, so the closure must read its inputs from, and write its outputs to,
15/// **stable** buffers held across the loop. Refresh those inputs in place with
16/// [`Tensor::inplace`](crate::Tensor::inplace)-style writes before each replay.
17///
18/// # Safety
19///
20/// The graph has no explicit input/state/output signature: whatever buffers the
21/// closure touched during capture are what every replay reads and writes, with
22/// nothing tracking them afterwards. [`replay`](Graph::replay) is therefore
23/// `unsafe` — see its safety contract. Safe, structured APIs can be layered on
24/// top of this mechanism for specific workloads (e.g. a decode step with pinned
25/// KV-cache and token buffers).
26pub struct Graph<T, F> {
27 device: Device,
28 output: T,
29 closure: F,
30 /// The captured hardware graph, or `None` to re-run the closure (a backend
31 /// without graph support, or a capture that failed).
32 hardware: Option<BackendGraph<Dispatch>>,
33}
34
35/// Capture `closure` for repeated replay (see [`Graph`]).
36///
37/// Runs `closure` twice: once to warm up (trigger autotuning and allocate every
38/// buffer, so the capture itself needs no fresh device allocation — which is
39/// illegal mid-capture), then once under capture. On a backend without graph
40/// support the warmup/capture collapses to a single run and replay just re-runs
41/// the closure.
42pub fn capture<T, F>(device: &Device, mut closure: F) -> Graph<T, F>
43where
44 F: FnMut() -> T,
45{
46 let dispatch = device.as_dispatch();
47 // Prepare the allocator for capture, then warm up: this triggers all
48 // autotuning and allocates every buffer the capture will reuse.
49 //
50 // Several warmup iterations, not one: with fusion the first run builds and
51 // autotunes the fused optimization (a different execution path, allocating
52 // different scratch/metadata buffers than steady state). Only from the
53 // second run on does the closure take the cached fused path whose buffers
54 // the capture must reuse. Warming up a few times lets those steady-state
55 // allocations populate the persistent pool before capture opens — otherwise
56 // the capture run allocates them fresh, which faults mid-capture.
57 const WARMUP_ITERS: usize = 3;
58 let _ = Dispatch::graph_prepare(dispatch);
59 for _ in 0..WARMUP_ITERS {
60 // Hold the output alive across the sync. A lazy backend (fusion) elides
61 // a computation whose result is dropped before it drains — so dropping
62 // the warmup output would skip the very kernels (and allocations) the
63 // capture must reuse, and the capture run would then be the first to
64 // allocate them, faulting mid-capture. Keeping `out` until after `sync`
65 // forces the closure to actually execute and populate the pool.
66 let out = closure();
67 let _ = Dispatch::sync(dispatch);
68 drop(out);
69 }
70
71 let (hardware, output) = match Dispatch::graph_start_capture(dispatch) {
72 // Record the closure's launches into a graph.
73 Ok(()) => {
74 let output = closure();
75 // A failed stop still resets the stream out of capture mode (the
76 // backend guarantees this even on error), so falling back to `None` —
77 // re-running the closure on replay — stays correct; we lose the
78 // graph, not stream health.
79 (Dispatch::graph_stop_capture(dispatch).ok(), output)
80 }
81 // No hardware graph support: fall back to re-running the closure.
82 Err(_) => (None, closure()),
83 };
84
85 Graph {
86 device: device.clone(),
87 output,
88 closure,
89 hardware,
90 }
91}
92
93impl<T, F> Graph<T, F>
94where
95 F: FnMut() -> T,
96{
97 /// Re-run the captured computation and return its output.
98 ///
99 /// On a captured graph this is one dispatch replaying the recorded launches
100 /// against their original buffers — so write fresh inputs into those
101 /// buffers first. On the fallback path it re-executes the closure. The
102 /// returned reference is the output produced during capture (whose buffer
103 /// the replay just overwrote), or the fresh output on the fallback path.
104 ///
105 /// # Safety
106 ///
107 /// On the hardware path this dispatches the recorded kernels against the raw
108 /// device buffers the closure touched during capture, with nothing checking
109 /// they are still valid. The caller must guarantee, until the replay's work
110 /// completes (e.g. it is followed by a read of the output or a device sync):
111 ///
112 /// - **Liveness** — every tensor the captured closure read or wrote still
113 /// exists. Dropping one frees its buffer for reuse by other allocations,
114 /// and a later replay would read or overwrite whatever now lives there.
115 /// Tensors owned by the closure (or by `self`, like the output) are kept
116 /// alive automatically; tensors the closure only borrowed must outlive
117 /// the replays.
118 /// - **No concurrent use** — no other stream or thread reads or writes a
119 /// tensor shared with the graph while the replay executes; the replay is
120 /// only ordered against work on its own capture stream.
121 /// - **Same-stream refreshes** — input refreshes and output reads are
122 /// issued on the stream the graph was captured on (the same device
123 /// thread/client), so they order correctly against the replay rather
124 /// than racing it with stale or torn data.
125 ///
126 /// On the fallback path (no hardware graph) this simply re-runs the closure
127 /// and is trivially safe.
128 pub unsafe fn replay(&mut self) -> &T {
129 match &self.hardware {
130 Some(graph) => {
131 // Safety: forwarded verbatim from this method's own contract.
132 unsafe { Dispatch::graph_replay(self.device.as_dispatch(), graph) }
133 .expect("graph replay should succeed");
134 }
135 None => {
136 self.output = (self.closure)();
137 }
138 }
139 &self.output
140 }
141
142 /// The output tensor(s) the graph writes to — stable across replays on the
143 /// hardware path (the same buffer is overwritten each time).
144 pub fn output(&self) -> &T {
145 &self.output
146 }
147
148 /// Whether this graph replays as a hardware dispatch (`true`) or by
149 /// re-running the closure (`false`).
150 pub fn is_hardware(&self) -> bool {
151 self.hardware.is_some()
152 }
153}