跳转到正文
YK Liu
返回

CUDA 优化:先减少搬运,再隐藏等待

我理解 CUDA 高性能的两条主线,是 shared memory tile 和 double buffer pipeline:一条让数据多用几次,一条让计算少等一会儿。

这两点在矩阵乘里很清楚。但给客户解决问题,不能停在这两个词上。需要问:哪个算子慢,为什么慢,改完能省多少端到端时间?

场景:一个推理服务为什么迟迟降不下延迟

假设一个团队在做批量文本向量服务。输入文本经过模型,输出 embedding;客户关心每秒能处理多少请求,以及高峰时的 P95 延迟。

这个例子是讨论方案的假设场景,不是实际客户项目。

假设 profiler 已确认:某个投影层的矩阵乘占了明显比例,形状和数据类型也比较固定。它可以写成:

YM×N=XM×KWK×NY_{M\times N}=X_{M\times K}W_{K\times N}

这里 MM 是这一层合并处理的 token 数,KK 是输入维度,NN 是输出维度。

先拿 cuBLAS / cuBLASLt 或框架已有实现做基线。标准矩阵乘通常已有成熟优化;只有特殊形状、融合需求或数据布局让通用实现吃亏时,自定义 kernel 才值得投入。客户要的是更低的服务成本,不是多一个手写 kernel。

如果瓶颈是分词、排队、CPU 调度或网络请求,接下来的两项优化不会解决它。

第一件事:同一份数据,别反复搬

最直观的写法是:每个线程算一个输出元素,沿着 KK 维不断读取输入。相邻输出会用到相同的输入值,许多线程都在索取同一份数据。

tile 的做法是:让一个 thread block 负责输出的一小块。线程合作,把这一轮要用的输入搬进 shared memory,然后多次使用。部分结果留在寄存器里累加,最后再写回。

NVIDIA CUTLASS 官方图:矩阵乘从 thread block 到 warp 和线程的分块层次

图:NVIDIA CUTLASS,Efficient GEMM in CUDA。从左到右是更细的计算分块;图中的层次也包含寄存器复用。

shared memory 是一个 block 内共享的片上工作区。收益来自减少重复读取并组织复用,不是给所有数据加一次“更快的中转”。如果数据只用一次,额外的写入、读取和同步可能得不偿失。NVIDIA CUDA Best Practices 用矩阵乘展示了这种复用方式。

算一笔账,知道 tile 在省什么

计算一个 T×TT\times T 输出块,每次沿 KK 维前进 TT,这一轮需要两个 T×TT\times T 输入块。以 FP32 为例:

计算量=2T3 FLOP,输入字节数=2T2×4\text{计算量}=2T^3\ \mathrm{FLOP},\qquad \text{输入字节数}=2T^2\times4 输入算术强度=2T38T2=T4 FLOP/byte\text{输入算术强度}=\frac{2T^3}{8T^2}=\frac{T}{4}\ \mathrm{FLOP/byte}
输出块边长 TT单轮计算量单轮输入量输入算术强度
168,192 FLOP2 KiB4 FLOP/byte
3265,536 FLOP8 KiB8 FLOP/byte
64524,288 FLOP32 KiB16 FLOP/byte

这是理论计数,不是 GPU 实测。 只计算输入搬运,忽略输出读写、缓存命中、边界浪费和其他开销;一次乘加按 2 FLOP 计。tile 边长也不等于线程块边长,较大的输出块通常由每个线程计算多个元素。

表里看起来越大越好,实际不会无限增长。tile 大了,占用的 shared memory、寄存器也更多;可能同时驻留的 block 更少,或者对小矩阵浪费大量线程。对客户的真实 shape 调参,比套用“最佳 tile 大小”更可靠。

第二件事:算这一块时,把下一块准备好

有了复用,仍可能出现这样的节奏:搬数据,等完成,计算,再搬下一块。

double buffer 准备两组缓冲区:计算当前 tile 时,在另一组里准备下一个 tile;本轮完成后交换角色。需要等待数据就绪,也需要确认旧数据已经用完,才能覆盖缓冲区。

