AI Infra · 第 5 章图解

算子与运行时

同一个矩阵乘,数学上只需 103 GFLOPs、128 MiB 数据;一种朴素的分块实现,却让数据在存储接口上来回走了 3 GiB。本章要回答的,就是这些多出来的搬移、等待和准备从哪里来,又能省掉多少。

三份矩阵各读写一次
X 8 + W 96 + C 24
128 MiB
64×64 分块的实际读写
3096 MiB ≈ 24×
128×128 分块
1560 MiB
哪些工作在重复?重复搬运的输入、重复提交的 kernel、重复编译的程序。
哪些数据能复用?留在片上的输入块、直接交给下一步的中间结果、缓存住的执行计划。
谁决定了总时间?较慢的流水阶段、主机提交、关键路径上的那条分支。
搬移 / 复制 计算 主机(CPU)工作 等待 / 空闲 代价 / 瓶颈
算例贯穿全章:Qwen3-8B 的 FFN,隐藏宽度 H=4096,中间宽度 F=12288,一次处理 M=1024 个 token,BF16 存储、FP32 累加。页面里的图都可以操作,带 ▶ 的会按时间轴播放。

全章的路线:从一次调用出发,由近到远,最后回到用户等待的完整请求。

5.1

加速器执行中的提交、搬移与同步

调用一次 torch.matmul,CPU 先跑框架代码:读形状和类型、挑实现、准备地址参数,再经运行时和驱动把任务交给 GPU。先分清三个经常混用的词:

算子描述数学工作:矩阵乘、激活、归一化。
kernel在加速器上真正完成工作的程序。一个算子可以拆成多个 kernel,几个逐元素算子也能合成一个。
launchCPU 发起一次 kernel 执行的动作。它是异步的:调用返回时 GPU 可能还没开始算。

CPU 提交、GPU 执行,两者构成一条流水。大矩阵算得久,CPU 来得及准备下一项;小算子转瞬结束,GPU 就停下来等 CPU。所以加速有两个方向:缩短 kernel,或者减少加速器等主机的时间。

数据在哪:H2D、D2H、D2D

主机内存(CPU)

普通内存页面可能被换出,不能直接搬
锁页缓冲(pinned)常驻内存,复制引擎可直接读写
结果缓冲
H2D →← D2H

显存(GPU)

权重加载一次,长期驻留,被读很多次
输入缓冲 → kernel → 输出
KV / 层间激活下一个 kernel 直接读,不回主机

普通内存的数据要先被 CPU 复制进锁页缓冲,复制引擎才能搬,等于多走一遍。让 CPU 直接在可复用的锁页缓冲里准备输入,能省掉这次中转和反复分配。

例 5-1 · 把 64 MiB 输入切几份传

PCIe Gen5 x16 · 64 GB/s 单向

stream 与 event:谁先谁后

stream 是一串按顺序执行的 GPU 任务:H2D 和读它的 kernel 放进同一个 stream,计算自然排在复制之后。复制和计算放在两个 stream 时,用 event 连起来:复制流在 H2D 后记录事件,计算流等它。这个等待发生在 GPU 的队列里,CPU 照样往下提交。

图 5-2 · 两条流、两个槽、两种事件

批 2 要写回槽 A,必须等“批 0 已用完”。“已传完”和“已用完”是同一块缓冲上的两个不同时刻。

程序停住不动?先分“慢”和“卡死”

每次等待都指向一个具体事件,事件永远不被记录,等待就永远不结束。被等的一侧仍有产出,说明只是慢,回到时间线逐段计时;完全不动,才是在等一个不会来的事件。

三种常见的“等不到”

① 漏记“缓冲已用完”,复制引擎一直不能写下一批;② A 流等 B 流的事件,B 的这项任务又排在 A 未完成的工作后面,循环等待;③ 多卡集合通信里有一张卡少发起一次调用。给等待加超时,超时后打印各流未完成的任务。

例 5-2 · kernel 快 4 倍,整次调用只快 1.75 倍

三种计时位置测到三种耗时:在异步调用前后读 CPU 时钟,只量到提交(3 μs);用 CUDA event 夹住 kernel,量到加速器执行;等结果回来再停表,才是调用者真实等待的时间。

