cubecl_server/memory_management/error_graph.rs
1//! The device-wide store of failures, refcounted by the allocations that
2//! carry them.
3//!
4//! Whether a buffer can be trusted is a property of that memory, so the fact
5//! lives on the allocation: a [`Slice`](super::memory_pool::Slice) carries the
6//! [`FailureId`] of the failure that tainted it, or none. What an id *means*
7//! lives here, device-wide, because a launch failing on one stream can taint a
8//! slice owned by another and both have to point at the same thing.
9//!
10//! A node is dropped when nothing carries its id any more, which is a
11//! reference count without atomics — the graph and the slices are both
12//! reachable only under the device handle's mutex. So the graph prunes
13//! itself, and what it prunes is exactly the failures nothing can still read:
14//! the graph leaks if and only if the program leaks memory.
15
16use crate::id::KernelId;
17use crate::memory_management::{ManagedMemoryHandle, ManagedMemoryId, WeakMemoryBinding};
18use crate::server::{IoError, ServerError};
19use alloc::boxed::Box;
20use alloc::vec::Vec;
21use core::num::NonZeroU64;
22use cubecl_environment::backtrace::BackTrace;
23use cubecl_environment::collections::HashMap;
24
25/// The id a tainted allocation carries, naming the failure that left its
26/// bytes unwritten.
27///
28/// Opaque on purpose: a carrier gains a word and nothing else. It does not
29/// learn what an error is, it cannot report one, and it has no opinion about
30/// streams. `NonZero` so `Option<FailureId>` is that one word rather than two.
31///
32/// Wide because ids are never reused: one is minted for every write scope
33/// that claims anything, which is every launch and every host copy, and a
34/// narrower counter would run out. Reuse is what a 32-bit id would need, and
35/// reuse is unsound here — [`prune`](ErrorGraph::prune) drops a node whose tag
36/// count is zero, which a freshly minted node also has, so a recycled id lets
37/// one scope's prune delete another's failure. Free in the carrier either way:
38/// [`Tainted`](super::Taint) pads a `u32` out to eight bytes before its
39/// ranges, which `a_failure_id_is_free_in_the_carrier` pins.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
41pub struct FailureId(NonZeroU64);
42
43impl core::fmt::Display for FailureId {
44 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
45 write!(f, "#{}", self.0)
46 }
47}
48
49/// Why the bytes of one buffer cannot be trusted, as a read finds it.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum Claim {
52 /// A failure claims bytes of the allocation: the work that was going to
53 /// write them did not run.
54 Failed(FailureId, ManagedMemoryId),
55 /// No reservation ever gave the memory a location. That failure has no
56 /// slice to be recorded on, so without this the buffer carries nothing,
57 /// passes every check, and a launch over it does nothing.
58 Unallocated(ManagedMemoryId),
59}
60
61/// Every failure the device is still holding, and how many allocations carry
62/// each one.
63#[derive(Debug, Default)]
64pub struct ErrorGraph {
65 nodes: HashMap<FailureId, Failure>,
66 /// The failures of buffers no allocation ever gave a location, which have no slice to
67 /// carry a failure id. Each holds one tag on its failure, released once the buffer is gone.
68 unallocated: HashMap<ManagedMemoryId, (FailureId, WeakMemoryBinding)>,
69 /// Ids handed out so far; the next one is `minted + 1`, which is never
70 /// zero and, being 64 bits wide, never wraps.
71 minted: u64,
72}
73
74#[derive(Debug)]
75struct Failure {
76 error: ServerError,
77 /// How many slices carry this id. The node lives while this is non-zero.
78 tagged: u32,
79 /// What this failure stopped, newest last, capped to the most recent —
80 /// see [`Skipped`]. On the failure rather than on the buffers because a
81 /// record stored on an allocation dies when that allocation does, and a
82 /// chain of links would break as soon as an intermediate buffer is freed
83 /// — the common case for a fused graph where only the last tensor is
84 /// kept. Holds ids, never handles, so the record of what a failure
85 /// stopped never retains the memory it names.
86 skipped: Vec<Skipped>,
87 /// Every skip, the capped list included: the report says how many are
88 /// missing from the walk.
89 skipped_total: u64,
90}
91
92/// One launch a failure stopped: the kernel that did not run, the buffer
93/// whose claim stopped it, and what it would have produced.
94///
95/// A read of a downstream buffer walks these backwards — the record that
96/// produced this buffer, then the record that produced what that one needed —
97/// so the report names the path from the read back to the root instead of
98/// only the root.
99#[derive(Debug, Clone)]
100pub struct Skipped {
101 /// The kernel the skip stopped.
102 pub kernel: KernelId,
103 /// The claimed buffer the kernel would have read.
104 pub needed: ManagedMemoryId,
105 /// The buffers the kernel would have written, which now carry the same
106 /// failure.
107 pub produced: Vec<ManagedMemoryId>,
108}
109
110impl ErrorGraph {
111 /// How many [`Skipped`] records one failure keeps. Newest win: the walk a
112 /// read makes starts from the most recent buffers, so keeping the oldest
113 /// would leave them with no entry to start from, while a deep chain
114 /// reaching a gap before the root costs nothing — the root is on the node
115 /// itself and never in the list.
116 pub const MAX_SKIPPED: usize = 16;
117
118 /// Hold `error` until nothing carries its id any more.
119 ///
120 /// The node starts carried by nothing, so a caller that taints no slice
121 /// with it must hand it back through [`prune`](Self::prune) — otherwise
122 /// the node waits forever for a decrement that is never coming.
123 pub fn insert(&mut self, error: ServerError) -> FailureId {
124 self.minted = self
125 .minted
126 .checked_add(1)
127 .expect("a failure id was minted for every u64");
128 let id = FailureId(NonZeroU64::new(self.minted).expect("minted starts above zero"));
129 self.nodes.insert(
130 id,
131 Failure {
132 error,
133 tagged: 0,
134 skipped: Vec::new(),
135 skipped_total: 0,
136 },
137 );
138 id
139 }
140
141 /// Record a launch `failure` stopped — see [`Skipped`]. Keeps the newest
142 /// [`MAX_SKIPPED`](Self::MAX_SKIPPED) records and counts them all.
143 pub fn skipped(&mut self, failure: FailureId, record: Skipped) {
144 let Some(node) = self.nodes.get_mut(&failure) else {
145 return;
146 };
147 node.skipped_total += 1;
148 if node.skipped.len() == Self::MAX_SKIPPED {
149 node.skipped.remove(0);
150 }
151 node.skipped.push(record);
152 }
153
154 /// The report a read of `memory` gets when `failure` claims its bytes:
155 /// the root error, and the path from this buffer back toward it,
156 /// reconstructed by walking the skip records backwards.
157 pub fn report(&self, failure: FailureId, memory: ManagedMemoryId) -> Option<ServerError> {
158 let node = self.nodes.get(&failure)?;
159
160 let mut chain = Vec::new();
161 let mut target = memory;
162 let mut upper = node.skipped.len();
163 while let Some(found) = node.skipped[..upper]
164 .iter()
165 .rposition(|record| record.produced.contains(&target))
166 {
167 let record = &node.skipped[found];
168 chain.push(alloc::format!(
169 "skipped `{}`: it needed memory {:?}, which carried the failure",
170 record.kernel.short_name(),
171 record.needed,
172 ));
173 target = record.needed;
174 upper = found;
175 }
176 let dropped = node.skipped_total.saturating_sub(node.skipped.len() as u64);
177 if !chain.is_empty() && dropped > 0 {
178 chain.push(alloc::format!(
179 "({dropped} older skip record(s) were dropped; the walk may stop before the root)"
180 ));
181 }
182
183 Some(ServerError::Unwritten {
184 failure: failure.0.get(),
185 claimed: node.tagged,
186 chain,
187 root: Box::new(node.error.clone()),
188 backtrace: BackTrace::capture(),
189 })
190 }
191
192 /// The report a read owes for the claims it found: one error per distinct
193 /// failure, however many of the buffers carry it, and one per buffer that
194 /// was never allocated. The second kind is not in the graph, since nothing
195 /// could carry it, but it is the same answer to the same question.
196 ///
197 /// This is the shape of every "were these bytes written" answer in the
198 /// system — [`FailureStore::ensure_written`](crate::stream::FailureStore::ensure_written)
199 /// and any harness standing in for it — so the dedup and the wrapping live
200 /// here rather than once per caller.
201 ///
202 /// # Errors
203 ///
204 /// [`ServerError::Several`] naming each failure once, in the order the
205 /// claims were found. The caller has nothing to retry — the bytes are gone
206 /// — so this is the answer to the read, not a hint to try again.
207 pub fn reports(&self, claims: impl Iterator<Item = Claim>) -> Result<(), ServerError> {
208 let mut seen: Vec<FailureId> = Vec::new();
209 let mut errors = Vec::new();
210
211 for claim in claims {
212 let (failure, memory) = match claim {
213 Claim::Failed(failure, memory) => (failure, memory),
214 Claim::Unallocated(memory) => match self.unallocated.get(&memory) {
215 Some((failure, _)) => (*failure, memory),
216 None => {
217 errors.push(Self::unallocated(memory));
218 continue;
219 }
220 },
221 };
222 if seen.contains(&failure) {
223 continue;
224 }
225 seen.push(failure);
226 // The full report: the root error, and the skip chain from this
227 // buffer back toward it.
228 if let Some(error) = self.report(failure, memory) {
229 errors.push(error);
230 }
231 }
232
233 match errors.is_empty() {
234 true => Ok(()),
235 false => Err(ServerError::Several {
236 errors,
237 backtrace: BackTrace::capture(),
238 }),
239 }
240 }
241
242 /// The cause is the reservation's own error, which the device thread logged
243 /// when it happened: nothing could carry it here.
244 fn unallocated(memory: ManagedMemoryId) -> ServerError {
245 IoError::NotFound {
246 backtrace: BackTrace::capture(),
247 reason: alloc::format!(
248 "memory {memory:?} was never allocated: the reservation behind it failed"
249 )
250 .into(),
251 }
252 .into()
253 }
254
255 /// One more allocation carries `failure`.
256 ///
257 /// The other half of [`untag`](Self::untag), called only by the taint
258 /// bookkeeping on the slices — [`Taint`](super::Taint) — which owns the
259 /// invariant that a claim tags exactly once however many times it is
260 /// re-tainted or split.
261 pub(crate) fn tag(&mut self, failure: FailureId) {
262 self.node_mut(failure).tagged += 1;
263 }
264
265 /// One fewer allocation carries `failure`; the node is dropped when none
266 /// does.
267 ///
268 /// This is what every shedding path calls — a slice written again, rebound
269 /// to a new allocation, coalesced away, tombstoned or swept — and the
270 /// decrement is immediate rather than collected into a list drained
271 /// later, so a node nothing can reach is never retained just because
272 /// nothing got around to saying so.
273 pub fn untag(&mut self, failure: Option<FailureId>) {
274 let Some(failure) = failure else {
275 return;
276 };
277 let node = self.node_mut(failure);
278 // Saturating rather than `-= 1`: an unbalanced shed would wrap the
279 // count in release and pin the node — and the error it holds — for the
280 // life of the device, with every read of that allocation failing
281 // forever. The floor drops the node instead, and the assertion makes
282 // the shedding path that lost count loud in a test rather than silent
283 // in production.
284 debug_assert!(node.tagged > 0, "{failure} was shed more often than tagged");
285 node.tagged = node.tagged.saturating_sub(1);
286 if node.tagged == 0 {
287 self.nodes.remove(&failure);
288 }
289 }
290
291 /// Swap the error behind `failure` for `error`, leaving every carrier
292 /// pointing at the new one.
293 ///
294 /// This is the exit half of a write scope: entry taints the write set
295 /// with a provisional node, because the real failure does not exist yet,
296 /// and exit lands the real one here. A missing node is left missing — the
297 /// id outlived its carriers, so nothing can read the error either way.
298 pub fn replace(&mut self, failure: FailureId, error: ServerError) {
299 if let Some(node) = self.nodes.get_mut(&failure) {
300 node.error = error;
301 }
302 }
303
304 /// Record `error` as the reason `memory` was never allocated, so a read of that memory
305 /// reports that error.
306 pub fn fail_unallocated(&mut self, memory: &ManagedMemoryHandle, error: ServerError) {
307 // Released lazily: a record whose buffer is gone is found here, when the next one is
308 // made, rather than through a drop hook the buffer does not have.
309 let dropped: Vec<_> = self
310 .unallocated
311 .iter()
312 .filter(|(_, (_, binding))| !binding.is_alive())
313 .map(|(id, _)| *id)
314 .collect();
315 for id in dropped {
316 if let Some((failure, _)) = self.unallocated.remove(&id) {
317 self.untag(Some(failure));
318 }
319 }
320
321 let failure = self.insert(error);
322 self.tag(failure);
323 let binding = memory.clone().binding();
324 let id = binding.id();
325 if let Some((previous, _)) = self.unallocated.insert(id, (failure, binding.downgrade())) {
326 self.untag(Some(previous));
327 }
328 }
329
330 /// The failure recorded for `memory` by [`fail_unallocated`](Self::fail_unallocated), if any.
331 pub fn unallocated_failure(&self, memory: ManagedMemoryId) -> Option<FailureId> {
332 self.unallocated.get(&memory).map(|(failure, _)| *failure)
333 }
334
335 /// Drop `failure` if nothing took its id — for a failure that turned out
336 /// to taint nothing, whose node would otherwise wait forever at zero.
337 pub fn prune(&mut self, failure: FailureId) {
338 if let Some(node) = self.nodes.get(&failure)
339 && node.tagged == 0
340 {
341 self.nodes.remove(&failure);
342 }
343 }
344
345 /// The error behind `failure`.
346 ///
347 /// `None` means the id outlived its node, which the refcount exists to
348 /// prevent; a reader treats it as no failure rather than panicking on the
349 /// device thread.
350 pub fn error(&self, failure: FailureId) -> Option<&ServerError> {
351 self.nodes.get(&failure).map(|node| &node.error)
352 }
353
354 /// How many failures the device is still holding — the bound the whole
355 /// design rests on, which is why the property harness watches it.
356 pub fn len(&self) -> usize {
357 self.nodes.len()
358 }
359
360 /// Whether the device is holding no failure at all.
361 pub fn is_empty(&self) -> bool {
362 self.nodes.is_empty()
363 }
364
365 fn node_mut(&mut self, failure: FailureId) -> &mut Failure {
366 self.nodes
367 .get_mut(&failure)
368 .expect("a carried failure id always has its node")
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375 use alloc::string::ToString;
376
377 fn error(reason: &str) -> ServerError {
378 ServerError::Generic {
379 reason: reason.to_string(),
380 backtrace: Default::default(),
381 }
382 }
383
384 /// The bound the whole design rests on: a node lives exactly as long as
385 /// some slice carries its id.
386 #[test]
387 fn a_node_lives_while_something_carries_it_and_no_longer() {
388 let mut graph = ErrorGraph::default();
389 let failure = graph.insert(error("launch"));
390
391 graph.tag(failure);
392 graph.tag(failure);
393 assert_eq!(graph.len(), 1);
394
395 graph.untag(Some(failure));
396 assert!(graph.error(failure).is_some(), "one carrier remains");
397
398 graph.untag(Some(failure));
399 assert!(
400 graph.error(failure).is_none(),
401 "nothing carries it, so it is gone"
402 );
403 assert!(graph.is_empty());
404 }
405
406 /// A failure that tainted nothing is pruned rather than retained: a dry
407 /// run's compile error, say, has no buffer to name.
408 #[test]
409 fn a_failure_that_tainted_nothing_is_pruned() {
410 let mut graph = ErrorGraph::default();
411 let failure = graph.insert(error("dry-run"));
412
413 graph.prune(failure);
414 assert!(graph.is_empty());
415 }
416
417 /// The exit half of a write scope: the provisional error a scope entered
418 /// with is swapped for the real one, and every carrier follows.
419 #[test]
420 fn replacing_an_error_leaves_the_carriers_pointing_at_the_new_one() {
421 let mut graph = ErrorGraph::default();
422 let failure = graph.insert(error("torn down"));
423
424 graph.tag(failure);
425 graph.replace(failure, error("launch"));
426
427 match graph.error(failure) {
428 Some(ServerError::Generic { reason, .. }) => assert_eq!(reason, "launch"),
429 other => panic!("expected the replaced error, got {other:?}"),
430 }
431
432 graph.untag(Some(failure));
433 assert!(graph.is_empty());
434 }
435
436 fn skip(
437 kernel_name: KernelId,
438 needed: ManagedMemoryId,
439 produced: &[ManagedMemoryId],
440 ) -> Skipped {
441 Skipped {
442 kernel: kernel_name,
443 needed,
444 produced: produced.to_vec(),
445 }
446 }
447
448 fn memory_id(value: usize) -> ManagedMemoryId {
449 ManagedMemoryId { value }
450 }
451
452 struct Fill;
453 struct Matmul;
454 struct Gelu;
455
456 /// The walk a read makes: from the buffer asked about, backwards through
457 /// the skip records, to the root — each hop the record that produced what
458 /// the previous one needed.
459 #[test]
460 fn a_report_walks_the_skip_chain_back_to_the_root() {
461 let mut graph = ErrorGraph::default();
462 let failure = graph.insert(error("fill_f32 failed to compile"));
463 graph.tag(failure);
464
465 let (root_out, mid, last) = (memory_id(77), memory_id(91), memory_id(103));
466 graph.skipped(failure, skip(KernelId::new::<Matmul>(), root_out, &[mid]));
467 graph.skipped(failure, skip(KernelId::new::<Gelu>(), mid, &[last]));
468
469 let report = graph.report(failure, last).unwrap();
470 let text = alloc::format!("{report}");
471 let gelu = text.find("Gelu").expect("the newest hop comes first");
472 let matmul = text.find("Matmul").expect("then the one it needed");
473 assert!(gelu < matmul, "newest skip first, root last: {text}");
474 assert!(
475 text.contains("fill_f32 failed to compile"),
476 "the root is always in the report: {text}"
477 );
478 assert!(
479 text.contains(&alloc::format!("#{}", failure.0.get())),
480 "the failure id ties reads of the same failure together: {text}"
481 );
482
483 // A buffer no record produced reports the root alone.
484 let report = graph.report(failure, memory_id(555)).unwrap();
485 let text = alloc::format!("{report}");
486 assert!(!text.contains("Gelu") && text.contains("fill_f32 failed to compile"));
487 }
488
489 /// The cap keeps the newest records — the walk starts from the most
490 /// recent buffers, so keeping the oldest would leave them with no entry
491 /// to start from — and the report says what it dropped.
492 #[test]
493 fn the_skip_cap_keeps_the_newest_records() {
494 let mut graph = ErrorGraph::default();
495 let failure = graph.insert(error("root"));
496 graph.tag(failure);
497
498 for i in 0..(ErrorGraph::MAX_SKIPPED + 4) {
499 graph.skipped(
500 failure,
501 skip(KernelId::new::<Fill>(), memory_id(i), &[memory_id(i + 1)]),
502 );
503 }
504
505 let newest = memory_id(ErrorGraph::MAX_SKIPPED + 4);
506 let report = graph.report(failure, newest).unwrap();
507 let text = alloc::format!("{report}");
508 assert!(
509 text.contains("Fill"),
510 "the newest buffer still has an entry to walk from: {text}"
511 );
512 assert!(
513 text.contains("4 older skip record(s) were dropped"),
514 "and the report says the walk may stop early: {text}"
515 );
516 }
517
518 /// Pruning is only for the untainted case: a failure something carries
519 /// stays for its carriers.
520 #[test]
521 fn pruning_leaves_a_carried_failure_alone() {
522 let mut graph = ErrorGraph::default();
523 let failure = graph.insert(error("launch"));
524
525 graph.tag(failure);
526 graph.prune(failure);
527
528 assert!(graph.error(failure).is_some());
529 }
530
531 /// A buffer that never got an allocation reports the failure recorded for it;
532 /// the record goes once the buffer does.
533 #[test]
534 fn an_unallocated_failure_is_reported_and_released_with_its_buffer() {
535 let mut graph = ErrorGraph::default();
536 let memory = ManagedMemoryHandle::new();
537 let id = memory.descriptor().id;
538
539 graph.fail_unallocated(
540 &memory,
541 crate::driver::DevicePoison::new("the device is poisoned").into(),
542 );
543 let reported = graph
544 .reports([Claim::Unallocated(id)].into_iter())
545 .expect_err("the buffer carries a failure");
546 assert!(
547 reported.to_string().contains("the device is poisoned"),
548 "{reported}"
549 );
550
551 let other = ManagedMemoryHandle::new();
552 let unrecorded = graph
553 .reports([Claim::Unallocated(other.descriptor().id)].into_iter())
554 .expect_err("an unallocated buffer never reads clean");
555 assert!(
556 unrecorded.to_string().contains("never allocated"),
557 "{unrecorded}"
558 );
559
560 // An unallocated buffer's sole reference belongs to its caller, not a pool.
561 graph.fail_unallocated(&other, error("again"));
562 let reported = graph
563 .reports([Claim::Unallocated(id)].into_iter())
564 .unwrap_err();
565 assert!(reported.is_device_poisoned(), "{reported}");
566 assert_eq!(graph.len(), 2);
567
568 // Released lazily, when the next record is made.
569 drop(memory);
570 graph.fail_unallocated(&other, error("again"));
571 assert_eq!(graph.len(), 1, "the dropped buffer's failure is released");
572 }
573}