错误
主线位置:贯穿算子主线。
a.matmul(&b)?的每一步都可能返回Err:Tensor::matmul检查秩和k,失败是ShapeMismatchBinaryOp;Storage::matmul检查设备和 dtype,失败是DeviceMismatchBinaryOp/DTypeMismatchBinaryOp;后端拒绝某种步长,是MatMulUnexpectedStriding或 Metal 的错误。本页讲这些值长什么样、?一路传上来时带不带调用栈。
flowchart TB
site["出错处构造变体"] --> bt["调用 bt"]
bt --> cap["Backtrace capture"]
cap --> st{"捕获状态"}
st -->|Disabled| plain["原变体"]
st -->|Captured| wrap["WithBacktrace 包一层"]
plain --> q["问号向上传"]
wrap --> q
q --> user["用户 match 或打印"]
实验 crate:exp-candle/tensor-exp(features metal accelerate),errors 和 fromvec 两个程序;M2 Max、macOS 27、rustc 1.99.0。
一个枚举、一个 Result
error.rs 只有两样公开的核心类型。Result 是别名:
pub type Result<T> = std::result::Result<T, Error>;
Error 用 thiserror 派生。源码用 // === DType Errors === 这样的注释给前面的变体分了组,下表按这些组再把后面的变体归一归:
| 组 | 变体 |
|---|---|
| DType | UnexpectedDType、DTypeMismatchBinaryOp、UnsupportedDTypeForOp |
| 维度下标 | DimOutOfRange、DuplicateDimIndex |
| 形状 | UnexpectedNumberOfDims、UnexpectedShape、ShapeMismatch、ShapeMismatchBinaryOp、ShapeMismatchCat、ShapeMismatchSplit、OnlySingleDimension、EmptyTensor |
| 设备 | DeviceMismatchBinaryOp |
| 具体算子 | NarrowInvalidArgs、Conv1dInvalidArgs、InvalidIndex、BroadcastIncompatibleShapes、CannotSetVar、MatMulUnexpectedStriding、RequiresContiguous、OpRequiresAtLeastOneTensor / Two、BackwardNotSupported |
| 编译选项 | NotCompiledWithCudaSupport、NotCompiledWithMetalSupport |
| 包装外部错误 | Cuda、Metal、Ug(ug feature)、TryFromIntError、Npy、Zip、ParseInt、FromUtf8、Io、SafeTensor、UnsupportedSafeTensorDtype |
| 包装 candle 自己的错误 | Wrapped、WrappedContext、Context、WithPath、WithBacktrace |
| 其他 | CannotFindTensor、Msg(bail! 用)、UnwrapNone |
几个设计上的细节:
变体字段只放
Shape、DType、DeviceLocation、&'static str这类小值,MatMulUnexpectedStriding有两个Layout,所以装进Box(注释:「Box indirection to avoid large variant」)。size_of::<Error>()是 80 字节。op字段是&'static str,由调用处传入算子名("matmul"、"add"),二元算子的名字来自stringify!($fn_name)或B::NAME。Debug直接输出Display的内容,所以fn main() -> candle_core::Result<()>出错时打印的是可读的一句话,不是结构体:Error: shape mismatch in add, lhs: [2, 3], rhs: [3, 2]
candle 的几乎所有张量方法都返回 Result<Tensor>,连 t()、narrow 这种只算布局的方法也是;+ - * / 运算符的 Output 也是 Result<Tensor>,所以写成 (&a + &b)?。和 burn 的做法正好相反:burn 在张量 API 的入口检查形状和设备,失败就 panic,只有读数据、同步这类操作返回 Result,见 burn 卷的检查与报错。
真实的报错
errors 在 CPU 上(release,不设任何环境变量)对 a: (2, 3)、b: (3, 2)、row: (3) 做一串会失败的操作,打印 to_string():
a + b: shape mismatch in add, lhs: [2, 3], rhs: [3, 2]
a + row: shape mismatch in add, lhs: [2, 3], rhs: [3]
a.broadcast_add(row): ok [2, 3]
a.matmul(a): shape mismatch in matmul, lhs: [2, 3], rhs: [2, 3]
a.matmul(row): shape mismatch in matmul, lhs: [2, 3], rhs: [3]
a.reshape(4): shape mismatch in reshape, lhs: [2, 3], rhs: [4]
from_vec 5 into (2,3): ok [2, 3]
a.narrow(1,2,2): narrow invalid args start + len > dim_len: [2, 3], dim: 1, start: 2, len:2
a.transpose(0,2): transpose: dimension index 2 out of range for shape [2, 3]
a.broadcast_as((3,3)): cannot broadcast [2, 3] to [3, 3]
row.t(): unexpected rank, expected: 2, got: 1 ([3])
...
to_scalar on (2,3): unexpected rank, expected: 0, got: 2 ([2, 3])
to_vec1 on (2,3): unexpected rank, expected: 1, got: 2 ([2, 3])
to_vec2::<f64> on f32: unexpected dtype, expected: F64, got: F32
(每行末尾还打印了 [lines=1, bt frames start=0] 这样的统计,这里略去;from_vec 那一行下一节讲。)对照源码:
- 逐元素的二元运算
add/mul/ ... 由same_shape_binary_op要求两边形状完全相同,(2, 3) + (3)不会自动广播,要显式写broadcast_add。 matmul的秩不一致、k不一致、批维不一致,报的都是同一种shape mismatch in matmul,只给两个形状,不说是哪一条不满足。reshape元素个数不对也借用了ShapeMismatchBinaryOp,op写成"reshape",于是两个形状被叫作lhs和rhs。
dtype、设备不匹配的原文见 DType 与 Device:
dtype mismatch in add, lhs: F32, rhs: F16
device mismatch in add, lhs: Cpu, rhs: Metal { gpu_id: 4294968354 }
不是所有失败都是 Err。本卷实测到的 panic:整数张量的一元运算(todo!,见 DType 与 Device)、Device::new_metal 序号越界、秩超过 8 的二元运算(见布局与视图)、对自己调 inplace_op2 则是死锁(见 Tensor 的结构)。
from_vec 不检查元素个数
Tensor::from_vec 和 from_slice 的文档都说「The number of elements in this vector must be the same as the number of elements defined by the shape」,但 from_vec_impl 只调了 into_shape:
pub(crate) fn from_vec_impl<S: ShapeWithOneHole, D: crate::WithDType>(
data: Vec<D>,
shape: S,
device: &Device,
is_variable: bool,
) -> Result<Self> {
let shape = shape.into_shape(data.len())?;
let storage = device.storage_owned(data)?;
let none = BackpropOp::none();
Ok(from_storage(storage, shape, none, is_variable))
}
而普通形状((2, 3)、vec![..],凡是 Into<Shape> 的)的 ShapeWithOneHole 实现直接忽略元素个数:
impl<S: Into<Shape>> ShapeWithOneHole for S {
fn into_shape(self, _el_count: usize) -> Result<Shape> {
Ok(self.into())
}
}
只有带 () 洞的形状((2, ()))才会在 hole_size 里检查能否整除。fromvec 用 5 个元素建一个 (2, 3) 张量,用 7 个元素的切片建另一个:
== Cpu
from_vec 5 -> [2, 3] elem_count=6
panic: range end index 6 out of range for slice of length 5
to_vec2: Err(Any { .. })
panic: range end index 6 out of range for slice of length 5
sum_all: Err(Any { .. })
panic: range end index 6 out of range for slice of length 5
t + t: Err(Any { .. })
from_slice 7 -> [2, 3]: Ok([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]])
== Metal(MetalDevice(DeviceId(1)))
from_vec 5 -> [2, 3] elem_count=6
panic: range end index 6 out of range for slice of length 5
to_vec2: Err(Any { .. })
sum_all: Ok(Ok(15.0))
t + t: Ok(Ok([[2.0, 4.0, 6.0], [8.0, 10.0, 0.0]]))
from_slice 7 -> [2, 3]: Ok([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]])
(Err(Any { .. }) 是 catch_unwind 接住的 panic,panic 信息由 hook 打印在上一行。debug 和 release 的输出相同。)建张量本身总是成功;CPU 上第一次读数据时切片越界 panic;Metal 上内核按形状读 6 个元素,第 6 个超出了传进去的数据(这次读到的是 0),求和、加法静默地给出结果,只有读回 CPU 时才 panic。元素多了则被静默截断。
PR #2905(「Allow from_vec/from_slice to use a ShapeWithOneHole as shape」,首次进入 0.9.0)之前,from_vec_impl 和 from_slice 的参数是 S: Into<Shape>,开头先比较元素个数:
let shape = shape.into();
let buffer_size = data.len();
if buffer_size != shape.elem_count() {
return Err(Error::ShapeMismatch { buffer_size, shape }.bt());
}
这个 PR 想让 from_vec(v, (2, ()), &dev) 这种带洞的写法也能用,把参数换成 ShapeWithOneHole,四行检查换成一行 into_shape(data.len())?。reshape 同样改用 ShapeWithOneHole,但它在 into_shape 之后还保留着自己的元素个数检查;from_vec、from_slice 没有。从那以后 Error::ShapeMismatch 只剩 new_impl 一处在用,而那里比较的两个形状都来自同一个数组,实际上不会触发。
bail! 与 Context
bail! 和 anyhow 的同名宏用法一样,生成的是 Error::Msg:
#[macro_export]
macro_rules! bail {
($msg:literal $(,)?) => {
return Err($crate::Error::Msg(format!($msg).into()).bt())
};
($err:expr $(,)?) => {
return Err($crate::Error::Msg(format!($err).into()).bt())
};
($fmt:expr, $($arg:tt)*) => {
return Err($crate::Error::Msg(format!($fmt, $($arg)*).into()).bt())
};
}
candle 内部大量用它报「没实现」「参数不对」:Metal 后端的 Metal contiguous unary uexp U8 not implemented、to_device 的 not implemented yet、permute 的 dimension mismatch in permute 都是 Msg。用户在自己返回 candle_core::Result 的函数里也可以用:
fn my_op(t: &Tensor) -> Result<Tensor> {
if t.rank() != 2 {
bail!("my_op wants a matrix, got {:?}", t.dims())
}
t.t()
}
my_op(row): my_op wants a matrix, got [3]
代价是这类错误只有字符串,调用方不能按变体区分。
Context trait 也照着 anyhow 写(文档注释「Inspired by anyhow::Context」),给 Result<T, E: std::error::Error> 和 Option<T> 加 context / with_context,结果是 WrappedContext 或 Context 变体。另外 Error 实现了 std::error::Error(thiserror 派生),在应用里直接用 ? 转进 anyhow::Error 即可。
backtrace 什么时候带上
几乎每个构造 Error 的地方都写成 Error::Xxx { .. }.bt()。bt:
pub fn bt(self) -> Self {
let backtrace = std::backtrace::Backtrace::capture();
match backtrace.status() {
std::backtrace::BacktraceStatus::Disabled
| std::backtrace::BacktraceStatus::Unsupported => self,
_ => Self::WithBacktrace {
inner: Box::new(self),
backtrace: Box::new(backtrace),
},
}
}
是否捕获完全交给标准库的 Backtrace::capture:先看 RUST_LIB_BACKTRACE,没设再看 RUST_BACKTRACE,值不是 0 就捕获(结果在进程内缓存)。捕获到了,就把原错误包进 WithBacktrace,Display 是 "{inner}\n{backtrace}"。
用同一个 errors 二进制(release)在四种环境下跑,看 a + b 的错误有几行、构造一个错误要多久:
| 环境变量 | a + b 的 to_string() | 失败的 a + b 每次耗时 |
|---|---|---|
| 不设 | 1 行 | 0.06 µs |
RUST_BACKTRACE=1 | 11 行(报错 + 调用栈) | 11.04 µs |
RUST_BACKTRACE=1 RUST_LIB_BACKTRACE=0 | 1 行 | 0.06 µs |
RUST_LIB_BACKTRACE=1 | 11 行 | 11.04 µs |
RUST_LIB_BACKTRACE=0 / RUST_BACKTRACE=0 | 1 行 | 0.06 µs |
想让 panic 带调用栈、但不让 candle 的每个错误都去抓栈,设 RUST_BACKTRACE=1 RUST_LIB_BACKTRACE=0(这时 anyhow 也不抓)。调用栈的前几帧是 bt 自己,接着才是出错的方法:
anyhow {:?}: lines=21, frame-0 lines=2
| shape mismatch in matmul, lhs: [2, 3], rhs: [2, 3]
| 0: <std::backtrace::Backtrace>::create
| 1: <std::backtrace::Backtrace>::capture
| 2: <candle_core::error::Error>::bt
| 3: <candle_core::tensor::Tensor>::matmul
| 4: errors::main
这是 RUST_BACKTRACE=1 下把错误转成 anyhow::Error 再打印 {:?},程序只转印了前 6 行(行首的 | 是实验程序加的)。整段输出 21 行,以 0: 开头的行有两处:candle 的错误把调用栈写在 Display 里,anyhow 在 ? 转换时又按同样的环境变量抓了一份自己的,于是同一个错误打印两遍调用栈。
打开 backtrace 还有一个后果:错误的变体变了。按变体处理错误的代码:
let e = (&a + &b).unwrap_err();
let plain = matches!(e, Error::ShapeMismatchBinaryOp { .. });
在不同环境下得到不同的结果,见文末动脑筋答案。
上面那段代码,不设环境变量运行时 plain 是 true。用 RUST_BACKTRACE=1 运行时,plain 是什么?
Option::context 还有一个小问题:Context 对 Option 的实现 写的是 Err(Error::msg(context).bt()),而 Error::msg 自己已经调过一次 bt()。打开 backtrace 时就包了两层 WithBacktrace:
Option::context: first line "no value", lines=18, backtraces=2
Option::with_context: first line "no value", lines=10, backtraces=1
backtraces 是输出里以 0: 开头的行数,也就是调用栈的个数。with_context 走的是 Error::UnwrapNone.context(..).bt(),只有一层。
问: 既然 WithBacktrace 会让 match 失灵,为什么不像 anyhow 那样把调用栈放在错误旁边,而要包在变体外面?
答: Error 是一个普通枚举,变体里没有放调用栈的位置;要放,就得给每个变体加一个字段,或者把 Error 改成「种类 + 调用栈」的结构体。包一层是改动最小的做法:只需要在构造处补一个 .bt(),变体定义不动,关闭时零开销(Disabled 直接返回原值)。代价落在按变体处理错误的调用方身上,它们要先剥掉可能存在的 WithBacktrace、Context、WithPath 几层包装。在 candle-core、candle-nn、candle-transformers 的 src/ 里找不到按 Error 变体分支的 match,错误都是 ? 往上传,所以这个代价在库内部感觉不到。
动脑筋答案
matches ShapeMismatchBinaryOp directly: false, inside WithBacktrace: true
是 false。RUST_BACKTRACE=1 让 Backtrace::capture 返回已捕获的调用栈,bt() 把 ShapeMismatchBinaryOp 装进了 WithBacktrace { inner, backtrace },最外层的变体变成 WithBacktrace,原来的变体要到 inner 里找(第二个 matches! 是 Error::WithBacktrace { inner, .. } if matches!(**inner, Error::ShapeMismatchBinaryOp { .. }))。不设环境变量时输出是 directly: true, inside WithBacktrace: false。同一份代码的错误处理分支,会因为运行时的一个环境变量走到不同的地方。
小结
candle-core只有一个Error枚举和一个Result<T>别名,几乎所有张量方法和运算符都返回Result;Debug等于Display。- 形状、dtype、设备不匹配的报错格式固定:
shape mismatch in {op}, lhs: .., rhs: ..、dtype mismatch in {op}, ..、device mismatch in {op}, ..;逐元素运算不自动广播。 from_vec/from_slice从 0.9.0 起不检查元素个数,错误要到读数据时才以 panic 出现,Metal 上可能静默读越界。bail!产生只有字符串的Msg;Context仿 anyhow。.bt()按RUST_LIB_BACKTRACE、RUST_BACKTRACE决定是否抓栈,打开后每个错误约 11 µs,错误被包进WithBacktrace,按变体match会失配;Option::context会包两层。