类型转换
上一节看到,bf16 在 TC VREG 中两个元素共用一个 32 bit 位置,算术却在 f32 中进行。本节逐一看各种类型转换在硬件上是哪几条指令、按什么规则舍入、怎样处理越界与特殊值:bf16 与 f32、f32 与 int32、int8 与 int32,最后比较 XLA 与 Pallas 写回 bf16 结果的两种方式。其中 f32 → int32 的指令带有一个 Mosaic 不使用的操作数,用 tpuasm 改变它,可以得到硬件的随机舍入。
写法:kernel 中的 astype
相对于第 1 节的最小 kernel,输出的 dtype 与输入不同,所以 out_type 和输出 buffer 改用目标 dtype,计算改为 astype:
@pl.kernel(
out_type=jax.ShapeDtypeStruct(x.shape, dtype),
mesh=tc_mesh,
scratch_types=(pltpu.VMEM(x.shape, x.dtype), pltpu.VMEM(x.shape, dtype), pltpu.SemaphoreType.DMA),
...
)
def kernel(x_hbm: Ref, o_hbm: Ref, x_vmem: Ref, o_vmem: Ref, sem: Ref) -> None:
pltpu.async_copy(x_hbm, x_vmem, sem).wait()
o_vmem[...] = x_vmem[...].astype(dtype)
pltpu.async_copy(o_vmem, o_hbm, sem).wait()这个 kernel 写在 conversion_common.py 中,本节第 4 个实验也用它。脚本对六组 (输入 dtype, 目标 dtype) 各编译一次,并与 XLA 的 x.astype(dtype) 逐元素比较。六组结果都与 XLA 完全一致。
bf16 → f32:一次 load、两次 unpack
bf16[16,128] 正好是一个打包的 TC VREG:
vld v0, [vmem:0x0]
vunpackl.8x128.f16.f32 v1, v0 # 第 0–7 行
vunpacku.8x128.f16.f32 v2, v0 # 第 8–15 行
vst [vmem:0x8], v1
vst [vmem:0x10], v2两条 unpack 在同一个 bundle 中,分别占 va0 和 va1。元素数不变,每个元素从 16 bit 变成 32 bit,所以 TC VREG 数从 1 变成 2。若没有边界处理,N 个打包的输入 tile 对应 N 条 vunpackl、N 条 vunpacku 和 2N 个 f32 tile。
bf16 就是 f32 的高 16 bit,所以这个方向不需要舍入:unpack 把 16 bit 放到高位、低位补 0。边界情况的实验(见下文“特殊值”)证实它逐位不变:非规格化数 0x0001 变为 0x00010000,带载荷的 NaN 0x7f81 变为 0x7f810000,都原样保留。
f32 → bf16:两次 load、一次 pack
反向转换把两个 f32 TC VREG 合成一个:
vld v0, [vmem:0x0]
vld v1, [vmem:0x8]
vpackc.8x128.f32.f16 v2, v1, v0
vst [vmem:0x10], v2vpackc 同时完成舍入。f32 → bf16 会丢掉低 16 位尾数,舍入发生在这条指令中;它的结果与 XLA 逐元素一致。若某个算法要求与参考实现逐 bit 相同,必须把这次舍入放在与参考实现相同的位置。舍入的具体规则见下文“特殊值”。
f32 ↔︎ int32:一条 vcvt
vcvt.8x128.f32.s32 v1, v0, 0xffffffff # f32 → int32
vcvt.8x128.s32.f32 v1, v0 # int32 → f32各一条指令。f32 → int32 的舍入方式由实验输入中恰在两整数中间的值确定:
f32 : [0.5, 1.5, 2.5, -0.5, -1.5, -2.5, 2.7, -2.7]
int32 : [0, 1, 2, 0, -1, -2, 2, -2]
结果是向零截断,与 C 语言和 astype 的语义相同,不是四舍五入。需要其他舍入方式时,先在 f32 中用 jnp.round 等函数处理,再转换。f32.s32 形式比反方向多一个操作数 0xffffffff,下一小节专门研究它。
只改 vcvt 的第三个操作数:硬件的随机舍入
tpuasm 的指令索引中,vcvt.8x128.f32.s32 的第三个操作数可以是 TC VREG、标量寄存器或 32 位立即数,本节的清单中 Mosaic 写的是立即数 0xffffffff。实验用第 3 节的 LccProbe 把手写的片段插进一个载体 kernel 执行(这里只用它执行片段、取回结果,不读周期计数器):先用 vimm 把一个 f32 常数的位型广播成整个 TC VREG,再用 vcvt 转换,第三个操作数取不同的值 T,写回后读出:
body += bundle(f'va0: vimm.8x128.s32 v13, 0x{as_bits(value):x}') + bundle(f'va0: vcvt.8x128.f32.s32 v14, v13, {operand}') + bundle(f'vst: vst.8x128 [vmem:0x{tile * 8:x}], v14')实验只改 operand:
| T | 0.25 | 0.5 | 0.75 | −0.25 | −0.5 | −0.75 | 2.5 | −2.5 |
|---|---|---|---|---|---|---|---|---|
0xffffffff |
0 | 0 | 0 | 0 | 0 | 0 | 2 | −2 |
0xc0000000 |
0 | 0 | 0 | 0 | 0 | 0 | 2 | −2 |
0xbfffffff |
0 | 0 | 1 | 0 | 0 | −1 | 2 | −2 |
0x80000000 |
0 | 0 | 1 | 0 | 0 | −1 | 2 | −2 |
0x7fffffff |
0 | 1 | 1 | 0 | −1 | −1 | 3 | −3 |
0x40000000 |
0 | 1 | 1 | 0 | −1 | −1 | 3 | −3 |
0x3fffffff |
1 | 1 | 1 | −1 | −1 | −1 | 3 | −3 |
0x00000000 |
1 | 1 | 1 | −1 | −1 | −1 | 3 | −3 |
把 |x| 的小数部分写成 32 位定点数 F:0.25 是 0x40000000,0.5 是 0x80000000,0.75 是 0xc0000000。表中的 64 个结果全部符合一条规则:F > T 时向远离零的方向进 1,否则向零截断。 正负数对称,进位的边界是严格的大于:0.5 在 T = 0x80000000 时不进位,在 0x7fffffff 时进位。T = 0xffffffff 时没有任何 F 能大于它,所以 Mosaic 写的这个值就是向零截断。
第三个操作数换成 TC VREG 时,每个元素用各自的 T。载体在片段之前已把一个随机的 u32 tile 读进 v10,以它为 T:
| x | 小数部分 | 1024 个元素中进 1 的个数 | 比例 |
|---|---|---|---|
| 2.25 | 0.25 | 236 | 0.230 |
| −2.25 | 0.25 | 236 | 0.230 |
| 0.75 | 0.75 | 755 | 0.737 |
| 0.5 | 0.5 | 476 | 0.465 |
| 1.125 | 0.125 | 123 | 0.120 |
| −7.875 | 0.875 | 885 | 0.864 |
每个元素都与上面的规则逐一吻合。T 在 32 位整数上均匀分布时,F > T 的概率恰好是 F / 2³²,即 x 的小数部分:进位的期望恰好补上截断丢掉的部分,舍入后的期望等于 x。这就是随机舍入(stochastic rounding),硬件在一条 vcvt 中完成,随机比特由第三个操作数提供。
暂且可以理解为:一个 TC VREG 的均匀随机
u32可以由硬件随机数生成器的一条指令产生。第四章第 1 节详细介绍这条指令,第四章第 5 节介绍 Pallas 中 f32 → bf16 的随机舍入怎样手写。
特殊值:越界、NaN、非规格化数
第一个实验的输入都是普通的数。本实验沿用同一个 kernel,只换输入:每种转换在第 0 行放入一组边界值,结果与 XLA 的 astype 按位比较。四组都与 XLA 按位一致;下面是硬件的具体行为。
f32 → int32 饱和。 超出 int32 范围的值不回绕,而是取最近的端点;NaN 变为最大值:
3000000000 → 2147483647 -3000000000 → -2147483648
inf → 2147483647 -inf → -2147483648
nan → 2147483647
2147483520 → 2147483520 2147483648 → 2147483647
2147483520 是小于 2³¹ 的最大 f32,原样转换;2³¹ 本身比 int32 的最大值大 1,饱和为 2147483647。
f32 → bf16 就近舍入、偶数优先。 低 16 bit 恰为 0x8000(恰在两个 bf16 中间)时,0x3f808000 舍为 0x3f80,0x3f818000 进为 0x3f82:都取末位为偶数的一边。略大于或略小于中间的值按距离舍入。最大的有限数 0x7f7fffff 舍入后溢出为无穷大 0x7f80。
f32 → bf16 中的 NaN 与非规格化数。 这两处与 IEEE 的直接推论不同:
| f32 | 硬件 | IEEE 就近舍入(保留符号、载荷与非规格化数) |
|---|---|---|
0x7fc00001(带载荷的 NaN) |
0x7fc0 |
0x7fc0 |
0xff800001(负号的 NaN) |
0x7fc0 |
0xffc0 |
0x007fffff(最大的非规格化数) |
0x0000 |
0x0080 |
0x807fffff |
0x8000 |
0x8080 |
所有 NaN 都变成同一个 0x7fc0,符号与载荷都不保留;f32 的非规格化数先被当作 0(保留符号),所以 0x007fffff 不会像 IEEE 那样进位成最小的规格化数 0x0080。反方向 bf16 → f32 则逐位保留,见上文。
int32 → f32 就近舍入、偶数优先。 2²⁴ 以上的整数不能被 f32 精确表示:16777217(2²⁴ + 1)舍为 16777216,16777219 进为 16777220,都取偶数一边;2147483647 舍入为 2147483648。
int8 ↔︎ int32:没有专用指令
上一节看到,int8 不能直接做向量运算,要先转换成 int32。TPU v4 没有 int8 的 unpack 指令,编译器用读入时的 sublane 复制、移位拼出转换。要读懂这段清单,先要知道 int8 的打包格式。
int8 的打包格式。 与第 5 节读 bf16 格式的方法相同,把第 r 行全为 r 的 int8[32,128] 用 pltpu.bitcast 按位读成 u32[8,128]:
sublane 0:字节 0–3 = 第 [0, 1, 2, 3] 行
sublane 1:字节 0–3 = 第 [4, 5, 6, 7] 行
...
sublane 7:字节 0–3 = 第 [28, 29, 30, 31] 行
一个 TC VREG 装 32 行:第 s 个子通道的 32 bit 依次装第 4s、4s+1、4s+2、4s+3 行,第 4s 行在最低字节。它与 bf16 的 vpackc 格式是同一个规律:相邻的行放进同一个 32 bit 位置,行号小的在低位。
int8 → int32 的清单。 int8[32,128] 是一个打包的 TC VREG,转换成 4 个 int32 TC VREG:
vld v0, [vmem:0x0] # 读入打包的 int8
vlaneseq.8x128.u32 v1 # 计算移位量,只做一次
vshrl.8x128.s32 v2, v1, 0x4
vand.8x128.u32 v3, 0x18, v2
vsub.8x128.s32 v4, 24, v3
vst [vmem:0x28], v0 # 把输入写到一块临时区域
vld.sshfl.8x128 v5, [vmem:0x28], 0x11110000 # 第 0 个输出 tile:读第 0、1 个 sublane,各复制 4 份
vshll.8x128.s32 v6, v5, v4 # 把目标字节移到最高 8 位
vshra.8x128.s32 v7, v6, 0x18 # 算术右移 24 位,完成符号扩展
vld.sshfl.8x128 v8, [vmem:0x2a], 0x11110000 # 第 1 个输出 tile:读第 2、3 个 sublane
...逐条用 tpuasm 单独执行,看每条做了什么:
vld.sshfl读入时重排 sublane。 立即数的 8 个十六进制位从低到高对应输出的第 0–7 个子通道,每一位给出它从地址起的第几个子通道读:地址 0x0,模式 0x76543210:[0, 1, 2, 3, 4, 5, 6, 7] # 原样读入 地址 0x0,模式 0x01234567:[7, 6, 5, 4, 3, 2, 1, 0] # 倒序 地址 0x0,模式 0x11110000:[0, 0, 0, 0, 1, 1, 1, 1] 地址 0x2,模式 0x11110000:[2, 2, 2, 2, 3, 3, 3, 3]vld.sshfl只能从 TC VMEM 读,所以编译器先把v0写到临时区域0x28,再从0x28、0x2a、0x2c、0x2e读,每次取两个子通道、各复制 4 份。清单中每次读之前都把同一个v0重写一遍,4 次写入的是同一个值,后 3 次是多余的。移位量。
vlaneseq给出每个元素的序号s × 128 + lane;右移 4 位再与0x18相与,得到(s mod 4) × 8;24 减去它,第 s 个子通道的左移量是[24, 16, 8, 0, 24, 16, 8, 0]。左移再算术右移。 第 0 个输出 tile 的第 s 个子通道装的是打包 TC VREG 第 ⌊s/4⌋ 个子通道的 32 bit,其中第
s mod 4个字节就是第 s 行。左移24 − 8 × (s mod 4)位把这个字节移到最高 8 位,算术右移 24 位把它放回最低 8 位,同时用符号位填满高 24 位:第 s 行的 int8 变成了 int32。
每个输出 tile 重复一组 vld.sshfl、vshll、vshra,共 4 组。
int32 → int8。 反方向同样没有专用指令。int32[32,128] 转 int8 共用了 8 条 vpackc、8 条 vand、4 条 vor、8 条 vshll,每个输入 TC VREG 一组:
vshll.8x128.s32 v4, v0, 0x18 # 每个元素的低 8 位移到最高 8 位,低 24 位为 0
vpackc.8x128.f32.f16 v5, 0.0, v4 # 第一次打包
vand.8x128.u32 v9, 0xff00, v5
vand.8x128.u32 v10, 0xff000000, v5
vshll.8x128.s32 v11, v9, 0x8
vor.8x128.u32 v13, v10, v11
vpackc.8x128.f32.f16 v15, 0.0, v13 # 第二次打包
vst [vmem:0x28, sm=3], v15 # 只写第 0、1 个 sublane编译器借用 f32 → bf16 的打包指令来搬运字节。按第 5 节的 vpackc 格式推导:
- 左移 24 位后,每个 32 bit 的最高字节是这一行的 int8,低 24 位为 0。把它当作 f32,指数的最低位总是 0,所以永远不是 NaN 或无穷大;字节为 0 或
0x80时是 ±0;低 16 位全为 0,舍入不改变任何 bit。所以这次vpackc只是取出每个元素的高 16 bit,按格式打包:结果的第 s 个子通道(s = 0–3)装第 2s 行(低 16 bit)和第 2s+1 行(高 16 bit),两行的 int8 分别在第 8–15 位和第 24–31 位;第 4–7 个子通道来自常数 0.0。 - 两条
vand与vshll、vor把第 2s 行的字节从第 8–15 位挪到第 16–23 位,于是第 2s、2s+1 行的字节占据了高 16 bit。 - 第二次
vpackc再取高 16 bit 打包:结果的第 t 个子通道(t = 0、1)装输入第 2t、2t+1 个子通道的高 16 bit,四个字节依次是第 4t、4t+1、4t+2、4t+3 行。这正是 int8 的打包格式。
一个 int32 TC VREG(8 行)只填满 int8 打包格式的 2 个子通道,所以用 sm=3(二进制 11)只写第 0、1 个子通道,4 个输入依次写到 0x28、0x2a、0x2c、0x2e,拼成一个完整的 int8 TC VREG,最后整体读出、写到输出。
可见 TPU v4 的向量单元以 32 bit 和 16 bit 为基本粒度,int8 只是一种存储格式。它的用处在于节省 HBM 和 TC VMEM 的容量与 DMA 带宽,以及送入矩阵单元(第 11 节),而不在向量运算。
只改实现入口:XLA 与 Pallas 写回 bf16 的方式
本小节 XLA 实验源码、输出;Pallas 版本即上一节的实验。
bf16[64,128] × 2 在 XLA 和 Pallas 中都是 load、unpack、f32 乘法、pack、store,差别在 pack 和 store:
vld |
unpack | vmul |
vpackc |
vst |
|
|---|---|---|---|---|---|
| XLA | 4 | 8 | 8 | 8 | 8(sm=15) |
| Pallas | 4 | 8 | 8 | 4 | 4 |
XLA 的写法是:
vpackc.8x128.f32.f16 v11, 0.0, v7
vst [vmem:0x20, sm=15], v11每个 f32 TC VREG 单独与常数 0.0 打包,结果只占第 0–3 个子通道,于是用 sm=15(二进制 1111)只写这 4 个子通道。Pallas 则把相邻两个 f32 TC VREG 合成一个满的打包 TC VREG,一次写满 8 个子通道。两者都正确;Pallas 的写法少一半 pack 和 store。
这个对照说明,编译器的默认写法不是唯一写法。当一对 f32 结果同时可用时,合成一次满写回,pack 和 store 各少一半;下一小节测得它在 bf16[64,128] 上少 4 个周期。
周期数
六种转换放进第 3 节的 TC VMEM 边界,输入都是 [64,128],原生 XLA 与 Pallas kernel 各测一次,两边的结果逐元素相同:
| 转换 | XLA | Pallas | 区间内的向量指令(Pallas) |
|---|---|---|---|
| bf16 → f32 | 50 | 50 | 4 vld、8 unpack、8 vst |
| f32 → bf16 | 49 | 49 | 8 vld、4 vpackc、4 vst |
| f32 → int32 | 50 | 50 | 8 vld、8 vcvt、8 vst |
| int32 → f32 | 50 | 50 | 8 vld、8 vcvt、8 vst |
| int8 → int32 | 59 | 114 | 8 vld、16 vst、8 vld.sshfl、8 vshll、8 vshra |
| int32 → int8 | 71 | 155 | 16 vld、16 vst、16 vshll、16 vand、8 vor、16 vpackc |
每个数都包含外壳固定的 39 个周期。
前四种是一条指令的转换,每个输出 TC VREG 一个周期,与逐元素的乘法相同:unpack、vpackc、vcvt 的结果虽然要 2 个周期后才可用(第 7 节),连续处理时相邻 TC VREG 的指令互相填补,看不出来。
int8 的两种转换,Pallas 是 XLA 的两倍。两边的移位和打包指令相同,差别在内存的读写:
int8 → int32。
vld.sshfl只能从 TC VMEM 读。XLA 的输入已经在 TC VMEM 中,它直接从输入读,区间里没有一条普通的vld。Pallas 的 kernel 把x_vmem[...]当作一个值:先vld读进 TC VREG,再vst写到临时区域,然后vld.sshfl从临时区域读回,而且每个输出 tile 之前都重写一遍(上文清单中的vst [vmem:0x28], v0)。读刚写过的行要等:暂且可以理解为:
vst写过的行,7 个周期之内不能被vld读,要读的那条指令和它后面的指令都停下来等。第三章第 4 节测出这条规则。8 个输出 tile 各等一次,就是多出来的 55 个周期。
int32 → int8。 Pallas 把 4 个输入各自打包出的 2 个子通道用带掩码的
vst拼进一块临时区域,再整体读出、写到输出,同样是写后立刻读;XLA 直接写进结果所在的内存。
这是本章第一次遇到“指令相同、周期数相差一倍”的情形,原因不在运算,而在一次多余的内存往返。在 Pallas 中处理 int8 数据时,这部分开销目前绕不开;它在一个更大的 kernel 中是一次性的,但如果在循环里反复转换,就值得把数据一直留在 int32。