创建与 dtype
主线位置:张量主线的第一步「建张量」和最后一步「读回主机」:数据怎么变成
Tensor,dtype 由谁决定,结果怎么拿回 Rust 的Vec。
版本:PyTorch 2.15.0(CPU)、candle 0.11.0(CPU,accelerate;舍入对照另在 Metal 上跑了一遍)
flowchart TB lit["Rust 字面量"] -->|"类型推断"| ty["f64 f32 i32 ..."] ty -->|"WithDType DTYPE"| dt["DType"] dt --> t["Tensor"] t -->|"同 dtype 运算"| r["结果"] t -.->|"dtype 不同"| err["dtype mismatch"] r -->|"to_dtype"| r2["换 dtype"] r -->|"to_vec1 T"| host["Vec T"]
本页的对照:PyTorch 侧是实验代码里的 create.py、cast_ref.py(torch.set_num_threads(1)),candle 侧是实验 crate tensora-t 的 create、cast、ce_scalar(features metal accelerate,release)。平台 M2 Max、macOS 27、rustc 1.99.0。candle 内部怎么表示 dtype,见源码卷的 DType 与 Device;本页只讲从 PyTorch 过来要改的写法。
字面量决定 dtype
torch.tensor([1.0, 2.0]); torch.tensor([1, 2]); torch.tensor([[1, 2], [3, 4.0]])
torch.tensor(3.5); torch.arange(0.0, 3.0); torch.arange(3); torch.zeros(2, 2)
torch.tensor([[1, 2], [3]])
Tensor::new(&[1.0, 2.0], &dev); Tensor::new(&[1f32, 2.0], &dev);
Tensor::new(&[1, 2], &dev); Tensor::new(&[1i64, 2], &dev);
Tensor::new(&[[1f32, 2.], [3., 4.]], &dev); Tensor::new(3.5f32, &dev);
Tensor::arange(0., 3., &dev); Tensor::arange(0f32, 3., &dev); Tensor::arange(0u32, 3, &dev);
Tensor::zeros((2, 2), DType::F32, &dev);
let ragged: Vec<Vec<f32>> = vec![vec![1., 2.], vec![3.]];
Tensor::new(ragged, &dev);
tensor([1.0, 2.0]) torch.float32 [2] [1.0, 2.0]
tensor([1, 2]) torch.int64 [2] [1, 2]
tensor([[1, 2], [3, 4.]]) torch.float32 [2, 2] [1.0, 2.0, 3.0, 4.0]
tensor(3.5) torch.float32 [] [3.5]
arange(0., 3.) torch.float32 [3] [0.0, 1.0, 2.0]
arange(3) torch.int64 [3] [0, 1, 2]
zeros(2, 2) torch.float32 [2, 2] [0.0, 0.0, 0.0, 0.0]
tensor([[1, 2], [3]]) ValueError: expected sequence of length 2 at dim 1 (got 1)
default dtype torch.float32
new(&[1.0, 2.0]) F64 [2] [1.0, 2.0]
new(&[1f32, 2.0]) F32 [2] [1.0, 2.0]
new(&[1, 2]) I32 [2] [1.0, 2.0]
new(&[1i64, 2]) I64 [2] [1.0, 2.0]
new(&[[1f32, 2.], [3., 4.]]) F32 [2, 2] [1.0, 2.0, 3.0, 4.0]
new(3.5f32) F32 [] [3.5]
arange(0., 3.) F64 [3] [0.0, 1.0, 2.0]
arange(0f32, 3.) F32 [3] [0.0, 1.0, 2.0]
arange(0u32, 3) U32 [3] [0.0, 1.0, 2.0]
zeros((2, 2), F32) F32 [2, 2] [0.0, 0.0, 0.0, 0.0]
new(vec![vec![1,2], vec![3]]) Err: two elements have different len 2 1
(candle 一侧的数值是实验程序转成 f64 打印的,所以整数也带 .0。)
两边都从数据定 dtype,但定法不同:
- PyTorch 在运行期看 Python 的值:全是
int就是 int64,有一个float就是默认 dtype(torch.get_default_dtype(),这里是 float32)。 - candle 在编译期看 Rust 的类型。
Tensor::new接受任何实现了NdArray的值(标量、&[T; N]、&[[T; N]; M]、Vec<Vec<T>>等),元素类型T的WithDType的关联常量DTYPE就是张量的 dtype。字面量没有别的类型约束时,Rust 把1.0定为f64、把1定为i32,于是得到 F64 和 I32,而不是 PyTorch 的 float32 和 int64。
要 F32,在第一个元素上写后缀(1f32),或者让变量带上类型(let v: Vec<f32>)。整数同理:下标要 u32 或 i64(见归约与索引),PyTorch 的 torch.tensor([2, 0]) 照搬成 Tensor::new(&[2, 0], &dev) 得到的是 I32,而 I32 在 Metal 上只能上传下载、不能计算(源码卷 DType 与 Device 的表)。
另一个区别:candle 没有默认 dtype。Tensor::zeros、ones、randn 这类不带数据的构造函数都要求显式传 DType 或从参数类型推出(randn(0f32, 1., ..)),也就没有 torch.set_default_dtype 的对应物。参差不齐的嵌套 Vec 两边都报错,candle 在 NdArray::shape 里检查。
问: Tensor::new(&[1.0, 2.0], &dev) 得到 F64 太容易踩,candle 为什么不像 PyTorch 那样把浮点数据默认存成 F32?
答: candle 根本看不到「字面量」。到 Tensor::new 被调用时,参数已经是一个类型确定的 &[f64; 2],类型推断由编译器在调用之前做完;new 按 f64::DTYPE 分派到 CpuStorage::F64,中间没有一个可以插入「默认 dtype」的地方。要改成 F32,只能对 f64 输入悄悄做一次转换,那 Tensor::new(&[0.1f64], ..) 就会丢精度,和类型签名说的不一致。
从 Vec、切片、迭代器建
Tensor::from_vec(vec![1f32, 2., 3., 4.], (2, 2), &dev);
Tensor::from_vec(vec![1f32, 2., 3., 4.], (2, ()), &dev);
let buf = [0f32, 1., 2., 3., 4., 5., 6., 7.];
Tensor::from_slice(&buf[2..6], 4, &dev);
Tensor::from_iter((0..4).map(|i| i as f32), &dev);
Tensor::full(7u8, 3, &dev);
from_vec(v, (2, 2)) F32 [2, 2] [1.0, 2.0, 3.0, 4.0]
from_vec(v, (2, ())) F32 [2, 2] [1.0, 2.0, 3.0, 4.0]
from_slice(&buf[2..6], 4) F32 [4] [2.0, 3.0, 4.0, 5.0]
from_iter((0..4).map(f32)) F32 [4] [0.0, 1.0, 2.0, 3.0]
full(7u8, 3) U8 [3] [7.0, 7.0, 7.0]
PyTorch 里这些都是 torch.tensor(list).reshape(...) 或 torch.from_numpy。candle 分成几个函数:from_vec 拿走 Vec 的所有权,from_slice 从切片复制,from_iter 收集一个迭代器,得到一维张量。形状参数里的 () 是「这一维自己算」,相当于 -1,规则见形状与广播。
from_vec 和 from_slice 在 0.11.0 里不比较数据长度和形状:5 个数建 (2, 3) 也返回 Ok,CPU 上到第一次读数据才 panic,Metal 上会静默读过缓冲区末尾。完整复现和修法见源码卷的坑 from_vec 元素个数不对也不报错;从 PyTorch 过来的写法 from_vec(v, (rows, cols), ..) 正是会中招的那种,稳妥的写法是 from_vec(v, v.len(), ..)?.reshape((rows, cols))?。
没有类型提升
a = torch.tensor([1.0, 2.0, 3.0])
a + torch.tensor([0.5, 0.5, 0.5], dtype=torch.float64)
a + torch.tensor([1, 1, 1]); a + a.bfloat16()
a[None, :] @ torch.tensor([0.5, 0.5, 0.5], dtype=torch.float64)[:, None]
a * torch.tensor(0.5, dtype=torch.float64); a * torch.tensor([0.5], dtype=torch.float64)
let a = Tensor::new(&[1f32, 2., 3.], &dev)?;
let b64 = Tensor::new(&[0.5, 0.5, 0.5], &dev)?;
let i64t = Tensor::new(&[1i64, 1, 1], &dev)?;
let bf = a.to_dtype(DType::BF16)?;
&a + &b64; &a + &i64t; &a + &bf;
a.unsqueeze(0)?.matmul(&b64.unsqueeze(1)?);
&a + &b64.to_dtype(DType::F32)?;
f32 + f64 torch.float64 [3] [1.5, 2.5, 3.5]
f32 + i64 torch.float32 [3] [2.0, 3.0, 4.0]
f32 + bf16 torch.float32 [3] [2.0, 4.0, 6.0]
f32 @ f64 RuntimeError: expected m1 and m2 to have the same dtype, but got: float != double
f32 * f64 0-dim torch.float32 [3] [0.5, 1.0, 1.5]
f32 * f64 [1] torch.float64 [3] [0.5, 1.0, 1.5]
f32 + f64 Err: dtype mismatch in add, lhs: F32, rhs: F64
f32 + i64 Err: dtype mismatch in add, lhs: F32, rhs: I64
f32 + bf16 Err: dtype mismatch in add, lhs: F32, rhs: BF16
f32.matmul(f64) Err: dtype mismatch in matmul, lhs: F32, rhs: F64
f32 + f64.to_dtype(F32) F32 [3] [1.5, 2.5, 3.5]
PyTorch 的逐元素运算按提升规则选一个公共 dtype(f32 + f64 → f64,f32 + int64 → f32,f32 + bf16 → f32),而且 0 维张量的类别相同时不参与提升(最后两行:0 维的 f64 不把结果拉成 f64,形状 [1] 的就会)。它的 matmul 本来就不提升。candle 对所有二元运算都和 PyTorch 的 matmul 一样:Storage 层的 same_dtype 检查 dtype 完全相同,否则返回 dtype mismatch(检查的位置和错误类型见源码卷 DType 与 Device)。burn 也不提升,但它在后端 panic,见 burn 卷的 dtype 与设备。
最常见的来源是上一节的 F64 字面量混进 F32 计算:
let scale = Tensor::new(&[0.5], &dev)?;
a.broadcast_mul(&scale);
a.broadcast_mul(&Tensor::new(&[0.5f32], &dev)?);
a.broadcast_mul(new(&[0.5])) Err: dtype mismatch in mul, lhs: F32, rhs: F64
a.broadcast_mul(new(&[0.5f32])) F32 [3] [0.5, 1.0, 1.5]
报错里的 rhs: F64 是线索:自己的模型里没有 F64 时,它几乎总是来自一个没写后缀的字面量。
和标量运算:只认 f64
和一个数相乘、相加不受 dtype 限制:
a * 2.5; a.bfloat16() * 0.1
&a * 2.5; &bf * 0.1;
f32 * 2.5 (scalar) torch.float32 [3] [2.5, 5.0, 7.5]
bf16 * 0.1 (scalar) torch.bfloat16 [3] [0.10009765625, 0.2001953125, 0.30078125]
f32 * 2.5 (scalar) F32 [3] [2.5, 5.0, 7.5]
bf16 * 0.1 (scalar) BF16 [3] [0.10009765625, 0.2001953125, 0.30078125]
结果相同,机制不同。candle 的 + - * / 由 bin_trait! 生成,右边是张量时走普通的二元运算,右边是 f64 时变成 affine(mul, add),后端用 T::from_f64 把两个系数转成张量的类型再算。只有 f64 有这个实现,变量是 f32 时编译不过:
let s: f32 = 0.5;
let b = (&a * s)?;
error[E0277]: the trait bound `f32: Borrow<candle_core::Tensor>` is not satisfied
--> src/bin/ce_scalar.rs:6:15
|
6 | let b = (&a * s)?;
| ^ the trait `Borrow<candle_core::Tensor>` is not implemented for `f32`
|
= note: required for `&candle_core::Tensor` to implement `std::ops::Mul<f32>`
编译器去找 Mul<B: Borrow<Tensor>>,报的是「f32 不是张量」,不提 f64。写成 &a * s as f64,或者 a.affine(s as f64, 0.)。系数先转成张量的类型这一步,对整数张量意味着 i64 * 0.5 的系数先变成 0,见整数运算。
to_dtype:浮点转整数是饱和的
f = torch.tensor([-1.7, -0.5, 0.5, 1.7, 2.5, 300.0, float("nan"), float("inf")])
f.to(torch.int64); f.to(torch.uint32); f.to(torch.uint8)
big = torch.tensor([16777217, -1])
big.to(torch.float32); big.to(torch.uint32); big.to(torch.uint8)
let f = Tensor::new(&[-1.7f32, -0.5, 0.5, 1.7, 2.5, 300.0, f32::NAN, f32::INFINITY], &dev)?;
f.to_dtype(DType::I64); f.to_dtype(DType::U32); f.to_dtype(DType::U8);
let big = Tensor::new(&[16777217i64, -1], &dev)?;
big.to_dtype(DType::F32); big.to_dtype(DType::U32); big.to_dtype(DType::U8);
to(torch.int64) torch.int64 [8] [-1, 0, 0, 1, 2, 300, 0, 9223372036854775807]
to(torch.uint32) torch.uint32 [8] [0, 0, 0, 1, 2, 300, 0, 4294967295]
to(torch.uint8) torch.uint8 [8] [255, 0, 0, 1, 2, 44, 0, 255]
i64 16777217,-1 -> f32 torch.float32 [2] [16777216.0, -1.0]
i64 16777217,-1 -> u32 torch.uint32 [2] [16777217, 4294967295]
i64 -> u8 torch.uint8 [2] [1, 255]
to_dtype(I64) I64 [8] [-1.0, 0.0, 0.0, 1.0, 2.0, 300.0, 0.0, 9.223372036854776e18]
to_dtype(U32) U32 [8] [0.0, 0.0, 0.0, 1.0, 2.0, 300.0, 0.0, 4294967295.0]
to_dtype(U8) U8 [8] [0.0, 0.0, 0.0, 1.0, 2.0, 255.0, 0.0, 255.0]
i64 16777217,-1 -> F32 F32 [2] [16777216.0, -1.0]
i64 16777217,-1 -> U32 U32 [2] [16777217.0, 4294967295.0]
i64 -1 -> U8 U8 [2] [1.0, 255.0]
两边都向零截断(-1.7 → -1),整数之间的转换都按位回绕(-1 → 4294967295,16777217 → 1)。只有浮点转 uint8 不同:-1.7 在 PyTorch 是 255,在 candle 是 0;300.0 在 PyTorch 是 44(300 − 256),在 candle 是 255。CPU 后端的 to_dtype 每一对都写成 Rust 的 as(例如 |v| v as u8),而 Rust 规定浮点到整数的 as 是饱和的、NaN 变 0,整数之间的 as 截掉高位。PyTorch 的浮点越界转换按 C++ 标准是未定义行为,上面只是这台机器上的实测,换平台可能不同;candle 的结果由语言保证。拿浮点张量做掩码或下标时,先确认值在目标类型的范围内。
半精度舍入两边逐位相同
f32 → BF16 / F16 → f32 的往返,两边都是就近舍入、偶数优先:
f32 -> f16 -> f32 torch.float32 [4] [0.333251953125, 0.0, 65504.0, inf]
f32 -> bf16 -> f32 torch.float32 [4] [0.333984375, 1.0011717677116394e-08, 65536.0, 70144.0]
f32 -> F16 -> F32 F32 [4] [0.333251953125, 0.0, 65504.0, inf]
f32 -> BF16 -> F32 F32 [4] [0.333984375, 1.0011717677116394e-8, 65536.0, 70144.0]
为了不只看四个数,cast_ref.py 生成 20018 个 f32:20000 个按 10 的 −9 到 5 次方缩放的正态数(其中 3018 个在 F16 下变成 0,659 个变成 inf),加上 18 个正好落在两个 BF16 / F16 之间的中点和次正规边界值。输入写进 JSON,candle 侧读同一份;两边把往返结果的 32 位比特当整数输出,compare.py 容差 0:
bf16 n=20018 max|diff|=0.00e+00
f16 n=20018 max|diff|=0.00e+00
worst=0.00e+00 tol=0e+00 OK
CPU 和 Metal 上都是 0。candle 的 CPU 一侧用 half crate 的 bf16::from_f32 / f16::from_f32,和 PyTorch 一样按 IEEE 的就近偶数舍入。反向验证这个比较:把参考里的 BF16 换成直接截掉低 16 位(不舍入)的结果,compare.py 报 MISMATCH,退出码 1:
bf16 n=20018 max|diff|=6.55e+04
f16 n=20018 max|diff|=0.00e+00
worst=6.55e+04 tol=0e+00 MISMATCH
(差 65536 是比特当整数比较时 BF16 的一个最低位。)所以权重从 F32 转 BF16 / F16 这一步两边不会引入差别,后面推理时的误差来自运算本身。
读回主机:类型要自己写对
a.tolist(); a.sum().item(); a.numpy().dtype
a.to_vec1::<f32>(); a.to_vec1::<f64>(); b64.to_vec1::<f32>();
a.sum_all()?.to_scalar::<f32>(); a.to_vec2::<f32>();
a.tolist() [1.0, 2.0, 3.0] a.sum().item() 6.0
a.numpy().dtype float32
a.to_vec1::<f32>() Ok([1.0, 2.0, 3.0])
a.to_vec1::<f64>() Err("unexpected dtype, expected: F64, got: F32")
b64.to_vec1::<f32>() Err("unexpected dtype, expected: F32, got: F64")
a.sum_all()?.to_scalar::<f32> Ok(6.0)
a.to_vec2::<f32>() Err("unexpected rank, expected: 2, got: 1 ([3])")
tolist() 和 item() 返回 Python 对象,dtype 和秩都不用调用方操心。candle 的 to_vec1::<T>、to_vec2、to_scalar 要调用方在类型参数里写出元素类型,在方法名里写出秩,两者都只做检查、不做转换:读 F64 张量要写 f64,或者先 to_dtype(DType::F32)。秩不对的情况(keepdim 归约、模型输出的 batch 维)见源码卷的坑 unexpected rank。
let ids = Tensor::new(&[2, 0], &dev)?;
println!("{:?}", ids.to_vec1::<i64>());
PyTorch 里 torch.tensor([2, 0]).tolist() 得到 [2, 0]。这里打印的是什么?
动脑筋答案
ids dtype: I32
ids.to_vec1::<i64>(): Err("unexpected dtype, expected: I64, got: I32")
Err。[2, 0] 的元素没有类型约束,Rust 定为 i32,张量是 I32;to_vec1::<i64> 要求 dtype 恰好是 I64,不做转换。改法是写 &[2i64, 0](或 2u32),或者读成 to_vec1::<i32>()。同一个 ids 拿去做 index_select 也会失败,报错里写的 dtype 还不是 I32,见归约与索引。
小结
- dtype 来自 Rust 类型,编译期定下:
1.0是 F64,1是 I32(PyTorch 是 float32 和 int64);写后缀1f32、2i64。 - 没有默认 dtype,
zeros/ones要显式传DType。 - 没有类型提升:任何二元张量运算 dtype 不同都返回
dtype mismatch,用to_dtype先对齐;报错里的F64多半是漏了后缀的字面量。 - 和标量运算只接受
f64,走affine;f32变量要as f64。 to_dtype用 Rust 的as:浮点转整数向零截断并饱和、NaN 变 0,和 PyTorch 在uint8上不同;整数之间回绕,两边相同。- f32 → BF16 / F16 的舍入两边逐位相同(20018 个值,CPU 和 Metal 都差 0)。
- 读回时
to_vec1::<T>的T和秩都要写对,不做转换;from_vec不检查元素个数。