Rust in Everything
基于 candle-core 0.11.0

from_vec 元素个数不对也不报错

主线位置:算子主线开始之前,张量刚被建出来的那一步。Tensor 只记形状和一块 Storage,形状和数据长度是否一致,此后没有任何一步再核对。张量的结构见 Tensor 的结构,这条检查是怎么丢的见 错误 的冷知识。

flowchart TB
  data["5 个数 形状 (2, 3)"] --> make["from_vec 成功"]
  make --> dev{"设备"}
  dev -->|CPU| cpu["读数据时切片越界 panic"]
  dev -->|Metal| metal["内核读过缓冲区末尾"]
  metal --> quiet["sum 静默给出结果"]
  quiet --> late["读回 CPU 时才 panic"]
  data --> fix["一维建好再 reshape"]
  fix --> err["shape mismatch in reshape"]

实验 crate exp-candle/pb-pit,bin fromvec,debug(release 输出相同),M2 Max、macOS 27、rustc 1.99.0。

现象

解析数据时少了一个数,5 个值去建 (2, 3) 的张量:

let data = vec![1f32, 2., 3., 4., 5.];
let t = Tensor::from_vec(data, (2, 3), &dev)?;
println!("from_vec ok: dims {:?}, elem_count {}", t.dims(), t.elem_count());
let s = t.sum_all()?.to_scalar::<f32>()?;
println!("sum_all = {s}");
let doubled = (&t + &t)?;
println!("(t + t).sum_all = {}", doubled.sum_all()?.to_scalar::<f32>()?);
println!("to_vec2 = {:?}", t.to_vec2::<f32>()?);

CPU:

from_vec ok: dims [2, 3], elem_count 6

thread 'main' (3921690) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/candle-core-0.11.0/src/cpu_backend/mod.rs:246:31:
range end index 6 out of range for slice of length 5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
exit=101

Metal:

from_vec ok: dims [2, 3], elem_count 6
sum_all = 15
(t + t).sum_all = 30

thread 'main' (3921692) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/candle-core-0.11.0/src/tensor.rs:1962:37:
range end index 6 out of range for slice of length 5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
exit=101
  • from_vec 返回 Ok,张量自称有 6 个元素。
  • CPU 上第一个用到数据的算子(这里是 sum_all 的 ReduceSum)按布局切 src[0..6],切片只有 5 个,panic。不是 Err,? 接不住。
  • Metal 上 sum_all 和加法都「成功」了:内核按形状读 6 个元素,第 6 个越过了 20 字节的缓冲区末尾,这次读到的是 0,所以和是 15。读到什么没有保证。直到 to_vec2 把数据拷回 CPU、按形状切片,才 panic。中间所有结果都是用一个不存在的元素算出来的。

多给数据时(from_slice 传 7 个值建 (2, 3))则是静默截断,见 错误 里的完整输出。

出处

from_vec 的文档注释写着「The number of elements in this vector must be the same as the number of elements defined by the shape」,实现 from_vec_impl 却只做了这些:

        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) 这样的普通形状走的是 ShapeWithOneHole 的通用实现,into_shape 直接忽略传进来的元素个数。from_slice 一样。比较长度的那四行是 0.9.0 的 #2905 删掉的(错误 的冷知识)。

之后谁都不再核对:CPU 的 ReduceSum::fold_impl 用 contiguous_offsets() 算出的 0..6 去切 Vec,Rust 的切片检查报越界;Metal 的内核在 GPU 上读缓冲区,没有切片检查;to_vec2 读回的 Vec 只有缓冲区里那 5 个,再按形状切时 panic。

这是 0.11.0 的上游 bug:文档说必须相等,代码不检查,后果因设备而异。最小复现就是上面三行。

修法

不要把「元素个数」交给形状去猜。先按真实长度建一维张量,再 reshape;reshape 在 into_shape 之后还保留了自己的元素个数检查:

let r = Tensor::from_vec(data.clone(), data.len(), &dev).and_then(|t| t.reshape((2, 3)));
let r = Tensor::from_vec(data.clone(), (2, ()), &dev);
let r = Tensor::new(&[[1f32, 2., 3.], [4., 5., 6.]], &dev);
let r = Tensor::from_vec(vec![1f32, 2., 3., 4.], (2, ()), &dev);
from_vec(n).reshape((2, 3)): Err("shape mismatch in reshape, lhs: [5], rhs: [2, 3]")
from_vec((2, ())): Err("cannot reshape tensor with 5 elements to (2, ())")
Tensor::new(&[[..; 3]; 2]): Ok([2, 3])
from_vec((2, ())) with 4 values: Ok([2, 2])
  • from_vec(v, v.len(), ..) 再 reshape:一维形状的元素个数就是数据长度,reshape 报出真正的错误。一维到 (2, 3) 的 reshape 在连续张量上只改布局(布局与视图),没有额外开销。
  • 带洞的形状 (2, ()) 只检查能不能整除:5 个值报错,4 个值却安静地得到 [2, 2]。它挡不住「少了一整列」。
  • 数据是编译期定长的数组时,Tensor::new 走 new_impl,形状从数组本身推出来,[[f32; 3]; 2] 的类型就保证了个数,参差不齐的嵌套 Vec 也会在这里报错。

怎么确认没中招

  • 在代码里搜 from_vec( 和 from_slice(,第二个参数是多维元组的,都改成「一维 + reshape」,或者在调用前 assert_eq!(data.len(), shape.elem_count())。
  • CPU 上 panic 信息是 range end index N out of range for slice of length M,位置在 cpu_backend 里:先查建这个张量时的数据长度。
  • 只在 Metal 上跑的代码最危险,结果不报错。怀疑时把同样的输入在 CPU 上跑一遍,或者对每个 from_vec 出来的张量立刻 to_vec1 / to_vec2 一次(拷回 CPU 时会按形状切片)。

小结

  • 0.11.0 的 from_vec / from_slice 不比较数据长度和形状,建张量总是成功,和文档注释不符。
  • 数据少了:CPU 在第一个读数据的算子里 panic;Metal 越过缓冲区末尾读,静默算出结果,读回时才 panic。数据多了:静默截断。
  • 先按真实长度建一维张量再 reshape;带洞的形状只检查整除,不够。

💬 关联讨论

发起讨论
加载中...