首次编译被多少次调用摊薄

编译 100 ms · 每次执行 1 ms

一个 kernel 在 SM 上怎么跑

图 5-3 里那段 20 μs 的 kernel 能不能缩短,要看 SM 里发生了什么。固定一个线程块:输出块 128×128、沿 K 每步 64;A 块、W 块各 16 KiB,FP32 累加器 64 KiB,都放共享内存,工作集共 96 KiB,256 个线程(8 个 warp)。硬件取 H100 的一个 SM。

warp

32 个线程一起发射同一条指令,各算各的数据。一条加载指令因此一次给出 32 个地址。

驻留与占用率

线程块要同时分到线程槽、寄存器、共享内存才能上 SM。每项资源能放几块取最小值;占用率 = 驻留 warp ÷ 64。

延迟隐藏

Little 定律:维持带宽需要的在途字节 = 带宽 × 延迟。在途请求都由驻留的 warp 发出,驻留越少,在途越少。

SM 占用率计算器

H100 · 2048 线程 · 64K 寄存器 · 228 KB 共享内存 · 3.35 TB/s ÷ 132 SM

矩阵指令的粒度

一条 MMA 是 16×8×16,做 4096 次浮点运算。128×128×64 的块需要 8×16×4 = 512 条,8 个 warp 每个发 64 条。累加器放在共享内存时,每个 K 步前后都要在寄存器和共享内存之间搬一次,放进寄存器就省了这笔搬移。

Hopper 的 warpgroup 指令

4 个相邻 warp 联合发出,直接从共享内存取操作数,异步执行:发出后可以去做别的事。FlashAttention-3 就靠它让矩阵乘与 Softmax 重叠。

图 5-5 · warp specialization:生产者搬、消费者算

每个槽配一对栅栏(barrier):“满”由异步复制完成触发,消费者等它;“空”由计算结束触发,下一次复制等它。按 H100 一个 SM 的份额:搬一个 32 KiB 的 K 块 1.29 μs,算它 0.28 μs。复制比计算慢 4.6 倍,因为这个块每搬 1 byte 只做 64 次运算,而 H100 的矩阵峰值与带宽之比约 295 FLOP/byte。

对应到昇腾:同样五步,换了一套部件
机制NVIDIA GPU昇腾
发射与分工warp 是 32 线程的发射单位,生产者/消费者 warp 在同一 SM 内分工AIC、AIV 是独立的核:矩阵在 AIC,向量在 AIV,各有指令流
驻留与容量寄存器、共享内存、线程槽决定每 SM 驻留块数L0、L1 与统一缓冲的容量决定每个核一次容纳的块
搬移与同步异步复制到达栅栏,warp 等栅栏MTE / NDDMA 搬移,BufferID 标识缓冲并组织交接

异步批量复制(TMA)按张量描述符整块搬,不能在行间插 padding,所以改用 swizzle 置换块内地址来分散 bank,见 5.2.3。

5.2

单个算子的分块、布局与实际访存

把投影写成 C = A·W,M=1024,K=4096,N=12288,A、W、C 分别 8、96、24 MiB。算 C[i,j] 要 A 的第 i 行和 W 的第 j 列;接着算右边的 C[i,j+1],A 还是同一行。这行 A 若还在片上,就能直接复用;若已被换走,就要重新搬。

图 5-6 · 同一行 A 被相邻输出反复使用

蓝色是正在用的 A 行,橙色是 W 列,绿色是刚算出的输出。

分块就是让搬来的数据多用几次。固定一块 m×n 的输出,沿 K 依次搬入 A 块(m×k)和 W 块(k×n),一直累加到同一份 FP32 部分和里,最后才写回。A 块每个元素服务 n 列输出,W 块每个元素服务 m 行输出。

片上需求 S = 2mk (A块) + 2kn (W块) + 4mn (累加器) bytes
接口读写 V = 2MK·(N/n) + 2KN·(M/m) + 2MN

分块计算器 · 例 5-3 与图 5-8、5-9

接口 = 工作缓冲 ↔ 下一级存储
RTX PRO 6000 一个 SM 的 100 KB 共享内存能同时放几个这样的线程块(每块另占 1 KiB 预留):