NVIDIA CUTLASS 官方图:通过双缓冲交叠数据搬运和矩阵乘计算

图:NVIDIA CUTLASS,Pipelining。该图描述其软件流水线;不同架构的指令和同步实现有所不同。

设有 SS 个 tile,每个搬运耗时 LL、计算耗时 CC。在搬运和计算能够充分重叠的理想模型中:

Tserial=S(L+C)T_{\mathrm{serial}}=S(L+C) Tpipeline≈L+C+(S−1)max⁡(L,C)T_{\mathrm{pipeline}}\approx L+C+(S-1)\max(L,C)

例如 S=8S=8,L=C=1L=C=1 个时间单位,串行是 16,理想流水线是 9。这是时间模型,不是“能加速 1.78 倍”的性能承诺。 开头要装入数据,结尾要排空流水线;搬运远慢于计算时,重叠也不能消除带宽瓶颈。

两份数组不等于流水线

必须让预取提前发出,并有可与其重叠的计算。若每次搬完立刻等待,再开始计算,代码用了双缓冲,执行仍然可能是串行。

Ampere 的硬件异步 global-to-shared copy,以及较新架构上的 TMA,给这种交叠提供了工具;具体指令、对齐和同步约束要看目标 GPU。较早架构也能通过寄存器预取组织软件流水线。双缓冲是容易理解的起点,实际实现可能使用更多 stage。NVIDIA 异步拷贝说明

伪代码只表达缓冲区的生命周期,不是可直接运行的 CUDA API:

预取 tile 0 到 buffer 0
for 每个 tile k:
    等待当前 buffer 的数据就绪
    若还有下一块:预取 tile k+1 到另一个空闲 buffer
    使用当前 buffer 计算,累加到寄存器
    确认所有消费者已用完当前 buffer,允许它被下一轮覆盖
写回结果

错误的等待位置会损失重叠,错误的复用时机则会算错。性能之前,先保证边界处理和同步正确。

把它交付成一个能验收的方案

对这个向量服务,我会按下面的顺序推进:

阶段做什么交付什么
定位用 Nsight Systems 看服务时间线;用 Nsight Compute 看目标 kernel瓶颈占比、真实 shape 分布、基线报告
减少搬运检查合并访存、tile 复用、shared memory bank conflict、寄存器 spill只改 tile 的对照结果
隐藏等待引入预取和双缓冲,比较不同 stage 的资源占用正确性与流水线效果对照
业务验证用真实请求回放测试吞吐、P50/P95、显存和并发可上线的版本、适用输入范围、回退方案

测量时固定 GPU、软件版本、精度和输入形状,先预热,再用 CUDA events 测 GPU 时间;异步执行不能只看 CPU 调用耗时。重复测量,记录分布。正确性测试要覆盖非整块尺寸,并使用与计算精度匹配的误差容限。生产对照保持批处理与请求负载一致。

“等待多”也不是立即加 buffer 的理由。先判断等的是全局内存、shared memory、依赖链还是同步;再看寄存器和 shared memory 是否已经限制了并发。

客户最终能得到多少收益?

假设目标 kernel 占原服务耗时的 40%,优化后快了 2 倍,其他部分不变:

端到端加速比=10.6+0.4/2=1.25\text{端到端加速比}=\frac{1}{0.6+0.4/2}=1.25

理论上总耗时降低 20%,不是降低 50%。这仍是假设计算;在线服务的排队和动态 batching 会改变结果,P95 需要实际压测。

还要注意:小 batch、自回归逐 token decode 与批量大矩阵乘的约束不同;改变 batching 可能增加排队等待。不要拿一个大矩阵的跑分去代表整个服务。

我会把验收标准写成:在约定输入、并发和精度下,延迟或吞吐达到目标,错误率不增加,并且维护成本可接受。 如果现成库已够快,采用它也是正确的交付。

我会记住的两句话

落实到 kernel,是访存布局、分块、寄存器、同步和流水线深度;落实到客户,是可复现的基线、可验证的收益,以及明确的适用范围。

参考资料

本文没有声称完成 GPU 实测,数值表与时间公式均为解释原理的理论示例。



上一篇
从这里开始,记录与探索