Skip to main content

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
407    /// discarded launch'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}