Pallas TPU Kernel 开发教程

由硬件推导集合通信

前面几节给出了跨芯片通信的全部基本操作和它们的开销。本节不介绍某个“标准”的集合通信算法,而是从这些开销出发,推导 all-reduce(每颗芯片的数组求和,结果分发回每颗芯片)应当怎样做,再用实验检验推导。目标不是背下一种算法,而是学会用硬件参数比较不同的设计。

推导用到的硬件事实

来自第 2、4–6 节与研究报告 31(K 为 KiB 数):

事实 数值
相邻芯片的 remote DMA 1941 + 24.77K 个周期
对角线(两跳)的 remote DMA 2908 + 24.77K 个周期
本 host 的四颗芯片排成 2×2,每颗有两条链路 0–1、0–2、1–3、2–3 相邻
不同链路、或同一链路的两个方向上的传输互不影响 每条链路每个方向约 43 GB/s
一次 remote DMA 的固定开销 约 1900–2900 个周期
同一条链路、同一方向上的两条流 共享约 43 GB/s
对角线的路由 两跳经过哪颗中间芯片由硬件决定;研究报告 31 的竞争实验支持“先沿 x、再沿 y”的推断,本节后面的实验也证实了它
HBM → TC VMEM 483.6 + 1.105K 个周期

设每颗芯片的数组为 M。三种设计:

一次交换:每颗芯片把整份数据同时发给其余三颗,再把收到的三份相加。只付一次固定开销(对角线的 2908 个周期),但每颗芯片发出 3M,其中发往对角线的那份还要经过中间芯片转发。数一数每条链路上的数据:四颗芯片排成环 0→1→3→2,每条链路有两个方向,共 8 个“有向链路”。每颗芯片发给两个邻居的数据各占一个有向链路,共 8 份 M,正好每个有向链路一份;四条对角线流每条走两跳,共 8 跳,按先 x 后 y 的路由,正好也是每个有向链路一跳。所以每个有向链路要传 2M,预计约 2908 + 2 × 24.77M 个周期(M 以 KiB 计)。

环形:四颗芯片按物理相邻排成环(第 5 节的 0→1→3→2),把数组分成 4 块。先 reduce-scatter:每一步每颗芯片把一块发给右边、收到左边的一块并累加,3 步后每颗芯片持有一块完整的和;再 all-gather:每一步把完整的一块传给右边,3 步后每颗芯片都有全部 4 块。共 6 步,每步只传 M/4,只用相邻链路,但要付 6 次固定开销。预计约 6 × (1941 + 24.77 × M/4) 个周期。环形会改写自己的数组,所以下文的实现每次开始前都从 HBM 重新读入,再加 483.6 + 1.105M 个周期。

双向环形:环形只用了每颗芯片向右的那条链路。把数组分成两半,一半沿环向右、一半沿环向左,同时进行。每步仍是 6 步,每步传 M/8,两条链路都用上。预计约 6 × (1941 + 24.77 × M/8) 个周期。

可以预见:M 小时固定开销占主导,一次交换最好;M 大时带宽占主导,双向环形最好:它的每个有向链路只传 6 × M/8 = 0.75M,而一次交换是 2M,单向环形是 1.5M。

写法

本小节实验源码、输出。

三种设计都是一个 Pallas kernel,在四颗芯片的 TensorCore 0 上运行,mesh 按物理环排列。它们共用的部分是一个四方汇合:

def barrier() -> None:
    ready = pltpu.get_barrier_semaphore()
    for rank in range(CHIPS):
        pl.semaphore_signal(ready, 1, device_id={'device': rank, 'tc': 0})
    pl.semaphore_wait(ready, CHIPS)

一次交换同时发出三个 remote DMA,写进对方 inbox 的不同格子,然后相加。offsets 默认为 (1, 2, 3),即依次发给 mesh 中的 me + 1、me + 2、me + 3:

