第一次跑慢、满载时慢二十倍
主线位置:变换主线的「编译、准备 → 执行」:给执行计时。
版本: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 倍以上,单线程不受影响;数值不变。
- 报时间时写明宽度、负载、第一次和稳态。