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
  jx["JAX 计时习惯"] --> blk["等结果 去掉第一次"]
  blk --> port["搬到 tenferro"]
  port --> fresh["新二进制第一次 5 ms"]
  port --> load["8 线程 机器满载"]
  load --> slow["慢 20 倍以上"]
  fresh --> skip["先跑一次再计时"]
  slow --> w1["单线程做基准 记负载"]

实验代码:tenferro 一侧是 bin p28_timing(release;对 f(x) = Σ sin(x)·x²,n = 1000 的 grad 程序和 256×256 的 svdvals,先跑一次,再跑 20 次取最小、中位、最大)和动手页的 ising_trg;JAX 一侧是 p28_timing.py。M2 Max(12 核)、macOS 27、rustc 1.99.0。

现象

JAX 计时有两条规矩:等结果(block_until_ready),第一次调用单独算(含追踪和编译):

g = jax.jit(jax.grad(lambda x: jnp.sum(jnp.sin(x) * x * x)))
g(x).block_until_ready()          # 第一次
r = g(x); r.block_until_ready()   # 稳态
jax 0.12.0.1: first jit(grad) call 12.952 ms
steady, no block_until_ready: min    0.003 median    0.004 max    0.013 ms
steady, block_until_ready   : min    0.016 median    0.017 max    0.053 ms

tenferro 的 run 返回时结果已经读回,不需要 block_until_ready;编译和准备是显式的一步,也不会混进 run。于是很容易以为第一次 run 就是稳态,以为「多线程总不会更慢」。

第一次。 同一个程序,刚复制出来的二进制和再跑一次:

fresh copy:
width 1: grad n=1000  prepare 1.803 ms | run+readback first    6.618 | min    0.015 median    0.017 max    0.028 ms
same copy again:
width 1: grad n=1000  prepare 0.152 ms | run+readback first    0.109 | min    0.015 median    0.015 max    0.022 ms
another fresh copy:
width 1: grad n=1000  prepare 1.752 ms | run+readback first    5.536 | min    0.015 median    0.016 max    0.021 ms

新二进制的第一次 run 5.5 到 6.6 ms,是稳态的 400 倍;同一个文件第二次运行,进程里的第一次只要 0.11 ms。只跑一次就记时间,量到的是这个。

满载。 先在机器空闲时量一次,再起 12 个 yes > /dev/null 把 12 个核占满,同样的程序再量一次:

# 空闲,负载均值 3.2
width 1: svdvals 256x256      | first    4.694 | min    4.425 median    4.529 max    4.793 ms
width 8: svdvals 256x256      | first    4.412 | min    4.074 median    4.140 max    4.237 ms
# 12 个忙循环
width 1: svdvals 256x256      | first    5.010 | min    4.610 median    4.629 max    5.413 ms
width 8: svdvals 256x256      | first   15.779 | min   19.709 median   91.540 max  298.057 ms

20 步 TRG(动手页的分段程序,每个 β 先跑一次,再取 3 次的最小值):

# 空闲,width=8
beta=0.3000 ... | value_and_grad 269.5 ms
# 12 个忙循环,width=8
beta=0.3000 ... | value_and_grad 7762.1 ms
beta=0.4000 ... | value_and_grad 7300.5 ms
beta=0.5000 ... | value_and_grad 9546.7 ms
beta=0.6000 ... | value_and_grad 9675.9 ms
# 12 个忙循环,width=1
beta=0.3000 ... | value_and_grad 571.5 ms
beta=0.4000 ... | value_and_grad 569.4 ms
  • 8 线程的 SVD 中位数慢 22 倍,最慢的一次 72 倍;TRG 慢 27 到 36 倍,3 次里的最小值也是这样,不是偶发的抖动。
  • 单线程几乎不受影响(556 对 570 ms)。满载时 8 线程比单线程慢 12 倍以上。
  • 数值不受影响:满载下 8 线程的 TRG 梯度和空闲时逐位相同。

出处

第一次:tenferro 没有 XLA 那样的代码生成(记图与 jit),没有 JAX 那样一次十几毫秒的编译。新二进制第一次执行时多出的几毫秒,从「同一个文件第二次运行就消失」看,是操作系统第一次载入这个可执行文件的开销,不是 tenferro 的初始化;本页没有进一步拆开。JAX 一侧的第一次调用拆成追踪、降级、编译三段,见 计时。

满载:8 线程的 engine 每个运算都要 8 个工作线程一起完成,一个线程被操作系统换下去,其余的都等它;机器上有别的进程抢核时,这种等待在每个运算上都发生。宽度是建 Pool 时写死的,tenferro 不会因为机器忙而自动降宽度。宽度和性能核、自旋等待的关系见 tenferro 卷的 12 个线程比 8 个慢。

修法

  • 计时前先把要测的程序跑一次,扔掉;稳态取多次的最小值或中位数,两个都报。比较「冷启动」时,单独测、单独报。
  • 写明 engine 宽度和测量时的负载(uptime)。比较速度用单线程做基准,8 线程的数字附上负载。
  • 机器上还有别的重活(并行跑的测试、别的进程)时,宽度不要超过空闲的核数。和别的进程共享机器的服务,用单线程或小宽度的 engine。

怎么确认没中招

  • 同一组测量里看 first 和 min:差两个数量级,第一次就不能进平均值。
  • 看 max 和 median:多线程的 max 比 median 大几倍,或者中位数比空闲时大很多,先查负载,不要先怀疑代码。
  • 把同一个测量在 serial() 下再跑一次:单线程正常、多线程异常,就是抢核。

小结

  • tenferro 的 run 带读回,不需要 block_until_ready;但新二进制第一次 run 要几毫秒,要先跑一次再计时。
  • 8 线程的 engine 在满载的机器上慢 20 倍以上,单线程不受影响;数值不变。
  • 报时间时写明宽度、负载、第一次和稳态。

💬 关联讨论

发起讨论
加载中...