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