Skip to main content

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}