copies = [pltpu.make_async_remote_copy(acc, inbox.at[k], send_sems.at[k], recv_sems.at[k], device_id={'device': (me + offset) % CHIPS, 'tc': 0}, device_id_type=pl.DeviceIdType.MESH) for k, offset in enumerate(offsets)]
for copy in copies:
    copy.start()
for copy in copies:
    copy.wait_send()
    copy.wait_recv()
total = acc[...]
for k in range(len(offsets)):
    total = total + inbox[k]
inbox[0] = total

环形的每一步是“等下游许可 → 发送 → 等发出和收到 → 累加 → 给上游许可”:

if not first:
    pl.semaphore_wait(credits.at[d], 1)          # 下游已用完上一次收到的数据
transfer = pltpu.make_async_remote_copy(source, destination, ..., device_id={'device': (me + sign) % CHIPS, 'tc': 0}, ...)
transfer.start()
transfer.wait_send()
transfer.wait_recv()
part(index)[...] = part(index)[...] + inbox[d]   # reduce-scatter 阶段累加
pl.semaphore_signal(credits.at[d], 1, device_id={'device': (me - sign) % CHIPS, 'tc': 0})

这里的许可(credit)是一个普通信号量,它代替了每一步的四方汇合:只有相邻的两颗芯片需要互相等待,不必等所有芯片。第 4 节说过,remote DMA 直接写对方的 buffer,必须先确认对方已经用完上一次的数据,许可就是这个确认。sign 为 1 时向右、为 −1 时向左,双向环形同时执行两个方向。

all-gather 阶段把完整的块直接写进下游 acc 的同一位置,不经过 inbox,省去一次复制。

结果

计时方法与第 2 节相同:在 kernel 内部把 all-reduce 重复 64 次和 32 次,各读出 kernel 在 device 0 上的周期数,相减除以 32。各种设计的数值都与求和结果逐元素一致。“对角线最后发起”是只改一次交换中三个 DMA 的发起顺序,原因见下一小节。

每颗芯片 一次交换 一次交换,对角线最后发起 环形 双向环形
16 KiB 3923 3684 14229 13901
128 KiB 10191 9625 18243 16221
1 MiB 78281 54504 52838 33889
预计:16 KiB 3701 3701 12741 12444
预计:128 KiB 9249 9249 17027 14649
预计:1 MiB 53637 53637 51307 32285

单位是周期。预计值由上面的公式算出,环形包含每次从 HBM 重新读入的时间。汇合单独测得每次 1374 个周期(只做四方汇合、不传数据的 kernel),不在预计值中。除了原顺序的一次交换在 1 MiB 时多出 2.4 万个周期,实测与预计相差不大:环形与双向环形多约 1200–1700 个周期,与一次汇合相当;一次交换在小尺寸上也只多几百到一千个周期,可见这样把各部分相加的公式只是近似。本节没有再拆分。推导的结论都得到了验证:

  • 16 KiB 时,环形的 6 次固定开销(约 11600 个周期)远大于数据量,一次交换快 3 倍多。
  • 1 MiB 时,数据量占主导:一次交换的每个有向链路传 2M,与单向环形的 1.5M 加上 6 次固定开销相当;双向环形每个有向链路只传 0.75M,最快。
  • 双向环形在 16 KiB 时没有收益:每步的数据减半,但固定开销不变。

拆开一次交换:发起顺序

原顺序的一次交换在 1 MiB 时是 78281 个周期,比链路负载推出的 53637 个多出近一半。为了找出原因,实验保留一次交换的写法,只改发给谁、按什么顺序发起:one_shot 的参数 offsets 列出每个 DMA 发给 mesh 中的 me + o,顺序就是发起的顺序。在物理环的 mesh 中,o = 1、3 是相邻芯片,o = 2 是对角线芯片:

