Rust in Everything
基于 burn 0.22.1

整数运算

主线位置:张量主线的「计算」一步,只看 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 的文档只有一个浮点例子。截断是各后端直接用了目标语言的整数 /:

后端除法取余
Flexint_div:闭包里是 a / b,在 i64 里算remainder_int
ndarrayNdArrayMathOps::div:lhs / rhsremainder
cubecl(Metal 等)DivOp:lhs / rhsRemainderOp:Vector::mod_floor
tchint_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」)。

📝NOTE
冷知识:Flex 的取余原来是 ((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。

📝NOTE
没有蠢问题

问: 为什么不写成 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。

同样的溢出,在不同后端、不同构建下结局不同:

运算FlexndarrayMetal
I32 max + 1回绕debug 下 panic「attempt to add with overflow」,release 回绕回绕
I32 sum回绕release 回绕(debug 下没走到,前一行已 panic)回绕
I32 cumsumdebug 下 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 这样的值。

💬 关联讨论

发起讨论
加载中...