Rust in Everything
基于 tenferro-runtime 0.8.0

整数运算

主线位置:张量主线的第二步「计算」,只看整数张量:除法和取余、除以 0、溢出、和浮点系数相乘、矩阵乘,以及整数当下标时越界会怎样。
版本:JAX 0.12.0.1(CPU,x64)、NumPy 2.5.4、tenferro-rs 0.8.0(CPU,native)
flowchart TB
  q["-7 除以 2"] --> fl["jnp //"]
  q --> tdiv["jnp /"]
  q --> ld["lax.div"]
  q --> tf["tenferro /"]
  fl --> m4["-4"]
  tdiv --> m35["-3.5 浮点"]
  ld --> m3["-3"]
  tf --> m3
  z["除以 0"] --> jz["JAX 给 -1 或 -2"]
  z --> tz["tenferro 返回 Err"]

实验代码:一个 JAX / NumPy 脚本、一个用 checkify 的 JAX 脚本,一个 tenferro 程序 integers(CpuEngine::serial();release 和 debug 各跑一遍,输出除了标题行完全相同)。traced 的结果是建图、编译、准备、执行之后读回的;「direct」是 Tensor 在 engine 上的直接运算(TensorSessionOpsExt);「eager」是 EagerRuntime::with_eager_session 里的运算。平台 M2 Max、macOS 27、rustc 1.99.0。

除法和取余:截断,不是向下取整

四种符号组合各取一个:

