2384 字
约 7 分钟
8
S23-J16 · 算子的提交、搬移与分块

适用读者:要解释"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. 自测

  1. 提交 5 μs、H2D 10 μs、kernel 40 μs、D2H 5 μs,结果何时可用?kernel 减半后加速比多少?
  2. 编译 200 ms、每次执行 2 ms,调用 50 次与 500 次的平均耗时各是多少?
  3. k=32、输出块 256×256 时,局部存储多少?比 128×128 多多少?
  4. 为什么"切 K"在多卡上必须通信,而"切 M"不用?
答案
  1. 5+10+40+5 = 60 μs;减半后 5+10+20+5 = 40 μs,加速比 1.5。
  2. 50 次:200+100 = 300 ms → 6 ms/次;500 次:200+1000 = 1200 ms → 2.4 ms/次。
  3. 2·256·32 + 2·32·256 + 4·256² = 16+16+256 = 288 KiB;128×128 为 80 KiB,多 208 KiB。
  4. 切 K 后每张卡只有部分和,必须相加;切 M 每张卡持有完整输出行。

11. 下一篇

S23-J17 · 融合、编译器与运行时 —— 把相邻算子合成一个 kernel(FlashAttention 就是典型),编译器如何表达分块与融合,以及"分桶 vs 特化"到底该选哪个。

S23-J16 · 算子的提交、搬移与分块
https://www.clxhxhhr.top/posts/5543/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。