Skip to main content

cubecl_server/stream/
capture.rs

1//! A backend stream's graph capture: where it is in the capture lifecycle, and the memory its
2//! recorded launches were given. Shared by every backend with graph support (see
3//! [`Server::graph_prepare`](crate::server::Server::graph_prepare)); the lifecycle itself is
4//! [`StreamCaptureState`], which also publishes it to the device's captures.
5
6use crate::memory_management::{ManagedMemoryBinding, MemoryLocation, PageUpdate};
7use crate::metadata_cache::CacheMode;
8use crate::server::{BufferBinding, ServerError, WeakBufferBinding};
9use alloc::vec::Vec;
10use cubecl_common::bytes::Bytes;
11use cubecl_environment::stream::StreamId;
12use cubecl_runtime::server::{CaptureStatus, DeviceCaptures, StreamCaptureState};
13
14pub use cubecl_runtime::server::CaptureEnd;
15
16/// The graph capture of one pooled backend stream: where it sits in the
17/// lifecycle, and the memory its recorded launches were given.
18///
19/// The two travel together because neither is meaningful alone. A launch is
20/// remembered only while the window is recording, and what it remembers is
21/// only ever read once the window closes — so pairing them
22/// makes "buffers accumulate inside a window and nowhere else" a property of
23/// the type rather than a rule each backend has to keep.
24///
25/// Why the memory is worth keeping: a replay runs every recorded launch or
26/// none, so a replay that fails to enqueue leaves all of them exactly as they
27/// were, and so does a capture that never seals. Either way a later read of one
28/// has to fail rather than copy out bytes nothing wrote — which needs the list
29/// the launches themselves no longer hold, as bindings, so the failure can be
30/// tainted onto the allocations they resolve to.
31#[derive(Debug)]
32pub struct StreamCapture {
33    /// Where the stream is in the capture lifecycle, published to the device's captures.
34    state: StreamCaptureState,
35    /// What the recorded launches write, kept without holding their memory:
36    /// a held binding keeps its slice from being reused, and the recording
37    /// has to reuse memory exactly as the warmup run did.
38    recorded: Vec<WeakBufferBinding>,
39    /// The memory the recorded launches were given, as the pages the graph
40    /// will [guard](crate::memory_management::PageGuard) once it seals.
41    touched: Vec<TouchedPage>,
42    /// The pinned staging the warmup run reserved, held until the window
43    /// opens. A recorded copy keeps its staging for the graph's life, so the
44    /// recorded run needs one slice per copy where the warmup run could reuse
45    /// one; holding them makes the pool that big before the window opens,
46    /// where nothing may be allocated.
47    primed: Vec<ManagedMemoryBinding>,
48    /// The host memory the recorded copies read from, held while the window
49    /// is open and handed to the graph it seals into. A recorded memcpy node
50    /// keeps the raw host pointer, so the bytes must live exactly as long as
51    /// the graph — whatever kind of allocation they are, pinned-pool slice or
52    /// plain heap. A window that never seals drops them here: its copies
53    /// never ran and now never will.
54    retained_host: Vec<Bytes>,
55    /// The failure that doomed the window, if one did — work inside it failed
56    /// or was skipped, so the recording is missing an operation. Sealing it
57    /// would hand back a graph silently missing that work, and the replay
58    /// contract has the caller write fresh inputs before each replay,
59    /// clearing the very taint that would explain the hole — so the window is
60    /// doomed instead, and `end_capture` refuses to seal it.
61    failed: Option<ServerError>,
62}
63
64impl StreamCapture {
65    /// The capture of one more stream of the device whose captures `device` records.
66    pub fn new(device: &DeviceCaptures) -> Self {
67        Self {
68            state: device.stream(),
69            recorded: Vec::new(),
70            touched: Vec::new(),
71            primed: Vec::new(),
72            retained_host: Vec::new(),
73            failed: None,
74        }
75    }
76
77    /// The captures of the whole device this stream belongs to, this one included.
78    pub fn device(&self) -> &CaptureStatus {
79        self.state.device()
80    }
81
82    /// Remember the memory a launch was given, when the stream is recording.
83    ///
84    /// A no-op outside a window, where a launch that fails taints its own
85    /// buffers on the spot and there is no graph to answer for them later.
86    pub fn record(&mut self, buffers: impl IntoIterator<Item = BufferBinding>) {
87        if self.state.is_recording() {
88            self.recorded
89                .extend(buffers.into_iter().map(|binding| binding.downgrade()));
90        }
91    }
92
93    /// Remember that a launch was given the memory at `location`, owned by
94    /// `stream`, when the stream is recording: the graph replays against that
95    /// page, so it has to stay where it is for the graph's life.
96    pub fn touch(&mut self, stream: StreamId, location: MemoryLocation) {
97        if self.state.is_recording() {
98            self.touched.push(TouchedPage { stream, location });
99        }
100    }
101
102    /// The pages the recording touched, each named once, taken as the window
103    /// closes. A launch touches its pages again on every recorded launch, so
104    /// the list is deduplicated here, once, rather than on every touch.
105    pub fn take_touched(&mut self) -> Vec<TouchedPage> {
106        let mut touched = core::mem::take(&mut self.touched);
107        touched.sort_unstable();
108        touched.dedup();
109        touched
110    }
111
112    /// What this stream's reservations may do to the pages held.
113    ///
114    /// A recording stream allocates nothing: an allocation would become part
115    /// of the recording. While any stream of the device records, no page is
116    /// released or renumbered, so the pages a recording touched are still the
117    /// ones it guards when it seals.
118    pub fn page_update(&self) -> PageUpdate {
119        match (self.is_recording(), self.device().any_recording()) {
120            (true, _) => PageUpdate::Forbidden,
121            (false, true) => PageUpdate::AddOnly,
122            (false, false) => PageUpdate::Allow,
123        }
124    }
125
126    /// Hold `staging` until the window opens, when the stream is preparing a
127    /// capture: a recorded copy keeps its staging for the graph's life, so the
128    /// recorded run needs a slice per copy where the warmup run could reuse
129    /// one, and holding them grows the pool before nothing may be allocated.
130    pub fn prime(&mut self, staging: &ManagedMemoryBinding) {
131        if self.state.is_preparing() {
132            self.primed.push(staging.clone());
133        }
134    }
135
136    /// The memory of the capture that just closed, each claim named once —
137    /// the same buffer comes back once per recorded launch that was given it.
138    ///
139    /// Deduplicated by [`claim_key`](BufferBinding::claim_key), because the
140    /// taint bookkeeping is range-exact and this list is what gets claimed
141    /// and released: two tensors carved from one batched allocation are two
142    /// claims, and collapsing them to their shared memory id would leave
143    /// every sibling but one unclaimed on a refusal and unreleased on a
144    /// replay.
145    ///
146    /// A buffer whose memory is gone by then is left out: nothing will read
147    /// it again.
148    pub fn take_recorded(&mut self) -> Vec<BufferBinding> {
149        let mut recorded: Vec<BufferBinding> = core::mem::take(&mut self.recorded)
150            .iter()
151            .filter_map(WeakBufferBinding::upgrade)
152            .collect();
153        recorded.sort_unstable_by_key(|binding| binding.claim_key());
154        recorded.dedup_by_key(|binding| binding.claim_key());
155        recorded
156    }
157
158    /// Doom the recording: work inside the window failed or was skipped —
159    /// see [`Self::take_failure`]. The first failure wins, and a stream that
160    /// is not recording has no window to doom.
161    pub fn fail(&mut self, error: ServerError) {
162        if self.state.is_recording() && self.failed.is_none() {
163            self.failed = Some(error);
164        }
165    }
166
167    /// Keep `bytes` alive for the graph this recording seals into: a
168    /// recorded copy holds their raw pointer and re-reads them on every
169    /// replay, so they must not return to any pool or allocator while the
170    /// graph lives. Handed to the graph by [`take_retained_host`](Self::take_retained_host).
171    ///
172    /// Only meaningful while recording — outside a window the bytes belong
173    /// in the drop queue, whose fence knows when the device is done with
174    /// them.
175    pub fn retain_host(&mut self, bytes: Bytes) {
176        debug_assert!(
177            self.state.is_recording(),
178            "host bytes are the window's to retain only while it records"
179        );
180        self.retained_host.push(bytes);
181    }
182
183    /// The host memory the window's recorded copies read from, taken as it
184    /// closes — onto the graph when it seals, or to be dropped when it does
185    /// not, since a recording that never becomes a graph never runs them.
186    pub fn take_retained_host(&mut self) -> Vec<Bytes> {
187        core::mem::take(&mut self.retained_host)
188    }
189
190    /// The failure that doomed this window, taken as it closes. `Some` means
191    /// the recording is missing at least one operation and must not seal.
192    pub fn take_failure(&mut self) -> Option<ServerError> {
193        self.failed.take()
194    }
195
196    /// Whether launches on the stream are being recorded into a graph right
197    /// now — the window during which a host sync would abort the capture, and
198    /// during which a neighbour sharing the pooled stream is refused.
199    pub fn is_recording(&self) -> bool {
200        self.state.is_recording()
201    }
202
203    /// Whether a capture is prepared or recording — the span over which the
204    /// pooled stream is committed to one logical stream's window.
205    pub fn is_active(&self) -> bool {
206        self.state.is_active()
207    }
208
209    /// The logical stream that opened the window, while one is open. `None`
210    /// outside a capture, which is what distinguishes a neighbour's operation
211    /// from the owner's.
212    pub fn owner(&self) -> Option<StreamId> {
213        self.state.owner()
214    }
215
216    /// The [`CacheMode`] the metadata info cache should run in at this lifecycle
217    /// position: a window pins what it builds, so a replay finds the same entries it recorded
218    /// against. Both while a graph is being *prepared* (warmup, which primes
219    /// the cache) and while it is being *recorded* the cache runs in
220    /// [`CacheMode::Capture`] — caching every buffer and invalidating none — so
221    /// the capture window finds every info buffer warm and drops none out from
222    /// under a recorded launch. Normal operation uses [`CacheMode::Normal`].
223    pub fn cache_mode(&self) -> CacheMode {
224        match self.state.is_active() {
225            true => CacheMode::Capture,
226            false => CacheMode::Normal,
227        }
228    }
229
230    /// Start the warmup run; [`begin`](Self::begin) may open the window
231    /// afterwards. A capture starts from an empty recording, so a window that
232    /// was abandoned mid-flight cannot leak its buffers into the next one.
233    ///
234    /// # Errors
235    ///
236    /// Fails when a capture is already prepared or recording, leaving both the
237    /// state and the recording untouched.
238    pub fn prepare(&mut self, owner: StreamId) -> Result<(), ServerError> {
239        self.state.prepare(owner)?;
240        self.clear();
241        Ok(())
242    }
243
244    /// Open the window: launches from here until [`end`](Self::end) are
245    /// recorded rather than executed.
246    ///
247    /// # Errors
248    ///
249    /// Fails when no capture is prepared, or one is already recording.
250    ///
251    /// The staging the warmup run primed goes back to the pool, for the
252    /// recorded run to reuse.
253    pub fn begin(&mut self) -> Result<(), ServerError> {
254        self.state.begin()?;
255        self.primed.clear();
256        Ok(())
257    }
258
259    /// Close the window, saying whether `caller` owned it — see
260    /// [`CaptureEnd`]. The recording survives the transition for
261    /// [`take_recorded`](Self::take_recorded) to collect.
262    ///
263    /// # Errors
264    ///
265    /// Fails when no capture is recording, leaving the state untouched.
266    pub fn end(&mut self, caller: StreamId) -> Result<CaptureEnd, ServerError> {
267        self.state.end(caller)
268    }
269
270    /// Give up a prepared capture that never opened, restoring the stream to
271    /// no-capture. Whatever it had recorded or retained goes with it.
272    pub fn abort(&mut self) {
273        self.state.abort();
274        self.clear();
275    }
276
277    fn clear(&mut self) {
278        self.recorded.clear();
279        self.touched.clear();
280        self.primed.clear();
281        self.retained_host.clear();
282        self.failed = None;
283    }
284}
285
286/// A page a recorded launch was given memory on.
287///
288/// Ordered by stream, then the whole location: a pool whose slices are their
289/// own allocations tells its pages apart by slice.
290#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
291pub struct TouchedPage {
292    /// The stream whose memory holds the page.
293    pub stream: StreamId,
294    /// Where the memory sat when the launch was recorded. Nothing moves or
295    /// renumbers pages while a stream records, so it still names the page
296    /// when the window closes.
297    pub location: MemoryLocation,
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use crate::memory_management::ManagedMemoryId;
304    use crate::server::Handle;
305
306    /// A service for handles that never reach a device.
307    fn service() -> cubecl_common::device::ServiceId {
308        cubecl_common::device::ServiceId::of::<()>(cubecl_common::device::DeviceId::new(0, 0))
309    }
310
311    const OWNER: StreamId = StreamId { value: 7 };
312
313    /// A distinct buffer per call, on the owner's stream.
314    fn buffer() -> BufferBinding {
315        let binding = Handle::new(service(), OWNER, 8).binding();
316        // The reference the pool's slice keeps on a real allocation, which
317        // no test here has: a recording only answers for allocations
318        // something other than their pool still holds.
319        core::mem::forget(binding.memory.clone());
320        binding
321    }
322
323    fn ids(bindings: &[BufferBinding]) -> Vec<ManagedMemoryId> {
324        bindings.iter().map(|binding| binding.memory.id()).collect()
325    }
326
327    /// A graph answers for every buffer its launches were given, and names each
328    /// one once however many launches shared it.
329    ///
330    /// A failed replay runs none of the recorded launches, so the list is what
331    /// a later read of any of those buffers fails on. Repeats would make that
332    /// list grow with the length of the capture rather than with the working
333    /// set — a chat step records hundreds of launches over the same handful of
334    /// weights.
335    #[test]
336    fn a_capture_names_each_buffer_its_launches_were_given_once() {
337        let (a, b, c) = (buffer(), buffer(), buffer());
338
339        let mut capture = StreamCapture::new(&DeviceCaptures::default());
340        capture.prepare(OWNER).unwrap();
341        capture.begin().unwrap();
342        capture.record([a.clone(), b.clone()]);
343        capture.record([b.clone(), c.clone()]);
344        capture.record([a.clone()]);
345
346        assert_eq!(
347            capture.end(OWNER).unwrap(),
348            CaptureEnd::Owned { owner: OWNER }
349        );
350        assert_eq!(ids(&capture.take_recorded()), ids(&[a, b, c]));
351        assert!(
352            capture.take_recorded().is_empty(),
353            "the recording moves onto the graph, it is not left on the stream"
354        );
355    }
356
357    /// Outside a window a launch answers for its own buffers as it fails, so
358    /// nothing is remembered for a graph that will never exist.
359    ///
360    /// A stream that accumulated ids while not recording would grow one entry
361    /// per launch for the life of the process, and hand the next capture a list
362    /// of buffers it never touched.
363    #[test]
364    fn a_launch_outside_a_window_is_not_recorded() {
365        let (before, warmup, recorded) = (buffer(), buffer(), buffer());
366        let mut capture = StreamCapture::new(&DeviceCaptures::default());
367
368        capture.record([before]);
369        capture.prepare(OWNER).unwrap();
370        // Prepared is the warmup run: it executes rather than records.
371        capture.record([warmup]);
372
373        capture.begin().unwrap();
374        capture.record([recorded.clone()]);
375        capture.end(OWNER).unwrap();
376
377        assert_eq!(ids(&capture.take_recorded()), ids(&[recorded]));
378    }
379
380    /// A batched allocation is carved into sibling tensors sharing one memory
381    /// id and nothing else, and the taint bookkeeping is range-exact — so the
382    /// write set keeps every range. Collapsed to the id, a refusal would
383    /// claim (and a replay release) only whichever sibling survived the
384    /// dedup, leaving the others readable with stale bytes or unreadable with
385    /// clean ones.
386    #[test]
387    fn a_capture_names_every_range_of_a_batched_allocation() {
388        let handle = Handle::new(service(), OWNER, 8);
389        let mut front = handle.clone().binding();
390        front.offset_end = Some(4);
391        let mut back = handle.clone().binding();
392        back.offset_start = Some(4);
393        assert_eq!(
394            front.memory.id(),
395            back.memory.id(),
396            "one allocation carved in two is the case under test"
397        );
398
399        let mut capture = StreamCapture::new(&DeviceCaptures::default());
400        capture.prepare(OWNER).unwrap();
401        capture.begin().unwrap();
402        capture.record([front.clone(), back.clone()]);
403        // The same range again, from a second launch given the same tensor:
404        // still one claim.
405        capture.record([front.clone()]);
406        capture.end(OWNER).unwrap();
407
408        let recorded = capture.take_recorded();
409        let keys: Vec<_> = recorded.iter().map(|binding| binding.claim_key()).collect();
410        assert_eq!(
411            keys,
412            alloc::vec![front.claim_key(), back.claim_key()],
413            "both siblings survive, each named once"
414        );
415    }
416
417    /// The host bytes a recorded copy reads from live with the window: taken
418    /// once as it closes — onto the graph, when one seals — and dropped with
419    /// an aborted window, whose copies never ran and now never will.
420    #[test]
421    fn a_window_owns_the_host_bytes_its_copies_read() {
422        let mut capture = StreamCapture::new(&DeviceCaptures::default());
423        capture.prepare(OWNER).unwrap();
424        capture.begin().unwrap();
425        capture.retain_host(Bytes::from_bytes_vec(alloc::vec![7u8; 4]));
426        capture.end(OWNER).unwrap();
427
428        assert_eq!(capture.take_retained_host().len(), 1);
429        assert!(
430            capture.take_retained_host().is_empty(),
431            "taken means moved onto the graph, not copied"
432        );
433
434        capture.prepare(OWNER).unwrap();
435        capture.begin().unwrap();
436        capture.retain_host(Bytes::from_bytes_vec(alloc::vec![7u8; 4]));
437        capture.abort();
438        capture.prepare(OWNER).unwrap();
439        assert!(
440            capture.take_retained_host().is_empty(),
441            "an aborted window keeps nothing alive"
442        );
443    }
444
445    /// A window that never sealed leaves nothing behind for the next one.
446    ///
447    /// The stream is re-capturable after an abort or an abandoned end, and a
448    /// second capture that inherited the first one's buffers would pin memory
449    /// its own launches never saw — failing reads that had nothing to do with
450    /// it, on a graph that outlives the mistake.
451    #[test]
452    fn a_new_capture_starts_from_an_empty_recording() {
453        let neighbour = StreamId { value: 8 };
454
455        let (aborted, abandoned) = (buffer(), buffer());
456        let mut capture = StreamCapture::new(&DeviceCaptures::default());
457        capture.prepare(OWNER).unwrap();
458        capture.begin().unwrap();
459        capture.record([aborted]);
460        capture.abort();
461
462        capture.prepare(OWNER).unwrap();
463        capture.begin().unwrap();
464        capture.record([abandoned.clone()]);
465        assert!(capture.end(neighbour).unwrap().is_abandoned());
466        assert_eq!(ids(&capture.take_recorded()), ids(&[abandoned]));
467
468        capture.prepare(neighbour).unwrap();
469        capture.begin().unwrap();
470        assert!(
471            capture.take_recorded().is_empty(),
472            "the abandoned window's buffers are not this capture's to answer for"
473        );
474    }
475
476    /// The cache runs in capture mode for the *whole* prepare → record window,
477    /// not just while recording: warmup is what makes the recorded launches hit
478    /// warm info buffers, and an entry evicted between the two would be one a
479    /// recorded launch dropped out from under itself.
480    #[test]
481    fn the_cache_captures_across_the_whole_window() {
482        let mut capture = StreamCapture::new(&DeviceCaptures::default());
483        assert_eq!(capture.cache_mode(), CacheMode::Normal);
484        capture.prepare(OWNER).unwrap();
485        assert_eq!(capture.cache_mode(), CacheMode::Capture);
486        capture.begin().unwrap();
487        assert_eq!(capture.cache_mode(), CacheMode::Capture);
488        capture.end(OWNER).unwrap();
489        assert_eq!(capture.cache_mode(), CacheMode::Normal);
490    }
491
492    /// A capture is visible to the stream that prepared it, from its warmup run
493    /// until it ends however it ends, and to no other stream: code that has to
494    /// decide the same way in the warmup and the recording reads it, and would
495    /// keep paying for it if a finished capture lingered.
496    #[test]
497    fn a_capture_is_seen_by_its_stream_until_it_ends() {
498        const OTHER: StreamId = StreamId { value: 8 };
499        let device = DeviceCaptures::default();
500        let status = device.status();
501        let mut capture = StreamCapture::new(&device);
502
503        assert!(!status.is_capturing(OWNER), "no capture before prepare");
504        capture.prepare(OWNER).unwrap();
505        assert!(status.is_capturing(OWNER), "the warmup run is part of it");
506        assert!(
507            !status.is_capturing(OTHER),
508            "another stream isn't capturing"
509        );
510        capture.begin().unwrap();
511        assert!(status.is_capturing(OWNER), "the recorded run is part of it");
512        capture.end(OWNER).unwrap();
513        assert!(!status.is_capturing(OWNER), "a sealed capture is over");
514
515        capture.prepare(OWNER).unwrap();
516        capture.begin().unwrap();
517        capture.end(OTHER).unwrap();
518        assert!(
519            !status.is_capturing(OWNER),
520            "an abandoned capture is over for its owner too"
521        );
522
523        capture.prepare(OWNER).unwrap();
524        capture.abort();
525        assert!(!status.is_capturing(OWNER), "an aborted capture is over");
526
527        let mut other = StreamCapture::new(&device);
528        other.prepare(OTHER).unwrap();
529        capture.prepare(OWNER).unwrap();
530        drop(capture);
531        assert!(
532            !status.is_capturing(OWNER),
533            "a dropped stream's capture is over"
534        );
535        assert!(
536            status.is_capturing(OTHER),
537            "another stream's capture is untouched"
538        );
539    }
540
541    /// Every stream of a device sees a window one of them opens, and the
542    /// window leaves the count however it closes: sealed, aborted, or with
543    /// its stream dropped.
544    #[test]
545    fn a_recording_is_seen_device_wide() {
546        let device = DeviceCaptures::default();
547        let mut recording = StreamCapture::new(&device);
548        let neighbour = StreamCapture::new(&device);
549        let status = device.status();
550        let recording_now =
551            |capture: &StreamCapture| (capture.page_update(), status.any_recording());
552
553        recording.prepare(OWNER).unwrap();
554        assert!(!status.any_recording(), "a warmup records nothing");
555        recording.begin().unwrap();
556        assert_eq!(recording_now(&recording), (PageUpdate::Forbidden, true));
557        assert_eq!(recording_now(&neighbour), (PageUpdate::AddOnly, true));
558        recording.end(OWNER).unwrap();
559        assert_eq!(recording_now(&neighbour), (PageUpdate::Allow, false));
560
561        recording.prepare(OWNER).unwrap();
562        recording.begin().unwrap();
563        recording.abort();
564        assert!(
565            !status.any_recording(),
566            "an aborted window leaves the count"
567        );
568
569        recording.prepare(OWNER).unwrap();
570        recording.abort();
571        assert!(
572            !status.any_recording(),
573            "aborting a warmup leaves the count alone"
574        );
575
576        recording.prepare(OWNER).unwrap();
577        recording.begin().unwrap();
578        drop(recording);
579        assert!(
580            !status.any_recording(),
581            "a stream dropped while recording leaves the count"
582        );
583    }
584}