整数运算
主线位置:张量主线的「计算」一步,只看整数张量:除法、和浮点标量的运算、溢出、除以 0,以及什么 dtype 能当下标。
版本:PyTorch 2.15.0(CPU、MPS)、candle 0.11.0(CPU,accelerate;Metal)
flowchart TB in["I64 张量乘 0.5"] --> pt["PyTorch"] in --> cpu["candle CPU"] in --> mtl["candle Metal"] pt --> p1["提升成 float32"] p1 --> p2["得 -3.5"] cpu --> c1["0.5 先转 i64 成 0"] c1 --> c2["得 0"] mtl --> m1["转 f32 再 fma"] m1 --> m2["截断得 -3"]
实验代码是一个 candle 程序 intops(参数选 CPU 或 Metal)和一个 PyTorch 脚本 intops.py(参数选 cpu 或 mps)。candle 侧 debug 和 release 各跑一遍,下文只在两者不同时分开写。平台 M2 Max、macOS 27、rustc 1.99.0。
除法:只有截断
四种符号组合各取一个:
a = torch.tensor([-7, -7, 7, 7])
b = torch.tensor([2, -2, 2, -2])
print(a / b, a // b, torch.div(a, b, rounding_mode="trunc"), a % b)
let a = Tensor::new(&[-7i64, -7, 7, 7], &dev)?;
let b = Tensor::new(&[2i64, -2, 2, -2], &dev)?;
let q = (&a / &b)?;
# PyTorch(CPU 和 MPS 相同)
a / b float32 [-3.5, 3.5, 3.5, -3.5]
a // b int64 [-4, 3, 3, -4]
div trunc int64 [-3, 3, 3, -3]
a % b int64 [1, -1, 1, -1]
# candle(CPU 和 Metal 相同)
a / b I64 [-3, 3, 3, -3]
两点不同:
- PyTorch 对整数张量写
/是真除法,结果是float32;candle 的/还是 I64,是整数除法。 - candle 的整数除法向零截断,对应
torch.div(rounding_mode="trunc"),不是//。
两个后端都直接用了语言自带的整数除法。CPU 上,Tensor::div 由 binary_op! 宏生成,后端按 dtype 调用 BinaryOpT 里对应的函数;这些函数由 op.rs 的 bin_op! 宏生成,Div 那一行是 bin_op!(Div, div, |v1, v2| v1 / v2),同一个闭包展开成 u8、u32、i64 等十个版本。Rust 的整数 / 向零截断。Metal 上,MetalStorage::binary_impl 把 B::KERNEL("bdiv")拼上 dtype 得到内核名 bdiv_i64,内核在 binary.metal 里是 define_binary_op(bdiv, x / y)。Metal 着色语言的整数除法同样向零截断,所以两边结果一致。减法同理:CPU 是 |v1, v2| v1 - v2,Metal 是 define_binary_op(bsub, x - y)。
candle 没有 //,也没有取余:candle-core/src 里没有给 Tensor 实现 Rem,也没有 remainder / fmod 方法。要 PyTorch 的 //,用截断的商修正一步,只用整数运算:
fn floor_div(a: &Tensor, b: &Tensor) -> Result<Tensor> {
// truncating quotient, then subtract 1 where the remainder and divisor have opposite signs
let q = (a / b)?;
let r = (a - (&q * b)?)?;
let fix = (&r * b)?.lt(0i64)?.to_dtype(DType::I64)?;
&q - &fix
}
floor_div(a, b) I64 [-4, 3, 3, -4]
r = a - q·b 是截断商对应的余数,它和除数异号(r·b < 0)时,向下取整的商比截断的商小 1。lt 返回 U8 掩码,转成 I64 再减。CPU 和 Metal 输出相同。两边各输出 floor_div 和 trunc_div 两个字段,用 compare.py 对比,容差 0:
floor_div n= 4 max|diff|=0.00e+00
trunc_div n= 4 max|diff|=0.00e+00
worst=0.00e+00 tol=0e+00 OK
整数运算没有舍入,误差正好是 0。反向验证:把 candle 侧的 floor_div 换成截断的 a / b,compare.py 报 floor_div ... max|diff|=1.00e+00、MISMATCH,退出码 1。
burn 的 Int 除法同样向零截断,但它有一个跟除数同号的取余,两者不配套,见 burn 卷的整数运算;candle 干脆没有取余。
整数张量和浮点标量
PyTorch 里 a * 0.5 会把整数张量提升成 float32。candle 不做类型提升(见创建与 dtype),结果还是 I64,但 CPU 和 Metal 算出来的数不一样:
print(a * 0.5, a * 2.5, a + 0.5)
show("a * 0.5", &a * 0.5);
show("a * 2.5", &a * 2.5);
show("a + 0.5", &a + 0.5);
# PyTorch(CPU 和 MPS 相同)
a * 0.5 float32 [-3.5, -3.5, 3.5, 3.5]
a * 2.5 float32 [-17.5, -17.5, 17.5, 17.5]
a + 0.5 float32 [-6.5, -6.5, 7.5, 7.5]
# candle CPU
a * 0.5 I64 [0, 0, 0, 0]
a * 2.5 I64 [-14, -14, 14, 14]
a + 0.5 I64 [-7, -7, 7, 7]
# candle Metal
a * 0.5 I64 [-3, -3, 3, 3]
a * 2.5 I64 [-17, -17, 17, 17]
a + 0.5 I64 [-6, -6, 7, 7]
a / 2.0 和 a * 0.5 的结果相同,两个后端都一样。CPU 上乘 0.5 得 0、乘 2.5 等于乘 2、加 0.5 什么也没做;Metal 上是先算出浮点结果再向零截断。
原因在 Tensor 和 f64 的运算符。bin_trait! 宏给 Tensor op f64 生成的实现全部转成 affine(mul, add):
impl std::ops::$trait<f64> for Tensor {
type Output = Result<Tensor>;
fn $fn1(self, rhs: f64) -> Self::Output {
self.affine($mul(rhs), $add(rhs))
}
}
// ...
bin_trait!(Add, add, |_| 1., |v| v);
bin_trait!(Sub, sub, |_| 1., |v: f64| -v);
bin_trait!(Mul, mul, |v| v, |_| 0.);
bin_trait!(Div, div, |v| 1. / v, |_| 0.);
a * 0.5 是 affine(0.5, 0.),a / 2.0 是 affine(1. / 2., 0.),a - 1.0 是 affine(1., -1.)。两个后端对 affine 的实现不同:
CPU 的
Affine先把两个系数转成张量的元素类型,再在元素类型里算:impl Map1 for Affine { fn f<T: WithDType>(&self, vs: &[T], layout: &Layout) -> Result<Vec<T>> { let mul = T::from_f64(self.0); let add = T::from_f64(self.1); Ok(unary_map(vs, layout, |v| v * mul + add)) } }i64 的
from_f64是with_dtype!生成的|v: f64| v as i64。0.5 as i64是 0,2.5 as i64是 2,于是乘 0.5 变成乘 0,加 0.5 变成加 0。Metal 的
MetalStorage::affine把系数以mul as f32、add as f32传给内核,affine_kernel在float里算完再转回去:for (uint i = tid; i < dim; i += step) { output[i] = static_cast<T>(fma(float(input[i]), mul, add)); }-7 × 0.5 = -3.5,转成整数向零截断得 -3。
Metal 这条路还有一个代价:经过 float,超过 2²⁴ 的整数会丢精度。
let big = Tensor::new(&[(1i64 << 24) + 1], &dev)?;
show("i64 2^24+1 + 1.0", &big + 1.0);
show("i64 2^24+1 + ones", &big + &big.ones_like()?);
# PyTorch:+ 1.0 提升成 float32 丢了精度,+ 1 是整数加法
i64 2^24+1 + 1.0 float32 [16777216.0]
i64 2^24+1 + 1 int64 [16777218]
# candle CPU(release)
i64 2^24+1 + 1.0 I64 [16777218]
i64 2^24+1 + ones I64 [16777218]
# candle Metal
i64 2^24+1 + 1.0 I64 [16777216]
i64 2^24+1 + ones I64 [16777218]
两个整数张量相加走的是 badd_i64,不经过浮点,结果正确。
main 上 CPU 的 Affine、with_dtype! 和 affine.metal 都没有改。
PR #3116「Fix beit on metal by adding additional affine implementations」(首次发布于 0.9.2-alpha.1,第一个正式版是 0.10.0)之前,MetalStorage::affine 的连续分支只认 F32、F16、BF16、U8、U32,I64 落进 dtype => crate::bail!("Metal contiguous affine {dtype:?} not implemented");跨步分支连 U8、U32 都没有。这个 PR 在两个分支里补上 affine_i64(跨步分支同时补上 U8、U32),在 affine.metal 里加了一行 AFFINE(affine_i64, int64_t)。当时的 AFFINE 宏已经是 output[id] = T(fma(float(input[id]), mul, add));,所以 I64 一接进来就是「转成 float、算完截断」的语义,和 CPU 上「系数先转成整数」不同。提交说明只有标题,没有讨论两个后端的差别。U8、U32 的连续版本更早,来自 PR #2376「Enable the affine kernel for u8/u32.」(首次发布于 0.7.0)。
要可移植的结果,就别让整数张量和浮点标量直接运算:想要浮点结果,先 a.to_dtype(DType::F32)? 再乘;想要整数结果,用整数张量做运算数(&a + &a.ones_like()?),或者整数除法 &a / &two。
let u = Tensor::new(&[3u32, 5], &Device::Cpu)?;
let r = (&u - 1.0)?;
r 是多少?在 Metal 上呢?
溢出:debug 下 panic,release 下回绕
u8 = torch.tensor([200, 100], dtype=torch.uint8)
print(u8 + torch.tensor([100, 0], dtype=torch.uint8), u8.sum())
print(torch.tensor([3, 5], dtype=torch.uint32) - torch.tensor([5, 3], dtype=torch.uint32))
("u32 [3,5] - [5,3]", Box::new(|| &Tensor::new(&[3u32, 5], &dev)? - &Tensor::new(&[5u32, 3], &dev)?)),
("u8 [200,100] + [100,0]", Box::new(|| &Tensor::new(&[200u8, 100], &dev)? + &Tensor::new(&[100u8, 0], &dev)?)),
("u8 sum_all", Box::new(|| Tensor::new(&[200u8, 100], &dev)?.sum_all())),
("u32 sum 2^31+2^31", Box::new(|| Tensor::new(&[1u32 << 31, 1 << 31], &dev)?.sum_all())),
("i64 MAX + 1 (tensor)", Box::new(|| &Tensor::new(&[i64::MAX], &dev)? + &Tensor::new(&[1i64], &dev)?)),
// 每一项包在 catch_unwind 里,panic 由 hook 打印
# PyTorch CPU
u32 [3,5] - [5,3] ERR NotImplementedError: "add_stub" not implemented for 'UInt32'
u8 [200,100] + [100,0] uint8 [44, 100]
u8 sum int64 [300]
u32 sum 2^31+2^31 int64 [4294967296]
# candle CPU,debug
PANIC attempt to subtract with overflow (op.rs:457)
u32 [3,5] - [5,3] (panicked, see line above)
PANIC attempt to add with overflow (op.rs:456)
u8 [200,100] + [100,0] (panicked, see line above)
PANIC attempt to add with overflow (kernels.rs:44)
u8 sum_all (panicked, see line above)
PANIC attempt to add with overflow (kernels.rs:44)
u32 sum 2^31+2^31 (panicked, see line above)
PANIC attempt to add with overflow (op.rs:456)
i64 MAX + 1 (tensor) (panicked, see line above)
# candle CPU release;Metal 的 debug 和 release 也是这几行
u32 [3,5] - [5,3] U32 [4294967294, 2]
u8 [200,100] + [100,0] U8 [44, 100]
u8 sum_all U8 [44]
u32 sum 2^31+2^31 U32 [0]
i64 MAX + 1 (tensor) I64 [-9223372036854775808]
PyTorch 的 MPS 上,uint32 的减法和求和都报 Failed to create function state object for: ...,uint8 那两行和 CPU 相同;int64 的 MAX + 1 在 CPU 和 MPS 上都回绕成 MIN。
几点:
PyTorch 的 uint32 不能做减法,candle 的 U32 可以,结果回绕。从 PyTorch 过来,没有可以对照的习惯,只能自己记住
3 - 5是 4294967294。整数求和不提升。PyTorch 对 uint8、uint32 求和返回 int64,结果正确;candle 的
sum_all保持元素类型,200 + 100回绕成 44。U8 正是比较运算(ge、eq)返回的掩码类型,对一个长度超过 255 的掩码直接sum_all来数个数,release 下会静默得到错的数。先转成更宽的类型再求和,CPU 和 Metal 上都得到 300:u8 to I64 then sum I64 [300]debug 和 release 不一样。CPU 的逐元素运算就是
bin_op!里的v1 + v2、v1 - v2,求和是vec_reduce_sum里的*res += *xs.add(i),都是普通的 Rust 整数运算。Cargo 的 dev 配置对依赖也打开溢出检查,所以 debug 构建 panic,release 构建回绕。Metal 内核没有溢出检查,两种构建都回绕。同一份代码,开发时 panic,上线后安静地出错。
除以 0 和 MIN / -1
torch.tensor([7, -7]) // torch.tensor([0, 0])
torch.div(torch.tensor([7, -7]), torch.tensor([0, 0]), rounding_mode="trunc")
let z = Tensor::new(&[0i64, 0], &dev)?;
show("i64 [7,-7] / [0,0]", &Tensor::new(&[7i64, -7], &dev)? / &z);
let zu = Tensor::new(&[0u32, 0], &dev)?;
show("u32 [7,9] / [0,0]", &Tensor::new(&[7u32, 9], &dev)? / &zu);
# PyTorch CPU
i64 [7,-7] / 0 (int) ERR RuntimeError: ZeroDivisionError
i64 [7,-7] div trunc 0 ERR RuntimeError: ZeroDivisionError
# PyTorch MPS
i64 [7,-7] / 0 (int) int64 [0, -1]
i64 [7,-7] div trunc 0 int64 [0, 0]
# candle CPU(debug 和 release 相同)
thread 'main' (8946082) panicked at .../candle-core-0.11.0/src/op.rs:459:28:
attempt to divide by zero
# candle Metal(debug 和 release 相同)
i64 [7,-7] / [0,0] I64 [0, 0]
u32 [7,9] / [0,0] U32 [4294967295, 4294967295]
(panic 行里的本地路径前缀换成了 ...。)CPU 上的 panic 来自 Rust 整数除法自己的检查,release 下也在,消息里没有张量、没有算子名,也不是 Err,? 接不住。Metal 不报错,给出 0 或全 1 的位模式。PyTorch 自己在 CPU 上报错、在 MPS 上同样静默,搬到 candle 后两种设备都要自己先处理掉 0 除数。
i64::MIN / -1:PyTorch 的 // 和 trunc 在 CPU 和 MPS 上都得到 MIN;candle Metal 也是 MIN;candle CPU 在 debug 和 release 下都 panic「attempt to divide with overflow」(同样在 op.rs:459)。
下标的 dtype
index_select、gather、embedding 要整数下标。两边接受的类型不同:
row = torch.tensor([10.0, 20.0, 30.0])
for dt in [torch.uint8, torch.int32, torch.int64, torch.int16]:
row.index_select(0, torch.tensor([2, 0], dtype=dt))
row[torch.tensor([1, 0, 1], dtype=torch.uint8)]
let ids = Tensor::new(&[2i64, 0], &dev)?;
for dt in [DType::U8, DType::U32, DType::I64, DType::I32, DType::I16, DType::F32] {
let r = ids.to_dtype(dt).and_then(|i| row.index_select(&i, 0));
show(&format!("index_select {dt:?}"), r);
}
show("index_select u8 [1,0,1]", row.index_select(&Tensor::new(&[1u8, 0, 1], &dev)?, 0));
# PyTorch(CPU 和 MPS 相同)
index_select uint8 ERR RuntimeError: index_select(): Expected dtype int32 or int64 for index
index_select int32 float32 [30.0, 10.0]
index_select int64 float32 [30.0, 10.0]
index_select int16 ERR RuntimeError: index_select(): Expected dtype int32 or int64 for index
row[uint8 [1,0,1]] float32 [10.0, 30.0]
# candle CPU
index_select U8 F32 [30, 10]
index_select U32 F32 [30, 10]
index_select I64 F32 [30, 10]
index_select I32 ERR unsupported dtype F32 for op index-select
index_select I16 ERR unsupported dtype F32 for op index-select
index_select F32 ERR unsupported dtype F32 for op index-select
index_select u8 [1,0,1] F32 [20, 10, 20]
# candle Metal(前三行和最后一行同 CPU)
index_select I32 ERR Metal contiguous to_dtype I64 I32 not implemented
index_select I16 ERR Metal contiguous to_dtype I64 I16 not implemented
index_select F32 ERR Metal contiguous index_select F32 F32 not implemented
(PyTorch 对 uint8 下标还打印一条 UserWarning: indexing with dtype torch.uint8 is now deprecated。)
PyTorch 的下标是 int32 / int64;candle 是 U8、U32、I64,I32 不行。
PyTorch 里
row[uint8 张量]是布尔掩码(选出第 0、2 个);candle 的 U8 下标就是下标,[1, 0, 1]取出第 1、0、1 个元素。从 PyTorch 搬掩码选择时别直接换成index_select。CPU 的报错说「unsupported dtype F32」,但 F32 是被索引的
row的类型,不是下标的类型。CpuStorage::index_select按下标的变体分派,落进_分支时报的是self.dtype():读到这条报错,要去查的是下标的 dtype。match ids { Self::U8(ids) => IndexSelect { ids, ids_l, dim }.map(self, l), Self::U32(ids) => IndexSelect { ids, ids_l, dim }.map(self, l), Self::I64(ids) => IndexSelect { ids, ids_l, dim }.map(self, l), _ => Err(Error::UnsupportedDTypeForOp(self.dtype(), "index-select").bt()), }Metal 上连 I32 都造不出来:
cast.metal只给 f32、f16、bf16、i64、u32、u8 生成转换内核。
argmax 返回 U32(见归约与索引),可以直接当下标用,但不能和 I64 张量做运算:
argmax_keepdim U32 [2]
argmax + i64 ERR dtype mismatch in add, lhs: U32, rhs: I64
浮点转整数两边都向零截断:[-1.5, 2.7] 转 int64 / I64 都是 [-1, 2];candle 转 U32 时负数变成 0([0, 2]),CPU 和 Metal 相同。
动脑筋答案
# candle CPU
u32 [3,5] - 1.0 U32 [3, 5]
# candle Metal
u32 [3,5] - 1.0 U32 [2, 4]
CPU 上 r 还是 [3, 5],减法什么也没做。&u - 1.0 是 affine(1., -1.),CPU 的 Affine 先算 u32::from_f64(-1.0),即 -1.0 as u32;Rust 的浮点转整数是饱和转换,负数变成 0,于是变成「乘 1 加 0」。&u + (-1.0) 同样得到 [3, 5],&u - 4.0 也是。Metal 在 float 里算 3 - 1、5 - 1,得到 [2, 4];&u - 4.0 在 Metal 上得 [0, 1](3 - 4 = -1.0 转 U32 成了 0)。PyTorch 的 u - 1.0 提升成 float32,得 [2.0, 4.0]。
小结
- candle 的整数
/向零截断,结果还是整数,CPU 和 Metal 一致;没有//和%,向下取整的除法用q - (r·b < 0)自己凑,和 PyTorch 误差为 0。 - 整数张量和浮点标量运算走
affine:CPU 先把系数转成整数(a * 0.5得 0,u32 - 1.0不变),Metal 经 f32 计算再截断(a * 0.5得 -3,超过 2²⁴ 丢精度)。要么先转浮点,要么用整数张量做运算数。 - 整数溢出在 CPU debug 下 panic,在 CPU release 和 Metal 上回绕;U8 / U32 求和不提升,数掩码之前先转 U32 或 I64。
- 整数除以 0:CPU panic(不是
Err),Metal 静默给出 0 或0xFFFFFFFF。 - 下标只收 U8、U32、I64;U8 是下标不是掩码;CPU 下标类型不对时报错里写的是被索引张量的 dtype。