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}