cubecl_runtime/server/capture.rs
1//! The graph-capture lifecycle of a device's streams, and the record of it every client reads.
2//!
3//! Only servers drive the lifecycle, yet it lives here beside the record rather than in the
4//! server crate: the record stays private to this module only if every transition that
5//! publishes to it does too, so no other code can write a phase the streams are not in.
6
7use alloc::format;
8use alloc::vec::Vec;
9use cubecl_environment::backtrace::BackTrace;
10use cubecl_environment::stream::StreamId;
11use cubecl_environment::sync::{Arc, AtomicUsize, Mutex, Ordering};
12
13use super::ServerError;
14
15/// The graph captures under way on one device.
16///
17/// [`ServerUtilities::init`](super::ServerUtilities::init) creates it with the read-only
18/// [status](Self::status) the utilities hand to every client. The server creates one
19/// [capture state](Self::stream) per backend stream from it, and only those states change what
20/// it records: each publishes its own transitions, so the record can't drift from the streams.
21#[derive(Debug, Clone, Default)]
22pub struct DeviceCaptures(Arc<CaptureRecord>);
23
24/// A read-only view of a device's [captures](DeviceCaptures).
25///
26/// Every client of the device reads it, through the [server utilities](super::ServerUtilities),
27/// without reaching the device thread. It is asked before every launch that has to decide the
28/// same way in a capture's warmup and its recording, so it can't cost a round trip: with no
29/// capture under way on the device, it is one atomic load.
30#[derive(Debug, Clone)]
31pub struct CaptureStatus(Arc<CaptureRecord>);
32
33/// Where a stream sits in the graph-capture lifecycle, and the only thing
34/// allowed to move it. Capture is a strict `NoCapture → Prepare → Capture →
35/// NoCapture` progression, driven by [`prepare`](Self::prepare),
36/// [`begin`](Self::begin) and [`end`](Self::end); each rejects an out-of-order
37/// call, so a capture can never start unprepared and two captures can never
38/// overlap on one stream.
39///
40/// # One capture, one logical stream
41///
42/// The three calls have to come from the same logical stream. The window is
43/// opened on the pooled stream that logical stream folds onto, and the launches
44/// in between are recorded there — so a caller whose [`StreamId`] changes
45/// half-way (an `.await` resuming on another thread under the default
46/// `PerThread` policy, without `set_stream` pinning) has already split its
47/// recording across two backend streams before it ever reaches `end`. Pin the
48/// stream around a capture; [`end`](Self::end) treats a caller that is not the
49/// owner as a window nobody is coming back for, and abandons it.
50///
51/// The transitions live here rather than in each backend server because the
52/// rule is the same on every one of them — a backend supplies only the work a
53/// transition brackets (arming its pools, opening the driver's capture), never
54/// the ordering rule itself.
55///
56/// # What the neighbours pay
57///
58/// The window is held on a pooled stream, and logical streams fold onto those
59/// with `id % max_streams` — so a capture costs every logical stream sharing
60/// that slot, not just the one recording. On a software-graph backend a
61/// neighbour's read, sync or profile is refused outright for the duration, and
62/// its write is refused with the refusal landing on its own destinations; on a
63/// hardware-graph backend a
64/// neighbour's fenced flush is deferred until the window closes. None of that
65/// is attributed to the capture, because a refusal is not a failure of the
66/// capture: the neighbour asked for something this slot cannot do right now.
67///
68/// It is a real cost of folding, and the reason a capture is worth pinning to a
69/// stream nothing else is scheduled on.
70///
71/// Both active states carry the logical stream that opened the capture. Several
72/// logical streams share one backend stream, so "the capture owns this stream
73/// for its window" only holds if the window remembers whose it is: an error
74/// raised inside it dooms the capture, not whichever neighbour happens to be
75/// using the slot.
76#[derive(Debug)]
77pub struct StreamCaptureState {
78 phase: CapturePhase,
79 /// The device's record, which every transition publishes the new phase to.
80 device: CaptureStatus,
81 /// This state's entry in the device's record.
82 entry: usize,
83}
84
85/// What [`StreamCaptureState::end`] found when it closed the window: the
86/// caller's own capture, or one belonging to a logical stream that never came
87/// back to close it.
88///
89/// Both close the window. Only the owner gets a graph out of it: the failures
90/// raised inside the window doom the recording, and sealing it for a caller
91/// that never saw them would hand back a graph silently missing whatever they
92/// rejected.
93///
94/// Refusing a foreign caller outright is the worse trade. Several logical
95/// streams share one pooled stream, and a window nobody closes rejects every
96/// read, write and sync that lands on the slot while recording launches into a
97/// graph no one can seal: the slot is lost for the life of the process. A
98/// foreign `end_capture` is a caller whose [`StreamId`] moved out from under it
99/// (see the type docs), which is exactly the case where the owner is gone — so
100/// the window is torn down and the failure reported, rather than kept for an
101/// owner that will never ask.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum CaptureEnd {
104 /// The caller opened this window: its recording may be sealed into a graph.
105 Owned {
106 /// The logical stream that opened the window, which is the caller.
107 owner: StreamId,
108 },
109 /// The window belonged to `owner`, not to the caller. It is closed, but
110 /// there is no graph to hand back: the backend tears the recording down and
111 /// reports, and a later `end_capture` from `owner` finds nothing recording.
112 Abandoned {
113 /// The logical stream that opened the window, which the report names
114 /// so the caller can see whose recording was discarded.
115 owner: StreamId,
116 },
117}
118
119impl CaptureEnd {
120 /// The logical stream the window belonged to.
121 pub fn owner(&self) -> StreamId {
122 match self {
123 CaptureEnd::Owned { owner } | CaptureEnd::Abandoned { owner } => *owner,
124 }
125 }
126
127 /// Whether the window was closed for a caller that did not own it, so its
128 /// recording is torn down instead of sealed.
129 pub fn is_abandoned(&self) -> bool {
130 matches!(self, CaptureEnd::Abandoned { .. })
131 }
132
133 /// The report a caller gets for closing a window it did not open: why the
134 /// recording was discarded, and then `doomed` — the failure that had
135 /// already sunk the recording, if one had, so the caller learns both
136 /// reasons rather than only the one that happened to be checked last.
137 ///
138 /// Only meaningful once [`is_abandoned`](Self::is_abandoned) says so; an
139 /// owned window is the caller's to seal and has nothing to report.
140 pub fn abandoned_error(&self, caller: StreamId, doomed: Option<ServerError>) -> ServerError {
141 let mut errors = alloc::vec![ServerError::graph_state(format!(
142 "end_capture: the capture belongs to logical stream {:?}, not to {caller:?}; it is \
143 discarded rather than left recording on a stream both share",
144 self.owner(),
145 ))];
146 errors.extend(doomed);
147
148 ServerError::Several {
149 errors,
150 backtrace: BackTrace::capture(),
151 }
152 }
153}
154
155/// Where one backend stream is in the capture lifecycle.
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157enum CapturePhase {
158 /// No capture is prepared or recording.
159 NoCapture,
160 /// `graph_prepare` has started the warmup run; `begin_capture` may now
161 /// open the window. The pinned staging the warmup run reserves stays held
162 /// until the window opens.
163 Prepare {
164 /// The logical stream that prepared the capture.
165 owner: StreamId,
166 },
167 /// Launches are being recorded into a graph instead of executing. On a
168 /// hardware-graph backend (CUDA, HIP) a host sync issued now aborts the
169 /// driver capture, so the execution path defers fenced flushes until
170 /// `end_capture`. A software-graph backend (wgpu) has no driver capture to
171 /// abort and instead refuses the operations it cannot record: a read, sync
172 /// or profile fails on the spot, while a write is rejected lazily — the
173 /// owner's own write dooms the recording so `end_capture` refuses to seal
174 /// it, since a graph missing an operation is worse than a late diagnostic.
175 Capture {
176 /// The logical stream recording the capture, which the errors raised
177 /// inside the window belong to.
178 owner: StreamId,
179 },
180}
181
182/// The phase of every backend stream of a device with a capture under way, and the counts read
183/// without the lock.
184#[derive(Debug, Default)]
185struct CaptureRecord {
186 /// How many streams are preparing or recording a capture: `streams.len()`.
187 active: AtomicUsize,
188 /// How many streams are recording: the [`Capture`](CapturePhase::Capture) entries of
189 /// `streams`.
190 recording: AtomicUsize,
191 /// One entry per stream with a capture under way: its [entry](StreamCaptureState::entry)
192 /// id and its phase, never [`NoCapture`](CapturePhase::NoCapture).
193 streams: Mutex<Vec<(usize, CapturePhase)>>,
194 /// The next [entry](StreamCaptureState::entry) id.
195 next_entry: AtomicUsize,
196}
197
198impl DeviceCaptures {
199 /// The capture state of one more backend stream of the device.
200 pub fn stream(&self) -> StreamCaptureState {
201 StreamCaptureState {
202 phase: CapturePhase::NoCapture,
203 device: self.status(),
204 entry: self.0.next_entry.fetch_add(1, Ordering::Relaxed),
205 }
206 }
207
208 /// The read-only view of these captures, for everyone but the streams.
209 pub fn status(&self) -> CaptureStatus {
210 CaptureStatus(self.0.clone())
211 }
212}
213
214impl CaptureStatus {
215 /// Whether any stream of the device is preparing or recording a capture: when not, no
216 /// logical stream is [capturing](Self::is_capturing), whichever it is.
217 #[inline]
218 pub fn any_active(&self) -> bool {
219 self.0.active.load(Ordering::Acquire) > 0
220 }
221
222 /// Whether any stream of the device records a graph.
223 #[inline]
224 pub fn any_recording(&self) -> bool {
225 self.0.recording.load(Ordering::Acquire) > 0
226 }
227
228 /// Whether the logical `stream` is preparing or recording a graph capture, from
229 /// `graph_prepare` until the capture ends however it ends.
230 ///
231 /// It takes the record's lock: a hot path asks [`any_active`](Self::any_active) first.
232 pub fn is_capturing(&self, stream: StreamId) -> bool {
233 self.0
234 .streams
235 .lock()
236 .iter()
237 .any(|(_, phase)| phase.owner() == Some(stream))
238 }
239}
240
241impl StreamCaptureState {
242 /// Whether launches on the stream are being recorded into a graph right
243 /// now — the window during which a host sync would abort (or is rejected
244 /// by) the capture.
245 pub fn is_recording(&self) -> bool {
246 matches!(self.phase, CapturePhase::Capture { .. })
247 }
248
249 /// Whether the warmup run is under way: prepared, and the window not open yet.
250 pub fn is_preparing(&self) -> bool {
251 matches!(self.phase, CapturePhase::Prepare { .. })
252 }
253
254 /// Whether a capture is prepared or recording — the whole window during
255 /// which the stream is not free to serve other work.
256 pub fn is_active(&self) -> bool {
257 !matches!(self.phase, CapturePhase::NoCapture)
258 }
259
260 /// The logical stream this capture belongs to, `None` outside a window.
261 pub fn owner(&self) -> Option<StreamId> {
262 self.phase.owner()
263 }
264
265 /// The captures of the whole device this stream belongs to, this one included.
266 pub fn device(&self) -> &CaptureStatus {
267 &self.device
268 }
269
270 /// `NoCapture → Prepare`, for `graph_prepare`. Call before arming the
271 /// pools; the caller owns the arming, this owns the rule that it happens
272 /// exactly once per capture.
273 ///
274 /// # Errors
275 ///
276 /// Fails when a capture is already prepared or already recording on this
277 /// stream, leaving the state untouched — two captures may never overlap on
278 /// one stream. The caller can retry after `end_capture`.
279 pub fn prepare(&mut self, owner: StreamId) -> Result<(), ServerError> {
280 match self.phase {
281 CapturePhase::NoCapture => {
282 self.move_to(CapturePhase::Prepare { owner });
283 Ok(())
284 }
285 CapturePhase::Prepare { .. } => Err(ServerError::graph_state(
286 "graph_prepare: a graph capture is already prepared on this stream",
287 )),
288 CapturePhase::Capture { .. } => Err(ServerError::graph_state(
289 "graph_prepare: a graph capture is already recording on this stream",
290 )),
291 }
292 }
293
294 /// `Prepare → Capture`, for `begin_capture`. Call *before* the work that
295 /// opens the window (ending the priming phase, starting the driver's
296 /// capture) so a rejected call cannot run any of it: on a stream that is
297 /// already recording, a drop-queue flush issued on the way to the rejection
298 /// would abort the live capture.
299 ///
300 /// Since the state moves before that work, a backend whose window fails to
301 /// open must undo it with [`abort`](Self::abort).
302 ///
303 /// # Errors
304 ///
305 /// Fails when [`prepare`](Self::prepare) has not run — the pools have to
306 /// be warmed by a warmup run first — or when a capture is already
307 /// recording. The state is left untouched.
308 pub fn begin(&mut self) -> Result<(), ServerError> {
309 match self.phase {
310 CapturePhase::Prepare { owner } => {
311 self.move_to(CapturePhase::Capture { owner });
312 Ok(())
313 }
314 CapturePhase::NoCapture => Err(ServerError::graph_state(
315 "begin_capture: call graph_prepare before starting a capture",
316 )),
317 CapturePhase::Capture { .. } => Err(ServerError::graph_state(
318 "begin_capture: a graph capture is already recording on this stream",
319 )),
320 }
321 }
322
323 /// `Capture → NoCapture`, for `end_capture`. Call before closing the
324 /// window, so the stream leaves capture state even if sealing the graph
325 /// then fails — a backend that returned an error with the state still set
326 /// would wedge the stream in capture mode forever.
327 ///
328 /// A caller that does not own the window closes it all the same, as
329 /// [`CaptureEnd::Abandoned`]: only the owner may *seal* a capture, but
330 /// leaving the window open until an owner that may never come back closes
331 /// it would wedge the pooled stream for every logical stream sharing it —
332 /// see [`CaptureEnd`] for why that is the lesser of the two.
333 ///
334 /// # Errors
335 ///
336 /// Fails when no capture is recording (nothing prepared or started, or the
337 /// capture already ended), leaving the state untouched — a stray
338 /// `end_capture` must not close a window that was never opened.
339 pub fn end(&mut self, caller: StreamId) -> Result<CaptureEnd, ServerError> {
340 match self.phase {
341 CapturePhase::Capture { owner } => {
342 self.move_to(CapturePhase::NoCapture);
343 Ok(match owner == caller {
344 true => CaptureEnd::Owned { owner },
345 false => CaptureEnd::Abandoned { owner },
346 })
347 }
348 CapturePhase::NoCapture | CapturePhase::Prepare { .. } => {
349 Err(ServerError::graph_state(
350 "end_capture: no graph capture is recording on this stream",
351 ))
352 }
353 }
354 }
355
356 /// Return to `NoCapture` from anywhere, for the failure path of a
357 /// transition's own work: the window never opened, so the stream must be
358 /// left fully usable and re-capturable rather than stuck preparing
359 /// forever. Unlike [`end`](Self::end) this asserts
360 /// nothing, because the state it is recovering from is precisely the one
361 /// that could not be completed.
362 pub fn abort(&mut self) {
363 self.move_to(CapturePhase::NoCapture);
364 }
365
366 /// Enter `phase` and publish it to the device's record, in one step so the record can't
367 /// miss a transition.
368 fn move_to(&mut self, phase: CapturePhase) {
369 self.phase = phase;
370 self.device.0.publish(self.entry, phase);
371 }
372}
373
374impl Drop for StreamCaptureState {
375 fn drop(&mut self) {
376 self.device.0.publish(self.entry, CapturePhase::NoCapture);
377 }
378}
379
380impl CapturePhase {
381 /// The logical stream the capture belongs to, `None` outside a window.
382 fn owner(&self) -> Option<StreamId> {
383 match self {
384 CapturePhase::NoCapture => None,
385 CapturePhase::Prepare { owner } | CapturePhase::Capture { owner } => Some(*owner),
386 }
387 }
388}
389
390impl CaptureRecord {
391 /// Record that the stream with `entry` is now in `phase`, and recompute the counts from the
392 /// entries under the lock, so they can't drift from the phases they summarize.
393 fn publish(&self, entry: usize, phase: CapturePhase) {
394 let mut streams = self.streams.lock();
395 streams.retain(|(id, _)| *id != entry);
396 if phase != CapturePhase::NoCapture {
397 streams.push((entry, phase));
398 }
399 let recording = streams
400 .iter()
401 .filter(|(_, phase)| matches!(phase, CapturePhase::Capture { .. }))
402 .count();
403 self.recording.store(recording, Ordering::Release);
404 self.active.store(streams.len(), Ordering::Release);
405 }
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411
412 const OWNER: StreamId = StreamId { value: 7 };
413
414 /// The ordering rule the three backends rely on: a capture cannot start
415 /// unprepared, and two cannot overlap on one stream. A backend that could
416 /// reach `Capture` without `Prepare` would record against pools no warmup
417 /// primed, and every allocation the window then makes is one the graph
418 /// replays against but nothing pins.
419 #[test]
420 fn transitions_follow_the_capture_order() {
421 let mut state = DeviceCaptures::default().stream();
422
423 assert!(state.begin().is_err(), "a capture must be prepared first");
424 assert!(state.end(OWNER).is_err(), "nothing is recording yet");
425 assert!(!state.is_active());
426
427 state.prepare(OWNER).unwrap();
428 assert!(state.is_preparing() && state.owner() == Some(OWNER));
429 assert!(state.prepare(OWNER).is_err(), "one prepare per capture");
430 assert!(state.end(OWNER).is_err(), "the window never opened");
431
432 state.begin().unwrap();
433 assert!(state.is_recording() && state.owner() == Some(OWNER));
434 assert!(state.begin().is_err(), "captures may not overlap");
435 assert!(state.prepare(OWNER).is_err(), "captures may not overlap");
436
437 assert_eq!(
438 state.end(OWNER).unwrap(),
439 CaptureEnd::Owned { owner: OWNER }
440 );
441 assert!(!state.is_active());
442 }
443
444 /// The window remembers whose it is from end to end, so a failure raised
445 /// inside it dooms the capture that was recording rather than whichever
446 /// neighbour happens to be sharing the backend stream.
447 #[test]
448 fn the_window_carries_its_owner() {
449 let mut state = DeviceCaptures::default().stream();
450 assert_eq!(state.owner(), None);
451
452 state.prepare(OWNER).unwrap();
453 assert_eq!(state.owner(), Some(OWNER));
454 assert!(state.is_active(), "the window is open from prepare on");
455
456 state.begin().unwrap();
457 assert_eq!(state.owner(), Some(OWNER));
458
459 assert_eq!(state.end(OWNER).unwrap().owner(), OWNER);
460 assert_eq!(state.owner(), None);
461 assert!(!state.is_active());
462 }
463
464 /// Only the stream that opened the window may seal it into a graph.
465 ///
466 /// A neighbour sealing it would hand back a recording built from a window
467 /// it never watched — the graph silently missing whatever the failures
468 /// raised inside it rejected.
469 #[test]
470 fn only_the_stream_that_opened_a_capture_may_seal_it() {
471 let neighbour = StreamId { value: 8 };
472
473 let mut state = DeviceCaptures::default().stream();
474 state.prepare(OWNER).unwrap();
475 state.begin().unwrap();
476
477 assert_eq!(
478 state.end(neighbour).unwrap(),
479 CaptureEnd::Abandoned { owner: OWNER },
480 "the window is not theirs to seal"
481 );
482 }
483
484 /// A window its owner never closes must not hold the pooled stream, which
485 /// every logical stream folded onto the slot shares.
486 ///
487 /// The owner's id can stop coming back — the thread that started the
488 /// capture exits, or an `.await` resumes it elsewhere under `PerThread`. A
489 /// window kept until that id returns rejects every read, write and sync on
490 /// the slot forever, so a foreign `end` closes it and leaves the stream
491 /// usable, reporting rather than sealing.
492 #[test]
493 fn a_capture_no_one_can_close_does_not_wedge_the_stream() {
494 let neighbour = StreamId { value: 8 };
495
496 let mut state = DeviceCaptures::default().stream();
497 state.prepare(OWNER).unwrap();
498 state.begin().unwrap();
499
500 assert!(state.end(neighbour).unwrap().is_abandoned());
501 assert!(!state.is_active(), "the slot serves other work again");
502 state
503 .prepare(neighbour)
504 .expect("the stream is re-capturable");
505
506 // The owner coming back late finds nothing recording, rather than a
507 // window it can still seal a graph out of.
508 state.begin().unwrap();
509 assert!(
510 state.end(OWNER).unwrap().is_abandoned(),
511 "the window it opened is long gone"
512 );
513 }
514
515 /// What a caller learns from closing a window that was not theirs: whose
516 /// it was, and whatever had already doomed the recording.
517 ///
518 /// The owner is the one piece of evidence the caller can act on — it names
519 /// the stream whose recording was thrown away. The doomed reason travels
520 /// with it because both are true at once, and reporting only the
521 /// abandonment would hide a failure that had already made the recording
522 /// unsealable.
523 #[test]
524 fn an_abandoned_window_reports_whose_it_was_and_what_doomed_it() {
525 let caller = StreamId { value: 8 };
526 let outcome = CaptureEnd::Abandoned { owner: OWNER };
527
528 let error = outcome.abandoned_error(caller, Some(ServerError::graph_state("doomed")));
529
530 let ServerError::Several { errors, .. } = &error else {
531 panic!("an abandoned window reports several failures at once, got: {error:?}");
532 };
533 let reported = alloc::format!("{error}");
534 assert!(
535 reported.contains(&alloc::format!("{OWNER:?}"))
536 && reported.contains(&alloc::format!("{caller:?}")),
537 "the report has to name the window's owner and the caller refused it, got: {reported}"
538 );
539 assert_eq!(errors.len(), 2, "the doomed reason travels with it");
540 assert!(
541 alloc::format!("{}", errors[1]).contains("doomed"),
542 "the explanation comes first, then what had already sunk it"
543 );
544 }
545
546 /// A rejected transition leaves the stream exactly as it was, so a caller
547 /// that misorders a call can recover by issuing the right one.
548 #[test]
549 fn a_rejected_transition_changes_nothing() {
550 let mut state = DeviceCaptures::default().stream();
551 state.prepare(OWNER).unwrap();
552 assert!(state.prepare(OWNER).is_err());
553 assert!(state.is_preparing() && state.owner() == Some(OWNER));
554 state.begin().unwrap();
555 }
556
557 /// `abort` recovers from a window that failed to open, from either of the
558 /// states a backend can be holding when that happens.
559 #[test]
560 fn abort_recovers_a_window_that_never_opened() {
561 for recorded in [false, true] {
562 let mut state = DeviceCaptures::default().stream();
563 state.prepare(OWNER).unwrap();
564 if recorded {
565 state.begin().unwrap();
566 }
567 state.abort();
568 assert!(!state.is_active());
569 state.prepare(OWNER).expect("the stream is re-capturable");
570 }
571 }
572
573 /// The device's record follows each stream's own state, whatever the streams do: a record
574 /// that believed no capture recorded while one did would let the neighbours release pages
575 /// the recording uses.
576 #[test]
577 fn the_record_counts_every_recording_stream() {
578 let device = DeviceCaptures::default();
579 let status = device.status();
580 let mut first = device.stream();
581 let mut second = device.stream();
582
583 first.prepare(OWNER).unwrap();
584 first.begin().unwrap();
585 second.prepare(OWNER).unwrap();
586 assert!(
587 status.any_recording(),
588 "a second stream of the owner hides nothing"
589 );
590 second.begin().unwrap();
591 first.end(OWNER).unwrap();
592 assert!(
593 status.any_recording(),
594 "one stream ending leaves the other recording"
595 );
596 assert!(status.is_capturing(OWNER));
597
598 drop(second);
599 assert!(
600 !status.any_recording(),
601 "a dropped stream leaves the record"
602 );
603 assert!(!status.is_capturing(OWNER));
604 assert!(!status.any_active());
605 }
606}