Skip to main content

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}