少读一半数据,为什么可能更慢

128×128 vs 64×64

大块只能驻留 1 份,小块能驻留 4 份。驻留越少,在途的访存请求越少,实际拿到的带宽越低(5.1 的 Little 定律)。若两者都受带宽限制:T₁₂₈/T₆₄ = (1560/3096) × (B₆₄/B₁₂₈)。

数据布局:让 32 个请求落到 32 个 bank

共享内存分 32 个 bank,每轮每个 bank 给一个 32-bit 字,bank 号 = 字地址 mod 32。一个 warp 的 32 个 lane 同时读 32×32 FP32 块的一列,行跨度 32 时全部撞进同一个 bank,要排 32 轮。

图 5-10 · bank 冲突可视化

32 lane · 32 bank

沿哪个维度切:并行任务与部分和

RMSNorm 对每行先求平方和(一次归约)再缩放。1024 行时一组一行就有 1024 个任务;decode 只有 1 行,同样的分法只产生 1 个任务,GPU 大面积空闲。把一行拆成几段,各自求局部平方和,再合并,再重读输入做归一化。

RMSNorm 拆分 · 图 5-11

[rows, 4096] BF16

切 M 或 N,各执行者得到不同位置的完整结果,需要时拼起来;切 K,各执行者得到同一输出的部分和,必须相加。5.2.2 的输出块就是同时切 M、N,再在一个线程块内沿 K 累加,不跨任何边界。到第 6 章,这三种切法会变成多卡并行。

拆分还改变了数值

浮点加法不满足结合律。在 FP32 里算 1 + 2⁻²⁴ + 2⁻²⁴:

先加前两项

先加后两项

在浏览器里复现图 5-13

随机生成一行 4096 个 BF16 元素(每项平方约 0.5),用 FP32 按不同段数求平方和,和双精度参考值比较。书中那一行的结果是:1 段差 −175.2 ULP,8 段只差 −9.2 ULP。

如果用原子加把局部和累到同一地址,到达顺序由调度决定,同一输入两次运行结果可能不同。先写缓冲、再由一个执行者按固定顺序相加,结果就确定,代价是多一次写读,并且要等最慢的一段。更麻烦的是,kernel 往往随 batch 大小改变段数,同一行输入在不同 batch 下会算出不同的输出(第 10.5.3 节的 logprob 不一致正源于此)。

5.3

相邻算子的融合、缓冲与流水执行

FFN 里 G = XWg、U = XWu,然后 Z = SiLU(G) ⊙ U,再交给 down 投影。G、U、Z 都是 [1024, 12288],BF16 各 24 MiB。SiLU 和 ⊙ 都是逐元素运算:Z[i,j] 只依赖 G[i,j]、U[i,j],读进来就能直接算出最终结果。

融合省掉的是中间结果的一次写回和一次重读,两步运算本身一步也没少。每少保存一个 24 MiB 的中间量,就少 48 MiB 读写。

例 5-4 与图 5-15 · 激活链加量化,三种融合程度

实测激活链 14.2 → 7.7 μs

理论

只看前两步:120 → 72 MiB,时间比 0.6,约 1.7×

实测

RTX PRO 6000:14.2 → 7.7 μs,约 1.8×

峰值显存

读写少了 96 MiB,峰值只从 72 → 60 MiB。容量和访问量是两回事,同一块空间可以被反复读写。

双缓冲:搬下一块的同时算这一块

布局转换也有同样的取舍:另建一份 24 MiB 的重排结果要多 48 MiB 读写,不如让前一个算子直接按后一个需要的布局写出。缓冲则决定两个阶段能否重叠。槽 A 的“搬完了”和“用完了”是两个时刻,只有最后一次读结束,才能覆盖它。

流水模拟器 · 例 5-5 与图 5-17

Tpipe = tm + (n−1)·max(tm, tc) + tc。书中的例子里搬移是慢的一方,双缓冲只能把 6.28 μs 降到 5.44 μs(−13%),计算单元只有两成时间在干活。试试把槽数加到 3,再加快计算:时间不变。要更快只能少搬字节(更大的输出块、L2 共享)或换带宽更高的卡。

