整数运算
主线位置:张量主线的第二步「计算」,只看整数张量:除法和取余、除以 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 修出来的(下一节)。
#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 落成常量」意味着系数先变成整数。
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 ofGather」,它首先是给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累加并把越界更新加到边上。