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}