背压(backpressure)

访问顺序不同的两个阶段之间要排队:三个 16 KiB 排队块加 32 KiB 重排空间共 80 KiB。RTX PRO 6000 单块最多 99 KB,单独驻留放得下;但一个 SM 要驻留两块时每块只能分到 49 KiB,扣掉重排只剩一个排队槽,搬移器写第二块前必须等计算释放。下游来不及处理,上游就被迫暂停。

FlashAttention:Softmax 也能逐块算

逐元素融合到 Softmax 这里卡住了:分母是整行指数和,只读到前一块时,既不知道最终分母,也不知道后面会不会出现更大的分数。标准做法 S = QKᵀ/√d、P = softmax(S)、O = PV,中间矩阵随序列长度平方增长。

单头,L = 8192,d = 128

Q K V O 各 2 MiB · S P 各 256 MiB (FP32)
必需:读 Q K V、写 O
S、P 各写一次读一次
1 GiB = 128× 必需量

要丢掉已经处理过的分数,只需保留三个量:最大分数 m,以 m 为基准的指数和 ℓ,同样权重下的值之和 u。最终输出 = u / ℓ。

在线 Softmax 单步演示 · 例 5-6、练习 5-3

每块一个元素
块sVm′r = em−m′ℓ′ = rℓ + pu′ = ru + pVu′/ℓ′

最大值变大时,旧的 ℓ 和 u 必须同乘 r,分子分母一起缩小,相对权重才不变。旧分数用完即丢,缓冲留给下一块。

K/V 块该切多大 · 图 5-20

99 KB 共享内存 = 101,376 bytes

2ad (Q) + 2bd (K/V 槽) + 4ab (分数/概率) + 4ad (输出累加器) + 12a (逐行状态) ≤ 101376

b 越小,留给 Q 的空间越大,整份 K/V 被重扫的次数越少;但每块只有几行,在线状态更新次数暴涨,矩阵乘的一个维度也太小。从 64 缩到 1,少读 144 MiB,更新次数却变成 41 倍。

实测时间

2.6 ms → 80 μs

数学后端 vs FlashAttention 后端,约 33×(因果注意力,8192 token,RTX PRO 6000)

新增显存峰值

848 → 22 MiB

计量输出与临时工作区,Q K V 预先驻留

之后的版本

FA-2 改进线程间分工、减少非矩阵运算;FA-3 用异步执行让搬移、矩阵乘、Softmax 相互重叠。

5.4

编译器:表达、变换与选择

前两节是手算的。实际模型有各种形状、各种算子组合、各种硬件,编译器做的是三件事:把运算写成循环与数组访问,按依赖和数值规则排除非法变换,结合容量与实测比较成本。

候选有多少

只算相邻边界的融合/分开

用循环变换表达分块与融合

以 Y = SiLU(AW) 为例。split 把 i 拆成块号 io 和块内坐标 ii(i = 64·io + ii);reorder 把块循环提到外层;把 K 按 32 拆成 128 个归约块;最后把 SiLU 挪到每个输出块的 ko 循环之后(Halide/TVM 叫 compute_at)。

原程序:先算出完整 C(24 MiB),再遍历一遍
for i in range(M):
  for j in range(N):
    acc = 0.0
    for k in range(K):
      acc += A[i,k] * W[k,j]
    C[i,j] = round_to_bf16(acc)
for i in range(M):           # 再读一遍 C
  for j in range(N):
    Y[i,j] = silu(C[i,j])
变换后:只保留当前输出块,省掉 48 MiB
for io in range(ceil_div(M, BM)):
  for jo in range(ceil_div(N, BN)):
    acc = zeros_fp32(BM, BN)    # 16 KiB
    for ko in range(ceil_div(K, BK)):
      a = load_tile(A, io, ko)   # 4 KiB
      w = load_tile(W, ko, jo)   # 4 KiB
      matmul_accumulate(acc, a, w)
    c = round_to_bf16(acc)
    store_tile(Y, io, jo, silu(c))