a = jnp.array([-7, -7, 7, 7]); b = jnp.array([2, -2, 2, -2])
print(a / b, a // b, a % b, lax.div(a, b), lax.rem(a, b))
let (ta, tb) = (tr(ti64(&a)?)?, tr(ti64(&b)?)?);   // I64 TracedTensor
let q = (&ta / &tb)?;
let r = ta.rem(&tb)?;
ti64(&a)?.div(&ti64(&b)?, &mut eng);                // direct
ert.with_eager_session(&mut eng, |s| s.div(&ea, &eb)); // eager
# JAX
  jnp a / b                  float64 [-3.5, 3.5, 3.5, -3.5]
  jnp a // b                 [-4, 3, 3, -4]
  jnp a % b                  [1, -1, 1, -1]
  lax.div(a, b)              [-3, 3, 3, -3]
  lax.rem(a, b)              [-1, -1, 1, 1]
...
  numpy a // b, a % b        [-4, 3, 3, -4] [1, -1, 1, -1]
# tenferro
traced a / b: (I64, [-3, 3, 3, -3])
traced a.rem(b): [-1, -1, 1, 1]
direct a.div(b): [-3, 3, 3, -3]
direct a.rem(b): [-1, -1, 1, 1]
eager s.div(a, b): [-3, 3, 3, -3]
  • jnp 的 / 是真除法,整数进、浮点出;tenferro 的 / 是整数除法,I64 进、I64 出。
  • tenferro 的商向零截断、余数和被除数同号,对应 lax.div 和 lax.rem,不对应 // 和 %(NumPy 的 // 向下取整、% 和除数同号)。traced、direct、eager 三个入口一样。
  • 要 JAX 的真除法,先把两边 cast(DType::F64) 再除。

tenferro 没有 //。用截断的商修一步,只用整数运算:

fn floor_div(a: &TracedTensor, b: &TracedTensor) -> R<TracedTensor> {
  let q = (a / b)?;
  let r = (a - &(&q * b)?)?;
  let zero = tr(ti64(&vec![0; a.concrete_shape()?[0]])?)?;
  let fix = (&r * b)?.compare(&zero, CompareDir::Lt)?.cast(DType::I64)?;
  Ok((&q - &fix)?)
}
floor_div(a, b): [-4, 3, 3, -4]

r 是截断商对应的余数,它和除数异号(r·b < 0)时,向下取整的商比截断的商小 1。JAX 自己的 floor_divide 也是这样从 lax.div 修出来的(下一节)。

📝NOTE
冷知识:tenferro 的整数除法一开始是真除法

#811 对应的提交 66d68de6(2026-05-05,「Add dtype promotion for mixed-dtype arithmetic (#811)」,首次进入 v0.1.0)建立了 dtype 提升格,提交说明写着「promotion lattice: I64 < F32 < F64 < C32 < C64」。同一个提交给除法单独加了一条规则 promote_dtype_div_like,文档注释是「Like [promote_dtype], but for division-like ops where I64 / I64 should produce F64 to avoid integer truncation」,Div 和 Pow 都走它:I64 除 I64 在图里的输出 dtype 是 F64,和 jnp 的 / 一样。两个月后的提交 e8eb9218(2026-07-08,「feat: complete integer ops and output APIs」,首次进入 v0.3.0)把 StdTensorOp::Div | StdTensorOp::Pow => promote_dtype_div_like 改成只剩 Pow,把 Div、Rem 加进保持输入 dtype 的那一组,同时在 CPU 上加了 wrapping_div_elem 和遇到 0 除数就报错的检查,成了今天的截断除法。旧函数没有删:0.8.0 的 shape_infer.rs 文档里还写着「I64 + I64 → I64 (integer-preserving; Div/Pow use [promote_dtype_div_like])」,但 promote_dtype_for_binary_op 对 Div 已经不调用它了,这行注释和行为不符。

除以 0:JAX 静默给数,tenferro 报错

z = jnp.zeros(4, dtype=jnp.int64)
print(a // z, a % z, lax.div(a, z), lax.rem(a, z), jax.jit(lax.div)(a, z), a / z)
let tz = tr(ti64(&[0; 4])?)?;
eval(&(&ta / &tz)?);
eval(&ta.rem(&tz)?);
eval(&(&tr(ti64(&[1, 2, 3, 4])?)? / &tr(ti64(&[1, 1, 0, 1])?)?)?);
# JAX
  jnp a // 0                 [-2, -2, -2, -2]
  jnp a % 0                  [0, 0, 0, 0]
  lax.div(a, 0)              [-1, -1, -1, -1]
  lax.rem(a, 0)              [-7, -7, 7, 7]
  jit(lax.div)(a, 0)         [-1, -1, -1, -1]
  jnp a / 0 (true div)       [-inf, -inf, inf, inf]
  numpy a // 0               [0, 0, 0, 0] warnings: ['divide by zero encountered in floor_divide']
# tenferro
traced a / 0: Err(div: extension cpu failed: div detected division by zero for dtype I64)
traced a.rem(0): Err(rem: extension cpu failed: rem detected division by zero for dtype I64)
direct a.div(0): Err(div: extension cpu failed: div detected division by zero for dtype I64)
eager s.div(a, 0): Err(div: extension cpu failed: div detected division by zero for dtype I64)
traced [1, 2, 3, 4] / [1, 1, 0, 1]: Err(div: extension cpu failed: div detected division by zero for dtype I64)
traced F64 a / 0.0: [-inf, -inf, inf, inf]
  • JAX 的 lax.div(x, 0) 给 −1,lax.rem(x, 0) 给 x,jit 前后一样。// 给 −2:JAX 0.12.0.1 的 floor_divide 对整数先算 quotient = lax.div(x1, x2),再在 lax.sign(x1) != lax.sign(x2) 且余数不为 0 时减 1;除数为 0 时符号不同、余数是 x1,于是 −1 再减 1。NumPy 给 0,但发一条警告。
  • tenferro 三个入口都返回错误。整数除法在 CPU 上经 integer_division_with_pool,strided-kernel 在逐元素计算时发现 0 除数,strided_error 把它映射成 cpu_division_by_zero。traced 是在执行时报的:TracedTensor::div 的文档写着「For integer inputs, a zero divisor is reported during execution」。
  • 只要有一个除数是 0,整个运算失败,没有部分结果。要 JAX 那样的「给个数继续」,自己先用 where_select 把 0 换掉。
  • 浮点除以 0 两边都按 IEEE 给 ±inf。

JAX 也能报错,但要显式要求:

err, out = checkify.checkify(lambda a, z: a // z, errors=checkify.div_checks)(a, z)
print(err.get(), out)
checkify div_checks a // 0: division by zero | value [-2, -2]

checkify 把检查编进函数,值照样算出来,错误放在另一个返回值里,要调用方自己查。

溢出:两边都回绕

# JAX(int32)
  MAX + 1                    [-2147483648]
  MAX * 2                    [-2]
  -MIN                       [-2147483648]
  abs(MIN)                   [-2147483648]
  lax.div(MIN, -1)           [-2147483648]
...
  lax.rem(MIN, -1)           [0]
  numpy array MAX + 1        [-2147483648] warnings: 0
# tenferro(I32,traced;debug 构建输出相同)
MAX + 1: [-2147483648]
MAX * 2: [-2]
-MIN: [-2147483648]
abs(MIN): [-2147483648]
MIN / -1: [-2147483648]
MIN rem -1: [0]
direct MAX + 1: [-2147483648]

七项逐个相同,都是二进制补码回绕。tenferro 的 debug 构建也一样回绕、不 panic,和 Rust 自己的 + 在 debug 下溢出就 panic 不同:上面冷知识里那个提交加的 CPU 整数内核就是按 wrapping_add、wrapping_div 这类运算写的。MIN / −1 在 Rust 的 / 下无论 debug 还是 release 都会 panic,在这里两边都给 MIN。

整数乘浮点系数

JAX 里整数数组乘 Python 浮点数,结果提升成浮点(开 x64 时是 float64)。tenferro 里乘一个 F64 张量也提升:

i = jnp.array([-7, 7])
print(i * 0.5, i * 2.5)
let i = tr(ti64(&[-7, 7])?)?;
let half = TracedTensor::from_vec_col_major(vec![2], vec![0.5_f64, 0.5])?;
let p = (&i * &half)?;
# JAX
  int64 * 0.5                float64 [-3.5, 3.5]
...
  int64 * 2.5                float64 [-17.5, 17.5]
# tenferro
I64 * F64 tensor 0.5: (F64, [-3.5, 3.5])

dtype 一页讲过,JAX 的 Python 标量在 tenferro 里对应 scale_real:系数按张量的 dtype 落成常量,dtype 不变。对 F32 这正是想要的;对整数张量,「按 dtype 落成常量」意味着系数先变成整数。

❗IMPORTANT
动脑筋:i = [-7, 7](I64),i.scale_real(0.5)、i.scale_real(0.4)、i.scale_real(2.5) 各得到什么?三个都不报错,结果都是 I64。先想 0.5 变成哪个整数。

整数矩阵乘

# JAX
  jnp int64 a @ b            int64 [[23, 31], [34, 46]]
  [[2^53+1]] @ [[1]]         [[9007199254740993]]
# tenferro
direct I64 matmul: Err(ContractionScalar::one: dtype mismatch: expected I64, actual F32)
traced I64 matmul: Err(ContractionScalar::one: dtype mismatch: expected I64, actual F32)

JAX 的整数矩阵乘是精确的整数运算,2⁵³ + 1 也不丢。tenferro 的 CPU 收缩只支持 F32 / F64 / C32 / C64,整数矩阵乘和 einsum 收缩都报这条把 expected 和 actual 写反的错误。原因和修法(转 F64 再 cast 回来,注意 2⁵³ 的上限)见常见坑 ContractionScalar::one: dtype mismatch。

整数当下标:越界不报错,但规则不同

JAX 的 x[i] 底层是 StableHLO 的 gather,tenferro 的 gather、dynamic_slice、take_along_axis 也是。StableHLO 规定越界的起点被夹到范围内,两边都不报错;差别在负下标和夹取之前的那一步。x = [10, 11, 12]:

x = jnp.array([10.0, 11.0, 12.0])
x[5]; x[jnp.array(-1)]; x[jnp.array(-4)]; jax.jit(lambda i: x[i])(5)
jnp.take(x, 5); lax.dynamic_slice(x, (5,), (1,)); lax.dynamic_slice(x, (-1,), (1,))
x.at[5].set(99.); x.at[jnp.array([1, 1])].set(99.); x.at[jnp.array([1, 1])].add(99.)
let gcfg = || GatherConfig {
  offset_dims: vec![],
  collapsed_slice_dims: vec![0],
  start_index_map: vec![0],
  index_vector_dim: 1,
  slice_sizes: vec![1],
};
let idx = || Tensor::from_vec_col_major(vec![4, 1], vec![1_i64, 5, -1, -4]);
eval(&tr(x()?)?.gather(&tr(idx()?)?, gcfg())?);
x()?.dynamic_slice(&starts, &[1], &mut eng);
x()?.scatter(&si, &su, scfg.clone(), &mut eng);   // indices [n, 1], updates 99
# NumPy
  numpy x[5]                 IndexError: index 5 is out of bounds for axis 0 with size 3
  numpy x[-1]                12.0
...
# JAX
  jnp x[5] (python int)      12.0
  jnp x[jnp.array(5)]        12.0
  jnp x[jnp.array(-1)]       12.0
  jnp x[jnp.array(-4)]       10.0
  jit(lambda i: x[i])(5)     12.0
...
  jnp.take(x, 5)             nan
  jnp.take(x, 5, mode='clip') 12.0
...
  x.at[5].get(mode='fill')   nan
  lax.dynamic_slice(x,(5,),(1,)) [12.0]
  lax.dynamic_slice(x,(-1,),(1,)) [12.0]
  x.at[5].set(99.)           [10.0, 11.0, 12.0]
  x.at[5].add(99.)           [10.0, 11.0, 12.0]
  x.at[jnp.array([1, 1])].set(99.) [10.0, 99.0, 12.0]
  x.at[jnp.array([1, 1])].add(99.) [10.0, 209.0, 12.0]
  x.at[-1].add(99.)          [10.0, 11.0, 111.0]
# tenferro
traced gather x[[1, 5, -1, -4]]: [11.0, 12.0, 10.0, 10.0]
direct gather x[[1, 5, -1, -4]]: [11.0, 12.0, 10.0, 10.0]
traced dynamic_slice(x, [5], [1]): [12.0]
...
traced dynamic_slice(x, [-1], [1]): [10.0]
...
direct scatter idx [1] upd 99: [10.0, 110.0, 12.0]
direct scatter idx [1, 1] upd 99: [10.0, 209.0, 12.0]
direct scatter idx [5] upd 99: [10.0, 11.0, 111.0]
direct scatter idx [-1] upd 99: [109.0, 11.0, 12.0]

逐条看:

  • 正的越界下标:两边都夹到最后一个。JAX 的 x[5] 连 Python 整数都不报错(NumPy 报 IndexError),jit 前后一样。
  • 负下标:JAX 先按 NumPy 的习惯换算(−1 → 2,−4 → −1),再夹取,所以 x[-1] 是 12、x[-4] 是 10。tenferro 不换算,直接夹:−1 和 −4 都变成 0,取到 10。dynamic_slice 同理:JAX 的 lax.dynamic_slice(x, (-1,), (1,)) 取最后一个,tenferro 取第一个。从 JAX 搬过来的负下标,在 tenferro 上会静默取到另一端。
  • take 系列:jnp.take 和 jnp.take_along_axis 默认模式是填 NaN,不是夹取;tenferro 的 take_along_axis 夹取(下面)。
  • scatter:JAX 的 .at[i].set / .add 默认丢弃越界的更新。tenferro 的 scatter 有两点不同:它是累加(下标 1 上加 99 得 110,重复下标 [1, 1] 加两次得 209,和 JAX 的 .add 一样,不是 .set),越界的更新被夹到边上加进去(下标 5 加到最后一个、−1 加到第一个)。规格 primitive-catalog.md 把 Scatter 写成「Write or accumulate values」、「Transpose of Gather」,它首先是给 gather 求导用的;TracedTensor::scatter 的文档写明「an out-of-range or negative index is clamped rather than rejected」。要 .at[i].set 的语义,不能直接拿 scatter 换。

take_along_axis 用同一个 3×2 的例子(沿第 0 轴,下标 [[5, -1]]):

# NumPy / JAX
  numpy take_along_axis      IndexError: index 5 is out of bounds for axis 0 with size 3
  jnp take_along_axis        [[nan, 22.0]]
  jnp ... mode='clip'        [[12.0, 22.0]]
  jit(jnp.take_along_axis)   [[nan, 22.0]]
...
# tenferro
direct take_along_axis: [12.0, 20.0]
traced take_along_axis: [12.0, 20.0]

JAX 默认把 5 填成 NaN、把 −1 换算成最后一行(22);mode="clip" 只改了越界的那个。tenferro 的结果和合法下标 [[2, 0]] 完全相同。夹取的实现、它在 v0.8.0 才被定为约定的经过,见常见坑越界下标不报错,取到边上的值。

还有一处文档和行为不一致:直接运算的 TensorSessionOpsExt::gather 的文档写着越界下标返回 Validation(「for an invalid configuration, index dtype or out-of-range index」),实测 direct gather 和 traced 一样夹取、返回 Ok。

JAX 有显式的越界检查,tenferro 没有,要在调用前自己查:

err, out = jax.jit(checkify.checkify(lambda i: x[i], errors=checkify.index_checks))(jnp.array(5))
jit(checkify index_checks) x[5]: out-of-bounds indexing for array of shape (3,): index 5 is out of bounds for axis 0 with size 3.  | value 12.0
checkify index_checks x[-1]: None | value 12.0

动脑筋答案

I64 .scale_real(0.5): [-7, 7]
I64 .scale_real(0.4): [0, 0]
I64 .scale_real(2.5): [-21, 21]
I64 .scale_real(-2.5): [21, -21]
I64 .scale_real(1.5): [-14, 14]
direct I64 scale_real(0.5): [-7, 7]
  • 系数先按 f64::round 舍入成整数再乘。round 把 .5 舍向远离 0 的一侧:0.5 → 1,所以「乘 0.5」原样返回;0.4 → 0,全变成 0;2.5 → 3,得 −21 和 21;−2.5 → −3;1.5 → 2。
  • 实现在 round_real_to_i64_for_op:finite_real_scalar(op, value)?.round(),只拒绝非有限值和超出 i64 的值,不拒绝非整数。direct 入口经另一份同样的实现,结果相同。
  • JAX 的 i * 0.5 是 [-3.5, 3.5]。从 JAX 搬过来的「整数乘一个系数」要么先 cast(DType::F64),要么乘一个 F64 张量,不能换成 scale_real。

小结

  • tenferro 的整数 / 和 rem 向零截断,等于 lax.div / lax.rem;jnp 的 / 是真除法、// 和 % 向下取整。// 用截断商加一次符号修正得到。
  • 整数除以 0:tenferro 的 traced、direct、eager 都返回 division by zero 错误;JAX 静默给 −1(lax.div)、−2(//)、0(%),要错误得用 checkify。
  • 溢出两边都按补码回绕,tenferro 的 debug 构建也一样。
  • 整数乘浮点系数:乘 F64 张量会提升成 F64,和 JAX 一样;scale_real 先把系数舍入成整数,0.5 等于什么都没做。
  • 整数矩阵乘:JAX 精确,tenferro 的 CPU 收缩不支持整数。
  • 越界下标两边都不报错;JAX 先换算负下标、take 默认填 NaN、scatter 丢弃越界更新,tenferro 一律夹到 [0, n-1],负下标取到第一个,scatter 累加并把越界更新加到边上。

💬 关联讨论

发起讨论
加载中...