逐元素运算
逐元素运算是 TensorCore 向量单元最基本的工作:一条指令对一个 TC VREG 的 8×128 个位置同时做同一种运算。本节要建立的是一张“价目表”:哪些运算是一条硬件指令,哪些要用多条指令拼出来,拼出来的结果精度如何,各要多少周期。设计算法时,这张表决定了哪些写法便宜、哪些写法昂贵。
写法:同一个 kernel 框架,只换中间一行
本节的实验都用同一个两输入 kernel,放在 elementwise_common.py 中。相对于第 1 节的最小 kernel,它多一个输入 y,输出的 shape 和 dtype 由运算本身推出,计算换成任意函数 f:
out = jax.eval_shape(f, jax.ShapeDtypeStruct(x.shape, x.dtype), jax.ShapeDtypeStruct(y.shape, y.dtype))
...
def kernel(x_hbm: Ref, y_hbm: Ref, o_hbm: Ref, x_vmem: Ref, y_vmem: Ref, o_vmem: Ref, sem: Ref) -> None:
pltpu.async_copy(x_hbm, x_vmem, sem).wait()
pltpu.async_copy(y_hbm, y_vmem, sem).wait()
o_vmem[...] = f(x_vmem[...], y_vmem[...])
pltpu.async_copy(o_vmem, o_hbm, sem).wait()jax.eval_shape 只推导函数输出的 shape 和 dtype,不实际计算。各实验脚本把一组 f 依次交给这个框架,例如 lambda x, y: jnp.exp(x),每个 f 单独编译一个 kernel,统计清单中的计算指令(不计 load/store、DMA 和标量指令)。
kernel 内可以直接用 jnp 和 jax.lax 的逐元素函数。它们由 Mosaic 降低成 TensorCore 指令,降低不了的会在编译时报错。
f32:一条指令的运算
输入是 [-4, 4) 和 [0.25, 4) 中的均匀随机数,误差与 float64 参考比较,以 ULP(相差几个 f32 可表示数)计:
| 运算 | 指令 | 误差 |
|---|---|---|
x + y |
vadd.8x128.f32 |
0 ULP |
x - y |
vsub.8x128.f32 |
0 ULP |
x * y |
vmul.8x128.f32 |
0 ULP |
maximum(x, y) |
vmax.8x128.f32 |
0 |
-x |
vsub.8x128.f32(0 − x) |
0 |
abs(x) |
vand.8x128.u32(清除符号位) |
0 |
加减乘的结果都是正确舍入的。-x 和 abs(x) 没有专用指令,分别用减法和按位与实现。
x * y + 1 是一条 vmul 加一条 vadd。TPU v4 的向量单元没有乘加融合(FMA),乘积先舍入一次,加法再舍入一次。乘积接近 −1 时发生相消,结果相对误差可达 21 ULP,而一次舍入的 FMA 不会有这个问题。依赖 FMA 精度的算法(例如某些补偿求和)在这里要重新设计。
第 5 节已经提到,vmul.8x128.f32 只能在 va0 槽发射,vadd.8x128.f32 只能在 va1 槽发射。一个 bundle 可以同时做一次 f32 乘法和一次 f32 加法,但不能同时做两次乘法。
周期数:结果要几个周期才可用
第 3 节测到,相互依赖的 f32 加法和乘法每条要 2 个周期。这里对向量 ALU 的各类指令逐一测:把 16 条同样的指令串成一条链,每一条用上一条写出的 v11,读 R2 − R0。结果下一个周期可用时,16 个 bundle 互不等待,读数是 16 + 13 = 29;要 2 个周期时是 2 × 16 + 11 = 43。
'vadd.8x128.f32': ('va1: vadd.8x128.f32 v11, v11, v10',),
'vadd.8x128.s32': ('va0: vadd.8x128.s32 v11, v11, v10',),
'vgt.8x128.f32 → vsel(两条一轮)': ('va0: vgt.8x128.f32 vm0, v11, v10', 'va0: vsel.8x128 v11, vm0, v11, v10'),| 结果 1 个周期后可用(读数 29) | 结果 2 个周期后可用(读数 43) |
|---|---|
整数加减 vadd.s32、vsub.s32 |
f32 加减乘 vadd.f32、vsub.f32、vmul.f32 |
位运算 vand、vor、vxor;移位 vshll、vshrl、vshra |
类型转换 vcvt.f32.s32、vcvt.s32.f32 |
vpcnt、vclz |
展开与打包 vunpackl、vpackc |
f32 的 vmax、vmin |
子通道循环移位 vrot.slane.down(第 9 节) |
比较(写掩码)与选择 vsel,vmov |
|
vld 读入的值 |
规律很简单:要做浮点舍入或重新排布数据的指令是 2 个周期,其余都是 1 个周期。f32 的 vmax、vmin 只是比较和选择,不需要舍入,是 1 个周期。两条一轮的链也符合这张表:“比较、选择”和“读入、整数加”每轮 2 个周期(读数 45,即 32 个 bundle 互不等待),“f32 加法、再取最大值”每轮 3 个周期(读数 60)。
指令相同,先后不同,周期数也不同。 延迟只在下一条指令依赖它时才要付。同一实验把 8 条互不依赖的 vadd.f32 和写出各自结果的 8 条 vst 用两种顺序排列:
| 排法 | R2 − R0 |
|---|---|
每条 vadd 后面紧跟它的 vst |
36 |
先 8 条 vadd,再 8 条 vst |
29 |
16 个 bundle 完全一样,只是顺序不同,相差 7 个周期。紧跟的排法里,每条 vst 都要等前一个 bundle 的加法结果多 1 个周期;向量一侧按顺序发射(第 3 节),它等的时候后面的指令也不能发射。分开的排法里,轮到第一条 vst 时它要的结果早已就绪。第 3 节的清单中,编译器把 vst 排在对应的 vmul 之后两个 bundle,就是在避开这个等待。手写或改写清单时,把依赖一个结果的指令往后挪、中间填上不依赖它的工作,是最基本的排法。
f32:超越函数与 EUP
exp(x) 的清单:
{ va0: vmul.8x128.f32 v1, 1.4426950216293335, v0 } # x × log2(e)
{ va0: vpow2.8x128.f32 erf, v1 } # 2 的幂,结果送入 erf
{ vr0: vpop.8x128 v2, erf } # 从 erf 取回结果超越函数由独立的超越函数单元 EUP 计算。vpow2 在 va0 槽发射,但它的目的操作数不是 TC VREG,而是结果队列 erf;结果要另用一条 vpop 从 vr0 或 vr1 槽取回。
周期数:EUP 的延迟与发射间隔
EUP 是本章遇到的第一个“提交—取回”的单元,它有两个时间参数,测法以后各节还会反复使用(tpuasm_tools.show_latency)。
延迟。 在提交与取回之间插入 d − 1 个 vnop:
show_latency(probe, f'EUP {function} → vpop erf', f'va0: {function}.8x128.f32 erf, v10', 'vr0: vpop.8x128 v11, erf')| d | 1 | 2 | 4 | 6 | 7 | 8 | 12 |
|---|---|---|---|---|---|---|---|
vpow2 → vpop,R2 − R0 |
20 | 20 | 20 | 20 | 21 | 22 | 26 |
d ≤ 6 时读数都是 20:vpop 在等结果,向量一侧停在这里,中间放几个 vnop 都一样。d = 7 起每多一个 vnop 多一个周期,说明结果已经就绪。20 减去固定的 13 个周期,从提交到可以取回是 7 个周期。五种 EUP 指令的读数完全相同。
发射间隔。 连续提交 k 次,看每多一次多几个周期:
| k | 1 | 2 | 4 | 8 | 16 |
|---|---|---|---|---|---|
k 次 vpow2,读数之后才取回 |
14 | 15 | 19 | 27 | 43 |
k 次 vpow2,再取回 k 次 |
20 | 22 | 26 | 35 | 59 |
第一行每多一次提交多 2 个周期:EUP 每 2 个周期接受一次提交。 第二行,16 次提交占 32 个周期,16 次取回紧随其后每周期一条。
这两个数给出了使用 EUP 的两种情形。只算一个 TC VREG 时,7 个周期是空等。连续计算许多个 TC VREG 时,提交每 2 个周期一次,延迟被后面的提交填满,EUP 每 2 个周期完成一个 TC VREG:它的吞吐是向量 ALU 上一条普通指令的一半。
本节实验中出现的 EUP 指令有 vpow2、vlog2、vrcp、vrsqrt、vtanh,每条都配一条 vpop。其他函数由它们加上普通向量运算拼成:
| 运算 | 计算指令数 | 核心指令 | 误差 | 最大绝对误差 |
|---|---|---|---|---|
exp2(x) |
2 | vpow2 |
57 ULP | 5.5e-5 |
exp(x) |
3 | vmul + vpow2 |
47 ULP | 1.8e-4 |
log(y) |
3 | vlog2 + vmul |
3652 ULP | 1.0e-4 |
tanh(x) |
2 | vtanh |
710 ULP | 4.2e-5 |
1 / y |
15 | vrcp + 迭代修正 |
1 ULP | 2.1e-7 |
x / y |
16 | vrcp + 迭代修正 + vmul |
2 ULP | 1.0e-6 |
rsqrt(y) |
11 | vrsqrt + 迭代修正 |
2 ULP | 1.7e-7 |
sqrt(y) |
13 | vrsqrt + 迭代修正 |
2 ULP | 2.4e-7 |
sigmoid(x) |
19 | vpow2 + vrcp |
58 ULP | 9.5e-7 |
sin(x) |
198 | 无 EUP,纯多项式 | 2 ULP | 8.7e-8 |
这张表中有两种不同的取舍:
- 倒数、平方根倒数和除法:EUP 只给出近似值,编译器再用几次乘加做牛顿迭代,把误差修正到 1–2 ULP。多出来的
vweird(判断是否为无穷或 NaN,结果写进掩码)、veq、vsel等指令处理无穷、零和 NaN:这些输入不能进入迭代,直接选用特殊值。 exp、log、tanh:直接使用 EUP 的近似结果,不做修正,误差为几十到几百 ULP。log的 ULP 数格外大,是因为y接近 1 时结果接近 0,约 1e-4 的绝对误差折合成很多个 ULP。
这两种取舍的依据是 EUP 指令本身的精度。实验源码、输出用 tpuasm 直接执行五条 EUP 指令,不加任何修正:先由 1024 个随机的 u32 拼出 [1, 2) 中的 y 和 [−4, 4) 中的 x,再对每条指令各提交、取回一次,与 float64 的参考值比较。
| 指令 | 最大相对误差 | 相当于 | 最大绝对误差 |
|---|---|---|---|
vrcp(1 / y) |
1.6e-05 | 约 16 位 | 1.1e-05 |
vrsqrt |
4.0e-06 | 约 18 位 | 3.9e-06 |
vlog2 |
2.6e-04 | 约 12 位 | 1.4e-04 |
vpow2 |
4.1e-06 | 约 18 位 | 4.0e-05 |
vtanh |
6.4e-05 | 约 14 位 | 4.3e-05 |
f32 的尾数是 24 位,而 EUP 只给出 12–18 位有效的结果。vrcp 的 16 位经过一次牛顿迭代(r + r × (1 − y × r),清单中的 2 条 vmul、1 条 vsub、1 条 vadd)翻倍,超过 24 位,所以 1 / y 只需要一步修正就达到 1 ULP;vrsqrt 同理。vpow2、vlog2、vtanh 没有这样便宜的修正公式,编译器直接使用原始结果,上表的误差就是 exp2、log、tanh 的误差。
sin 不使用 EUP,而是范围约化加多项式,共 198 条指令,换来 2 ULP 的精度。用它之前要先想清楚是否值得。
对照实验源码、输出:原生 XLA 对 x / y、exp、log、tanh、sin、x * y + 1 生成的计算指令和误差与 Pallas 完全相同,两者用的是同一套降低规则。需要更高精度时,例如要求 exp 或 log 达到 f32 的完整精度,就必须自己在 EUP 结果上追加修正步骤,XLA 和 Pallas 都不会自动做。
整数:没有整数乘法器
输入是随机的 uint32(脚本第二部分把同样的 bit 解释为 int32),结果与 CPU 上的 JAX 逐元素比较,全部一致:
| 运算 | 计算指令数 | 指令 |
|---|---|---|
x + y、x - y |
1 | vadd.8x128.s32、vsub.8x128.s32 |
x & y、x \| y、x ^ y |
1 | vand、vor、vxor |
x << 3、x >> 3 |
1 | vshll、vshrl(逻辑)、vshra(算术) |
population_count(x)、clz(x) |
1 | vpcnt、vclz |
int32 maximum(x, y) |
2 | vgt + vsel |
x * y |
33 | 6 次 vcvt 转 f32、6 次 vmul.f32、6 次 vcvt 转回,加移位和掩码 |
uint32 x // y |
318 | 32 轮移位—比较—相减 |
int32 x // y、x % y |
347、333 | 同上,另加符号处理 |
加减、位运算、移位、位计数各是一条指令。但 32 bit 整数乘法没有对应的硬件指令。编译器把两个操作数拆成若干段,每段小到可以精确地放进 f32 的 24 bit 尾数,用 6 次 f32 乘法算出部分积,再转回整数、移位相加,共 33 条指令。整数除法和取余更贵,是逐位的长除法,约 330 条指令。
这个事实直接影响算法设计。例如第四章介绍的计数器式随机数生成器 Philox 以 32 bit 整数乘法为核心,在 TPU v4 上它的每次乘法都要付出这 33 条指令的开销;而只用加法、异或和移位的生成器则便宜得多。
另外两点:
- 无符号的
maximum无法编译(failed to legalize operation 'arith.maxui')。有符号的maximum是一次比较加一次选择。需要无符号比较时,可以先把两数都异或0x80000000再做有符号比较。 x << y中,脚本写的是x << (y & 31):移位量来自数据时,要自己保证它在 0–31 之间。
比较、选择与向量掩码
jnp.where(x > y, x, y) 是两条指令:
vgt.8x128.f32 vm0, v0, v1 # 比较结果写进掩码寄存器 vm0
vsel.8x128 v2, vm0, v0, v1 # 按掩码逐元素选择比较指令的目的操作数是向量掩码寄存器 vm0–vm7,每个元素一个 bit;vsel 按掩码在两个 TC VREG 之间逐元素选择。比较的一侧可以是立即数,where(x > 0, x, 0.1 * x) 就是一条 vgt、一条 vmul、一条 vsel。
掩码之间的逻辑运算有专用指令 vmand、vmor、vmxor、vmneg,在 misc 槽执行。但编译器不一定用它们:where((x > 0) & (y > 0), x, y) 被改写成两次嵌套的 vsel,where((x > 0) | ~(y > 0), x, y) 则用了 vmor 和 vmxor。
TPU v4 只有 8 个向量掩码寄存器。实验在函数中先算出 count 个掩码,再依次使用它们,迫使所有掩码同时活跃:
| 同时活跃的掩码 | 用到的掩码寄存器 | vgt |
vsel |
额外指令 |
|---|---|---|---|---|
| 6 | vm0–vm5 |
6 | 12 | 无 |
| 12 | vm0–vm7 |
13 | 28 | vimm×4、vsmask×4 |
掩码超过 8 个时,编译器把一部分掩码转存到 TC VREG 中:
vimm.8x128.s32 v31, 0 # 先把一个 TC VREG 清零
vsel.8x128 v31, vm6, 0xffffffff, v31 # 掩码为 1 的位置写全 1,掩码转存为 32 bit 数据
...
vsmask vm6, v31 # 需要时再从 TC VREG 恢复成掩码每转存一个掩码要多一条 vimm、一条 vsel、一条 vsmask,还要占用一个 TC VREG;另有一个掩码被重新比较了一次(13 条 vgt)。这是第 5 节所说的掩码开销。需要大量条件的算法,应尽量让每个掩码产生后立刻用掉。研究报告 58 记录了这种转存的更多细节。
周期数:价目表的最后一栏
把前面各种运算放进第 3 节的 TC VMEM 边界,每种运算测两种大小:1 个 TC VREG([8,128])和 128 个 TC VREG([1024,128])。前者看单独算一次要多少周期,后者减去前者再除以 127,是连续处理时每个 TC VREG 的周期数。原生 XLA 与 Pallas kernel 各测一次,结果逐元素相同;两边周期数不同的只有最后注明的两行。
for name, f, used in OPS:
timings = tpuasm_tools.compare_in_vmem(clock, f'{name},[{rows},128]', f, [tuple(inputs[key] for key in used)] * 2)| 运算 | 计算指令数 | 1 个 TC VREG | 128 个 TC VREG | 每多一个 TC VREG |
|---|---|---|---|---|
x * 2 |
1 | 42 | 170 | 1.0 |
x + y、x * y、maximum(x, y)、int32 a + b |
1 | 44 | 298 | 2.0 |
x * y + 1、where(x > y, x, y) |
2 | 45 | 299 | 2.0 |
exp(x) |
3 | 44 | 305 | 2.1 |
log(y) |
3 | 44 | 304 | 2.0 |
tanh(x) |
2 | 43 | 303 | 2.0 |
sqrt(y) |
13 | 52 | 817 | 6.0 |
1 / y |
15 | 52 | 875 | 6.5 |
x / y |
16 | 54 | 940 | 7.0 |
sigmoid(x) |
19 | 56 | 1148 | 8.6 |
int32 a * b |
33 | 60 | 2156 | 16.5 |
sin(x) |
198 | 149 | 12660 | 98.5 |
int32 a // b |
347 | 244 | 25390 | 198 |
每个数包含外壳固定的 39 个周期。sin(x) 和 int32 a // b 两行是 Pallas 的读数,XLA 分别是 150、12342 和 242、25058,其余各行两边相同(x / y 在 128 个 TC VREG 时 XLA 是 941)。
连续处理时每个 TC VREG 的周期数,由最紧的那种资源决定。表中出现了三种:
- 读写内存的槽。 一个 bundle 只有一个
vld槽。x * 2每个 TC VREG 读一次,每个周期完成一个;两个输入的运算每个 TC VREG 要读两次,就是 2 个周期,与中间做的是加法、乘法还是比较加选择无关。这一类运算的瓶颈不在计算,而在把数据读进寄存器。 - EUP 的发射间隔。
exp、log、tanh各只有一条 EUP 指令,每个 TC VREG 2 个周期,正是上文测得的间隔。 - 向量 ALU 的两个槽。 指令多的运算,每个 TC VREG 的周期数接近计算指令数的一半:
sqrt13 条是 6.0,1 / y15 条是 6.5,int32 乘法 33 条是 16.5,sin198 条是 98.5。va0、va1两个槽每个周期最多发射两条指令,编译器把它们几乎填满了。
单独算一个 TC VREG 时,周期数没有这样的比例:1 / y 只比 x + y 多 8 个周期,sin 的 198 条指令是 110 个周期。只有一个 TC VREG 时,决定周期数的是最长的那条依赖链,不是指令的总数。
这张表把本节开头的“价目表”补完整了。几条直接的用法:
- 整数乘法每个 TC VREG 16.5 个周期,是加法的 8 倍;整数除法是加法的 100 倍。算法里能用移位和加法代替的,就不要用乘除。
1 / y与x / y的精度是 1 至 2 ULP,每个 TC VREG 6.5 至 7 个周期;直接用vrcp的近似结果只要 2 个周期。精度要求不高时(上文vrcp约 16 位有效),手写可以省掉迭代。- 逐元素运算上 Pallas 与 XLA 没有差别,两边用的是同一套降低规则。逐元素的 kernel 要比 XLA 快,只能靠别处:与前后的计算融合,少搬几次数据。