Skip to main content

cubecl_server/command/
capture.rs

1//! Recording a stream's launches into a replayable graph.
2//!
3//! The driver's rules are what shapes this. A stream in capture mode records
4//! every launch issued on it, so a window that opened has to be closed even
5//! when what it recorded is worthless. Nothing may allocate inside the window,
6//! so the pools are warmed before it opens and pinned after it closes. And a
7//! host sync would abort the recording, so the fenced flushes the execution
8//! path would otherwise run are deferred across it.
9//!
10//! All of that is the same whichever driver is underneath. What is not is
11//! [`GraphDriver`]: opening the recording, closing it into an executable,
12//! staging that executable, and replaying it.
13
14use super::{DeviceStream, Driver};
15use crate::id::GraphId;
16use crate::memory_management::ManagedMemoryHandle;
17use crate::server::{BufferBinding, ServerError};
18use alloc::format;
19use alloc::vec::Vec;
20use core::marker::PhantomData;
21use cubecl_common::bytes::Bytes;
22use cubecl_environment::backtrace::BackTrace;
23use cubecl_environment::collections::HashMap;
24use cubecl_environment::stream::StreamId;
25
26/// A driver that can record a stream's launches into a replayable graph.
27pub trait GraphDriver: Driver {
28    /// An instantiated graph, released when it drops.
29    ///
30    /// Dropping is how it is destroyed, so no path can hand back a graph and
31    /// leak the executable behind it — including the ones that instantiate
32    /// successfully and then find the window was abandoned.
33    type Executable;
34
35    /// Put `stream` into recording mode: from here the driver records every
36    /// launch issued on it.
37    ///
38    /// # Errors
39    ///
40    /// The driver's refusal to begin recording. The caller restores the stream
41    /// to what it was, so the whole sequence can be retried.
42    fn begin(stream: &mut Self::Stream) -> Result<(), ServerError>;
43
44    /// Close the recording on `stream` and instantiate what it recorded.
45    ///
46    /// `doomed` is a recording already known not to become a graph — work
47    /// inside the window failed or was skipped, so an operation is missing.
48    /// The driver capture is closed either way: a stream left in capture mode
49    /// records every launch that follows it.
50    ///
51    /// A recording that allocated is refused here too. A graph owning memory
52    /// nodes allocates on launch and never frees, so the driver rejects every
53    /// relaunch while the first quietly succeeds.
54    ///
55    /// # Errors
56    ///
57    /// Whatever stopped the recording from becoming a graph, `doomed`
58    /// included.
59    fn instantiate(
60        stream: &mut Self::Stream,
61        doomed: Option<ServerError>,
62    ) -> Result<Self::Executable, ServerError>;
63
64    /// Pre-stage `exec` so the first replay does not pay the upload cost.
65    ///
66    /// Non-fatal by contract: a replay uploads on demand if this does nothing.
67    fn upload(exec: &Self::Executable, stream: &mut Self::Stream);
68
69    /// Enqueue `exec`'s recorded sequence on `stream`.
70    ///
71    /// # Errors
72    ///
73    /// The driver's refusal to enqueue the replay.
74    fn replay(exec: &Self::Executable, stream: &mut Self::Stream) -> Result<(), ServerError>;
75}
76
77/// An instantiated graph, and everything its window pinned for it.
78///
79/// Owned by [`Captures`] and referenced by [`GraphId`]; the executable never
80/// leaves the server actor, which serializes access, so it is only ever
81/// touched on the one thread allowed to. The client references the graph by id
82/// and, on the last drop, asks the actor to release it — the server syncs the
83/// stream before [`Captures::destroy`], so the executable is never destroyed
84/// while a replay is still running.
85pub struct Graph<D: GraphDriver> {
86    exec: D::Executable,
87    /// Every buffer the graph touches, pinned for its lifetime. A replay
88    /// re-runs the recorded kernels against these exact device pointers;
89    /// retaining the handles keeps the memory pool from reusing those slices,
90    /// which would let a later allocation share memory the replay overwrites.
91    _retained: Vec<ManagedMemoryHandle>,
92    /// The host memory the graph's recorded copies read from, alive for its
93    /// lifetime for the same reason: a memcpy node keeps the raw host
94    /// pointer, and every replay reads through it again.
95    _retained_host: Vec<Bytes>,
96    /// The buffers the recorded launches write, deduplicated. A replay that
97    /// fails to enqueue runs none of those launches, so it leaves every one of
98    /// these as it was — claiming them is what makes a later read of one fail,
99    /// whichever stream asks.
100    written: Vec<BufferBinding>,
101}
102
103/// A capture window that closed without a graph to hand back.
104///
105/// Not an error on its own, because the recorded launches never ran and now
106/// never will: the memory they would have written is left exactly as it was,
107/// and someone has to claim it before a later read of one of those buffers
108/// returns bytes nothing wrote. The window cannot do that itself — it has no
109/// failure store — so it says what to claim and for which error.
110pub struct Refused {
111    /// Why no graph came out.
112    pub error: ServerError,
113    /// The memory the recording's launches would have written.
114    pub written: Vec<BufferBinding>,
115}
116
117/// The graphs this device has instantiated, keyed by the [`GraphId`] handed to
118/// the client.
119///
120/// Referencing a graph by id is what keeps the executable inside the server:
121/// nothing across the actor boundary ever holds one.
122pub struct Captures<D: GraphDriver> {
123    graphs: HashMap<GraphId, Graph<D>>,
124}
125
126impl<D: GraphDriver> core::fmt::Debug for Captures<D> {
127    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
128        // The executables are opaque driver handles, so the count is the whole
129        // of what there is to say.
130        f.debug_struct("Captures")
131            .field("graphs", &self.graphs.len())
132            .finish()
133    }
134}
135
136impl<D: GraphDriver> Default for Captures<D> {
137    fn default() -> Self {
138        Self {
139            graphs: HashMap::default(),
140        }
141    }
142}
143
144impl<D: GraphDriver> Captures<D> {
145    /// Register a freshly instantiated graph under the id its capture was
146    /// given.
147    pub fn insert(&mut self, id: GraphId, graph: Graph<D>) {
148        self.graphs.insert(id, graph);
149    }
150
151    /// Whether `id` still names a live graph.
152    pub fn contains(&self, id: GraphId) -> bool {
153        self.graphs.contains_key(&id)
154    }
155
156    /// Add the buffers `id`'s recorded launches write to `written`.
157    ///
158    /// Extends rather than answers with a vector of its own, because the
159    /// caller is filling a pooled write set and a replay should allocate for
160    /// it no more than a launch does.
161    ///
162    /// An unknown id adds nothing, which is the honest answer rather than a
163    /// missing one: a graph that is gone took the record of which buffers went
164    /// with it, and a replay of it writes nothing.
165    pub fn extend_written(&self, id: GraphId, written: &mut Vec<BufferBinding>) {
166        if let Some(graph) = self.graphs.get(&id) {
167            written.extend(graph.written.iter().cloned());
168        }
169    }
170
171    /// Enqueue `id`'s recorded sequence on `stream`.
172    ///
173    /// The stream's existing errors are ignored — they surface on the next
174    /// sync — so a replay only ever adds its own.
175    ///
176    /// # Errors
177    ///
178    /// [`ServerError::Generic`] when `id` names no live graph, which the
179    /// caller hands straight back: nothing was enqueued and nothing is stale.
180    pub fn replay(&self, id: GraphId, stream: &mut D::Stream) -> Result<(), ServerError> {
181        let graph = self.graphs.get(&id).ok_or_else(|| ServerError::Generic {
182            reason: "replay was given an unknown or already-destroyed graph".into(),
183            backtrace: BackTrace::capture(),
184        })?;
185        D::replay(&graph.exec, stream)
186    }
187
188    /// Drop the executable `id` names and release what it held: the buffers it
189    /// pinned go with it, and the info-cache entries no other live graph still
190    /// pins are freed. A no-op for an unknown id, so a double release is one.
191    ///
192    /// The caller syncs `stream` first — a replay enqueued against this
193    /// executable may still be running.
194    pub fn destroy(&mut self, id: GraphId, stream: &mut D::Stream) {
195        self.graphs.remove(&id);
196        stream.info_cache().graph_release(id);
197    }
198}
199
200/// A capture window on one stream: arming the pools before it opens, opening
201/// it, and instantiating what it recorded.
202///
203/// The three steps are ordered and the stream refuses them out of order (see
204/// [`StreamCapture`](crate::stream::StreamCapture)); this type is where each
205/// one's shared half lives.
206pub struct Window<'a, D: GraphDriver> {
207    stream: &'a mut D::Stream,
208    driver: PhantomData<D>,
209}
210
211impl<'a, D: GraphDriver> Window<'a, D> {
212    /// The capture window on `stream`.
213    pub fn on(stream: &'a mut D::Stream) -> Self {
214        Self {
215            stream,
216            driver: PhantomData,
217        }
218    }
219
220    /// Arm the pools for a window about to open, before the warmup run.
221    ///
222    /// Every allocation from here until the window closes is routed into the
223    /// persistent pool, and which slices are already in use is snapshotted.
224    /// The pool is warm by the time the window opens, so the run reuses those
225    /// slices with no device allocation — which mid-capture is illegal.
226    /// Instantiating pins everything the window added on the graph.
227    ///
228    /// Both pools are armed: the device pool for tensor and kernel-info
229    /// buffers, and the pinned host pool that stages each kernel's info bytes
230    /// to the device, where a fresh allocation mid-capture faults the same way.
231    ///
232    /// # Errors
233    ///
234    /// The stream's refusal, when it is not in a state a window can open from.
235    pub fn prepare(&mut self, stream_id: StreamId) -> Result<(), ServerError> {
236        self.stream.capturing().prepare(stream_id)?;
237        self.stream.device_memory().capture_begin();
238        self.stream.host_memory().capture_begin();
239        Ok(())
240    }
241
242    /// Open the window: from here the driver records every launch issued on
243    /// this stream until the window closes.
244    ///
245    /// # Errors
246    ///
247    /// The driver's refusal to begin recording, with the stream left as it was
248    /// found — retention disarmed, allocation mode restored, capture state
249    /// back to none — so the whole prepare-then-open sequence can be retried.
250    pub fn begin(&mut self) -> Result<(), ServerError> {
251        // Rejected before the reclaim below runs: a drop-queue flush issued on
252        // a stream that is already recording would abort its live capture.
253        self.stream.capturing().begin()?;
254        // Reclaim deferred frees before the window opens: warmup's pinned
255        // staging buffers (and any other drop-queued slices) sit in the drop
256        // queue until drained, so without this the recorded run finds no free
257        // staging slice and allocates a fresh one mid-capture — which faults.
258        let signal = self.stream.signal();
259        self.stream.drop_queue().drain(|| D::Stream::fence(signal));
260        // Warmup is over: release the slices it retained so the recorded run
261        // reuses them instead of allocating. Mandatory rather than an
262        // optimization — priming retention is shared behaviour, so leaving it
263        // armed here would hold warmup's slices for the whole window and force
264        // a mid-capture allocation, which invalidates the capture.
265        self.stream.device_memory().capture_priming_end();
266        self.stream.host_memory().capture_priming_end();
267
268        if let Err(err) = D::begin(self.stream) {
269            self.stream.device_memory().capture_end();
270            self.stream.host_memory().capture_end();
271            self.stream.info_cache().capture_discard();
272            self.stream.capturing().abort();
273            return Err(err);
274        }
275        // Recording now: fenced drop-queue flushes on the execution path are
276        // suppressed for as long as the window is open, since a host sync
277        // would abort it. The deferred buffers are reclaimed when it closes.
278        Ok(())
279    }
280
281    /// Close the window and instantiate what it recorded into a graph
282    /// registered under `id`.
283    ///
284    /// The window leaves capture mode first, so none of the paths below can
285    /// wedge the stream in it — they re-enable the deferred fenced flushes and
286    /// restore the allocation mode on the way out. A window the caller does
287    /// not own is closed and torn down all the same, since nobody else is
288    /// coming back to close it; only its owner gets a graph out of it.
289    ///
290    /// # Errors
291    ///
292    /// [`Refused`], which every failure here becomes — the driver's refusal to
293    /// close or instantiate, a recording an unreadable input left incomplete,
294    /// an allocation inside the window, or no window at all. It carries the
295    /// memory the caller now has to claim, empty when there was nothing to
296    /// close.
297    pub fn instantiate(&mut self, stream_id: StreamId, id: GraphId) -> Result<Graph<D>, Refused> {
298        let outcome = match self.stream.capturing().end(stream_id) {
299            Ok(outcome) => outcome,
300            // No window to close, so nothing was recorded and the drained set
301            // is empty — drained rather than assumed so the claim is right
302            // even if that ever stops being true.
303            Err(error) => {
304                let written = self.stream.capturing().take_recorded();
305                // A capture prepared but never opened still armed the pools:
306                // every allocation since `prepare` routes to the persistent
307                // pool and is retained by priming, and a `graph_prepare`
308                // retry is refused while the state holds. Closing is the only
309                // call the caller has left — a warmup that failed never
310                // reaches `begin` — so a close from `Prepare` disarms, the
311                // same unwinding `begin` does when the driver refuses to
312                // open, instead of leaving the stream armed forever.
313                if self.stream.capturing().is_active() {
314                    drop(self.stream.device_memory().capture_end());
315                    drop(self.stream.host_memory().capture_end());
316                    self.stream.info_cache().capture_discard();
317                    self.stream.capturing().abort();
318                }
319                return Err(Refused { error, written });
320            }
321        };
322        // Work inside the window failed or was skipped, so the recording is
323        // missing an operation and must not seal; the driver capture still
324        // has to be closed either way.
325        let doomed = self.stream.capturing().take_failure().map(|reason| {
326            ServerError::graph_state(format!(
327                "an operation inside the capture window failed or was skipped, so the \
328                 recording is missing an operation and cannot seal: {reason}"
329            ))
330        });
331        let exec = D::instantiate(self.stream, doomed.clone());
332        // Pin every buffer the window touched so the pool never reuses that
333        // memory for the graph's lifetime — both the device slices and the
334        // pinned staging slices the recorded info copies still read from on
335        // replay. On failure the handles drop with `retained`, unpinning them.
336        let mut retained = self.stream.device_memory().capture_end();
337        retained.extend(self.stream.host_memory().capture_end());
338        // The host bytes the recorded copies read from, whatever their kind —
339        // a pool slice, a user buffer, a heap fallback. The nodes keep their
340        // raw pointers, so they live with the graph; on a window that seals
341        // no graph they drop here, since its copies never ran and never will.
342        let retained_host = self.stream.capturing().take_retained_host();
343        // Reclaim the buffers dropped while the window was open, whose fenced
344        // flushes were deferred for as long as it was.
345        let signal = self.stream.signal();
346        self.stream.drop_queue().drain(|| D::Stream::fence(signal));
347        // The memory the recorded launches write. A recording that becomes a
348        // graph answers for it on a failed replay; one that does not is
349        // answered for by the caller, since those launches never ran and now
350        // never will.
351        let written = self.stream.capturing().take_recorded();
352        // An abandoned window has no graph to hand back: whatever was
353        // instantiated drops here, and the report carries along whatever had
354        // already doomed the recording so the caller sees both reasons.
355        let exec = match outcome.is_abandoned() {
356            false => exec,
357            true => Err(outcome.abandoned_error(stream_id, doomed)),
358        };
359        match exec {
360            Ok(exec) => {
361                // Seal the info-cache entries this window pinned under the
362                // graph's id, so destroying it can release them later.
363                self.stream.info_cache().capture_commit(id);
364                D::upload(&exec, self.stream);
365                Ok(Graph {
366                    exec,
367                    _retained: retained,
368                    _retained_host: retained_host,
369                    written,
370                })
371            }
372            Err(error) => {
373                // Unpin the entries this window pinned — they stay as ordinary
374                // cached values — and drop `retained`.
375                self.stream.info_cache().capture_discard();
376                Err(Refused { error, written })
377            }
378        }
379    }
380}