换了线程数,末位不一样
主线位置:张量主线的「读回主机」:拿结果和存下来的参考值比较。
版本:JAX 0.12.0.1(CPU,x64)、tenferro-rs 0.8.0(CPU,native;engine 宽度 1、8 和默认)
flowchart TB gold["存下参考结果"] --> eq["逐位比较"] eq --> w1["serial 跑 通过"] eq --> w8["8 线程跑 失败"] w8 --> why["求和顺序变了"] eq --> def["默认宽度 换机器失败"] why --> tol["按量级给容差"] def --> fix["写死宽度"]
实验代码:tenferro 一侧是 bin p27_threads_bits(release)和动手页的 ising_trg,JAX 一侧是 p27_threads_bits.py。输入是精确的有理数 xᵢ = ((7919·i) mod 1000003) / 1000003 − 0.5,两边能逐位重建。M2 Max(12 核:8 性能核加 4 能效核)、macOS 27、rustc 1.99.0。
现象
JAX 在同一台机器上反复跑,结果逐位相同,所以常见的回归测试是把结果存下来,以后 assert_array_equal:
jax.jit(jnp.sum)(x) # 100 万个数
jax.jit(lambda m: jnp.linalg.svd(m, compute_uv=False))(g) # 256×256
fsum (exact) = -1.95248614254157249e+00
jax jit sum = -1.95248614254401787e+00
jax run twice bitwise equal: sum True svd True
tenferro 在单线程(CpuEngine::serial())下存参考值,再在 8 线程的 engine 上跑同样的运算:
let mut e1 = CpuEngine::serial();
let mut e8 = CpuEngine::new(Pool::builder().threads(8).build()?);
x.reduce_sum(Some(&[0]), engine)?; a.matmul(&b, engine)?; g.svdvals(engine)?;
sum serial = -1.95248614254350450e0 w8 = -1.95248614254227348e0
reduce_sum 1e6 serial vs w8 n= 1 max|a-b|=1.231e-12 max|x|=1.952e0 bitwise equal 0/1
matmul serial vs w8 n= 4096 max|a-b|=0.000e0 max|x|=8.920e0 bitwise equal 4096/4096
svdvals 256 serial vs w8 n= 256 max|a-b|=2.043e-14 max|x|=4.110e1 bitwise equal 13/256
serial run twice bitwise equal: sum true matmul true svd true
w8 run twice bitwise equal: sum true matmul true svd true
不写宽度,Pool::builder().build() 取这台机器的可用并行度:
default Pool::builder() threads = 12
reduce_sum default vs serial n= 1 max|a-b|=1.231e-12 max|x|=1.952e0 bitwise equal 0/1
svdvals default vs serial n= 256 max|a-b|=2.842e-14 max|x|=4.110e1 bitwise equal 10/256
svdvals default vs w8 n= 256 max|a-b|=2.842e-14 max|x|=4.110e1 bitwise equal 11/256
TRG 20 步(β = 0.3),同一个程序只换宽度:
# width=1
hex lnZ/N=3fe94c4285929bdc grad=3fe68b40d094f9f3 c=3fd2528c7d5f23ef
# width=8
hex lnZ/N=3fe94c4285929bdc grad=3fe68b40d094f9fd c=3fd2528c7d60c9d0
- 同一宽度连跑两次逐位相同,换宽度就不同:求和差 1.2e-12,SVD 只有 13 / 256 个奇异值逐位相同,TRG 的梯度差 10 个 ulp,对梯度做差分得到的
c差 6e-12。 - 矩阵乘在这个形状上不受影响,所以「换线程会不会改末位」要按运算、按形状看。
- 默认宽度是 12,和 8 又是另一组数;换一台核数不同的机器,参考文件就失效。
出处
浮点加法不满足结合律。宽度不同,engine 切分工作的方式不同,加的顺序就不同;同一宽度的切法固定,所以可重复。这和 JAX 与 tenferro 之间的末位差是同一个量级,怎么读这一卷 的「线程数也会改末位」一节有完整的对照和容差的依据。默认宽度取可用并行度(这台机器上含 4 个能效核),它为什么还会更慢,见 tenferro 卷的 12 个线程比 8 个慢。
修法
- 和参考值比较用按量级的容差,不用逐位相等。本卷用的是:小规模 f64 结果
1e-14 × max(1, max|ref|);TRG 的 lnZ/N 1e-14,导数 1e-11(单线程对 8 线程实测 2.5e-12,留 4 倍)。上面的求和差 1.2e-12 对 1.95 是 6e-13 的相对误差,在大规模求和里属于正常的舍入,测试要按问题规模定。 - 确实需要逐位可重复(比如调试时对比两次运行),engine 宽度写死,写进参考文件的元数据里:
let mut eng = CpuEngine::new(Pool::builder().threads(8).build()?); // 不要省略 threads
- 和 JAX 比时,同时记下 tenferro 一侧的宽度。JAX 一侧本身也不等于精确值:上面 JAX 的和与
math.fsum差 2.4e-12,比 tenferro 换宽度的差还大。
怎么确认没中招
- 参考值测试在
serial()和 8 线程下各跑一次,两次都要过;只在一种宽度下过的测试,容差就是写得太紧。 - 搜代码里的
Pool::builder().build():没有.threads(n)的地方,结果随机器变。
小结
- tenferro 同一宽度逐位可重复,换宽度后求和、SVD 的末位会变;TRG 的梯度差 10 个 ulp。
- 不写线程数时宽度随机器变,逐位相同的参考文件换台机器就失效。
- 比较用按量级的容差;需要逐位可重复时写死宽度。
💬 关联讨论
发起讨论加载中...