Skip to main content

fuel_ir/
error.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Fuel-specific Error and Result types.
3use std::{convert::Infallible, fmt::Display};
4
5use crate::{DType, DeviceLocation, Layout, Shape};
6
7/// Diagnostic information for a matrix multiplication that encountered unexpected striding.
8#[derive(Debug, Clone)]
9pub struct MatMulUnexpectedStriding {
10    pub lhs_l: Layout,
11    pub rhs_l: Layout,
12    pub bmnk: (usize, usize, usize, usize),
13    pub msg: &'static str,
14}
15
16impl std::fmt::Debug for Error {
17    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18        write!(f, "{self}")
19    }
20}
21
22/// The main error type for the fuel library.
23#[derive(thiserror::Error)]
24pub enum Error {
25    // === DType Errors ===
26    #[error("{msg}, expected: {expected:?}, got: {got:?}")]
27    UnexpectedDType {
28        msg: &'static str,
29        expected: DType,
30        got: DType,
31    },
32
33    #[error("dtype mismatch in {op}, lhs: {lhs:?}, rhs: {rhs:?}")]
34    DTypeMismatchBinaryOp {
35        lhs: DType,
36        rhs: DType,
37        op: &'static str,
38    },
39
40    #[error("unsupported dtype {0:?} for op {1}")]
41    UnsupportedDTypeForOp(DType, &'static str),
42
43    #[error("no zero scalar for dtype {0:?} (scale format has no exact zero)")]
44    NoZeroScalar(DType),
45    #[error("no one scalar for dtype {0:?}")]
46    NoOneScalar(DType),
47    #[error("value {1} not representable as a scalar of dtype {0:?}")]
48    ScalarUnrepresentable(DType, f64),
49    #[error("dtype {0:?} is a packed element format with no scalar representation")]
50    PackedElementHasNoScalar(DType),
51
52    // === Dimension Index Errors ===
53    #[error("{op}: dimension index {dim} out of range for shape {shape:?}")]
54    DimOutOfRange {
55        shape: Box<Shape>,
56        dim: i32,
57        op: &'static str,
58    },
59
60    #[error("{op}: duplicate dim index {dims:?} for shape {shape:?}")]
61    DuplicateDimIndex {
62        shape: Box<Shape>,
63        dims: Vec<usize>,
64        op: &'static str,
65    },
66
67    // === Shape Errors ===
68    #[error("unexpected rank, expected: {expected}, got: {got} ({shape:?})")]
69    UnexpectedNumberOfDims {
70        expected: usize,
71        got: usize,
72        shape: Box<Shape>,
73    },
74
75    #[error("{msg}, expected: {expected:?}, got: {got:?}")]
76    UnexpectedShape {
77        msg: String,
78        expected: Box<Shape>,
79        got: Box<Shape>,
80    },
81
82    #[error(
83        "Shape mismatch, got buffer of size {buffer_size} which is compatible with shape {shape:?}"
84    )]
85    ShapeMismatch {
86        buffer_size: usize,
87        shape: Box<Shape>,
88    },
89
90    #[error("shape mismatch in {op}, lhs: {lhs:?}, rhs: {rhs:?}")]
91    ShapeMismatchBinaryOp {
92        lhs: Box<Shape>,
93        rhs: Box<Shape>,
94        op: &'static str,
95    },
96
97    #[error(
98        "shape mismatch in cat for dim {dim}, shape for arg 1: {first_shape:?} shape for arg {n}: {nth_shape:?}"
99    )]
100    ShapeMismatchCat {
101        dim: usize,
102        first_shape: Box<Shape>,
103        n: usize,
104        nth_shape: Box<Shape>,
105    },
106
107    #[error("Cannot divide tensor of shape {shape:?} equally along dim {dim} into {n_parts}")]
108    ShapeMismatchSplit {
109        shape: Box<Shape>,
110        dim: usize,
111        n_parts: usize,
112    },
113
114    #[error("{op} can only be performed on a single dimension")]
115    OnlySingleDimension { op: &'static str, dims: Vec<usize> },
116
117    #[error("empty tensor for {op}")]
118    EmptyTensor { op: &'static str },
119
120    // === Device Errors ===
121    #[error("device mismatch in {op}, lhs: {lhs:?}, rhs: {rhs:?}")]
122    DeviceMismatchBinaryOp {
123        lhs: DeviceLocation,
124        rhs: DeviceLocation,
125        op: &'static str,
126    },
127
128    // === Op Specific Errors ===
129    #[error("narrow invalid args {msg}: {shape:?}, dim: {dim}, start: {start}, len:{len}")]
130    NarrowInvalidArgs {
131        shape: Box<Shape>,
132        dim: usize,
133        start: usize,
134        len: usize,
135        msg: &'static str,
136    },
137
138    #[error(
139        "conv1d invalid args {msg}: inp: {inp_shape:?}, k: {k_shape:?}, pad: {padding}, stride: {stride}"
140    )]
141    Conv1dInvalidArgs {
142        inp_shape: Box<Shape>,
143        k_shape: Box<Shape>,
144        padding: usize,
145        stride: usize,
146        msg: &'static str,
147    },
148
149    #[error("{op} invalid index {index} with dim size {size}")]
150    InvalidIndex {
151        op: &'static str,
152        index: usize,
153        size: usize,
154    },
155
156    #[error("cannot broadcast {src_shape:?} to {dst_shape:?}")]
157    BroadcastIncompatibleShapes {
158        src_shape: Box<Shape>,
159        dst_shape: Box<Shape>,
160    },
161
162    #[error("cannot set variable {msg}")]
163    CannotSetVar { msg: &'static str },
164
165    #[error("{0:?}")]
166    MatMulUnexpectedStriding(Box<MatMulUnexpectedStriding>),
167
168    #[error("{op} only supports contiguous tensors")]
169    RequiresContiguous { op: &'static str },
170
171    #[error("{op} expects at least one tensor")]
172    OpRequiresAtLeastOneTensor { op: &'static str },
173
174    #[error("{op} expects at least two tensors")]
175    OpRequiresAtLeastTwoTensors { op: &'static str },
176
177    #[error("backward is not supported for {op}")]
178    BackwardNotSupported { op: &'static str },
179
180    // === Phase 7.5 storage-unification dispatch errors ===
181    /// No registered backend supports `(op, dtypes)` for the given
182    /// input residency. Fires at DAG construction (planning time),
183    /// not at execution.
184    ///
185    /// `dtypes` is the per-operand dtype list (inputs in order, then
186    /// outputs) used as the binding-table lookup key. For uniform-
187    /// precision ops the list contains a single repeated dtype; for
188    /// mixed-precision ops (e.g. Cast: src→dst) the list distinguishes
189    /// the operands.
190    #[error(
191        "no backend supports {op} on {dtypes:?}; available backends: \
192         {available_backends:?}; supported (op, dtypes) by backend: \
193         {supported_combinations:?}"
194    )]
195    NoBackendForOp {
196        op: crate::dispatch::OpKind,
197        dtypes: Vec<DType>,
198        available_backends: Vec<crate::probe::BackendId>,
199        supported_combinations: Vec<(crate::probe::BackendId, crate::dispatch::OpKind, Vec<DType>)>,
200    },
201
202    /// No transfer path connects `from` and `to` directly, and
203    /// host-staging fallback isn't available either (e.g. neither
204    /// device can copy_to_host). Fires at DAG construction.
205    #[error("unsupported transfer from {from:?} to {to:?}; available paths: {available_paths:?}")]
206    UnsupportedTransfer {
207        from: DeviceLocation,
208        to: DeviceLocation,
209        available_paths: Vec<crate::backend::TransferPath>,
210    },
211
212    /// Source storage's alignment doesn't meet the destination
213    /// backend's required alignment, and Router has no way to
214    /// repack on this path. Fires at DAG construction.
215    #[error("alignment mismatch on {backend:?}: required {required} bytes, got {actual} bytes")]
216    AlignmentMismatch {
217        backend: crate::probe::BackendId,
218        required: usize,
219        actual: usize,
220    },
221
222    /// The `ExecutionPlan` consumed by the executor was built against
223    /// an older `SystemTopology` generation than the one observable
224    /// at dispatch time — backends were added, removed, or had their
225    /// capabilities re-probed since the plan was committed. Caught
226    /// at dispatch-chunk boundaries by Phase 4.3 of the picker-work
227    /// arc; the realize layer catches this and rebuilds the plan
228    /// against the fresh topology.
229    ///
230    /// `plan_generation` is the counter the plan was stamped with at
231    /// `compile_plan` time. `current_generation` is the value
232    /// `fuel_dispatch::dispatch::topology_generation()` observed when
233    /// the executor crossed the chunk boundary.
234    #[error(
235        "execution plan was built against topology generation {plan_generation}, \
236         but generation {current_generation} is now current; rebuild and retry"
237    )]
238    TopologyChanged {
239        plan_generation: u64,
240        current_generation: u64,
241    },
242
243    // === Other Errors ===
244    #[error("the fuel crate has not been built with cuda support")]
245    NotCompiledWithCudaSupport,
246
247    #[error("the fuel crate has not been built with metal support")]
248    NotCompiledWithMetalSupport,
249
250    #[error("the fuel crate has not been built with vulkan support")]
251    NotCompiledWithVulkanSupport,
252
253    #[error("cannot find tensor {path}")]
254    CannotFindTensor { path: String },
255
256    // === Wrapped Errors ===
257    #[error(transparent)]
258    Cuda(Box<dyn std::error::Error + Send + Sync>),
259
260    #[error(transparent)]
261    Metal(Box<dyn std::error::Error + Send + Sync>),
262
263    #[error(transparent)]
264    Vulkan(Box<dyn std::error::Error + Send + Sync>),
265
266    #[cfg(all(not(target_arch = "wasm32"), not(target_os = "ios"), feature = "ug"))]
267    #[error(transparent)]
268    Ug(Box<dyn std::error::Error + Send + Sync>),
269
270    #[error(transparent)]
271    TryFromIntError(#[from] core::num::TryFromIntError),
272
273    #[error("npy/npz error {0}")]
274    Npy(String),
275
276    /// Zip file format error.
277    #[error(transparent)]
278    Zip(#[from] zip::result::ZipError),
279
280    /// Integer parse error.
281    #[error(transparent)]
282    ParseInt(#[from] std::num::ParseIntError),
283
284    /// Utf8 parse error.
285    #[error(transparent)]
286    FromUtf8(#[from] std::string::FromUtf8Error),
287
288    /// I/O error.
289    #[error(transparent)]
290    Io(#[from] std::io::Error),
291
292    /// SafeTensor error.
293    #[error(transparent)]
294    SafeTensor(#[from] safetensors::SafeTensorError),
295
296    #[error("unsupported safetensor dtype {0:?}")]
297    UnsupportedSafeTensorDtype(safetensors::Dtype),
298
299    /// Arbitrary errors wrapping.
300    #[error("{0}")]
301    Wrapped(Box<dyn std::fmt::Display + Send + Sync>),
302
303    /// Arbitrary errors wrapping with context.
304    #[error("{wrapped:?}\n{context:?}")]
305    WrappedContext {
306        wrapped: Box<dyn std::error::Error + Send + Sync>,
307        context: String,
308    },
309
310    #[error("{context}\n{inner}")]
311    Context {
312        inner: Box<Self>,
313        context: Box<dyn std::fmt::Display + Send + Sync>,
314    },
315
316    /// Adding path information to an error.
317    #[error("path: {path:?} {inner}")]
318    WithPath {
319        inner: Box<Self>,
320        path: std::path::PathBuf,
321    },
322
323    #[error("{inner}\n{backtrace}")]
324    WithBacktrace {
325        inner: Box<Self>,
326        backtrace: Box<std::backtrace::Backtrace>,
327    },
328
329    /// User generated error message, typically created via `bail!`.
330    #[error("{0}")]
331    Msg(String),
332
333    #[error("unwrap none")]
334    UnwrapNone,
335
336    /// A hard filter in the optimizer ranker's filter chain rejected
337    /// every candidate at this decision point. Phase 1.1 of the
338    /// picker-work arc. The user asked for something the binding-table
339    /// can't deliver — typically a precision floor or tolerance budget
340    /// no registered kernel meets — and the ranker surfaces it rather
341    /// than silently substituting a non-admissible alternative.
342    ///
343    /// Soft filters (caps preferences, empirical refinements) never
344    /// raise this error; only filters classified `FilterClass::Hard`
345    /// fail loudly when they would leave the candidate set empty.
346    #[error(
347        "ranker hard filter `{filter}` rejected all {available_alternatives} candidate(s); \
348         context: {ctx_summary}"
349    )]
350    FilterRejected {
351        /// Name of the filter that triggered the rejection.
352        filter: &'static str,
353        /// Short diagnostic summary of the decision point's context
354        /// (op kind, dtypes, device).
355        ctx_summary: String,
356        /// How many candidates entered the filter (zero on the way
357        /// in is also possible — the candidate enumerator may have
358        /// produced an empty set).
359        available_alternatives: usize,
360    },
361
362    /// Build-time rejection from the `Op::Branch` builders
363    /// (`Graph::open_branch` / `BranchBuilder::add_arm` /
364    /// `BranchBuilder::finalize_branches`). Phase A PR-A1 of the
365    /// "plan IS the graph" rebuild. The multi-path structure is an
366    /// arena fact validated at graph-build time — never a realize-time
367    /// panic — so an ill-formed branch (a `reconverge_at` that is not a
368    /// descendant of the diverge point, arms that are not internally
369    /// disjoint, arm exits that disagree on shape/dtype at reconverge)
370    /// surfaces here with a human-readable reason rather than corrupting
371    /// the arena.
372    #[error("invalid branch: {reason}")]
373    InvalidBranch {
374        /// Why the branch was rejected — names the violated invariant
375        /// and the offending node ids for localization.
376        reason: String,
377    },
378}
379
380/// A specialized [`Result`](std::result::Result) type for fuel operations.
381pub type Result<T> = std::result::Result<T, Error>;
382
383// ── Error size guard (clippy::result_large_err) ────────────────────────────
384// `Result<T>` here is `Result<T, Error>`, so EVERY fallible fuel-ir function —
385// **including the Ok path** — carries `size_of::<Error>()` bytes inline. The
386// diagnostic variants used to embed `Shape` (128 bytes) BY VALUE, which bloated
387// `Error` to 288 bytes and every `Result<T>` with it (clippy fired
388// `result_large_err` on 58 sites); the `Shape` fields are now `Box<Shape>` and
389// `Error` measures 80 bytes (`-Zprint-type-sizes`, 2026-08-14).
390//
391// This guard keeps that win maintained rather than one-time: a NEW variant that
392// embeds a large type by value (another `Shape`, a big struct) is a HOT-PATH
393// regression — not cosmetic — and trips here at COMPILE time, before it can
394// re-inflate every `Result<T>`. Same shape as a retained sabotage control: a
395// one-time fix becomes a maintained property. Raising the bound is a DELIBERATE
396// decision about that Ok-path cost, not a formality — measure the new size and
397// justify it. 96 = the measured 80 + headroom, and stays well under clippy's
398// 128-byte threshold so this catches a regression before the lint would.
399const _: () = assert!(
400    std::mem::size_of::<Error>() <= 96,
401    "fuel_ir::Error exceeded its size budget: a Result<T> hot-path regression. \
402     Box the large field, or consciously raise the bound (see the comment above)."
403);
404
405impl Error {
406    /// Wraps an arbitrary displayable error.
407    pub fn wrap(err: impl std::fmt::Display + Send + Sync + 'static) -> Self {
408        Self::Wrapped(Box::new(err)).bt()
409    }
410
411    /// Creates an error message from any displayable value.
412    pub fn msg(err: impl std::fmt::Display) -> Self {
413        Self::Msg(err.to_string()).bt()
414    }
415
416    /// Creates an error message from the debug representation of a value.
417    pub fn debug(err: impl std::fmt::Debug) -> Self {
418        Self::Msg(format!("{err:?}")).bt()
419    }
420
421    /// Wraps a CUDA backend error.
422    pub fn cuda(err: impl std::error::Error + Send + Sync + 'static) -> Self {
423        Self::Cuda(Box::new(err)).bt()
424    }
425
426    /// Wraps a Metal backend error.
427    pub fn metal(err: impl std::error::Error + Send + Sync + 'static) -> Self {
428        Self::Metal(Box::new(err)).bt()
429    }
430
431    /// Wraps a Vulkan backend error.
432    pub fn vulkan(err: impl std::error::Error + Send + Sync + 'static) -> Self {
433        Self::Vulkan(Box::new(err)).bt()
434    }
435
436    /// Captures a backtrace and attaches it to this error.
437    pub fn bt(self) -> Self {
438        let backtrace = std::backtrace::Backtrace::capture();
439        match backtrace.status() {
440            std::backtrace::BacktraceStatus::Disabled
441            | std::backtrace::BacktraceStatus::Unsupported => self,
442            _ => Self::WithBacktrace {
443                inner: Box::new(self),
444                backtrace: Box::new(backtrace),
445            },
446        }
447    }
448
449    /// Attaches a filesystem path to this error for better diagnostics.
450    pub fn with_path<P: AsRef<std::path::Path>>(self, p: P) -> Self {
451        Self::WithPath {
452            inner: Box::new(self),
453            path: p.as_ref().to_path_buf(),
454        }
455    }
456
457    /// Wraps this error with additional context.
458    pub fn context(self, c: impl std::fmt::Display + Send + Sync + 'static) -> Self {
459        Self::Context {
460            inner: Box::new(self),
461            context: Box::new(c),
462        }
463    }
464}
465
466/// Returns early from a function with a formatted error message.
467#[macro_export]
468macro_rules! bail {
469    ($msg:literal $(,)?) => {
470        return Err($crate::Error::Msg(format!($msg).into()).bt())
471    };
472    ($err:expr $(,)?) => {
473        return Err($crate::Error::Msg(format!($err).into()).bt())
474    };
475    ($fmt:expr, $($arg:tt)*) => {
476        return Err($crate::Error::Msg(format!($fmt, $($arg)*).into()).bt())
477    };
478}
479
480/// Combines two results into a single result containing a tuple.
481pub fn zip<T, U>(r1: Result<T>, r2: Result<U>) -> Result<(T, U)> {
482    match (r1, r2) {
483        (Ok(r1), Ok(r2)) => Ok((r1, r2)),
484        (Err(e), _) => Err(e),
485        (_, Err(e)) => Err(e),
486    }
487}
488
489#[doc(hidden)]
490pub mod private {
491    pub trait Sealed {}
492
493    impl<T, E> Sealed for std::result::Result<T, E> where E: std::error::Error {}
494    impl<T> Sealed for Option<T> {}
495}
496
497/// Attach more context to an error.
498pub trait Context<T, E>: private::Sealed {
499    /// Wrap the error value with additional context.
500    fn context<C>(self, context: C) -> std::result::Result<T, Error>
501    where
502        C: Display + Send + Sync + 'static;
503
504    /// Wrap the error value with additional context evaluated lazily.
505    fn with_context<C, F>(self, f: F) -> std::result::Result<T, Error>
506    where
507        C: Display + Send + Sync + 'static,
508        F: FnOnce() -> C;
509}
510
511impl<T, E> Context<T, E> for std::result::Result<T, E>
512where
513    E: std::error::Error + Send + Sync + 'static,
514{
515    fn context<C>(self, context: C) -> std::result::Result<T, Error>
516    where
517        C: Display + Send + Sync + 'static,
518    {
519        self.map_err(|e| Error::WrappedContext {
520            wrapped: Box::new(e),
521            context: context.to_string(),
522        })
523    }
524
525    fn with_context<C, F>(self, context: F) -> std::result::Result<T, Error>
526    where
527        C: Display + Send + Sync + 'static,
528        F: FnOnce() -> C,
529    {
530        match self {
531            Ok(ok) => Ok(ok),
532            Err(error) => Err(Error::WrappedContext {
533                wrapped: Box::new(error),
534                context: context().to_string(),
535            }
536            .bt()),
537        }
538    }
539}
540
541impl<T> Context<T, Infallible> for Option<T> {
542    fn context<C>(self, context: C) -> std::result::Result<T, Error>
543    where
544        C: Display + Send + Sync + 'static,
545    {
546        // Not using ok_or_else to save 2 useless frames off the captured
547        // backtrace.
548        match self {
549            Some(ok) => Ok(ok),
550            None => Err(Error::msg(context).bt()),
551        }
552    }
553
554    fn with_context<C, F>(self, context: F) -> std::result::Result<T, Error>
555    where
556        C: Display + Send + Sync + 'static,
557        F: FnOnce() -> C,
558    {
559        match self {
560            Some(v) => Ok(v),
561            None => Err(Error::UnwrapNone.context(context()).bt()),
562        }
563    }
564}