整数运算
主线位置:张量主线的「计算」一步,只看
Int张量:除法、取余、加法和求和在什么输入下两边给出不同的数。
版本:PyTorch 2.15.0(CPU)、burn 0.22.1(Flex、ndarray、Metal 三个设备)
flowchart TB in["a = -7, b = 2"] --> pf["PyTorch 地板除"] in --> pt["PyTorch trunc 和 fmod"] in --> bn["burn div 和 remainder"] pf --> r1["-4 余 1 配套"] pt --> r2["-3 余 -1 配套"] bn --> r3["-3 余 1 不配套"]
实验 crate tensorb-intops(burn =0.22.1,feature std、flex、ndarray、metal),PyTorch 侧是 intops.py。debug 和 release 各跑一遍,下文只在两者不同时分开写。平台 M2 Max、macOS 27、rustc 1.99.0。
除法和取余
四种符号组合各取一个:
a = torch.tensor([-7, -7, 7, 7])
b = torch.tensor([2, -2, 2, -2])
fl, md = a // b, a % b
tr, fm = torch.div(a, b, rounding_mode="trunc"), torch.fmod(a, b)
print("a / b ", vals(a / b), (a / b).dtype)
print("floor pair ok", bool(((fl * b + md) == a).all()))
print("trunc pair ok", bool(((tr * b + fm) == a).all()))
let a = Tensor::<1, Int>::from_ints([-7, -7, 7, 7], device);
let b = Tensor::<1, Int>::from_ints([2, -2, 2, -2], device);
let q = a.clone() / b.clone();
let r = a.clone() % b.clone();
let back = q.clone() * b.clone() + r.clone();
# PyTorch
a // b [-4, 3, 3, -4] torch.int64
a % b [1, -1, 1, -1]
div trunc [-3, 3, 3, -3]
fmod [-1, -1, 1, 1]
a / b [-3.5, 3.5, 3.5, -3.5] torch.float32
floor pair ok True
trunc pair ok True
# burn(Flex;ndarray 和 Metal 逐行相同,只有 dtype 一行不同)
flex dtype I32
flex a / b [-3, 3, 3, -3]
flex a % b [1, -1, 1, -1]
flex a / 2, a % 2 [-3, -3, 3, 3] [1, 1, 1, 1]
flex q*b + r [-5, -7, 7, 5] (a = [-7, -7, 7, 7])
ndarray dtype I64
metal dtype I32
三点不同:
- PyTorch 对整数张量写
/是真除法,结果是float32;burn 的/对Int张量是整数除法,结果还是Int。 - burn 的整数除法向零截断(
-7 / 2 = -3),对应torch.div(rounding_mode="trunc"),不是//。 - burn 的取余跟除数同号(
-7 % 2 = 1),和 PyTorch 的%一样,不是torch.fmod。
PyTorch 的两套是配套的:// 配 %,trunc 配 fmod,都满足 q·b + r = a。burn 把截断的商和跟除数同号的余数放在一起,商和余数异号的两个位置上 q·b + r 差了一个 b:-7 算回来是 -5,7 ÷ -2 算回来也是 5。同号的两个位置(-7 ÷ -2、7 ÷ 2)没问题,所以只用非负数测的代码看不出来。
出处:四个后端同一种写法
后端 trait 里,int_remainder 的文档写明是「Element-wise floor modulo」,「A nonzero result has the same sign as the divisor」;int_div 只写「Element-wise division」,没说怎么取整。用户层的 Tensor::remainder 同样写着「For integer tensors, this is floor modulo」;Tensor::div 的文档只有一个浮点例子。截断是各后端直接用了目标语言的整数 /:
| 后端 | 除法 | 取余 |
|---|---|---|
| Flex | int_div:闭包里是 a / b,在 i64 里算 | remainder_int |
| ndarray | NdArrayMathOps::div:lhs / rhs | remainder |
| cubecl(Metal 等) | DivOp:lhs / rhs | RemainderOp:Vector::mod_floor |
| tch | int_div:转成浮点除,再转回整数 | 同样经浮点,调 libtorch 的 remainder |
Rust 的整数 / 和 % 都向零截断。三个能跑的后端取余时都先算截断的 %,再在余数和除数异号时加上除数。Flex 的版本:
/// Python/PyTorch-style remainder: result has same sign as divisor.
#[inline]
fn remainder_int(a: i64, b: i64) -> i64 {
let r = a.wrapping_rem(b);
if r != 0 && (r < 0) != (b < 0) {
r.wrapping_add(b)
} else {
r
}
}
cubecl 在 signed_mod_floor 里做同样的修正(remainder + select_many(adjust, rhs, zero))。取余照着 PyTorch 的 % 修了符号,除法没有对应地改成向下取整,两者就不配套了。tch 后端要下载 libtorch,本机离线编不了,上表那一行只来自源码:浮点的真除法转回整数时向零截断,结果和另外三个一致,但 i64 超过 2⁵³ 的值经 f64 会丢精度。burn 自带的 cpu(cubecl-cpu)后端构建时要下载 LLVM,同样没跑。
main 上这几处没有改:git diff 看 Flex 的 int.rs 只删了一个移位测试,cubecl 的 binary.rs 和 numeric.rs 没有变化;ndarray 后端整个被删掉了(#5999「remove deprecated ndarray backend」)。
((a % b) + b) % b#5652「fix(flex): handle remainder edge cases」(首次发布于 v0.22.0-pre.4)之前,Flex 的整数取余是 int_binary_op(lhs, rhs, |a, b| ((a % b) + b) % b):先截断取余,加上除数,再取一次余,结果同样跟除数同号。问题在中间那一步:(i64::MAX - 1) % i64::MAX 加上 i64::MAX 就溢出了。提交说明写的是改用「PyTorch's single-modulo remainder」,让「signed integer overflow no longer panics」,同时把 ndarray 改成同样的写法。上面那个 remainder_int 就是这次加的,wrapping_rem 也让 MIN % -1 不再 panic(结果是 0)。后来的 #5909(首次发布于 v0.22.0)把后端 trait 里 int_remainder 的文档从「Element-wise modulus」改成现在的「Element-wise floor modulo」,并写明除数为 0 和 MIN % -1 不在可移植的范围内。
改法:要哪一套就自己配
需要 PyTorch 的 //,用 burn 的取余把商凑出来;需要 fmod,用截断的商把余数凑出来。两个都只用整数运算:
// floor division and truncating remainder, built from the two ops
let floor_div = (a.clone() - r.clone()) / b.clone();
let fmod = a.clone() - q.clone() * b.clone();
flex (a - a%b)/b [-4, 3, 3, -4]
flex a - (a/b)*b [-1, -1, 1, 1]
a - a % b 一定是 b 的整数倍,所以这里的截断除法是精确的,得到的就是向下取整的商。ndarray、Metal 上的输出相同。两边各自输出 floor_div、remainder、trunc_div、fmod 四个字段,用 compare.py 对比,容差 0:
floor_div n= 4 max|diff|=0.00e+00
remainder n= 4 max|diff|=0.00e+00
trunc_div n= 4 max|diff|=0.00e+00
fmod n= 4 max|diff|=0.00e+00
worst=0.00e+00 tol=0e+00 OK
整数运算没有舍入,误差应当正好是 0。反向验证:把 burn 侧的 floor_div 换成直接的 a / b,compare.py 报 floor_div ... max|diff|=1.00e+00、MISMATCH,退出码 1。
问: 为什么不写成 a.float().div(b.float()).floor().int()?
答: 走 f32 时,绝对值超过 2²⁴ 的整数没法精确表示,商可能差 1。Flex 的 int_into_float 上面也有一行注释提醒这件事:「Precision limits: i64/u64 > 2^24 for f32/f16/bf16, > 2^53 for f64」。上面的两行公式只用整数运算,没有这个问题。
溢出:I32 默默回绕
Flex 和 Metal 上 Int 默认是 I32,PyTorch 默认是 int64(原因见 burn 卷同一段代码,Int 在不同设备上位宽不同)。这里把两边都固定成 32 位,看越界时的结果:
x32 = torch.tensor([2**31 - 1], dtype=torch.int32)
print("int32 max + 1 ", vals(x32 + 1), (x32 + 1).dtype)
s = torch.tensor([2**30, 2**30], dtype=torch.int32)
print("int32 sum ", vals(s.sum()), s.sum().dtype)
print("int32 cumsum ", vals(s.cumsum(0)), s.cumsum(0).dtype)
let i32_opt = (device, DType::I32);
let x = Tensor::<1, Int>::from_data(TensorData::new(vec![i32::MAX], [1]), i32_opt);
let y = x + 1;
let s = Tensor::<1, Int>::from_data(TensorData::new(vec![1i32 << 30, 1 << 30], [2]), i32_opt);
let total = s.clone().sum();
let wide = s.cast(burn::tensor::IntDType::I64).sum();
// cumsum 单独一个进程跑
# PyTorch
int32 max + 1 [-2147483648] torch.int32
int32 sum 2147483648 torch.int64
int32 sum(dim=0) 2147483648 torch.int64
int32 cumsum [1073741824, 2147483648] torch.int64
# burn(Flex,debug 和 release 相同;Metal 相同)
flex i32 max + 1 I32 [-2147483648]
flex i32 sum I32 [-2147483648]
flex i32 sum_dim(0) [-2147483648]
flex cast I64 then sum I64 [2147483648]
逐元素加法两边一样回绕。求和不一样:PyTorch 的 sum、cumsum 对 int32 输入返回 int64,结果是对的;burn 的 sum 保持 I32,2³⁰ + 2³⁰ 回绕成 -2³¹,不报错。Flex 的 widening_scalar_reduce 其实在 i64 里累加,最后一步才截回元素类型,注释写着「Truncate back to target type (wrapping, matches PyTorch)」。实测 PyTorch 并不截回,它把输出类型提升到了 int64。
同样的溢出,在不同后端、不同构建下结局不同:
| 运算 | Flex | ndarray | Metal |
|---|---|---|---|
I32 max + 1 | 回绕 | debug 下 panic「attempt to add with overflow」,release 回绕 | 回绕 |
I32 sum | 回绕 | release 回绕(debug 下没走到,前一行已 panic) | 回绕 |
I32 cumsum | debug 下 panic「attempt to add with overflow」,release 回绕 | debug 下 panic,release 回绕 | 回绕 |
Flex 的 cumsum 用的是普通的 acc + val(cumsum),所以跟着 Rust 的溢出检查走:debug panic,release 回绕。同一个后端的 add 和 sum 却在两种构建下都回绕。release 下没有任何提示。要可靠的结果,在可能超过 2³¹ 的地方先 cast(IntDType::I64)(上面最后一行),或者建张量时就指定 DType::I64。
除数为 0
torch.tensor([7]) // 0
torch.tensor([7]) % 0
torch.div(torch.tensor([7]), 0, rounding_mode="trunc")
let a = Tensor::<1, Int>::from_ints([7, -7], &device);
let z = Tensor::<1, Int>::from_ints([0, 0], &device);
println!("{} {:?} a / 0: {:?}", args[2], a.dtype(), vals(a / z));
// 另一次运行:a % z
# PyTorch
// 0 RuntimeError: ZeroDivisionError
% 0 RuntimeError: ZeroDivisionError
div trunc 0 RuntimeError: ZeroDivisionError
# burn,Flex(debug 和 release 相同)
thread 'main' (8544227) panicked at .../burn-flex-0.22.1/src/ops/int.rs:740:40:
attempt to divide by zero
# burn,ndarray(debug 和 release 相同)
thread 'main' (8544273) panicked at .../library/core/src/ops/function.rs:79:5:
attempt to divide by zero
# burn,Metal(debug 和 release 相同)
metal I32 a / 0: [-1, 1]
metal I32 a % 0: [7, -7]
(panic 行里的本地路径前缀换成了 ...。)取余除以 0 时,Flex 和 ndarray 同样 panic,消息是「attempt to calculate the remainder with a divisor of zero」。
两个 CPU 后端的 panic 来自 Rust 整数除法自己的检查,release 下也在,但报错里没有张量、没有算子名。Metal 不报错,7 / 0 得到 -1,7 % 0 得到 7。这是文档允许的:remainder 的「Integer portability」一节写着除数为 0 时「no particular result or error behavior is guaranteed across backends」。PyTorch 在 CPU 上会报错,搬过来以后要自己先用 mask_fill 或 equal_elem(0) 处理掉 0 除数。
MIN / -1 的结果也不可移植:PyTorch 两种位宽都得到 MIN;burn 的 Metal 和 Flex 的 I32 也得到 MIN;Flex 的 I64 在 debug 和 release 下都 panic「attempt to divide with overflow」,ndarray 的 I32 和 I64 也一样。MIN % -1 在所有组合下都是 0。
小结
- burn 的
Int除法向零截断(对应torch.div(rounding_mode="trunc")),取余跟除数同号(对应%);两者不配套,商和余数异号时q·b + r ≠ a。Flex、ndarray、Metal 实测一致,main上没改。 - PyTorch 对整数写
/得到浮点,burn 得到截断的整数。 - 要
//写(a - a % b) / b,要fmod写a - (a / b) * b,都是精确的整数运算,和 PyTorch 对比误差为 0。 - I32 的
sum在 burn 里回绕,PyTorch 会提升到 int64;有的溢出在 debug 下 panic、release 下回绕,因后端和算子而异。先转 I64。 - 除数为 0:PyTorch 报错,Flex / ndarray panic,Metal 静默给出
-1、7这样的值。