适用读者:要解释"GPU 利用率只有 35%"、要读懂 profiler 时间线、或要写/改 kernel 的人。
前置:S23-J13、J15(算术强度与下界 vs 实测)。
覆盖旧文:A-4338 §5.1–§5.2(提交/搬移/同步 + 分块/布局/切分,301 点)→ 301 点。
扩展点:E-S23-048(算子 / kernel / launch 三层责任)、E-S23-049(四块缓冲的"最后使用者"与死锁排查)、E-S23-050(提交 3 μs + 复制 12 μs:kernel 快 4 倍只换来 1.8×)、E-S23-051(分块:24 KiB→80 KiB 换 3096→1560 MiB)、E-S23-052(沿哪个维度切决定要不要归约)。
上一篇:S23-J15 · 架构演进与加速器选型
下一篇:S23-J17 · 融合、编译器与运行时。
1. 一句话结论
"kernel 很快"不等于"调用很快"。 提交 3 μs + H2D 8 μs + kernel 20 μs + D2H 4 μs = 35 μs:把 kernel 提速 4 倍,端到端只从 35 降到 20 μs(1.8×),剩下 15 μs 是提交与复制。
出处:AI Infra 教材 §5.1–§5.2(
id 4338)。
端到端 = 提交 + 复制 + 执行 + 回传
优化任一段,只能改善它在总时间里的那份
2. 三层责任边界(E-S23-048)
| 层 | 是什么 | 优化手段 | 谁负责 |
|---|---|---|---|
| 算子(operator) | 数学工作:矩阵乘、激活、softmax | 换算法、融合 | 框架/编译器 |
| kernel | 在加速器上完成这项工作的一段程序 | tile、缓冲、循环顺序 | 算子开发者 |
| launch | CPU 发起这次执行的动作 | 减少次数、异步、图模式 | 框架/运行时 |
关键关系(原文明确):一个矩阵乘可以由多个 kernel 完成;几个逐元素算子也可以由同一个 kernel 完成。 所以"算子数 ≠ kernel 数 ≠ launch 数"。
2.1 执行组织
grid(全部线程块)
└── thread block(块内线程协作读输入、累加结果)
└── 由硬件调度到有资源的 SM 上
早完成的块释放资源 → 后续块开始(所以 grid 是分批执行的)
launch 是异步的:调用返回时 CPU 已完成提交,GPU 按队列与依赖继续执行;只有"要用结果"或"要覆盖还在用的数据"时才需要等。
3. 复制、stream 与 event(E-S23-049)
三种复制方向:H2D(主机→设备)、D2H(设备→主机)、D2D(显存内部)。
同一条 stream:任务按顺序执行 → 把 H2D 和读取它的 kernel 放一起,天然有序
跨 stream:用 event 建立顺序 → 复制流记录事件,计算流等待它
3.1 四块缓冲、四个"最后使用者"
| 缓冲 | 最后使用者 | 之后才能做什么 |
|---|---|---|
| H2D 的主机源缓冲 | 复制引擎读 | CPU 写入下一批 |
| GPU 输入缓冲 | 读取它的 kernel | 复制引擎写下一批 |
| D2H 的加速器源缓冲 | 复制引擎读 | GPU 写新结果 |
| D2H 的主机目标缓冲 | 复制引擎写 | CPU 读当前结果 |
判据:这四个"最后使用者"就是四个最容易漏记的事件。 漏掉"GPU 输入缓冲已用完",复制引擎就永远不能写下一批。
3.2 卡住 ≠ 变慢(排查口诀)
| 现象 | 判断 | 下一步 |
|---|---|---|
| 仍有结果产出 | 只是慢 | 按时间线逐段计时(§4) |
| 完全不动 | 在等一个不会到来的事件 | 设超时,打印各 stream 未完成任务 |
| 多卡集合通信 | 有一张卡少发起一次 | 其余卡永远等这个对端(J19) |
| 跨流循环等待 | A 等 B 的事件、B 排在 A 之后 | 找出环,打破它 |
4. 三段计时与 Amdahl(E-S23-050)
例 5-2:输入已在主机准备好。
| 段 | 耗时 |
|---|---|
| CPU 提交(0–3 μs) | 3 μs |
| H2D | 8 μs |
| kernel | 20 μs |
| D2H | 4 μs |
| 结果可用 | 35 μs |
把 kernel 从 20 μs 缩到 5 μs(4 倍):3+8+5+4 = 20 μs,加速比 35/20 = 1.75≈1.8。
判据:三种计时方式测的是三件不同的事:
| 计时位置 | 测到什么 |
|---|---|
| 异步调用前后的 CPU 时钟 | 提交耗时 |
| 结束前等待结果 | 从发起到完成 |
| CUDA event | 加速器上执行这段任务的时间 |
4.1 编译/初始化也要摊
首次编译 100 ms + 每次 1 ms
调 1 次:101 ms;调 100 次:100 + 100 = 200 ms → 平均 2 ms/次
判据:摊销次数决定"特化"值不值——这就是后面"分桶 vs 特化"的判据来源。
5. 分块:局部容量换重复读取(E-S23-051)
固定一个 m×n 输出块,沿 K 维依次搬入 A[m,k]、W[k,n],部分和留在累加器:
局部存储 S = 2mk(A 块)+ 2kn(W 块)+ 4mn(FP32 累加器)
全矩阵读写 V = 2MK·(N/n) + 2KN·(M/m) + 2MN
(A 被 N/n 个列块各读一遍;W 被 M/m 个行块各读一遍;C 只写一次)
场景:M=1024, K=4096, N=12288(X 8 MiB、W 96 MiB、C 24 MiB),取 k=32。
| 输出块 | 局部存储 S | A 读取 | W 读取 | C | 总量 V |
|---|---|---|---|---|---|
| 64×64 | 24 KiB | 1,536 MiB | 1,536 MiB | 24 MiB | 3,096 MiB |
| 128×128 | 80 KiB | 768 MiB | 768 MiB | 24 MiB | 1,560 MiB |
| 理想(各读一次) | — | 8 MiB | 96 MiB | 24 MiB | 128 MiB |
行块 16→8、列块 192→96,两份输入读取各减半;但累加器从 16 KiB 涨到 64 KiB
判据:分块的收益是"少读输入",代价是"多占片上存储"。 而且累加器增长比输入更快(4mn vs 2mk)——这就是分块尺寸不能无限放大的原因。
6. 沿哪个维度切(E-S23-052)
| 切分维度 | 得到什么 | 代价 |
|---|---|---|
| M(行) | 独立输出行,互不干扰 | 无需归约 |
| N(列) | 独立输出列 | 无需归约 |
| K(归约维) | 并行计算部分和 | 必须再归约一次(额外通信/同步) |
判据:切 K 是唯一需要"再合并"的方向。 多卡场景下,切 K 意味着 AllReduce(J19);切 M/N 只需数据并行。
6.1 布局与 bank
同一份数据在不同排布下访问开销不同:让并行请求分散到不同 bank,避免多个线程同时打同一 bank 造成串行。这与 J14 的步长问题是同一件事的两种视角——一个看"地址跨度",一个看"片上 bank 分布"。
7. 可复算(实测)
$ cd /root/博客运营官/blog-v2 && python3 tools/s23_book_calc.py | tail -9
例5-2 时间线: 提交 3 + H2D 8 + kernel 20 + D2H 4 = 35 μs
kernel 20→5 μs: 3+8+5+4 = 20.0 μs,加速比 1.75
编译摊销: 100 ms + 1 ms/次 → 1 次 101 ms(平均 101.0 ms)
编译摊销: 100 ms + 1 ms/次 → 100 次 200 ms(平均 2.0 ms)
分块 k=32(M=1024,K=4096,N=12288):
64×64 : S=24 KiB, A 1536 + W 1536 + C 24 = 3096 MiB
128×128 : S=80 KiB, A 768 + W 768 + C 24 = 1560 MiB
理想各读一次: A 8 + W 96 + C 24 = 128 MiB
8. 前提与边界
| 结论 | 成立条件 | 不成立时 |
|---|---|---|
| 端到端 = 四段之和 | 无重叠(串行 stream) | 多 stream/双缓冲 → 取 max |
V 公式 |
各输出块独立读输入、无缓存复用 | 有 L2/共享缓存 → 实际流量更低 |
| 分块越大越省读取 | 片上容量够 | 超容量 → 溢出到 HBM,反而更慢 |
| 切 K 需归约 | 部分和分布在多执行单元 | 单卡内可用原子/共享内存归约 |
9. 常见误区
| 误区 | 纠正 |
|---|---|
| "kernel 提速 4 倍,整体就快 4 倍" | 提交+复制固定 15 μs,端到端只 1.8× |
| 用 CPU 时钟测 kernel 时间 | 那测到的是提交;kernel 时间要用 event |
| 只算一次 kernel 时间忽略 launch | 小算子时 launch 开销可能超过计算 |
| 分块越大越好 | 累加器 4mn 增长更快,容量先爆 |
| 随便选切分维度 | 切 K 要归约,切 M/N 不要 |
10. 自测
- 提交 5 μs、H2D 10 μs、kernel 40 μs、D2H 5 μs,结果何时可用?kernel 减半后加速比多少?
- 编译 200 ms、每次执行 2 ms,调用 50 次与 500 次的平均耗时各是多少?
k=32、输出块 256×256 时,局部存储多少?比 128×128 多多少?- 为什么"切 K"在多卡上必须通信,而"切 M"不用?
答案
5+10+40+5 = 60 μs;减半后5+10+20+5 = 40 μs,加速比 1.5。- 50 次:
200+100 = 300 ms→ 6 ms/次;500 次:200+1000 = 1200 ms→ 2.4 ms/次。 2·256·32 + 2·32·256 + 4·256² = 16+16+256 = 288 KiB;128×128 为 80 KiB,多 208 KiB。- 切 K 后每张卡只有部分和,必须相加;切 M 每张卡持有完整输出行。
11. 下一篇
S23-J17 · 融合、编译器与运行时 —— 把相邻算子合成一个 kernel(FlashAttention 就是典型),编译器如何表达分块与融合,以及"分桶 vs 特化"到底该选哪个。