Rust in Everything
基于 candle 0.11.0

Result 到处都是

主线位置:张量主线的「计算」一步:每个运算都可能失败,失败怎么表示、怎么传上去、怎么找到是哪一行。
版本:PyTorch 2.15.0(CPU、MPS)、candle 0.11.0(CPU,accelerate;Metal)
flowchart TB
  op["x.matmul(w1)"] --> r{"Result"}
  r -->|Ok| next["relu 继续"]
  r -->|Err| q["问号返回调用方"]
  q --> ctx["调用处 context"]
  ctx --> top["main 打印一行"]

实验代码:candle 程序 errs(release)和几个故意编不过的小程序(cargo build --color never,debug),PyTorch 侧是 errs.py;平台 M2 Max、macOS 27、rustc 1.99.0。编译器输出里的本地路径前缀换成了 ...。

每一步都是 Result

h = (x @ w1).relu()
let h = x.matmul(&w1)?.relu()?;

PyTorch 的方法返回张量,失败时抛 RuntimeError。candle 的方法返回 candle_core::Result<Tensor>:tensor.rs 里直接写出来的 122 个 &self 公开方法,105 个返回 Result;不返回的 17 个是读元数据的(dtype、shape、dims、rank、device、is_contiguous 等)以及 detach、storage_and_layout 这类不会失败的。宏生成的一元、二元运算(relu、exp、add、broadcast_mul……)也都返回 Result,连只改布局的 t()、narrow 都是。

漏写一个 ?,不是运行时错误,是编译错误:

let h = x.matmul(&w).relu()?;
error[E0599]: no method named `relu` found for enum `Result<T, E>` in the current scope
   --> src/bin/noq.rs:6:24
    |
  6 |   let h = x.matmul(&w).relu()?;
    |           -            ^^^^ method not found in `Result<candle_core::Tensor, candle_core::Error>`
    |           |
    |           method `relu` is available on `&candle_core::Tensor`
    |
note: the method `relu` exists on the type `candle_core::Tensor`
   --> .../candle-core-0.11.0/src/tensor.rs:80:9
...
help: use the `?` operator to extract the `candle_core::Tensor` value, propagating a `Result::Err` value to the caller
    |
  6 |   let h = x.matmul(&w)?.relu()?;
    |                       +

rustc 的建议就是改法。? 要求所在的函数也返回 Result:fn main() -> candle_core::Result<()> 或 anyhow::Result<()>,模块的 forward 返回 candle_core::Result<Tensor>(candle_core::Module trait 就是这么定义的)。

运算符也返回 Result

+ - * / 的 Output 是 Result<Tensor>,所以要写 (&a + &b)?。连着写几个:

let d = (&a + &b + &c)?;
let e = ((&a * 2.0)? + 1.0)?;
let f = (2.0 * &a + &b)?;
[3.0, 3.0, 3.0] [3.0, 3.0, 3.0] [3.0, 3.0, 3.0]

&a + &b + &c 能编过,因为 bin_trait! 也给 Result<B> 实现了运算符,左边的 Result 在里面用 self? 解开:

        impl<B: std::borrow::Borrow<Tensor>> std::ops::$trait<&Tensor> for Result<B> {
            type Output = Result<Tensor>;

            fn $fn1(self, rhs: &Tensor) -> Self::Output {
                Tensor::$fn1(self?.borrow(), rhs)
            }
        }

同一个宏给 Tensor 和 &Tensor 实现了 op f64,宏外面另有 f64 op Tensor 和 f64 op &Tensor,但没有 Result op f64。所以 &x * 2.0 + 1.0 编不过:

let y = (&x * 2.0 + 1.0)?;
error[E0277]: cannot add `{float}` to `Result<candle_core::Tensor, candle_core::Error>`
    --> src/bin/scalarchain.rs:5:21
     |
   5 |   let y = (&x * 2.0 + 1.0)?;
     |                     ^ no implementation for `Result<candle_core::Tensor, candle_core::Error> + {float}`
     |
     = help: the trait `std::ops::Add<{float}>` is not implemented for `Result<candle_core::Tensor, candle_core::Error>`
...

在中间加一个 ?(上面的 e),或者写成一次 x.affine(2.0, 1.0)?。整数张量和浮点标量的运算还有别的问题,见整数运算。

📝NOTE
冷知识:Result + Tensor 是后来补的

