cubecl_server/stream/capture.rs
1//! The stream-side graph-capture lifecycle, shared by every backend with
2//! graph support (see [`Server::graph_prepare`](crate::server::Server::graph_prepare)).
3
4use crate::metadata_cache::CacheMode;
5use crate::server::{BufferBinding, ServerError};
6use alloc::format;
7use alloc::vec::Vec;
8use cubecl_common::bytes::Bytes;
9use cubecl_environment::backtrace::BackTrace;
10use cubecl_environment::stream::StreamId;
11
12/// Where a stream sits in the graph-capture lifecycle, and the only thing
13/// allowed to move it. Capture is a strict `NoCapture → Prepare → Capture →
14/// NoCapture` progression, driven by [`prepare`](Self::prepare),
15/// [`begin`](Self::begin) and [`end`](Self::end); each rejects an out-of-order
16/// call, so a capture can never start unprepared and two captures can never
17/// overlap on one stream.
18///
19/// # One capture, one logical stream
20///
21/// The three calls have to come from the same logical stream. The window is
22/// opened on the pooled stream that logical stream folds onto, and the launches
23/// in between are recorded there — so a caller whose [`StreamId`] changes
24/// half-way (an `.await` resuming on another thread under the default
25/// `PerThread` policy, without `set_stream` pinning) has already split its
26/// recording across two backend streams before it ever reaches `end`. Pin the
27/// stream around a capture; [`end`](Self::end) treats a caller that is not the
28/// owner as a window nobody is coming back for, and abandons it.
29///
30/// The transitions live here rather than in each backend server because the
31/// rule is the same on every one of them — a backend supplies only the work a
32/// transition brackets (arming its pools, opening the driver's capture), never
33/// the ordering rule itself.
34///
35/// # What the neighbours pay
36///
37/// The window is held on a pooled stream, and logical streams fold onto those
38/// with `id % max_streams` — so a capture costs every logical stream sharing
39/// that slot, not just the one recording. On a software-graph backend a
40/// neighbour's read, sync or profile is refused outright for the duration, and
41/// its write is refused with the refusal landing on its own destinations; on a
42/// hardware-graph backend a
43/// neighbour's fenced flush is deferred until the window closes. None of that
44/// is attributed to the capture, because a refusal is not a failure of the
45/// capture: the neighbour asked for something this slot cannot do right now.
46///
47/// It is a real cost of folding, and the reason a capture is worth pinning to a
48/// stream nothing else is scheduled on.
49///
50/// Both active states carry the logical stream that opened the capture. Several
51/// logical streams share one backend stream, so "the capture owns this stream
52/// for its window" only holds if the window remembers whose it is: an error
53/// raised inside it dooms the capture, not whichever neighbour happens to be
54/// using the slot.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
56pub(crate) enum StreamCaptureState {
57 /// No capture is prepared or recording.
58 #[default]
59 NoCapture,
60 /// `graph_prepare` has armed the persistent pools for the warmup run;
61 /// `begin_capture` may now open the window. Slices the warmup run reserves
62 /// are retained by the memory manager's priming until `begin_capture` calls
63 /// [`capture_priming_end`](crate::memory_management::MemoryManagement::capture_priming_end),
64 /// so the pool ends up owning the capture run's full working set.
65 Prepare {
66 /// The logical stream that prepared the capture.
67 owner: StreamId,
68 },
69 /// Launches are being recorded into a graph instead of executing. On a
70 /// hardware-graph backend (CUDA, HIP) a host sync issued now aborts the
71 /// driver capture, so the execution path defers fenced flushes until
72 /// `end_capture`. A software-graph backend (wgpu) has no driver capture to
73 /// abort and instead refuses the operations it cannot record: a read, sync
74 /// or profile fails on the spot, while a write is rejected lazily — the
75 /// owner's own write dooms the recording so `end_capture` refuses to seal
76 /// it, since a graph missing an operation is worse than a late diagnostic.
77 Capture {
78 /// The logical stream recording the capture, which the errors raised
79 /// inside the window belong to.
80 owner: StreamId,
81 },
82}
83
84/// What [`StreamCapture::end`] found when it closed the window: the
85/// caller's own capture, or one belonging to a logical stream that never came
86/// back to close it.
87///
88/// Both close the window. Only the owner gets a graph out of it: the failures
89/// raised inside the window doom the recording, and sealing it for a caller
90/// that never saw them would hand back a graph silently missing whatever they
91/// rejected.
92///
93/// Refusing a foreign caller outright is the worse trade. Several logical
94/// streams share one pooled stream, and a window nobody closes rejects every
95/// read, write and sync that lands on the slot while recording launches into a
96/// graph no one can seal: the slot is lost for the life of the process. A
97/// foreign `end_capture` is a caller whose [`StreamId`] moved out from under it
98/// (see the type docs), which is exactly the case where the owner is gone — so
99/// the window is torn down and the failure reported, rather than kept for an
100/// owner that will never ask.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum CaptureEnd {
103 /// The caller opened this window: its recording may be sealed into a graph.
104 Owned {
105 /// The logical stream that opened the window, which is the caller.
106 owner: StreamId,
107 },
108 /// The window belonged to `owner`, not to the caller. It is closed, but
109 /// there is no graph to hand back: the backend tears the recording down and
110 /// reports, and a later `end_capture` from `owner` finds nothing recording.
111 Abandoned {
112 /// The logical stream that opened the window, which the report names
113 /// so the caller can see whose recording was discarded.
114 owner: StreamId,
115 },
116}
117
118impl CaptureEnd {
119 /// The logical stream the window belonged to.
120 pub fn owner(&self) -> StreamId {
121 match self {
122 CaptureEnd::Owned { owner } | CaptureEnd::Abandoned { owner } => *owner,
123 }
124 }
125
126 /// Whether the window was closed for a caller that did not own it, so its
127 /// recording is torn down instead of sealed.
128 pub fn is_abandoned(&self) -> bool {
129 matches!(self, CaptureEnd::Abandoned { .. })
130 }
131
132 /// The report a caller gets for closing a window it did not open: why the
133 /// recording was discarded, and then `doomed` — the failure that had
134 /// already sunk the recording, if one had, so the caller learns both
135 /// reasons rather than only the one that happened to be checked last.
136 ///
137 /// Only meaningful once [`is_abandoned`](Self::is_abandoned) says so; an
138 /// owned window is the caller's to seal and has nothing to report.
139 pub fn abandoned_error(&self, caller: StreamId, doomed: Option<ServerError>) -> ServerError {
140 let mut errors = alloc::vec![ServerError::graph_state(format!(
141 "end_capture: the capture belongs to logical stream {:?}, not to {caller:?}; it is \
142 discarded rather than left recording on a stream both share",
143 self.owner(),
144 ))];
145 errors.extend(doomed);
146
147 ServerError::Several {
148 errors,
149 backtrace: BackTrace::capture(),
150 }
151 }
152}
153
154/// The graph capture of one pooled backend stream: where it sits in the
155/// lifecycle, and the memory its recorded launches were given.
156///
157/// The two travel together because neither is meaningful alone. A launch is
158/// remembered only while the window is recording, and what it remembers is
159/// only ever read once the window closes — so pairing them
160/// makes "buffers accumulate inside a window and nowhere else" a property of
161/// the type rather than a rule each backend has to keep.
162///
163/// Why the memory is worth keeping: a replay runs every recorded launch or
164/// none, so a replay that fails to enqueue leaves all of them exactly as they
165/// were, and so does a capture that never seals. Either way a later read of one
166/// has to fail rather than copy out bytes nothing wrote — which needs the list
167/// the launches themselves no longer hold, as bindings, so the failure can be
168/// tainted onto the allocations they resolve to.
169#[derive(Debug, Default)]
170pub struct StreamCapture {
171 state: StreamCaptureState,
172 recorded: Vec<BufferBinding>,
173 /// The host memory the recorded copies read from, held while the window
174 /// is open and handed to the graph it seals into. A recorded memcpy node
175 /// keeps the raw host pointer, so the bytes must live exactly as long as
176 /// the graph — whatever kind of allocation they are, pinned-pool slice or
177 /// plain heap. A window that never seals drops them here: its copies
178 /// never ran and now never will.
179 retained_host: Vec<Bytes>,
180 /// The failure that doomed the window, if one did — work inside it failed
181 /// or was skipped, so the recording is missing an operation. Sealing it
182 /// would hand back a graph silently missing that work, and the replay
183 /// contract has the caller write fresh inputs before each replay,
184 /// clearing the very taint that would explain the hole — so the window is
185 /// doomed instead, and `end_capture` refuses to seal it.
186 failed: Option<ServerError>,
187}
188
189impl StreamCapture {
190 /// Remember the memory a launch was given, when the stream is recording.
191 ///
192 /// A no-op outside a window, where a launch that fails taints its own
193 /// buffers on the spot and there is no graph to answer for them later.
194 pub fn record(&mut self, buffers: impl IntoIterator<Item = BufferBinding>) {
195 if self.state.is_recording() {
196 self.recorded.extend(buffers);
197 }
198 }
199
200 /// The memory of the capture that just closed, each claim named once —
201 /// the same buffer comes back once per recorded launch that was given it.
202 ///
203 /// Deduplicated by [`claim_key`](BufferBinding::claim_key), because the
204 /// taint bookkeeping is range-exact and this list is what gets claimed
205 /// and released: two tensors carved from one batched allocation are two
206 /// claims, and collapsing them to their shared memory id would leave
207 /// every sibling but one unclaimed on a refusal and unreleased on a
208 /// replay.
209 pub fn take_recorded(&mut self) -> Vec<BufferBinding> {
210 let mut recorded = core::mem::take(&mut self.recorded);
211 recorded.sort_unstable_by_key(|binding| binding.claim_key());
212 recorded.dedup_by_key(|binding| binding.claim_key());
213 recorded
214 }
215
216 /// Doom the recording: work inside the window failed or was skipped —
217 /// see [`Self::take_failure`]. The first failure wins, and a stream that
218 /// is not recording has no window to doom.
219 pub fn fail(&mut self, error: ServerError) {
220 if self.state.is_recording() && self.failed.is_none() {
221 self.failed = Some(error);
222 }
223 }
224
225 /// Keep `bytes` alive for the graph this recording seals into: a
226 /// recorded copy holds their raw pointer and re-reads them on every
227 /// replay, so they must not return to any pool or allocator while the
228 /// graph lives. Handed to the graph by [`take_retained_host`](Self::take_retained_host).
229 ///
230 /// Only meaningful while recording — outside a window the bytes belong
231 /// in the drop queue, whose fence knows when the device is done with
232 /// them.
233 pub fn retain_host(&mut self, bytes: Bytes) {
234 debug_assert!(
235 self.state.is_recording(),
236 "host bytes are the window's to retain only while it records"
237 );
238 self.retained_host.push(bytes);
239 }
240
241 /// The host memory the window's recorded copies read from, taken as it
242 /// closes — onto the graph when it seals, or to be dropped when it does
243 /// not, since a recording that never becomes a graph never runs them.
244 pub fn take_retained_host(&mut self) -> Vec<Bytes> {
245 core::mem::take(&mut self.retained_host)
246 }
247
248 /// The failure that doomed this window, taken as it closes. `Some` means
249 /// the recording is missing at least one operation and must not seal.
250 pub fn take_failure(&mut self) -> Option<ServerError> {
251 self.failed.take()
252 }
253
254 /// Whether launches on the stream are being recorded into a graph right
255 /// now — the window during which a host sync would abort the capture, and
256 /// during which a neighbour sharing the pooled stream is refused.
257 pub fn is_recording(&self) -> bool {
258 self.state.is_recording()
259 }
260
261 /// Whether a capture is prepared or recording — the span over which the
262 /// pooled stream is committed to one logical stream's window.
263 pub fn is_active(&self) -> bool {
264 self.state.is_active()
265 }
266
267 /// The logical stream that opened the window, while one is open. `None`
268 /// outside a capture, which is what distinguishes a neighbour's operation
269 /// from the owner's.
270 pub fn owner(&self) -> Option<StreamId> {
271 self.state.owner()
272 }
273
274 /// How the metadata caches behave for this stream right now: a window
275 /// pins what it builds, so a replay finds the same entries it recorded
276 /// against.
277 pub fn cache_mode(&self) -> CacheMode {
278 self.state.cache_mode()
279 }
280
281 /// Arm the persistent pools for the warmup run; [`begin`](Self::begin)
282 /// may open the window afterwards. A capture starts from an empty
283 /// recording, so a window that was abandoned mid-flight cannot leak its
284 /// buffers into the next one.
285 ///
286 /// # Errors
287 ///
288 /// Fails when a capture is already prepared or recording, leaving both the
289 /// state and the recording untouched.
290 pub fn prepare(&mut self, owner: StreamId) -> Result<(), ServerError> {
291 self.state.prepare(owner)?;
292 self.recorded.clear();
293 self.retained_host.clear();
294 self.failed = None;
295 Ok(())
296 }
297
298 /// Open the window: launches from here until [`end`](Self::end) are
299 /// recorded rather than executed.
300 ///
301 /// # Errors
302 ///
303 /// Fails when no capture is prepared, or one is already recording.
304 pub fn begin(&mut self) -> Result<(), ServerError> {
305 self.state.begin()
306 }
307
308 /// Close the window, saying whether `caller` owned it — see
309 /// [`CaptureEnd`]. The recording survives the transition for
310 /// [`take_recorded`](Self::take_recorded) to collect.
311 ///
312 /// # Errors
313 ///
314 /// Fails when no capture is recording, leaving the state untouched.
315 pub fn end(&mut self, caller: StreamId) -> Result<CaptureEnd, ServerError> {
316 self.state.end(caller)
317 }
318
319 /// Give up a prepared capture that never opened, restoring the stream to
320 /// no-capture. Whatever it had recorded or retained goes with it.
321 pub fn abort(&mut self) {
322 self.state.abort();
323 self.recorded.clear();
324 self.retained_host.clear();
325 self.failed = None;
326 }
327}
328
329impl StreamCaptureState {
330 /// Whether launches on the stream are being recorded into a graph right
331 /// now — the window during which a host sync would abort (or is rejected
332 /// by) the capture.
333 pub(crate) fn is_recording(&self) -> bool {
334 matches!(self, StreamCaptureState::Capture { .. })
335 }
336
337 /// Whether a capture is prepared or recording — the whole window during
338 /// which the stream is not free to serve other work.
339 pub(crate) fn is_active(&self) -> bool {
340 !matches!(self, StreamCaptureState::NoCapture)
341 }
342
343 /// The logical stream this capture belongs to, `None` outside a window.
344 pub(crate) fn owner(&self) -> Option<StreamId> {
345 match self {
346 StreamCaptureState::NoCapture => None,
347 StreamCaptureState::Prepare { owner } | StreamCaptureState::Capture { owner } => {
348 Some(*owner)
349 }
350 }
351 }
352
353 /// The [`CacheMode`] the metadata info cache should run in at this lifecycle
354 /// position. Both while a graph is being *prepared* (warmup, which primes
355 /// the cache) and while it is being *recorded* the cache runs in
356 /// [`CacheMode::Capture`] — caching every buffer and invalidating none — so
357 /// the capture window finds every info buffer warm and drops none out from
358 /// under a recorded launch. Normal operation uses [`CacheMode::Normal`].
359 pub(crate) fn cache_mode(&self) -> CacheMode {
360 match self {
361 StreamCaptureState::NoCapture => CacheMode::Normal,
362 StreamCaptureState::Prepare { .. } | StreamCaptureState::Capture { .. } => {
363 CacheMode::Capture
364 }
365 }
366 }
367
368 /// `NoCapture → Prepare`, for `graph_prepare`. Call before arming the
369 /// pools; the caller owns the arming, this owns the rule that it happens
370 /// exactly once per capture.
371 ///
372 /// # Errors
373 ///
374 /// Fails when a capture is already prepared or already recording on this
375 /// stream, leaving the state untouched — two captures may never overlap on
376 /// one stream. The caller can retry after `end_capture`.
377 pub(crate) fn prepare(&mut self, owner: StreamId) -> Result<(), ServerError> {
378 match self {
379 StreamCaptureState::NoCapture => {
380 *self = StreamCaptureState::Prepare { owner };
381 Ok(())
382 }
383 StreamCaptureState::Prepare { .. } => Err(ServerError::graph_state(
384 "graph_prepare: a graph capture is already prepared on this stream",
385 )),
386 StreamCaptureState::Capture { .. } => Err(ServerError::graph_state(
387 "graph_prepare: a graph capture is already recording on this stream",
388 )),
389 }
390 }
391
392 /// `Prepare → Capture`, for `begin_capture`. Call *before* the work that
393 /// opens the window (ending the priming phase, starting the driver's
394 /// capture) so a rejected call cannot run any of it: on a stream that is
395 /// already recording, a drop-queue flush issued on the way to the rejection
396 /// would abort the live capture.
397 ///
398 /// Since the state moves before that work, a backend whose window fails to
399 /// open must undo it with [`abort`](Self::abort).
400 ///
401 /// # Errors
402 ///
403 /// Fails when [`prepare`](Self::prepare) has not run — the persistent pools
404 /// have to be primed by a warmup run first — or when a capture is already
405 /// recording. The state is left untouched.
406 pub(crate) fn begin(&mut self) -> Result<(), ServerError> {
407 match self {
408 StreamCaptureState::Prepare { owner } => {
409 *self = StreamCaptureState::Capture { owner: *owner };
410 Ok(())
411 }
412 StreamCaptureState::NoCapture => Err(ServerError::graph_state(
413 "begin_capture: call graph_prepare before starting a capture",
414 )),
415 StreamCaptureState::Capture { .. } => Err(ServerError::graph_state(
416 "begin_capture: a graph capture is already recording on this stream",
417 )),
418 }
419 }
420
421 /// `Capture → NoCapture`, for `end_capture`. Call before closing the
422 /// window, so the stream leaves capture state even if sealing the graph
423 /// then fails — a backend that returned an error with the state still set
424 /// would wedge the stream in capture mode forever.
425 ///
426 /// A caller that does not own the window closes it all the same, as
427 /// [`CaptureEnd::Abandoned`]: only the owner may *seal* a capture, but
428 /// leaving the window open until an owner that may never come back closes
429 /// it would wedge the pooled stream for every logical stream sharing it —
430 /// see [`CaptureEnd`] for why that is the lesser of the two.
431 ///
432 /// # Errors
433 ///
434 /// Fails when no capture is recording (nothing prepared or started, or the
435 /// capture already ended), leaving the state untouched — a stray
436 /// `end_capture` must not close a window that was never opened.
437 pub(crate) fn end(&mut self, caller: StreamId) -> Result<CaptureEnd, ServerError> {
438 match self {
439 StreamCaptureState::Capture { owner } => {
440 let owner = *owner;
441 *self = StreamCaptureState::NoCapture;
442 Ok(match owner == caller {
443 true => CaptureEnd::Owned { owner },
444 false => CaptureEnd::Abandoned { owner },
445 })
446 }
447 StreamCaptureState::NoCapture | StreamCaptureState::Prepare { .. } => {
448 Err(ServerError::graph_state(
449 "end_capture: no graph capture is recording on this stream",
450 ))
451 }
452 }
453 }
454
455 /// Return to `NoCapture` from anywhere, for the failure path of a
456 /// transition's own work: the window never opened, so the stream must be
457 /// left fully usable and re-capturable rather than stuck arming its
458 /// persistent pools forever. Unlike [`end`](Self::end) this asserts
459 /// nothing, because the state it is recovering from is precisely the one
460 /// that could not be completed.
461 pub(crate) fn abort(&mut self) {
462 *self = StreamCaptureState::NoCapture;
463 }
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469 use crate::memory_management::ManagedMemoryId;
470 use crate::server::Handle;
471
472 /// A service for handles that never reach a device.
473 fn service() -> cubecl_common::device::ServiceId {
474 cubecl_common::device::ServiceId::of::<()>(cubecl_common::device::DeviceId::new(0, 0))
475 }
476
477 const OWNER: StreamId = StreamId { value: 7 };
478
479 /// A distinct buffer per call, on the owner's stream.
480 fn buffer() -> BufferBinding {
481 Handle::new(service(), OWNER, 8).binding()
482 }
483
484 fn ids(bindings: &[BufferBinding]) -> Vec<ManagedMemoryId> {
485 bindings.iter().map(|binding| binding.memory.id()).collect()
486 }
487
488 /// The ordering rule the three backends rely on: a capture cannot start
489 /// unprepared, and two cannot overlap on one stream. A backend that could
490 /// reach `Capture` without `Prepare` would record against pools no warmup
491 /// primed, and every allocation the window then makes is one the graph
492 /// replays against but nothing pins.
493 #[test]
494 fn transitions_follow_the_capture_order() {
495 let mut state = StreamCaptureState::NoCapture;
496
497 assert!(state.begin().is_err(), "a capture must be prepared first");
498 assert!(state.end(OWNER).is_err(), "nothing is recording yet");
499 assert_eq!(state, StreamCaptureState::NoCapture);
500
501 state.prepare(OWNER).unwrap();
502 assert_eq!(state, StreamCaptureState::Prepare { owner: OWNER });
503 assert!(state.prepare(OWNER).is_err(), "one prepare per capture");
504 assert!(state.end(OWNER).is_err(), "the window never opened");
505
506 state.begin().unwrap();
507 assert_eq!(state, StreamCaptureState::Capture { owner: OWNER });
508 assert!(state.begin().is_err(), "captures may not overlap");
509 assert!(state.prepare(OWNER).is_err(), "captures may not overlap");
510
511 assert_eq!(
512 state.end(OWNER).unwrap(),
513 CaptureEnd::Owned { owner: OWNER }
514 );
515 assert_eq!(state, StreamCaptureState::NoCapture);
516 }
517
518 /// The window remembers whose it is from end to end, so a failure raised
519 /// inside it dooms the capture that was recording rather than whichever
520 /// neighbour happens to be sharing the backend stream.
521 #[test]
522 fn the_window_carries_its_owner() {
523 let mut state = StreamCaptureState::NoCapture;
524 assert_eq!(state.owner(), None);
525
526 state.prepare(OWNER).unwrap();
527 assert_eq!(state.owner(), Some(OWNER));
528 assert!(state.is_active(), "the window is open from prepare on");
529
530 state.begin().unwrap();
531 assert_eq!(state.owner(), Some(OWNER));
532
533 assert_eq!(state.end(OWNER).unwrap().owner(), OWNER);
534 assert_eq!(state.owner(), None);
535 assert!(!state.is_active());
536 }
537
538 /// Only the stream that opened the window may seal it into a graph.
539 ///
540 /// A neighbour sealing it would hand back a recording built from a window
541 /// it never watched — the graph silently missing whatever the failures
542 /// raised inside it rejected.
543 #[test]
544 fn only_the_stream_that_opened_a_capture_may_seal_it() {
545 let neighbour = StreamId { value: 8 };
546
547 let mut state = StreamCaptureState::NoCapture;
548 state.prepare(OWNER).unwrap();
549 state.begin().unwrap();
550
551 assert_eq!(
552 state.end(neighbour).unwrap(),
553 CaptureEnd::Abandoned { owner: OWNER },
554 "the window is not theirs to seal"
555 );
556 }
557
558 /// A window its owner never closes must not hold the pooled stream, which
559 /// every logical stream folded onto the slot shares.
560 ///
561 /// The owner's id can stop coming back — the thread that started the
562 /// capture exits, or an `.await` resumes it elsewhere under `PerThread`. A
563 /// window kept until that id returns rejects every read, write and sync on
564 /// the slot forever, so a foreign `end` closes it and leaves the stream
565 /// usable, reporting rather than sealing.
566 #[test]
567 fn a_capture_no_one_can_close_does_not_wedge_the_stream() {
568 let neighbour = StreamId { value: 8 };
569
570 let mut state = StreamCaptureState::NoCapture;
571 state.prepare(OWNER).unwrap();
572 state.begin().unwrap();
573
574 assert!(state.end(neighbour).unwrap().is_abandoned());
575 assert_eq!(state, StreamCaptureState::NoCapture);
576 assert!(!state.is_active(), "the slot serves other work again");
577 state
578 .prepare(neighbour)
579 .expect("the stream is re-capturable");
580
581 // The owner coming back late finds nothing recording, rather than a
582 // window it can still seal a graph out of.
583 state.begin().unwrap();
584 assert!(
585 state.end(OWNER).unwrap().is_abandoned(),
586 "the window it opened is long gone"
587 );
588 }
589
590 /// A graph answers for every buffer its launches were given, and names each
591 /// one once however many launches shared it.
592 ///
593 /// A failed replay runs none of the recorded launches, so the list is what
594 /// a later read of any of those buffers fails on. Repeats would make that
595 /// list grow with the length of the capture rather than with the working
596 /// set — a chat step records hundreds of launches over the same handful of
597 /// weights.
598 #[test]
599 fn a_capture_names_each_buffer_its_launches_were_given_once() {
600 let (a, b, c) = (buffer(), buffer(), buffer());
601
602 let mut capture = StreamCapture::default();
603 capture.prepare(OWNER).unwrap();
604 capture.begin().unwrap();
605 capture.record([a.clone(), b.clone()]);
606 capture.record([b.clone(), c.clone()]);
607 capture.record([a.clone()]);
608
609 assert_eq!(
610 capture.end(OWNER).unwrap(),
611 CaptureEnd::Owned { owner: OWNER }
612 );
613 assert_eq!(ids(&capture.take_recorded()), ids(&[a, b, c]));
614 assert!(
615 capture.take_recorded().is_empty(),
616 "the recording moves onto the graph, it is not left on the stream"
617 );
618 }
619
620 /// Outside a window a launch answers for its own buffers as it fails, so
621 /// nothing is remembered for a graph that will never exist.
622 ///
623 /// A stream that accumulated ids while not recording would grow one entry
624 /// per launch for the life of the process, and hand the next capture a list
625 /// of buffers it never touched.
626 #[test]
627 fn a_launch_outside_a_window_is_not_recorded() {
628 let (before, warmup, recorded) = (buffer(), buffer(), buffer());
629 let mut capture = StreamCapture::default();
630
631 capture.record([before]);
632 capture.prepare(OWNER).unwrap();
633 // Prepared is the warmup run: it executes rather than records.
634 capture.record([warmup]);
635
636 capture.begin().unwrap();
637 capture.record([recorded.clone()]);
638 capture.end(OWNER).unwrap();
639
640 assert_eq!(ids(&capture.take_recorded()), ids(&[recorded]));
641 }
642
643 /// A batched allocation is carved into sibling tensors sharing one memory
644 /// id and nothing else, and the taint bookkeeping is range-exact — so the
645 /// write set keeps every range. Collapsed to the id, a refusal would
646 /// claim (and a replay release) only whichever sibling survived the
647 /// dedup, leaving the others readable with stale bytes or unreadable with
648 /// clean ones.
649 #[test]
650 fn a_capture_names_every_range_of_a_batched_allocation() {
651 let handle = Handle::new(service(), OWNER, 8);
652 let mut front = handle.clone().binding();
653 front.offset_end = Some(4);
654 let mut back = handle.clone().binding();
655 back.offset_start = Some(4);
656 assert_eq!(
657 front.memory.id(),
658 back.memory.id(),
659 "one allocation carved in two is the case under test"
660 );
661
662 let mut capture = StreamCapture::default();
663 capture.prepare(OWNER).unwrap();
664 capture.begin().unwrap();
665 capture.record([front.clone(), back.clone()]);
666 // The same range again, from a second launch given the same tensor:
667 // still one claim.
668 capture.record([front.clone()]);
669 capture.end(OWNER).unwrap();
670
671 let recorded = capture.take_recorded();
672 let keys: Vec<_> = recorded.iter().map(|binding| binding.claim_key()).collect();
673 assert_eq!(
674 keys,
675 alloc::vec![front.claim_key(), back.claim_key()],
676 "both siblings survive, each named once"
677 );
678 }
679
680 /// The host bytes a recorded copy reads from live with the window: taken
681 /// once as it closes — onto the graph, when one seals — and dropped with
682 /// an aborted window, whose copies never ran and now never will.
683 #[test]
684 fn a_window_owns_the_host_bytes_its_copies_read() {
685 let mut capture = StreamCapture::default();
686 capture.prepare(OWNER).unwrap();
687 capture.begin().unwrap();
688 capture.retain_host(Bytes::from_bytes_vec(alloc::vec![7u8; 4]));
689 capture.end(OWNER).unwrap();
690
691 assert_eq!(capture.take_retained_host().len(), 1);
692 assert!(
693 capture.take_retained_host().is_empty(),
694 "taken means moved onto the graph, not copied"
695 );
696
697 capture.prepare(OWNER).unwrap();
698 capture.begin().unwrap();
699 capture.retain_host(Bytes::from_bytes_vec(alloc::vec![7u8; 4]));
700 capture.abort();
701 capture.prepare(OWNER).unwrap();
702 assert!(
703 capture.take_retained_host().is_empty(),
704 "an aborted window keeps nothing alive"
705 );
706 }
707
708 /// A window that never sealed leaves nothing behind for the next one.
709 ///
710 /// The stream is re-capturable after an abort or an abandoned end, and a
711 /// second capture that inherited the first one's buffers would pin memory
712 /// its own launches never saw — failing reads that had nothing to do with
713 /// it, on a graph that outlives the mistake.
714 #[test]
715 fn a_new_capture_starts_from_an_empty_recording() {
716 let neighbour = StreamId { value: 8 };
717
718 let (aborted, abandoned) = (buffer(), buffer());
719 let mut capture = StreamCapture::default();
720 capture.prepare(OWNER).unwrap();
721 capture.begin().unwrap();
722 capture.record([aborted]);
723 capture.abort();
724
725 capture.prepare(OWNER).unwrap();
726 capture.begin().unwrap();
727 capture.record([abandoned.clone()]);
728 assert!(capture.end(neighbour).unwrap().is_abandoned());
729 assert_eq!(ids(&capture.take_recorded()), ids(&[abandoned]));
730
731 capture.prepare(neighbour).unwrap();
732 capture.begin().unwrap();
733 assert!(
734 capture.take_recorded().is_empty(),
735 "the abandoned window's buffers are not this capture's to answer for"
736 );
737 }
738
739 /// What a caller learns from closing a window that was not theirs: whose
740 /// it was, and whatever had already doomed the recording.
741 ///
742 /// The owner is the one piece of evidence the caller can act on — it names
743 /// the stream whose recording was thrown away. The doomed reason travels
744 /// with it because both are true at once, and reporting only the
745 /// abandonment would hide a failure that had already made the recording
746 /// unsealable.
747 #[test]
748 fn an_abandoned_window_reports_whose_it_was_and_what_doomed_it() {
749 let caller = StreamId { value: 8 };
750 let outcome = CaptureEnd::Abandoned { owner: OWNER };
751
752 let error = outcome.abandoned_error(caller, Some(ServerError::graph_state("doomed")));
753
754 let ServerError::Several { errors, .. } = &error else {
755 panic!("an abandoned window reports several failures at once, got: {error:?}");
756 };
757 let reported = alloc::format!("{error}");
758 assert!(
759 reported.contains(&alloc::format!("{OWNER:?}"))
760 && reported.contains(&alloc::format!("{caller:?}")),
761 "the report has to name the window's owner and the caller refused it, got: {reported}"
762 );
763 assert_eq!(errors.len(), 2, "the doomed reason travels with it");
764 assert!(
765 alloc::format!("{}", errors[1]).contains("doomed"),
766 "the explanation comes first, then what had already sunk it"
767 );
768 }
769
770 /// A rejected transition leaves the stream exactly as it was, so a caller
771 /// that miss orders a call can recover by issuing the right one — the
772 /// property `wgpu_graph_lifecycle_state_errors` defends end to end.
773 #[test]
774 fn a_rejected_transition_changes_nothing() {
775 let mut state = StreamCaptureState::Prepare { owner: OWNER };
776 assert!(state.prepare(OWNER).is_err());
777 assert_eq!(state, StreamCaptureState::Prepare { owner: OWNER });
778 state.begin().unwrap();
779 }
780
781 /// `abort` recovers from a window that failed to open, from either of the
782 /// states a backend can be holding when that happens.
783 #[test]
784 fn abort_recovers_a_window_that_never_opened() {
785 for state in [
786 StreamCaptureState::Prepare { owner: OWNER },
787 StreamCaptureState::Capture { owner: OWNER },
788 ] {
789 let mut state = state;
790 state.abort();
791 assert_eq!(state, StreamCaptureState::NoCapture);
792 state.prepare(OWNER).expect("the stream is re-capturable");
793 }
794 }
795
796 /// The cache runs in capture mode for the *whole* prepare → record window,
797 /// not just while recording: warmup is what makes the recorded launches hit
798 /// warm info buffers, and an entry evicted between the two would be one a
799 /// recorded launch dropped out from under itself.
800 #[test]
801 fn the_cache_captures_across_the_whole_window() {
802 assert_eq!(
803 StreamCaptureState::NoCapture.cache_mode(),
804 CacheMode::Normal
805 );
806 assert_eq!(
807 StreamCaptureState::Prepare { owner: OWNER }.cache_mode(),
808 CacheMode::Capture
809 );
810 assert_eq!(
811 StreamCaptureState::Capture { owner: OWNER }.cache_mode(),
812 CacheMode::Capture
813 );
814 }
815}