遍历输出块 io, jo(16 × 192 = 3072 块,fuse 后可编成 0–3071 的一维编号)
创建 FP32 累加器 16 KiB,保留到整个 ko 循环结束
ko 循环 128 次:读 A、W 块各 4 KiB → 更新累加器,用完即换
归约结束 → BF16 舍入 → SiLU → 写出

缩进层级就是保存期限。计算位置还决定复用还是重算:放在外层,先算完整结果供大家读;放进消费者的分块循环,只算当前需要的部分,多个块要用同一份时就会重复计算。

哪些变换不合法

激活不能挪进归约

分块 Softmax 要留统计量

两块各一个值 1 和 3,块内归一化后还是 1 和 3。只凭这两个输出,分不清整行应该给它们 1:2 还是 2:1 的权重。所以 FlashAttention 必须保留 m、ℓ、u。

舍入位置是运算定义

原程序先舍入到 BF16 再激活,融合后的程序也必须保留这次 round_to_bf16。中间值可以从寄存器直接传,舍入不能省。数据放在哪和数值格式是两项独立选择。

例 5-7 · 整行 FP8 scale 必须先读完整行

E4M3FN · 最大值 448

AKG:多面体编译

AKG 把规则循环表示成三类信息:要执行哪些迭代、每次读写哪些数组元素、哪些读写必须保持先后。循环边界和下标能用线性约束描述(“多面体”由此得名),编译器据此推导依赖、调整顺序,并由数组访问反推每个 tile 需要哪块数据(64×64×32 的 tile 需要 A 的 64×32、W 的 32×64)。外层安排算子顺序,内层安排块的连续计算和局部缓存。手工做的“拆循环—定读取区域—留累加结果—归约后激活”,在这里成了一组联动的编译步骤。

例 5-8 · 融合反而多读 40%

A [4096,4096] FP16 → FP8 · W [4096,1536] FP8

单独量化:读 32 MiB 原输入,写 16 MiB FP8,每个输出列块再读 16 MiB FP8。融合进矩阵乘:先读 32 MiB 求每行 scale,每个列块都重读 32 MiB FP16 原输入、就地量化。权重与输出两种方案相同,各 204 MiB。

成本估计与实测选择

步骤矩阵算例要填什么排除或比较的依据
1 固定目标输入形状、精度、误差、调用频数同一项数学工作、同一负载
2 列出候选tile、循环次序、缓冲数、融合范围编译器与硬件是否支持
3 检查可行性共享内存、寄存器、线程、对齐、依赖编译资源报告、正确性检查
4 估计时间计算量、实际读写、启动、布局转换各资源服务时间与关键路径
5 实测并选择代表形状的时间、波动、完整算子链最小化负载总成本,保留相近候选

实测往往推翻直觉:配套的矩阵转置程序在 RTX PRO 6000 上,16×16 块 7.9 μs,32×32 块 2.9 μs,2.7×。TVM 自动调优或 Agent 改 kernel,评价对象都是编译后的程序,因为编译器可能把源码里分开的算子融合成一个 kernel。

例 5-9 · 选哪个实现取决于调用比例

旧:A、B 都 10 μs · 新:A 5 μs、B 20 μs
5.5

运行时:提交、重放与动态形状

编译器选好程序后,运行时每一步还要准备输入、挑程序、提交任务、管理缓冲。kernel 代码一字不改,换一种提交方式或复用已有的准备结果,总时间也会变。

例 5-10 · GPU 越快,越是在等 CPU

真实案例:vLLM v0.6.0(2024 年 9 月)

单张 H100 · Llama 3 8B

API 服务器和引擎在同一个 Python 进程里争 GIL,一步里 GPU 只忙 38%。v0.6.0 把 API 服务器挪到独立进程、引入多步调度、把输出处理改成异步。没动一个 kernel、没换硬件,吞吐到 v0.5.3 的 2.7×(70B 为 1.8×)。

2.7× 是三项改动合计,在请求同时到达、--num-scheduler-steps 10 下测得,不能全部归于 GIL;多步调度在低负载下可能拉长首 token 等待。

CUDA Graph:重放提交序列

先把一串 kernel 及其依赖录成图,之后主机每次只发一次 graph launch。GPU 还是逐个执行图里的节点。图重放减少的是主机提交次数,减少 kernel 数量靠的是融合。