bin_trait! 里 Result<B> op Tensor 和 Result<B> op &Tensor 这两组实现来自 PR #697「More ops again.」。在这之前宏里已经有 Tensor op Result<B> 和 &Tensor op Result<B>,即右边可以是 Result,所以 &a + (&b + &c) 能写,左结合的 &a + &b + &c 却不能:&a + &b 的结果是 Result,它没有 +。这次提交在宏里加了 16 行,补上左边是 Result 的情况。提交说明只有标题。它早于本地仓库最早的 tag,git tag --contains 只能查到 0.5.1。

三类报错的原文

同一组失败的操作,两边的报错:

a = torch.ones(2, 3)
b = torch.ones(3, 2)
a + b
a @ a
a @ b.double()
a + b.t().double()
a @ b.to("mps")
("a + b", &a + &b),
("a.matmul(a)", a.matmul(&a)),
("a.matmul(b f64)", b.to_dtype(DType::F64).and_then(|b| a.matmul(&b))),
("a + b.t() f64", b.t().and_then(|b| b.to_dtype(DType::F64)).and_then(|b| &a + &b)),
("a cpu .matmul(b metal)", b.to_device(&metal).and_then(|b| a.matmul(&b))),
# PyTorch
a + b                RuntimeError: The size of tensor a (3) must match the size of tensor b (2) at non-singleton dimension 1
a @ a                RuntimeError: mm: mat1 and mat2 shapes cannot be multiplied (2x3 and 2x3)
a @ b.double()       RuntimeError: expected m1 and m2 to have the same dtype, but got: float != double
a + b.t().double()   ok torch.float64 [2, 3]
a.cpu() @ b.mps      RuntimeError: Tensor for argument #1 'mat1' is on CPU, but expected it to be on GPU (while checking arguments for mm)
# candle
a + b                  Err: shape mismatch in add, lhs: [2, 3], rhs: [3, 2]
a.matmul(a)            Err: shape mismatch in matmul, lhs: [2, 3], rhs: [2, 3]
a.matmul(b f64)        Err: dtype mismatch in matmul, lhs: F32, rhs: F64
a + b.t() f64          Err: dtype mismatch in add, lhs: F32, rhs: F64
a cpu .matmul(b metal) Err: device mismatch in matmul, lhs: Cpu, rhs: Metal { gpu_id: 4294968354 }
  • candle 的三类报错格式固定:{shape|dtype|device} mismatch in {op}, lhs: .., rhs: ..。PyTorch 每个算子各说各的,但会说到哪一维(at non-singleton dimension 1)。
  • PyTorch 的 float32 + float64 自动提升,不报错;candle 报 dtype mismatch。只有 matmul 这类算子 PyTorch 才要求同 dtype。
  • candle 的 a + b 是形状不同就报错,不看能不能广播,见形状与广播。

每个变体的定义和 Display 格式见源码卷的错误。

错误里没有位置

一个三层的 MLP,第二个权重的形状写错了。PyTorch:

def mlp(x, w1, w2, w3):
  h = (x @ w1).relu()
  h = (h @ w2).relu()
  return h @ w3
Traceback (most recent call last):
  File ".../errs.py", line 17, in <module>
    mlp(x, w1, w2, w3)
    ~~~^^^^^^^^^^^^^^^
  File ".../errs.py", line 10, in mlp
    h = (h @ w2).relu()
         ~~^~~~
RuntimeError: mm: mat1 and mat2 shapes cannot be multiplied (4x16 and 8x16)

candle,main 返回 anyhow::Result<()>,错误直接 ? 上去:

fn mlp(x: &Tensor, w1: &Tensor, w2: &Tensor, w3: &Tensor) -> candle_core::Result<Tensor> {
  let h = x.matmul(w1)?.relu()?;
  let h = h.matmul(w2)?.relu()?;
  h.matmul(w3)
}
Error: shape mismatch in matmul, lhs: [4, 16], rhs: [8, 16]

退出码 1。异常栈指到了第 10 行和出错的那个 @;candle 只有一行,三个 matmul 里是哪一个,要自己从形状推。错误值里没有位置:ShapeMismatchBinaryOp 只有 lhs、rhs、op 三个字段,调用栈要设 RUST_BACKTRACE=1 才抓,release 构建下只有函数名,还会改变错误的变体,见源码卷的错误里看不出是哪一行。

实用的做法是在调用处加一句说明(anyhow::Context):

use anyhow::Context as _;

