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::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}