发射模型与静态分析
第一章每认识一个硬件单元,就测出它的延迟和发射间隔;第 3 节给出了两级发射的规则。本节把它们整理成一个可以计算的模型:给定一段实际执行的 bundle 序列,逐个 bundle 推算它的标量发射和向量发射时刻,从而预测 LCC 读数。模型需要的参数只有几类:VIF 的容量和释放延迟、每种结果的可用延迟、每个单元的发射间隔。本节先把这些参数汇总起来,用模型预测前面各节全部手写片段的读数,与真机比较;然后说明模型与实测不符时怎样找出缺少的规则,以及在动手推算之前怎样先判断一段代码受哪一类资源限制。
模型
VIF 的两个参数来自 tpu-v4-latency-numbers 第 0 节,它用 vdelay 人为堵住向量发射,直接测出了 VIF 的容量与释放延迟;各个单元的参数来自第一章。规则如下:
- 标量发射按顺序进行,没有阻塞时每周期一个 bundle。标量指令(包括读 LCC)在此执行。
- VIF:含向量或 DMA 指令的 bundle 标量发射后进入 VIF。一项在向量发射后 10 个周期才在标量一侧释放;未释放的项达到 20 个时,任何 bundle 都不能标量发射。
- 向量发射按顺序进行,每周期最多一个 bundle,最早在标量发射后 1 个周期;含访问内存的
vld、vst、cld的 bundle 最早在 2 个周期后。此外要等计分板放行:源操作数就绪、对应的单元和队列允许;vld要读的行刚被vst写过时,还要等到那条vst向量发射后 7 个周期。模型中的ready、queues、last三张表就是对计分板的模拟。 sfence所在 bundle 在向量一侧占一个位置,下一 bundle 要等 VIF 中全部项释放之后才标量发射。
单元与队列的参数:
| 通路 | 结果可用,或提交 → 可以取回 | 相邻两条提交指令的发射间隔 | 相邻两条取回指令的发射间隔 | 测得的位置 |
|---|---|---|---|---|
向量 ALU:整数运算、位运算、移位、比较、选择、vmax/vmin、vmov |
1 | 1 | — | 第一章第 7 节 |
向量 ALU:f32 的加、减、乘,vcvt,打包与展开,vrot.slane.down |
2 | 1 | — | 第一章第 7、12 节 |
vld 读 TC VMEM |
1 | 1 | — | 第一章第 3、7 节 |
EUP:vpow2、vrcp、vrsqrt、vtanh、vlog2 → erf |
7 | 2 | 1 | 第一章第 7 节 |
MXU0:vmatmul → mrf0 |
83 | 8 | 8 | 第一章第 11 节 |
XLU:最后一次 vxpose → trf0 |
6 | 8 | 8 | 第一章第 10 节 |
XLU:vadd.xlane、vmax.xlane、vmax.index.xlane、vmin.xlane → trf0 |
79 | 8 | — | 第一章第 12、15 节 |
XLU:通道循环移位 vrot、重排 vperm → trf0 |
69 | 8 | — | 第一章第 9 节 |
CMEM:cld → crf |
53 | 2 | 2 | 第 3 节,第二章第 3 节 |
TC VMEM 写后读:vst → 读同一批行的 vld |
7 | 1 | — | 本节 |
向量 → 标量:vpush → v2sf → spop |
42(从 vpush 向量发射算起;它最早在标量发射后 1 个周期向量发射,所以 spop 最早在 vpush 标量发射后 43 个周期执行),spop 在标量一侧等待 |
— | — | 第一章第 12 节 |
标量:sld |
4(相邻两条 sld 也至少相隔 4) |
— | — | 第一章第 8 节 |
mrf2、mrf3 的结果比 mrf0、mrf1 晚 18 个周期,即 101(研究报告 55)。
模型写在仓库根目录的 issue_model.py 中,一百多行。核心是每个 bundle 的向量发射时刻:
issue = max(vector_free, scalar + 1 + any(decode(item)[0].startswith(MEMORY) for item in vector))
for item in vector:
mnemonic, operands = decode(item)
if mnemonic.startswith('vpop'):
events.append(('pop', operands[1]))
issue = max(issue, queues[operands[1]][0]) # 等结果进入队列
elif operands and kind(operands[0]) in PUSH_LATENCY:
events.append(('push', operands[0]))
unit = next((prefix for prefix in UNIT_INTERVAL if mnemonic.startswith(prefix)), None)
if unit:
events.append(('unit', unit))
issue = max([issue, *(ready.get(source, 0) for source in operands[1:])]) # 等源操作数
for event, queue in events:
issue = max(issue, last.get(f'{event} {queue}', -99) + (UNIT_INTERVAL[queue] if event == 'unit' else INTERVAL.get(f'{event} {kind(queue)}', 1))) # 等单元TWO_CYCLES 列出结果 2 个周期后才可用的向量 ALU 指令,其余按 1 个周期处理。UNIT_INTERVAL 记录不经过队列、但同一单元相邻两条有最小间隔的指令,目前只有一项:第四章第 6 节测得的 vrng,每 8 个周期一条。
模型的输入是动态执行的 bundle 序列:循环要按实际迭代次数展开。模型不处理 DMA 和 vwait,它们的时间取决于数据通路,由第二章的开销模型给出。标量一侧加入了第一章第 8 节测得的 sld 规则:
if mnemonic == 'sld':
scalar = max(scalar, last_sld + SLD_INTERVAL) # 相邻两条 sld 至少相隔 4 个周期
# 读取尚未就绪的标量寄存器时,整个 bundle 等待。
scalar = max([scalar, *(scalar_ready.get(source, 0) for source in sources)])sld 写入的寄存器在 4 个周期后就绪(SLD_LATENCY);其余标量指令的结果都按下一周期可用处理。有一点与预期不同:紧接在 sld 之后的 vmov.8x128 v11, s24 读同一个寄存器,读数里看不到任何等待,模型不加等待时恰好与真机一致(下表的“sld 与依赖 sld 结果的向量运算”)。第一章第 8 节的实验确认,此时 vmov 读到的就是 sld 载入的新值,而不是旧值;vmov 读标量操作数的时刻本节没有进一步确定。
用模型预测,再与真机比较
脚本把每段片段同时交给模型和真机:
measured = sorted({tuple(row) for row in probe.run(program, setup=setup).tolist()})
reads = issue_model.replay(issue_model.parse(trace))
predicted = (reads[21] - reads[20], reads[22] - reads[20])片段取自前面各节的时序实验:独立与相互依赖的向量指令、EUP、MXU、转置、归约、循环移位、vpush/spop、sld、CMEM 的 cld,以及计数循环。51 段片段中 50 段的 R1 − R0、R2 − R0 与模型完全一致,包括 VIF 积压、挡住标量发射的情况。例如 16 组 cld + vpop:模型和真机都是 (337, 877)。R1 = 337 的来历是:R1 前面有 32 个进入 VIF 的 bundle,R1 要等到只剩 19 项未释放,也就是第 13 项(第 7 条 cld)释放;它在第 327 个周期向量发射,第 337 个周期释放。
不一致的只有 1 段:1 组 cld + vpop,模型给出 (3, 67),实测在 (3, 66) 与 (3, 67) 之间变化。
编译器生成的代码同样可以预测。第一章第 13、15 节把 kernel 的计算部分原样插进载体,模型与实测逐个相同:
| 计算段 | bundle 数 | 实测 R2 − R0 | 模型 |
|---|---|---|---|
| 前缀和,沿通道的 Hillis–Steele | 35 | 526 | 526 |
| 前缀和,沿子通道 | 27 | 58 | 58 |
| 前缀和,沿子通道逐行广播 | 14 | 30 | 30 |
| argmax | 9 | 103 | 103 |
lax.top_k,k = 8 |
54 | 671 | 671 |
| 手写 top-8,掩码排除已选位置 | 88 | 1337 | 1337 |
| 手写 top-8,抬高 | 72 | 681 | 681 |
这张表的后几行在加入“f32 的加、减和类型转换也是 2 个周期”这条规则之前,模型比实测少 1 到 7 个周期。差几个周期也说明缺了规则,下一小节讲怎样把它找出来。
模型与实测不符时:逐个 bundle 定位
上面那张参数表不是一次写全的。模型预测与实测不符时,说明缺了一条规则,要找的是它在哪一个 bundle 上起作用。办法是第 3 节末尾的那一条:起点的读数不动,把终点的读数逐个 bundle 往后移,每个位置各运行一次,得到“前 p 个 bundle 全部发射完”的时刻;模型对同样的前 p 个 bundle 也给出一个预测。两者之差在哪个 bundle 上跳变,缺的规则就与那个 bundle 有关。
下面这段清单把中间结果存进 TC VMEM,稍后再读回,是寄存器不够用时常见的写法:
SECTION = (
'va0: vadd.8x128.s32 v11, 1, v10',
'vst: vst.8x128 [vmem:0x80], v11',
'va0: vadd.8x128.s32 v12, 2, v10',
'va0: vadd.8x128.s32 v13, v12, v11',
'vld: vld.8x128 v14, [vmem:0x80]',
...
)
for count in range(1, len(SECTION) + 1):
body = ''.join(bundle(text) for text in SECTION[:count])
measured, without = measure(probe, body), predict(body, 0)predict(body, 0) 是去掉写后读规则的模型,也就是这条规则被发现之前的模型。括号中是差值比上一行增加了多少,每行末尾是新加进来的那个 bundle:
p = 1:实测 14,没有写后读规则的模型 14,差 0(+0),加上规则的模型 14 va0: vadd.8x128.s32 v11, 1, v10
p = 2:实测 15,没有写后读规则的模型 15,差 0(+0),加上规则的模型 15 vst: vst.8x128 [vmem:0x80], v11
p = 3:实测 16,没有写后读规则的模型 16,差 0(+0),加上规则的模型 16 va0: vadd.8x128.s32 v12, 2, v10
p = 4:实测 17,没有写后读规则的模型 17,差 0(+0),加上规则的模型 17 va0: vadd.8x128.s32 v13, v12, v11
p = 5:实测 22,没有写后读规则的模型 18,差 4(+4),加上规则的模型 22 vld: vld.8x128 v14, [vmem:0x80]
p = 6:实测 23,没有写后读规则的模型 19,差 4(+0),加上规则的模型 23 va0: vadd.8x128.s32 v15, v14, v13
p = 7:实测 24,没有写后读规则的模型 20,差 4(+0),加上规则的模型 24 vst: vst.8x128 [vmem:0x88], v15
p = 8:实测 25,没有写后读规则的模型 21,差 4(+0),加上规则的模型 25 vld: vld.8x128 v16, [vmem:0x90]
p = 9:实测 26,没有写后读规则的模型 22,差 4(+0),加上规则的模型 26 va0: vadd.8x128.s32 v17, v16, v15
p = 10:实测 31,没有写后读规则的模型 23,差 8(+4),加上规则的模型 31 vld: vld.8x128 v18, [vmem:0x88]
p = 11:实测 32,没有写后读规则的模型 24,差 8(+0),加上规则的模型 32 va0: vadd.8x128.s32 v19, v18, v17
p = 12:实测 33,没有写后读规则的模型 25,差 8(+0),加上规则的模型 33 vst: vst.8x128 [vmem:0x80], v19
差值只在第 5、第 10 个 bundle 上跳变,每次 4 个周期。这两个 bundle 都是 vld,读的都是 3 个 bundle 之前刚由 vst 写过的 tile;第 8 个 bundle 也是 vld,读的是没有被写过的 0x90,差值不变。所以缺的规则是:读刚写过的 tile 要等。
接着像前面测通路参数那样,单独测出这条规则的参数。vst 之后隔 d − 1 个 vnop 再 vld:
| d | 1 | 2 | 4 | 6 | 7 | 8 | 10 |
|---|---|---|---|---|---|---|---|
读同一个 tile(0x80) |
21 | 21 | 21 | 21 | 21 | 22 | 24 |
读错开 4 行的 tile(0x84) |
21 | 21 | 21 | 21 | 21 | 22 | 24 |
读相邻的 tile(0x88) |
15 | 16 | 18 | 20 | 21 | 22 | 24 |
读相邻的 tile 时 R2 − R0 = 14 + d,没有等待。读同一个 tile 时,d ≤ 7 都是 21,d = 8 起才随 d 增加:vld 最早在写同一批行的 vst 向量发射之后 7 个周期发射。 判断的单位是行,不是 tile:0x84 起的 8 行与 0x80 起的 8 行有 4 行重叠,同样要等。另外几种排列:
| 序列 | 实测 R2 − R0 | 模型 |
|---|---|---|
vst 与读同一个 tile 的 vld 在同一个 bundle |
14 | 14 |
8 组(vst、读同一个 tile 的 vld) |
77 | 77 |
8 组(vst、读相邻 tile 的 vld) |
29 | 29 |
8 条 vst,再 8 条读同一个 tile 的 vld |
35 | 35 |
8 组(读同一个 tile 的 vld、vst) |
71 | 71 |
同一个 bundle 中的 vst 与 vld 同时发射,不等。交替地写一个 tile、读一个 tile,每组 8 个周期;先写完再读,只有第一条 vld 等一次。把这条规则加进模型(issue_model.STORE_LOAD = 7,地址是立即数时才能判断读写的是哪几行)之后,上面逐 bundle 的 12 个时刻与表中的 5 种排列全部与实测一致,此前各段片段的结果不变。第一章第 6 节中 Pallas 的 int8 转换比 XLA 慢一倍,就是这条规则的后果。
这条规则对写 kernel 的含义是:存进 TC VMEM 的中间结果不要马上读回,中间至少隔 7 个周期的其他工作;编译器把寄存器溢出到 TC VMEM 时也受它约束。top-k 论文的 3.3 节用同样的办法测出了按索引读写(第一章第 9 节)的等待:vsetiar 之后的 vld.iar 要等,任何一次写内存之后的按索引读也要等,不论写的是哪个 tile。后一条正是用逐 bundle 的办法发现的:实测与模型之差只在含 vld.iar 的 bundle 上跳变。本节的模型没有包含按索引读写。
先判断受哪一类资源限制
逐个 bundle 推算之前,往往只要数清楚一段计算占用哪些资源,就能知道它的时间由什么决定、换哪一种写法才有用。上面的参数分成三类:
- 提交—取回的往返延迟。 一个结果要送进某个单元、等它出来才能接着用时,时间是依赖链上往返的次数乘以延迟:XLU 的归约 79 个周期,MXU 83 个周期,
vpush/spop43 个周期。链上的往返不能重叠,指令再少也快不了。 - 单元的发射间隔。 往返很多但互不依赖时,延迟被流水掩盖,时间是提交的条数乘以发射间隔:XLU、MXU 每 8 个周期一条,EUP 每 2 个周期一条。
- 逐元素指令的条数。 完全不经过这些单元时,时间是向量 ALU 指令的条数,每个周期可以发射两条(
va0、va1两个槽),再加上内存读写的等待。
这就是第一章第 15 节归纳的三类资源。那里的 top-k 是第一类:k 轮 argmax 串成一条链,每轮 82 个周期,其中 79 个在等 XLU,所以 f32[8,128] 取前 8 名的 685 个周期与指令条数几乎无关。要让它更快,减少指令没有用,只能缩短链,或者让链不经过 XLU。第一章第 12 节的累加链是第三类里“依赖链的长度”的例子,拆成两条之后瓶颈换成了发射槽。top-k 论文按这三类逐一给出了写法,并把这种估计给出的下限与实测放在一起(论文表 19):
| 写法 | 判断的瓶颈 | 下限 | 实测 | 下限占实测 |
|---|---|---|---|---|
| k 轮 argmax 的链,每轮 | XLU 往返 | 79 | 82 | 96% |
| 行宽 1024 时的链,每轮 | XLU 往返,两次 | 148 | 178.7 | 83% |
| 秩计数,k = 16 | XLU 发射间隔 | 573 | 830 | 69% |
| 折叠后数秩,k = 8 | XLU 往返,三趟 | 274 | 568 | 48% |
链的估计最准;XLU 不再是瓶颈之后,剩下的时间分散在写后读、按索引读这类等待和向量 ALU 的工作上,只数一类资源就不够了,要回到逐个 bundle 的模型。这种分类用来决定换哪一类写法,精确的周期数仍然要实测。
指令相同,先后不同,周期数也不同。 向量一侧按序发射,一个 bundle 在等,后面的都在等。同一组指令,把要等的那条往后挪、把不依赖它的工作提到前面,等待就被填上了。论文的 7.2 节给出了一个数量:同一个算法、同一组指令,编译器排出的顺序是 714 个周期,按实测的延迟重排之后是 595 个周期,一条指令也没有改。差别来自编译器的排程器不知道某些等待(转置之后 XLU 队列的占用、按索引读之前的等待),把指令排在一起,硬件只好一条条地等。手写或改写一段清单时,周期数取决于每种资源被谁占用、占多久,以及这些占用怎样错开,而不只是指令的条数。
编译器自己也有一张延迟表。 libtpu 的排程器按一张内部的延迟表决定两条指令之间隔几个周期。论文的 3.3 节在编译 top-k 时记录了它的回答:归约从提交到取回 79,循环移位和重排从提交到取回 69,同一个 XLU 上相邻两次提交 8,与本节实测的参数一致。这是一个独立的旁证。但这张表并不完整:转置之后队列的占用,表里是 104 个周期,实测约 223 个;与索引寄存器有关的等待,表里没有。编译器生成的清单大体上已经按延迟排好,上面那 119 个周期的差别就出在这张表缺失和偏小的地方;参数以真机实测为准。
由参数得到的写法规则
第一章各节在测出参数的同时,已经给出了相应的写法。把它们放在一起,就是排一段代码时要遵守的规则,每一条都对应模型中的一个参数:
- 循环。 周期数是循环体的 bundle 数(含延迟槽)乘以迭代次数,再加上循环体内的等待;分支本身没有额外开销。展开让不同迭代的指令互相填补(第一章第 8 节)。
- 提交—取回的单元要让足够多的提交同时在途。 单独一次往返,延迟是空等;连续提交时延迟被流水掩盖,时间由发射间隔决定。EUP 的延迟是 7、间隔是 2,要 4 条在途;XLU 的归约是 79 比 8,MXU 是 83 比 8,都要约 10 条在途(第一章第 7、11、12 节)。
- 取回有自己的间隔。 MXU 和 XLU 的取回每 8 个周期一条,积压的结果不能一口气取完,提交与取回要交错排列;两个 XLU 的提交和取回要成对放进同一个 bundle(第一章第 10、11 节)。
- 依赖链按延迟计。 f32 的加、减、乘和类型转换每条 2 个周期,整数运算每条 1 个周期。一条长的累加链可以拆成几条互不依赖的链(第一章第 12 节)。
- 向量 → 标量很贵。
spop最早在vpush标量发射之后 43 个周期执行,期间标量一侧停止发射。把向量的计算结果变成循环次数或分支条件,每次都要付这个开销(第一章第 12 节)。 - TC VMEM 写后读。
vst之后 7 个周期之内,读同一批行的vld不能发射。暂存的中间结果隔 7 个周期以上再读回,或者在这 7 个周期里安排别的工作。 - CMEM。
cld之后 53 个周期才能vpop,cld每 2 个周期可以发射一次。串行地“读一个、取一个”时每组恰好 54 个周期,提前发出多个cld才能接近每 2 个周期一个 TC VREG(第二章第 3 节)。
模型的边界
模型可以预测不含 DMA 的直线代码,输入是动态执行的 bundle 序列。它没有包含按索引读写(vsetiar、vld.iar、vst.iar)、分段转置和转置之后 XLU 队列的占用;用到这些指令的清单,按上面逐个 bundle 的办法先补上规则。对整个 kernel,还需要把 DMA 的开销(第二章第 2 节)和各 TensorCore 之间的等待加进来。TC VMEM 读写、向量 ALU、EUP、MXU 各类指令实际执行了多少条,可以用 TensorCore 的硬件性能计数器从主机读出来,用于核对展开后的序列(研究报告 61)。研究报告 49 讨论了把这样的模型扩展成读取真实清单的静态计时器还缺什么:DMA 与向量工作的组合、并发 DMA、完整的 MXU 规则。本节的参数可以作为这项工作的输入。