fn mlp_ctx(x: &Tensor, w1: &Tensor, w2: &Tensor, w3: &Tensor) -> anyhow::Result<Tensor> {
  let h = x.matmul(w1).context("mlp: x @ w1")?.relu()?;
  let h = h.matmul(w2).context("mlp: h @ w2")?.relu()?;
  Ok(h.matmul(w3).context("mlp: h @ w3")?)
}
{}:   mlp: h @ w2
{:#}: mlp: h @ w2: shape mismatch in matmul, lhs: [4, 16], rhs: [8, 16]

注意 {} 只打印最外层的说明,要用 {:#} 才能在一行里看到整条链。

和别的库的错误放在一起

搬模型时,一个函数里会同时出现 candle、tokenizers、serde_json、文件读写的错误。anyhow::Result 能接住大多数,但接不住 tokenizers:

fn main() -> anyhow::Result<()> {
  let tok = Tokenizer::from_file("tokenizer.json")?;
  // ...
}
error[E0277]: `?` couldn't convert the error: `dyn std::error::Error + Send + Sync: Sized` is not satisfied
 --> src/bin/tokq.rs:4:51
  |
3 | fn main() -> anyhow::Result<()> {
  |              ------------------ required `dyn std::error::Error + Send + Sync: Sized` because of this
4 |   let tok = Tokenizer::from_file("tokenizer.json")?;
  |             --------------------------------------^ doesn't have a size known at compile-time
  |             |
  |             this can't be annotated with `?` because it has type `Result<_, Box<(dyn std::error::Error + Send + Sync + 'static)>>`
  |
  = help: the trait `Sized` is not implemented for `dyn std::error::Error + Send + Sync`
  = note: the question mark operation (`?`) implicitly performs a conversion on the error value using the `From` trait
  = note: required for `Box<dyn std::error::Error + Send + Sync>` to implement `std::error::Error`
  = note: required for `anyhow::Error` to implement `From<Box<dyn std::error::Error + Send + Sync>>`

tokenizers 的错误类型是 Box<dyn std::error::Error + Send + Sync>,这个 Box 本身没有实现 std::error::Error,anyhow 的 From 就用不上。函数返回 candle_core::Result 时同样不行,报的是 the trait From<Box<dyn std::error::Error + Send + Sync>> is not implemented for candle_core::Error,后面列出了 candle_core::Error 能从哪些类型转过来(std::io::Error、SafeTensorError、ParseIntError 等)。

改法是显式转一下,candle 自己的例子程序就是这么写的(candle-examples 里有 65 个文件用了 map_err(E::msg),E 是 anyhow::Error):

let tok = Tokenizer::from_file(&path).map_err(anyhow::Error::msg)?;
vocab 49152
Error: No such file or directory (os error 2)

第一行是读本地的 SmolLM2-135M-Instruct 分词器,第二行是文件不存在时的报错:里面连文件名都没有,同样值得加一句 .context(..)。

try/except 对 match

PyTorch 里 try: ... except RuntimeError as e: 接住任何失败。candle 的 Err 是普通的值,用 match 或 if let Err(e) 处理,不会跳出调用栈:

for (tag, r) in cases {
  match r {
    Ok(t) => println!("{tag:22} ok {:?} {:?}", t.dtype(), t.dims()),
    Err(e) => println!("{tag:22} Err: {e}"),
  }
}

上一节的报错表就是这个循环打印的。

两个要注意的地方:

  • 不是所有失败都是 Err。 整数除以 0、debug 构建下的整数溢出(见整数运算)、整数张量的 exp、Device::new_metal 序号越界都是 panic,? 和 match 接不住,进程直接退出。源码卷的错误列了实测到的几种。
  • 按变体 match 不可靠。 打开 RUST_BACKTRACE=1 后,错误被包进 Error::WithBacktrace,matches!(e, Error::ShapeMismatchBinaryOp { .. }) 从 true 变成 false;加过 context 的错误也多了一层。按错误种类分支时先剥掉包装,做法见错误里看不出是哪一行。PyTorch 的 except RuntimeError 不受环境变量影响。

小结

  • candle 的张量方法和 + - * / 几乎都返回 Result;漏写 ? 是 E0599 编译错误,rustc 会指出该加在哪。
  • &a + &b + &c 能链着写(左边的 Result 也实现了运算符),&x * 2.0 + 1.0 不行,中间加 ? 或用 affine。
  • 形状、dtype、设备三类报错格式固定,{kind} mismatch in {op}, lhs: .., rhs: ..;PyTorch 的 f32 + f64 会提升,candle 报错。
  • candle 的错误只有一行,不带位置;在调用处 .context("…"),用 {:#} 打印整条链。
  • tokenizers 的错误要 .map_err(anyhow::Error::msg) 才能 ?。
  • panic(整数除以 0、溢出、越界的设备序号)不是 Err;按变体 match 之前先剥包装。

💬 关联讨论

发起讨论
加载中...