随机数的计时
前几节用指令数比较了不同的生成方法。本节用第三章的方法实测:vrng 自身的时序,以及在 kernel 中连续生成时,各种方法每个 TC VREG 要多少周期。
vrng 的时序
与第三章第 4 节相同,用 LccProbe 插入手写片段,setup 中先 setrngseed。读数 (R1 − R0, R2 − R0):
| 片段 | 读数 | 结论 |
|---|---|---|
vrng 后紧接使用其结果的 vadd |
(3, 15) | 没有等待:结果下一个周期就可用 |
连续 k 条 vrng,k = 1、2、4、8、16 |
R2 = 14、21、37、69、133 | 每多一条加 8 个周期 |
va0 与 va1 交替,k = 2、8 |
R2 = 21、69 | 与全在 va0 相同 |
k 组“vrng + 一条无关的 vadd”,k = 2、4 |
R2 = 22、38 | vadd 不增加时间 |
vrng 后紧接 getrngseed 或 setrngseed |
(3, 21) | 同样等 8 个周期 |
setrngseed 后紧接 vrng |
(3, 23) | vrng 晚 10 个周期 |
vrng 的发射间隔是 8 个周期:一条 vrng 让每个生成器连续走 8 步(第 1 节),每个周期一步。两个槽共用同一个生成器,换到 va1 不能并行。间隔中的空闲周期可以发射其他向量运算。按第三章第 4 节的模型,它与 XLU 的转置相似:一个单元每 8 个周期接收一次,期间不挡住其他指令。
由此,硬件生成器的吞吐上限是每 8 个周期 4 KiB,即每周期 512 字节,按 1.05 GHz 约 540 GB/s。
在 kernel 中连续生成
kernel 在 pl.loop 中反复生成,每次 rows 行,结果异或进同一个 buffer,使编译器不能删去任何一次生成:
pltpu.prng_seed(1)
acc_vmem[...] = jnp.zeros((rows, 128), jnp.uint32)
@pl.loop(0, steps)
def _(step: jax.Array) -> None:
acc_vmem[...] = acc_vmem[...] ^ generate(rows, step)每种方法编译两个 kernel,分别生成 64 个和 576 个 TC VREG,各用设备上的 LCC 读出 kernel 的周期数(第一章第 3 节的 KernelClock),相减后除以多生成的 512 个,消去种子混合、DMA 等固定开销。实验只改 generate 和 rows,十种方法见源码中的 METHODS。
同时统计清单:pl.loop 不展开,循环体在清单中只出现一次;与循环体只生成全零的 kernel 相比,多出的计算指令除以每次的 TC VREG 数,就是每个 TC VREG 的计算指令。两个向量 ALU 槽每周期各发射一条,指令数的一半是只受 ALU 限制时的周期下限。
| 方法 | 实测:每个 TC VREG 的周期 | 吞吐 | 计算指令 | ALU 下限(指令数 / 2) |
|---|---|---|---|---|
硬件 vrng |
8.4 | 514 GB/s | 2.0 | 1.0 |
vrng → 均匀分布 |
8.6 | 499 GB/s | 6.0 | 3.0 |
vrng → 伯努利(整数阈值) |
8.5 | 506 GB/s | 5.1 | 2.6 |
vrng → 指数分布 |
10.0 | 430 GB/s | 10.0 | 5.0 |
vrng → Gumbel 分布 |
11.5 | 374 GB/s | 14.0 | 7.0 |
vrng → 正态分布(stateful_normal) |
36.9 | 117 GB/s | 72.1 | 36.1 |
vrng → 正态分布(Box–Muller) |
62.7 | 69 GB/s | 120.9 | 60.4 |
| Philox 4x32,每次 64 行 | 137.8 | 31 GB/s | 268.6 | 134.3 |
| threefry2x32,每次 64 行 | 119.8 | 36 GB/s | 139.2 | 69.6 |
| threefry2x32,每次 16 行 | 293.0 | 15 GB/s | 193.5 | 96.8 |
这里的计算指令包括异或进 buffer 等循环本身的指令,比第 4、5 节单独统计的变换略多。按瓶颈可以分成三类:
生成器受限。 vrng、均匀分布、伯努利都在 8.4–8.6 个周期,ALU 下限只有 1–3 个周期:瓶颈是 vrng 每 8 个周期一条的发射间隔,变换的指令完全藏在间隔里。指数分布与 Gumbel 分布的 10.0、11.5 个周期超过 8,也远高于 ALU 下限;vlog2 的发射间隔是 2 个周期(第三章第 4 节),每个 TC VREG 一到两条,也不是瓶颈。多出的部分来自循环体的结构,下一小节把它拆开。
ALU 受限。 两种正态分布与 Philox 的实测与 ALU 下限相差不到 4 个周期:两个向量 ALU 槽几乎每个周期都在发射,生成器反而闲着。要让这类变换更快,只能减少指令,例如选择不需要正弦、余弦的方法(第 5 节)。
依赖与槽位受限。 threefry2x32 的实测比 ALU 下限多 70% 以上。原因之一是槽位:tpuasm 的指令索引中,vshll、vshrl 只出现在 va1,不能在 va0 发射。统计每次 64 行的 kernel 段(含少量循环外的指令):va1 上有 vshll 208 条、vshrl 201 条,va0 上有 vadd 310 条、vor 179 条、vxor 144 条。两个槽的指令数并不均衡,每个 TC VREG 的 va0 就有约 80 条,下限实际上是 80 而不是 70。其余的差距来自 hash 每一轮的依赖链(第 4 节)和溢出到 TC VMEM 的读写。每次只生成 16 行时更慢:每次只有 2 个 TC VREG 可以交错执行,依赖链无法被掩盖,而 fold_in 的固定开销只分摊到 2 个 TC VREG 上。
循环体:生成器之外的周期
为什么指数分布每个 TC VREG 多出约 1.5 个周期?脚本编译与上一小节相同的 kernel,从清单中取出 pl.loop 的循环体:从循环的标号到回跳分支之后的延迟槽,去掉计数与回跳的标量指令(只含标量指令的 bundle 变成空 bundle,仍占一个周期)。然后做两件事:
- 用
LccProbe把循环体重复 4 次插入载体,直接测它的周期数。循环体会改写全部 32 个 TC VREG,其中 v20–v30 是载体保存标量寄存器的位置(第一章第 3 节),所以setup先把它们存进 TC VMEM,片段在最后一次读数之后再读回。 - 把同样的 bundle 序列交给第三章第 4 节的发射模型,分别在不考虑与考虑
vrng的 8 周期间隔时预测。
| 方法,每次生成 | 循环体 bundle 数 | 相邻 vrng 相隔的 bundle |
LCC 实测(每个 TC VREG) | 模型:不考虑 vrng 间隔 |
模型:考虑间隔 | 上一小节的整个 kernel |
|---|---|---|---|---|---|---|
| 均匀分布,64 行 | 54 | 6 | 8.22 | 7.44 | 8.22 | 8.6 |
| 指数分布,64 行 | 65 | 5、5、7… | 9.59 | 8.75 | 9.59 | 10.0 |
| Gumbel 分布,64 行 | 77 | 5、5、7… | 11.12 | 10.38 | 11.12 | 11.5 |
| 指数分布,256 行 | 233 | 5、5、7… | 8.40 | 7.44 | 8.40 | — |
考虑 vrng 间隔的模型与 LCC 实测完全相同;不考虑时少 0.7–1 个周期。LCC 测的是去掉了循环计数和回跳的循环体,比上一小节的整个 kernel 少约 0.4 个周期。
指数分布的循环体(输出中有完整的 65 个 bundle)是这样排的:
[ 3] { va0: vrng.8x128.u32 v23 }
[ 4] { va1: vshrl.8x128.s32 v24, v23, 0x9 }
[ 5] { va0: vor.8x128.u32 v25, 0x3f800000, v24 }
[ 6] { va1: vadd.8x128.f32 v26, -1.0, v25 }
[ 7] { va1: vsub.8x128.f32 v27, 1.0, v26 ; vld: vld.8x128 v26, [vmem:0x10] }
[ 8] { va0: vlog2.8x128.f32 erf, v27 ; va1: vrng.8x128.u32 v28 }
...
[48] { va0: vrng.8x128.u32 v20 ; vr0: vpop.8x128 v19, erf }
...
[55] { va0: vlog2.8x128.f32 erf, v28 ; vr0: vpop.8x128 v29, erf }
[56] { va0: vmul.8x128.f32 v30, 0.6931471824645996, v29 }
[57] { va1: vsub.8x128.f32 v0, 0.0, v30 }
[58] { va0: vxor.8x128.u32 v1, v0, v31 }
[59] { vst: vst.8x128 [vmem:0x30], v1 }
[60] { vr0: vpop.8x128 v2, erf }
[61] { va0: vmul.8x128.f32 v3, 0.6931471824645996, v2 }
[62] { va1: vsub.8x128.f32 v5, 0.0, v3 }
[63] { va0: vxor.8x128.u32 v6, v5, v4 }
[64] { vst: vst.8x128 [vmem:0x38], v6 }每个 TC VREG 的变换是一条链:vrng → 右移 9 位 → 或上 0x3F800000 得到 [1, 2) 中的浮点数 → 减 1 → 1 − u → vlog2 提交 → 7 个周期后取回 → 乘 ln 2 → 取负 → 异或进 buffer → 写回。编译器把相邻两个 TC VREG 的链错开 5–7 个 bundle,交错排列。从清单上看,8 条 vrng 之间相隔 5–7 个 bundle,比 8 个周期的间隔短:编译器排指令时没有考虑这个间隔,每条 vrng 都要在向量一侧多等 1–3 个周期,后面所有的 bundle 一起推迟(第三章第 4 节的按序发射)。
于是一次迭代的时间可以拆成三段:
- 第一条
vrng之前:读入 buffer 的vld等 3 个 bundle。 - 8 条
vrng之间:7 个间隔,按 8 个周期计共 56 个周期。均匀分布与指数分布都一样,与编译器排得多紧无关。 - 最后一条
vrng之后:最后一个 TC VREG 的整条链,加上倒数第二个的vlog2取回与写回。指数分布有 16 个 bundle,其中[55]的vlog2到[60]的取回还要再等 2 个周期;均匀分布的链只有 5 条运算加一次写回,这一段只有 6 个 bundle。
pl.loop 的迭代之间不重叠:下一次迭代的 vrng 要等这一次的最后一个 bundle 发射之后才发射。所以首尾两段的周期每次迭代都要付一次,摊到 8 个 TC VREG 上,就是指数分布比 8 多出的部分;Gumbel 分布的链多一次 vlog2 和几条运算,尾段更长,多出的也更多。
验证这个解释的办法是只改每次生成的行数:64 行改为 256 行,循环体中的 vrng 从 8 条变为 32 条,首尾两段的长度不变,摊到 32 个 TC VREG 上。实测每个 TC VREG 从 9.59 降到 8.40 个周期,接近 vrng 的间隔 8。要在 kernel 中以接近生成器上限的速度产生需要较长变换的分布,每次迭代应当生成足够多的行;代价是寄存器与 TC VMEM 的占用随之增加。
结论
对比第二章第 2 节:HBM 读入一个 TC VREG 约需 4 个周期。所以:
- 用
vrng在 kernel 内生成均匀分布或伯努利掩码,每个 TC VREG 约 8.5 个周期(指数分布这类短变换,每次迭代生成足够多的行时也能接近这个数),比从 HBM 读入预先生成的随机数慢一倍;但它不占 HBM 带宽,可以与其他读写、计算并行,而生成器在 8 个周期的间隔中只占用极少的 ALU。 - 正态分布这类变换受 ALU 限制,会与 kernel 的其他向量计算争用两个 ALU 槽;在计算密集的 kernel 中,它的真实开销就是这些指令本身。
- threefry2x32 每个 TC VREG 约 120 个周期,比 HBM 读入慢约 30 倍,只在需要与
jax.random逐位一致、或需要按位置寻址时才值得;使用时每次至少生成几十行,让多个 TC VREG 交错掩盖依赖链。
这些计时补充了研究报告 57 未测的时序部分。