copies = [pltpu.make_async_remote_copy(acc, inbox.at[k], send_sems.at[k], recv_sems.at[k], device_id={'device': (me + offset) % CHIPS, 'tc': 0}, device_id_type=pl.DeviceIdType.MESH) for k, offset in enumerate(offsets)]
发给(按发起顺序) 每个有向链路最多传 128 KiB 1 MiB
一颗相邻芯片:1 M 5834 28468
两颗相邻芯片:1、3 M 5697 28629
对角线芯片:2 M 6175 28641
同一颗相邻芯片发两份:1、1 2M 8653 54072
相邻与对角线,先发相邻:3、2 2M 9210 54560
相邻与对角线,先发对角线:2、3 2M 10332 78174
全部三颗:1、2、3 2M 10184 78466
全部三颗,对角线最后:1、3、2 2M 9640 54865
全部三颗,对角线最先:2、1、3 2M 10645 79180

单位是周期。前四行与链路负载吻合:一个有向链路传 M 时约 28600 个周期(1941 + 24.77 × 1024 是 27305,加上汇合的 1374),传 2M 时约 54000 个周期。两颗相邻芯片同时发送互不影响;对角线流与相邻流的时间几乎相同,带宽与一跳一样。

后五行的流量完全相同,差别只在发起顺序:只要有一个发往相邻芯片的 DMA 在发往对角线的 DMA 之后发起,1 MiB 就从约 5.4 万个周期变成约 7.8 万个;对角线放在最后发起,就回到链路负载的预计。“3、2”与“2、3”是同样的两条流,只是顺序相反。下一小节把流再拆细,找出原因。

一条链路上的流按顺序通过

本小节实验源码、输出。

为了看清发起顺序为什么起作用,实验不再让四颗芯片做同样的事,而是只发出指定的几条流。每条流写成(源, 目的),都是物理环 mesh 中的位置(位置 0、1、2、3 依次是 device 0、1、3、2);kernel 用 pl.when(me == position) 让每颗芯片只执行属于自己的那一段:

for position in range(CHIPS):
    @pl.when(me == position)
    def _() -> None:
        outgoing = [flow(k) for k, (source, _) in enumerate(flows) if source == position]
        for copy in outgoing:
            copy.start()
        for copy in outgoing:
            copy.wait_send()
        for k, (_, destination) in enumerate(flows):
            if destination == position:
                flow(k).wait_recv()

实验只改 flows,每条流 1 MiB:

流(按发起顺序) 每次的周期数
(0, 2) 28128
(0, 1)、(0, 2) 54044
(0, 2)、(0, 1) 52986
(0, 3)、(0, 2) 28222
(0, 2)、(0, 3) 28250
(1, 3)、(0, 3) 53562
(0, 3)、(1, 3)、(1, 0) 77516
(0, 3)、(1, 0)、(1, 3) 53625

逐行读:

  • 对角线流走哪边。 位置 0 的对角线流 (0, 2) 与发往位置 1 的相邻流一起发时要两倍时间,与发往位置 3 的相邻流一起发时不变:它经过位置 1,占用链路 0→1 和 1→2。位置 1 的对角线流 (1, 3) 与位置 0 发出的 (0, 3) 一起时也是两倍:它经过位置 0,占用 1→0 和 0→3。换成芯片坐标,两条都是先沿 x、再沿 y,证实了前面表中的推断。
  • 同一个源的两条流共用一条链路时,顺序无关。 (0, 1) 与 (0, 2) 无论谁先,都是约 5.3 万个周期:这条链路总共要传 2 MiB。
  • 三条流的最后两行是关键。 流量相同,只是位置 1 的两条流顺序相反,时间相差 2.4 万个周期。按链路数:0→3 上有 (0, 3) 和 (1, 3) 的第二跳,1→0 上有 (1, 0) 和 (1, 3) 的第一跳,每条链路都是 2 MiB,按链路负载应当都是约 5.4 万个周期。

