Rust in Everything
基于 tenferro-cpu 0.8.0

换了线程数,末位不一样

主线位置:张量主线的「读回主机」:拿结果和存下来的参考值比较。
版本: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。
  • 不写线程数时宽度随机器变,逐位相同的参考文件换台机器就失效。
  • 比较用按量级的容差;需要逐位可重复时写死宽度。

💬 关联讨论

发起讨论
加载中...