我理解 CUDA 高性能的两条主线,是 shared memory tile 和 double buffer pipeline:一条让数据多用几次,一条让计算少等一会儿。
这两点在矩阵乘里很清楚。但给客户解决问题,不能停在这两个词上。需要问:哪个算子慢,为什么慢,改完能省多少端到端时间?
场景:一个推理服务为什么迟迟降不下延迟
假设一个团队在做批量文本向量服务。输入文本经过模型,输出 embedding;客户关心每秒能处理多少请求,以及高峰时的 P95 延迟。
这个例子是讨论方案的假设场景,不是实际客户项目。
假设 profiler 已确认:某个投影层的矩阵乘占了明显比例,形状和数据类型也比较固定。它可以写成:
这里 是这一层合并处理的 token 数, 是输入维度, 是输出维度。
先拿 cuBLAS / cuBLASLt 或框架已有实现做基线。标准矩阵乘通常已有成熟优化;只有特殊形状、融合需求或数据布局让通用实现吃亏时,自定义 kernel 才值得投入。客户要的是更低的服务成本,不是多一个手写 kernel。
如果瓶颈是分词、排队、CPU 调度或网络请求,接下来的两项优化不会解决它。
第一件事:同一份数据,别反复搬
最直观的写法是:每个线程算一个输出元素,沿着 维不断读取输入。相邻输出会用到相同的输入值,许多线程都在索取同一份数据。
tile 的做法是:让一个 thread block 负责输出的一小块。线程合作,把这一轮要用的输入搬进 shared memory,然后多次使用。部分结果留在寄存器里累加,最后再写回。

图:NVIDIA CUTLASS,Efficient GEMM in CUDA。从左到右是更细的计算分块;图中的层次也包含寄存器复用。
shared memory 是一个 block 内共享的片上工作区。收益来自减少重复读取并组织复用,不是给所有数据加一次“更快的中转”。如果数据只用一次,额外的写入、读取和同步可能得不偿失。NVIDIA CUDA Best Practices 用矩阵乘展示了这种复用方式。
算一笔账,知道 tile 在省什么
计算一个 输出块,每次沿 维前进 ,这一轮需要两个 输入块。以 FP32 为例:
| 输出块边长 | 单轮计算量 | 单轮输入量 | 输入算术强度 |
|---|---|---|---|
| 16 | 8,192 FLOP | 2 KiB | 4 FLOP/byte |
| 32 | 65,536 FLOP | 8 KiB | 8 FLOP/byte |
| 64 | 524,288 FLOP | 32 KiB | 16 FLOP/byte |
这是理论计数,不是 GPU 实测。 只计算输入搬运,忽略输出读写、缓存命中、边界浪费和其他开销;一次乘加按 2 FLOP 计。tile 边长也不等于线程块边长,较大的输出块通常由每个线程计算多个元素。
表里看起来越大越好,实际不会无限增长。tile 大了,占用的 shared memory、寄存器也更多;可能同时驻留的 block 更少,或者对小矩阵浪费大量线程。对客户的真实 shape 调参,比套用“最佳 tile 大小”更可靠。
第二件事:算这一块时,把下一块准备好
有了复用,仍可能出现这样的节奏:搬数据,等完成,计算,再搬下一块。
double buffer 准备两组缓冲区:计算当前 tile 时,在另一组里准备下一个 tile;本轮完成后交换角色。需要等待数据就绪,也需要确认旧数据已经用完,才能覆盖缓冲区。

图:NVIDIA CUTLASS,Pipelining。该图描述其软件流水线;不同架构的指令和同步实现有所不同。
设有 个 tile,每个搬运耗时 、计算耗时 。在搬运和计算能够充分重叠的理想模型中:
例如 , 个时间单位,串行是 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 倍,其他部分不变:
理论上总耗时降低 20%,不是降低 50%。这仍是假设计算;在线服务的排队和动态 batching 会改变结果,P95 需要实际压测。
还要注意:小 batch、自回归逐 token decode 与批量大矩阵乘的约束不同;改变 batching 可能增加排队等待。不要拿一个大矩阵的跑分去代表整个服务。
我会把验收标准写成:在约定输入、并发和精度下,延迟或吞吐达到目标,错误率不增加,并且维护成本可接受。 如果现成库已够快,采用它也是正确的交付。
我会记住的两句话
- tile:把数据搬进来以后,让它多干几次活。
- pipeline:这一份在干活,下一份提前到位。
落实到 kernel,是访存布局、分块、寄存器、同步和流水线深度;落实到客户,是可复现的基线、可验证的收益,以及明确的适用范围。
参考资料
- NVIDIA:CUDA C++ Best Practices Guide — shared memory 与异步拷贝。
- NVIDIA:Efficient GEMM in CUDA — tile 层次与软件流水线,本文两张图的来源。
- NVIDIA:CUTLASS: Fast Linear Algebra in CUDA C++ — 矩阵乘的数据复用与融合思路。
本文没有声称完成 GPU 实测,数值表与时间公式均为解释原理的理论示例。