图 5-27 ~ 5-31 · 3 次 FFN,四种方式

每次 FFN 6 个 kernel,融合激活链后 5 个

例 5-11 · 输入复制会吃掉图重放的收益

普通 40 μs = 等提交 20 + 计算 20 · 图 = 准备 5 + 复制 + 计算 20

图按录制时的地址 G 读输入。新输入在别处(地址 X)时,必须先复制到 G,复制要读一遍、写一遍。

同样的思路用在准备阶段:FlashInfer 的 plan / run

plan 根据本步各请求长度和 KV 存放位置选 kernel、划分任务、安排工作区,并把元数据拷到 GPU;run 照计划算注意力。同一步 36 层的长度和 KV 布局都相同,只 plan 一次:36 层注意力从 2.0 / 2.4 ms 降到 0.72 / 0.75 ms,H2D 复制从 144 次降到 4 次,kernel 数不变。反过来,请求长度变了还沿用旧计划,结果就错了。

动态形状:通用、分桶,还是特化

例 5-12 · 重复多少组,特化才值得

每组 10 次 FFN:8×256 行 + 1536 + 2048
策略准备 P每组执行 T做法
通用100 ms17.0 ms按实际 5632 行执行,MFU 20%
分桶 512 / 2048400 ms12.4 ms补齐到 8192 行(多算 45%),MFU 40%
逐形状特化900 ms6.8 ms三个形状各编一份,MFU 50%

自动调优多久回本

Persistent kernel:按数据块交接

后一个 kernel 通常要等前一个全部算完。如果它只需要前一个的一块结果,就把等待条件缩小到数据块:kernel 常驻 GPU,从队列取任务,用事件确认数据就绪(Mirage Persistent Kernel 即此类)。例子:512 个 token 的 up 投影接 SiLU,每 64 个 token 一块,共 8 块。

图 5-36 / 5-37 · 粗粒度 vs 按块流水

每块投影 12.8 μs、激活 1.76 μs,每次 launch 5 μs;按块调度每个任务另加 0.7 μs。省下的约 11 μs:少一次 launch,七块激活藏进投影;任务开销又加回 6.3 μs。两阶段越接近,按块重叠省得越多;一边远长于另一边时,基本只剩少一次 launch。

5.6

从局部优化到完整请求

局部加速比必须放回整条请求里看。串行阶段按 Amdahl 定律相加;并行分支要沿依赖图找最长路径;共享资源还会让一个分支的优化拖慢另一个。

Amdahl 定律

S = 1 / ((1−f) + f/s)

图 5-38 · 关键路径会切换

准备 10 → A ∥ B → 收尾 10

容量的节省 ≠ 读取的节省

DeepSeek V4.1 让 38 个全局注意力层共享 4 份全局 KV:8K 下全局 KV 驻留从 27.455 降到 6.953 MiB(约四分之一),逐层逻辑读取却只从 12.556 降到 11.375 MiB(约 −9%)。共享状态不必每层各存一份,但用到它的每层仍要读它。

综合案例:一次 Qwen3-8B 请求

RTX PRO 6000,输入 7239 token,输出 32 token,并发 1,BF16、eager。36 层 × 32 步 = 1152 次 SwiGLU 激活调用:prefill 36 次(每次 7239 行),decode 1116 次(每次 1 行)。替换成一个新调度的 kernel(block256 / 4 warps)。

从 kernel 加速比到用户等待

vLLM 0.23 · 11 对交错计时
阶段形状 × 次数原 kernel新 kernel
Prefill7239 行 × 3611.22 ms11.24 ms
Decode1 行 × 11162.37 ms0.92 ms(2.6×)
激活合计13.6 ms12.2 ms
激活占请求1.6%
省光也最多13.6 ms
预计节省≈1.4 ms
实测配对差中位数1.3 ms
首 token436 ms 不变

11 对里 9 对变快、2 对变慢,约占请求 0.16%。decode 的改进发生在首输出之后,所以首 token 时间仍由 prefill 决定;输出越长,单行激活重复越多,累计节省越多。方法总结:先统计各形状的调用次数,再算每次省多少,最后按依赖关系判断这些节省能否缩短请求。

