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;带洞的形状只检查整除,不够。