7.7 万个周期是三条流一条接一条的时间。这说明一条链路上的流不是交替着分享带宽,而是按顺序一条一条通过,并且两跳的流在等第二跳时一直占着第一跳:

  1. 位置 0 的 (0, 3) 和位置 1 的 (1, 3) 几乎同时发起。(0, 3) 就在链路 0→3 的起点,立刻占用它;(1, 3) 占用 1→0,等它到达位置 0 时,0→3 已经被占。
  2. (1, 3) 停在位置 0 等 0→3,同时占着 1→0;排在它后面的 (1, 0) 也只能等。这是第一个 2.6 万个周期。
  3. (0, 3) 传完,(1, 3) 通过,第二个 2.6 万个周期;然后才轮到 (1, 0),第三个。

把位置 1 的顺序改为先发 (1, 0):(1, 0) 与 (0, 3) 用的是不同的链路,同时进行;等它们传完,(1, 3) 的两跳都空了。只要两段。

回到一次交换。按 1、2、3 的顺序发起时,每颗芯片的对角线流都排在自己的一条相邻流之前、另一条相邻流之后,而它的第二跳会被中间芯片自己的相邻流抢先占用:对角线流占着第一跳等待,后面的相邻流被它挡住,于是成了三段。对角线最后发起时,所有相邻流先同时传完(每个有向链路一条),再同时传所有对角线流(每个有向链路一跳),是两段。

所以设计跨芯片通信时,除了数每条链路上的数据量,还要看流的先后:让只走一跳的流先发,多跳的流后发,避免多跳的流占着前一跳等后一跳。

对照:原生 XLA

本小节实验源码、输出。

在 shard_map 中调用 jax.lax.psum,XLA 生成一个 psum 段,其中有 16 条 dma.general、20 条 vsyncadd.remote,三种大小都相同。读清单的开头可以看出它和谁通信:它从 [smem:0x0] 读出本芯片的编号,拆出坐标的 x、y 位,算出 x + 1、x − 1、y + 1、y − 1 四个邻居的编号,汇合与数据都只发给这四颗芯片,从不直接发往对角线。在 2×2 中,x + 1 与 x − 1 是同一颗芯片,y 方向也一样,所以每个邻居要收两路数据。传数据的部分是几个循环,循环体中 4 条 dma.general 同时发往四个方向,ici_dest 分别由这四个编号拼成,每条 4 KiB 或 8 KiB。这符合沿 x、y 两个方向各做一次双向环形的结构,本节没有进一步逐条验证。

计时与 Pallas 版本一样取差值:用 fori_loop 循环 64 次与 32 次,用 KernelClock.time_ops 在整个程序的起止处插入 LCC 读数,取 device 0 的 TensorCore 0 上的周期数之差,除以 32:

每颗芯片 XLA psum 本节最快的设计
16 KiB 13316 3684(一次交换,对角线最后发起)
128 KiB 15928 9625(一次交换,对角线最后发起)
1 MiB 33346 33889(双向环形)

XLA 对所有大小使用同一种算法,它在大数组上与双向环形相当,在小数组上的耗时是一次交换的 1.7–3.6 倍:后者正是固定开销占主导、一次交换更合适的区域。被计时的两种循环不完全相同(XLA 的循环在 kernel 之外,每次多一次乘法,数组可能被放进 CMEM,第 2 节),但小数组时差距主要来自算法本身的固定开销次数。

这个对照说明了手写的价值所在:XLA 必须选一种对所有情况都过得去的算法;知道硬件参数和自己的数据大小,就能选出更合适的那一种。本节的设计还有继续改进的余地,例如让每颗芯片的两个 TensorCore 各负责一半(第 1 节),或在中等大小时把一次交换与环形结合。在两个 host 的 TPU v4 (2x2x2) 上,芯片多了 z 方向的链路,研究报告 32 按同样的思路推导了三维的 all-reduce。

文件

正在载入文件…