常见误区

四个容易下错的结论

kernel 数量越少,执行成本越低

图重放把 3 次 FFN 的主机提交从 18 降到 3,GPU 仍执行 18 个 kernel;融合才把 kernel 减到 15。主机提交和加速器计算要分开看。

访问量最小的实现一定最快

K/V 块从 64 行缩到 1 行,少读 144 MiB,状态更新却变成约 41 倍。读取、状态更新与矩阵处理率一起决定成本。

多加缓冲就能提高吞吐

搬 1.29 μs、算 0.28 μs 时,两个槽已让搬移器满负荷,第三个槽没用,加快计算也没用。只能少搬字节或提高带宽。

稳态最快的方案总耗时最短

只跑 70 组时,分桶 1.27 s,特化 1.38 s。特化每组更快,但省下的时间还没抵消额外的准备开销。

本章小结

三个反复用到的判断

一、复用需要空间

更大的 tile 让输入多用几次,也要更多累加存储;保留低位宽中间结果让后续少读,也要中间存储。在 SM 上,这份空间还决定驻留几个线程块、有多少在途请求能隐藏延迟。

二、依赖决定顺序

逐元素运算拿到输入就能完成,归约要保存部分和或在线统计。编译器按依赖移动计算位置,运行时用事件确认数据就绪、缓冲可释放。融合和流水都建在依赖之上。

三、总时间取决于执行顺序

带宽决定一批数据传多久,较慢阶段决定流水节拍,复用次数决定准备开销能否回本,关键路径决定局部改进能否缩短请求。

下一章把这些关系推广到多张加速器:一片结果何时就绪、谁要读、保存多久,将决定通信何时开始,以及两端要多少缓冲。

习题与配套实验

动手验证

实验 5-1 · 矩阵块大小如何改变局部存储与读取量

把例 5-3 的输出块改成 64×128(k=32),求局部存储、A/W 读取次数与总访问量;64×128 的有效带宽至少要达到 64×64 的多少才更快?(上面的分块计算器可以直接选 m=64、n=128。)

实验 5-2 · 激活融合能减少多少访存与显存(核心)

画出三步独立与完全融合时各张量的分配、使用、释放顺序,推导 156 / 60 MiB 与峰值显存。若融合每次多 5 μs 固定开销、带宽 1792 GB/s,输入至少多少行时融合才划算?

实验 5-3 · 分块归约与预取

在例 5-6 里加入分数 ln4、值 5 的元素(在线 Softmax 演示里点“练习 5-3”)。再从 99 KB 中划出一个 b×d 的 BF16 预取槽(b=64),求新的最大 Q 行数、扫描次数与访问量。

实验 5-4 · 融合改变的是结果还是成本

把 SiLU 移进 ko 循环、删掉中间 BF16 舍入、把量化移进每个输出列块循环,分别说明改变了数学运算、数值舍入还是执行成本,并各给一个能区分前后的输入。

实验 5-5 · 分块循环与临时存储

写出 i-k-j 的分块循环,标出输入缓存和累加器的创建、最后使用与释放位置;对照转置程序两种块大小,比较访问连续性、同步与临时数据量。

实验 5-6 · 调用比例与调优收益

p 取 1/2、2/3、9/10 计算两种实现的平均时间。若按形状选实现每次多 1 μs,何时优于始终用旧实现?

实验 5-7 · 特化要重复多少次

把小形状调用从 8 次改为 4 次,求三种方案两两相等时的重复次数;再假设两个分桶程序已缓存,重新求各方案最优的区间。

实验 5-8 · 图重放与融合各减少什么(核心)

统计四种方式下的主机提交次数和 kernel 数;把例 5-11 换到 RTX 4090(1008 GB/s)求临界输入大小(上面的复制计算器可以直接切换);再让上游直接写图缓冲,比较 2 MiB 与 16 MiB 时的完成时间。

实验 5-9 · kernel 加速如何改变请求耗时(核心)

用 11 对请求记录计算配对差的中位数,解释它和“两组中位数相减”的区别;decode 从 31 步增至 127 步时,激活总共能省多少?在图 5-38 中,A 缩到多少后请求时间不再下降?