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::ManagedMemoryId;
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 /// Ids handed out so far; the next one is `minted + 1`, which is never
67 /// zero and, being 64 bits wide, never wraps.
68 minted: u64,
69}
70
71#[derive(Debug)]
72struct Failure {
73 error: ServerError,
74 /// How many slices carry this id. The node lives while this is non-zero.
75 tagged: u32,
76 /// What this failure stopped, newest last, capped to the most recent —
77 /// see [`Skipped`]. On the failure rather than on the buffers because a
78 /// record stored on an allocation dies when that allocation does, and a
79 /// chain of links would break as soon as an intermediate buffer is freed
80 /// — the common case for a fused graph where only the last tensor is
81 /// kept. Holds ids, never handles, so the record of what a failure
82 /// stopped never retains the memory it names.
83 skipped: Vec<Skipped>,
84 /// Every skip, the capped list included: the report says how many are
85 /// missing from the walk.
86 skipped_total: u64,
87}
88
89/// One launch a failure stopped: the kernel that did not run, the buffer
90/// whose claim stopped it, and what it would have produced.
91///
92/// A read of a downstream buffer walks these backwards — the record that
93/// produced this buffer, then the record that produced what that one needed —
94/// so the report names the path from the read back to the root instead of
95/// only the root.
96#[derive(Debug, Clone)]
97pub struct Skipped {
98 /// The kernel the skip stopped.
99 pub kernel: KernelId,
100 /// The claimed buffer the kernel would have read.
101 pub needed: ManagedMemoryId,
102 /// The buffers the kernel would have written, which now carry the same
103 /// failure.
104 pub produced: Vec<ManagedMemoryId>,
105}
106
107impl ErrorGraph {
108 /// How many [`Skipped`] records one failure keeps. Newest win: the walk a
109 /// read makes starts from the most recent buffers, so keeping the oldest
110 /// would leave them with no entry to start from, while a deep chain
111 /// reaching a gap before the root costs nothing — the root is on the node
112 /// itself and never in the list.
113 pub const MAX_SKIPPED: usize = 16;
114
115 /// Hold `error` until nothing carries its id any more.
116 ///
117 /// The node starts carried by nothing, so a caller that taints no slice
118 /// with it must hand it back through [`prune`](Self::prune) — otherwise
119 /// the node waits forever for a decrement that is never coming.
120 pub fn insert(&mut self, error: ServerError) -> FailureId {
121 self.minted = self
122 .minted
123 .checked_add(1)
124 .expect("a failure id was minted for every u64");
125 let id = FailureId(NonZeroU64::new(self.minted).expect("minted starts above zero"));
126 self.nodes.insert(
127 id,
128 Failure {
129 error,
130 tagged: 0,
131 skipped: Vec::new(),
132 skipped_total: 0,
133 },
134 );
135 id
136 }
137
138 /// Record a launch `failure` stopped — see [`Skipped`]. Keeps the newest
139 /// [`MAX_SKIPPED`](Self::MAX_SKIPPED) records and counts them all.
140 pub fn skipped(&mut self, failure: FailureId, record: Skipped) {
141 let Some(node) = self.nodes.get_mut(&failure) else {
142 return;
143 };
144 node.skipped_total += 1;
145 if node.skipped.len() == Self::MAX_SKIPPED {
146 node.skipped.remove(0);
147 }
148 node.skipped.push(record);
149 }
150
151 /// The report a read of `memory` gets when `failure` claims its bytes:
152 /// the root error, and the path from this buffer back toward it,
153 /// reconstructed by walking the skip records backwards.
154 pub fn report(&self, failure: FailureId, memory: ManagedMemoryId) -> Option<ServerError> {
155 let node = self.nodes.get(&failure)?;
156
157 let mut chain = Vec::new();
158 let mut target = memory;
159 let mut upper = node.skipped.len();
160 while let Some(found) = node.skipped[..upper]
161 .iter()
162 .rposition(|record| record.produced.contains(&target))
163 {
164 let record = &node.skipped[found];
165 chain.push(alloc::format!(
166 "skipped `{}`: it needed memory {:?}, which carried the failure",
167 record.kernel.short_name(),
168 record.needed,
169 ));
170 target = record.needed;
171 upper = found;
172 }
173 let dropped = node.skipped_total.saturating_sub(node.skipped.len() as u64);
174 if !chain.is_empty() && dropped > 0 {
175 chain.push(alloc::format!(
176 "({dropped} older skip record(s) were dropped; the walk may stop before the root)"
177 ));
178 }
179
180 Some(ServerError::Unwritten {
181 failure: failure.0.get(),
182 claimed: node.tagged,
183 chain,
184 root: Box::new(node.error.clone()),
185 backtrace: BackTrace::capture(),
186 })
187 }
188
189 /// The report a read owes for the claims it found: one error per distinct
190 /// failure, however many of the buffers carry it, and one per buffer that
191 /// was never allocated. The second kind is not in the graph, since nothing
192 /// could carry it, but it is the same answer to the same question.
193 ///
194 /// This is the shape of every "were these bytes written" answer in the
195 /// system — [`FailureStore::ensure_written`](crate::stream::FailureStore::ensure_written)
196 /// and any harness standing in for it — so the dedup and the wrapping live
197 /// here rather than once per caller.
198 ///
199 /// # Errors
200 ///
201 /// [`ServerError::Several`] naming each failure once, in the order the
202 /// claims were found. The caller has nothing to retry — the bytes are gone
203 /// — so this is the answer to the read, not a hint to try again.
204 pub fn reports(&self, claims: impl Iterator<Item = Claim>) -> Result<(), ServerError> {
205 let mut seen: Vec<FailureId> = Vec::new();
206 let mut errors = Vec::new();
207
208 for claim in claims {
209 let (failure, memory) = match claim {
210 Claim::Failed(failure, memory) => (failure, memory),
211 Claim::Unallocated(memory) => {
212 errors.push(Self::unallocated(memory));
213 continue;
214 }
215 };
216 if seen.contains(&failure) {
217 continue;
218 }
219 seen.push(failure);
220 // The full report: the root error, and the skip chain from this
221 // buffer back toward it.
222 if let Some(error) = self.report(failure, memory) {
223 errors.push(error);
224 }
225 }
226
227 match errors.is_empty() {
228 true => Ok(()),
229 false => Err(ServerError::Several {
230 errors,
231 backtrace: BackTrace::capture(),
232 }),
233 }
234 }
235
236 /// The cause is the reservation's own error, which the device thread logged
237 /// when it happened: nothing could carry it here.
238 fn unallocated(memory: ManagedMemoryId) -> ServerError {
239 IoError::NotFound {
240 backtrace: BackTrace::capture(),
241 reason: alloc::format!(
242 "memory {memory:?} was never allocated: the reservation behind it failed"
243 )
244 .into(),
245 }
246 .into()
247 }
248
249 /// One more allocation carries `failure`.
250 ///
251 /// The other half of [`untag`](Self::untag), called only by the taint
252 /// bookkeeping on the slices — [`Taint`](super::Taint) — which owns the
253 /// invariant that a claim tags exactly once however many times it is
254 /// re-tainted or split.
255 pub(crate) fn tag(&mut self, failure: FailureId) {
256 self.node_mut(failure).tagged += 1;
257 }
258
259 /// One fewer allocation carries `failure`; the node is dropped when none
260 /// does.
261 ///
262 /// This is what every shedding path calls — a slice written again, rebound
263 /// to a new allocation, coalesced away, tombstoned or swept — and the
264 /// decrement is immediate rather than collected into a list drained
265 /// later, so a node nothing can reach is never retained just because
266 /// nothing got around to saying so.
267 pub fn untag(&mut self, failure: Option<FailureId>) {
268 let Some(failure) = failure else {
269 return;
270 };
271 let node = self.node_mut(failure);
272 // Saturating rather than `-= 1`: an unbalanced shed would wrap the
273 // count in release and pin the node — and the error it holds — for the
274 // life of the device, with every read of that allocation failing
275 // forever. The floor drops the node instead, and the assertion makes
276 // the shedding path that lost count loud in a test rather than silent
277 // in production.
278 debug_assert!(node.tagged > 0, "{failure} was shed more often than tagged");
279 node.tagged = node.tagged.saturating_sub(1);
280 if node.tagged == 0 {
281 self.nodes.remove(&failure);
282 }
283 }
284
285 /// Swap the error behind `failure` for `error`, leaving every carrier
286 /// pointing at the new one.
287 ///
288 /// This is the exit half of a write scope: entry taints the write set
289 /// with a provisional node, because the real failure does not exist yet,
290 /// and exit lands the real one here. A missing node is left missing — the
291 /// id outlived its carriers, so nothing can read the error either way.
292 pub fn replace(&mut self, failure: FailureId, error: ServerError) {
293 if let Some(node) = self.nodes.get_mut(&failure) {
294 node.error = error;
295 }
296 }
297
298 /// Drop `failure` if nothing took its id — for a failure that turned out
299 /// to taint nothing, whose node would otherwise wait forever at zero.
300 pub fn prune(&mut self, failure: FailureId) {
301 if let Some(node) = self.nodes.get(&failure)
302 && node.tagged == 0
303 {
304 self.nodes.remove(&failure);
305 }
306 }
307
308 /// The error behind `failure`.
309 ///
310 /// `None` means the id outlived its node, which the refcount exists to
311 /// prevent; a reader treats it as no failure rather than panicking on the
312 /// device thread.
313 pub fn error(&self, failure: FailureId) -> Option<&ServerError> {
314 self.nodes.get(&failure).map(|node| &node.error)
315 }
316
317 /// How many failures the device is still holding — the bound the whole
318 /// design rests on, which is why the property harness watches it.
319 pub fn len(&self) -> usize {
320 self.nodes.len()
321 }
322
323 /// Whether the device is holding no failure at all.
324 pub fn is_empty(&self) -> bool {
325 self.nodes.is_empty()
326 }
327
328 fn node_mut(&mut self, failure: FailureId) -> &mut Failure {
329 self.nodes
330 .get_mut(&failure)
331 .expect("a carried failure id always has its node")
332 }
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338 use alloc::string::ToString;
339
340 fn error(reason: &str) -> ServerError {
341 ServerError::Generic {
342 reason: reason.to_string(),
343 backtrace: Default::default(),
344 }
345 }
346
347 /// The bound the whole design rests on: a node lives exactly as long as
348 /// some slice carries its id.
349 #[test]
350 fn a_node_lives_while_something_carries_it_and_no_longer() {
351 let mut graph = ErrorGraph::default();
352 let failure = graph.insert(error("launch"));
353
354 graph.tag(failure);
355 graph.tag(failure);
356 assert_eq!(graph.len(), 1);
357
358 graph.untag(Some(failure));
359 assert!(graph.error(failure).is_some(), "one carrier remains");
360
361 graph.untag(Some(failure));
362 assert!(
363 graph.error(failure).is_none(),
364 "nothing carries it, so it is gone"
365 );
366 assert!(graph.is_empty());
367 }
368
369 /// A failure that tainted nothing is pruned rather than retained: a dry
370 /// run's compile error, say, has no buffer to name.
371 #[test]
372 fn a_failure_that_tainted_nothing_is_pruned() {
373 let mut graph = ErrorGraph::default();
374 let failure = graph.insert(error("dry-run"));
375
376 graph.prune(failure);
377 assert!(graph.is_empty());
378 }
379
380 /// The exit half of a write scope: the provisional error a scope entered
381 /// with is swapped for the real one, and every carrier follows.
382 #[test]
383 fn replacing_an_error_leaves_the_carriers_pointing_at_the_new_one() {
384 let mut graph = ErrorGraph::default();
385 let failure = graph.insert(error("torn down"));
386
387 graph.tag(failure);
388 graph.replace(failure, error("launch"));
389
390 match graph.error(failure) {
391 Some(ServerError::Generic { reason, .. }) => assert_eq!(reason, "launch"),
392 other => panic!("expected the replaced error, got {other:?}"),
393 }
394
395 graph.untag(Some(failure));
396 assert!(graph.is_empty());
397 }
398
399 fn skip(
400 kernel_name: KernelId,
401 needed: ManagedMemoryId,
402 produced: &[ManagedMemoryId],
403 ) -> Skipped {
404 Skipped {
405 kernel: kernel_name,
406 needed,
407 produced: produced.to_vec(),
408 }
409 }
410
411 fn memory_id(value: usize) -> ManagedMemoryId {
412 ManagedMemoryId { value }
413 }
414
415 struct Fill;
416 struct Matmul;
417 struct Gelu;
418
419 /// The walk a read makes: from the buffer asked about, backwards through
420 /// the skip records, to the root — each hop the record that produced what
421 /// the previous one needed.
422 #[test]
423 fn a_report_walks_the_skip_chain_back_to_the_root() {
424 let mut graph = ErrorGraph::default();
425 let failure = graph.insert(error("fill_f32 failed to compile"));
426 graph.tag(failure);
427
428 let (root_out, mid, last) = (memory_id(77), memory_id(91), memory_id(103));
429 graph.skipped(failure, skip(KernelId::new::<Matmul>(), root_out, &[mid]));
430 graph.skipped(failure, skip(KernelId::new::<Gelu>(), mid, &[last]));
431
432 let report = graph.report(failure, last).unwrap();
433 let text = alloc::format!("{report}");
434 let gelu = text.find("Gelu").expect("the newest hop comes first");
435 let matmul = text.find("Matmul").expect("then the one it needed");
436 assert!(gelu < matmul, "newest skip first, root last: {text}");
437 assert!(
438 text.contains("fill_f32 failed to compile"),
439 "the root is always in the report: {text}"
440 );
441 assert!(
442 text.contains(&alloc::format!("#{}", failure.0.get())),
443 "the failure id ties reads of the same failure together: {text}"
444 );
445
446 // A buffer no record produced reports the root alone.
447 let report = graph.report(failure, memory_id(555)).unwrap();
448 let text = alloc::format!("{report}");
449 assert!(!text.contains("Gelu") && text.contains("fill_f32 failed to compile"));
450 }
451
452 /// The cap keeps the newest records — the walk starts from the most
453 /// recent buffers, so keeping the oldest would leave them with no entry
454 /// to start from — and the report says what it dropped.
455 #[test]
456 fn the_skip_cap_keeps_the_newest_records() {
457 let mut graph = ErrorGraph::default();
458 let failure = graph.insert(error("root"));
459 graph.tag(failure);
460
461 for i in 0..(ErrorGraph::MAX_SKIPPED + 4) {
462 graph.skipped(
463 failure,
464 skip(KernelId::new::<Fill>(), memory_id(i), &[memory_id(i + 1)]),
465 );
466 }
467
468 let newest = memory_id(ErrorGraph::MAX_SKIPPED + 4);
469 let report = graph.report(failure, newest).unwrap();
470 let text = alloc::format!("{report}");
471 assert!(
472 text.contains("Fill"),
473 "the newest buffer still has an entry to walk from: {text}"
474 );
475 assert!(
476 text.contains("4 older skip record(s) were dropped"),
477 "and the report says the walk may stop early: {text}"
478 );
479 }
480
481 /// Pruning is only for the untainted case: a failure something carries
482 /// stays for its carriers.
483 #[test]
484 fn pruning_leaves_a_carried_failure_alone() {
485 let mut graph = ErrorGraph::default();
486 let failure = graph.insert(error("launch"));
487
488 graph.tag(failure);
489 graph.prune(failure);
490
491 assert!(graph.error(failure).is_some());
492 }
493}