这个问题我是真被问住了。我们在一台 12GB 显存的 GPU 上做推理服务优化:先用 CUDA Graph 把 launch overhead 压了下去,吞吐从 1480 samples/s 提到 1758 samples/s,效果很漂亮。接着做显存分析,发现一批中间结果在生命周期里被框架保留得太久,同一轮推理白白多占了接近 4GB 显存。于是改逻辑、调分配器,峰值显存从 10.5GB 直接降到 6.7GB。省下这么多显存,下一步自然是想把 batch 从 16 拉到 24,让空闲显存去干活。可一测吞吐,差点以为计时器坏了——step 耗时几乎没有下降,样本吞吐也只涨了 15% 左右,完全对不上省下 4GB 显存应有的收益。
这篇文章就是这次调参经历的完整复盘。我会把显存优化到底做了什么、CUDA Graph 在显存上搞了什么“小动作”、以及为什么省下显存不等于吞吐提升这三件事分别讲清楚。如果你是做 CUDA 优化或推理服务性能调优的人,尤其是分不清“容量”“带宽”“吞吐”三者关系的那一类,这篇文章应该能帮你少走一大段弯路。
1. 项目背景:一个把启动开销和显存容量同时压到临界点的推理服务
1.1 业务形态与性能基线
模拟项目 X 是一个实时视频流目标检测服务,模型结构不算大,但每个 stage 里塞了大量小算子。单帧输入进入模型后,至少要向 GPU 发几十个 kernel,而单个 kernel 的执行时间只有几十微秒。这里有个很尴尬的数字:GPU 一次 kernel launch 从 host 到 device 的延迟通常在十几微秒量级,当 kernel 本身执行时间只有几十微秒时,CPU 在“发命令”上的消耗反而一点不比“干活”少。
基线数据是这样的:batch=16,一个 step 的 wall time 约 10.8ms,其中纯 GPU kernel 执行时间只有 7.1ms,剩下 3.7ms 全部是 launch 间隙、同步等待和调度抖动。SM 利用率只有 65% 左右。用性能分析工具看时间轴,GPU 每跑一小段就停一会儿,完全处于“CPU 供不上命令”的状态。这是 CUDA Graph 最典型、收益也最明显的应用场景。
1.2 为什么选 CUDA Graph,而不是一上来就做 kernel 融合
我也知道 kernel 融合能减少 launch 次数,而且长期看收益更扎实。但那意味着要重构模型算子,改动量大,还容易在精度上出问题。CUDA Graph 的思路完全不一样:它在 capture 阶段把一系列 kernel 的启动参数、依赖关系、stream 信息全部固化到一张图里,运行时一次 graph launch 就把整张图交给 GPU,CPU 不再需要挨个发指令。更关键的是,正式执行时可以用 device-side launch 或者 graph instantiation 后的 exec 对象反复重放,CPU 侧的每 kernel 开销被摊薄成每图一次。
我一般用“火车站调度”来类比:原来每趟列车都要单独发一条指令,现在把整本时刻表交给调度员,调度员自己按图发车。对这个项目来说,graph 是最小改动、最大收益的路径。当时的目标是让 SM 利用率至少回到 85% 以上,这个预期后来也确实达到了。
1.3 显存的“隐性占用”是怎么发现的
优化前的 batch=16 已经跑到峰值显存 10.5GB,而 12GB 的卡留给系统的余量非常少,一旦输入分辨率再高一点就可能 OOM。可模型权重加输入输出只有大概 4GB,剩下 6GB 去哪了?用显存 timeline 逐 tensor 看,发现几个大块中间特征图的存活时间异常长。
其中一块 2GB 左右的中间结果,逻辑上只在第三个检测头用到,但框架为了图方便,会把所有阶段输出保留到整轮推理函数返回后才统一释放。于是我在不改模型结构的前提下做三件事:对每个中间 tensor 做生命周期分析,在最后一次使用点之后立刻释放引用;把只在内部使用的 buffer 从 fp32 降到 fp16,确认对精度无影响;再调整框架显存分配器对空闲缓存块的持有策略,减少“看起来占用很大”的虚高。最终峰值显存从 10.5GB 降到 6.7GB,省出了接近 4GB。
这一段优化完成后,我以为接下来的工作会非常顺:batch 从 16 提到 24,多一点并发,吞吐应该能跟着涨。但现实给了我一个响亮的耳光。
2. 显存优化初战告捷,但实测吞吐数据让人沉默
2.1 优化后的一轮完整对比
为了说清楚问题,我把四个配置全部测了一遍,分别是:无 graph 原始版本、启用 graph 但未做显存优化、启用 graph 且做了显存优化、启用 graph 且 visible batch 提到 24。
| 配置 | 峰值显存 | step 耗时 | 样本吞吐 |
|---|---|---|---|
| 无 graph / batch=16 / 未优化显存 | 10.5GB | 10.8ms | 1480 samples/s |
| 有 graph / batch=16 / 未优化显存 | 11.2GB | 9.1ms | 1758 samples/s |
| 有 graph / batch=16 / 优化显存 | 7.1GB | 8.9ms | 1799 samples/s |
| 有 graph / batch=24 / 优化显存 | 9.4GB | 11.6ms | 2069 samples/s |
数据摆出来,问题很刺眼。同样 batch=16,显存从 10.5GB 优化到 7.1GB,吞吐只从 1758 涨到 1799,涨幅 2% 都不到。这不是“优化了个寂寞”是什么?更别扭的是 batch 从 16 提到 24,理论上样本数增加 50%,但样本吞吐只涨了约 15%,换算下来 step 耗时反而从 8.9ms 拉长到 11.6ms。
我当时的第一反应是:是不是显存优化引入了重计算,让 graph 里的节点变多了?查了一遍,没有。我们只改了生命周期、数据类型和分配器缓存策略,没有加任何重算子。graph 的节点数量几乎没有变化。这就排除了最无脑的解释,也意味着问题比想象中深。
2.2 显存优化本身没有拖慢推理
这里要先确认一件事:显存优化不是用“时间换空间”的方式做的。很多人做显存优化时会用 activation checkpointing 或者临时 offload,换取容量的同时必然增加计算或拷贝开销。如果你省显存的手段是重计算,那吞吐下降甚至不变都很正常,因为多出来的算力开销把省下的容量红利抵消了。
但我们的做法不同。fp16 只是让读写 buffer 的字节数变少,理论上还能降低访存压力;生命周期释放只是让 tensor 更早归还显存,不会增加 kernel 数量。所以从机制上它就不该让 CNN 变慢。可实测结果是没有变快,这说明一个问题——显存优化释放出来的资源,并不是当前吞吐所缺的资源。用大白话说:你省了一堆“仓库空间”,但吞吐卡在“传送带速度”上,仓库再空也没用。
这个判断虽然方向对了,但当时还没有数据支撑,只是猜测。真正把它钉死,是从排查 CUDA Graph 的内存池开始的。
3. 排查第一关:CUDA Graph 的内存池才是“显存刺客”
3.1 为什么启用 graph 后,显存占用不降反升
注意表格里第二行:启用 graph 但未做显存优化时,峰值显存从 10.5GB 涨到了 11.2GB。显存优化明明没有增加任何大 tensor,graph 反而让它多出了 0.7GB。这就是 Graph 最容易被忽视的问题:stream capture 期间的 cudaMalloc 调用,会被 graph context 接管,分配内存不再进入普通 allocator 池,而是进入一个和 graph 绑定的私有内存池。
更重要的是,graph 里保存的不只是 kernel 启动参数,还有 kernel 之间的依赖关系。普通流式执行时,一个中间 tensor 用完就能释放,但在 capture 后的 graph 对象里,这个 tensor 可能作为后续节点的依赖被一直保留到整图执行结束。这就导致一个常见局面:代码里看着可以释放的 buffer,被 graph “冻结”了。
结果就是,12GB 的卡,原本 OOM 风险很高的 batch=16,加上 graph 后更是到 11.2GB,离天花板只有不到 1GB。显存优化把 batch=16 的占用拉到 7.1GB 后,你以为手里有 4GB 以上可支配空间,实际上 graph 私有池仍然抱着一大块内存不肯放。
3.2 怎么把 graph 内存池的占用挖出来
这一步要用显存 timeline 和 CUDA 提供的内存池属性查询接口。核心看两个字段:reservedMem 代表这个池子从驱动手里预留了多少显存,usedMem 代表当前实际用了多少。问题通常就出在两者差距很大。
我在优化后的配置里看到,graph 私有池的 reserved 大概 3GB,而 used 只有不到 2GB,说明有 1GB 以上是被“占着不用”的。如果把这个预留上限压下来,batch=24 甚至能有机会往上再探一点。具体做法可以调池子的 maxPoolSize 上限,或者提前在 capture 外分配好固定工作 buffer,让 capture 期间尽量不触发新的池子扩张。
调完之后,batch=24 的峰值显存从 9.4GB 降到 8.3GB,可吞吐依然没有任何变化。这个结果反而很有价值:它证明“显存不够”已经不是当前配置的主要矛盾。就算把显存问题全部解决,吞吐也动不了。
3.3 但这也解释了“省了显存却没完全省”的错觉
很多做优化的人有一个习惯:看到全卡显存数字降了,就以为这些显存可以自由支配。实际上,框架的 allocator 会缓存最近释放的空闲块,graph 的私有池又锁住了一部分中间结果,这两个机制叠加之后,真正能拿去做新分配的空间远没有表面上看起来那么多。
我后来在类似项目里养成了一个习惯:看显存不能只看全卡峰值,要看“普通分配池 + graph 私有池 + allocator 缓存”三段各自的占用。尤其在和 CUDA Graph 打交道时,如果不对 graph pool 做限制,它很容易吃掉你辛苦优化出来的容量。但注意,这一层只解决了“容量错觉”,还没有解决“吞吐为什么不动”的问题。
4. 排查第二关:省下的显存和推高吞吐之间隔着一堵叫“带宽”的墙
4.1 用 profiler 看 kernel 的瓶颈标签
光靠目测没有说服力。我用性能分析工具逐 kernel 采样,看两个核心指标:SM busy(计算单元忙不忙)和 memory throughput(显存带宽用到多少)。不看不知道,一看吓一跳。
这个模型里最耗时的几个 kernel,Memory Throughput 普遍顶在 90% 以上,而 SM busy 却只有 40% 上下。卷积层还算好,compute 占到了 20% 到 30%,可激活层、缩放层、拷贝层这些轻算子几乎就是纯访存操作,计算单元闲着等数据。整张图的特征非常明确:这是一个 memory-bound 模型,链路走的是 HBM 带宽,而不是 GPU 核心的计算吞吐。
这就解释了为什么省显存毫无用处。显存容量只决定了这张卡能“同时放多少任务”,而吞吐是单位时间内能完成多少计算量,后者被带宽、算力、启动开销这些“速度型资源”卡住。容量是房间大小,带宽是门和传送带的速度;你清理出一堆空房间,传送带的速度一点没变,每秒运出去的箱子数当然也不会变。
4.2 加 batch 为什么也救不了吞吐
很多人看到这里会问:那我把 batch 从 16 加到 24,不是让同一批 kernel 处理更多数据,传送带应该更满啊?问题就出在“处理更多数据”本身就要搬运更多字节。batch 从 16 变 24,每个样本需要的访存量基本不变,总访存量线性增长约 50%,而 HBM 带宽是固定值,所以完成一整轮的总时间也接近线性增长。最后折算成 samples/s,相当于你每次发车装的箱子多了,但装车时间也成比例变长,出货率并没有真正提升。
实测数据能直接验证这一点。主卷积层在 batch=16 时 kernel 耗时 3.2ms,achieved bandwidth 91%;batch=24 时 kernel 耗时 4.7ms,achieved bandwidth 92%。耗时与访存量几乎等比增长,带宽利用率没有降。这就不是并发度不够的问题,是物理资源被打满了。
4.3 反直觉的一个点:显存占用高反而可能是带宽利用率高的结果
顺着这个思路我还发现一个挺反直觉的现象:在 memory-bound 场景下,那些占用显存多的大 tensor,往往也是正在被反复读写的大 tensor。显存占用高不代表浪费,它可能只是当前工作集的自然大小。真正该做的不是减少这些 tensor 在显存里的“停留时间”,而是减少它们被搬进搬出的“字节数”。停留时间对应容量,访问量对应带宽,这两个维度经常被混淆。
到这里,排查链路已经清晰了:启动开销大,所以上 CUDA Graph 有效;带宽饱和,所以省显存无用;容量不是当前吞吐的约束,所以 batch 拉大一点收益也很有限。接下来的调参方向,必须从“省显存”转向“减访存”。
5. 把省下的显存真正用起来:三次调整才拿到可观收益
5.1 第一刀:kernel 融合,减少中间 tensor 的显存往返
memory-bound 模型里,最奢侈的操作就是“写完 HBM,立刻再读出来”。原模型里有一连串 elementwise 小算子:激活层把 tensor 写回显存,紧接着归一化层又从显存读出来运算,再写回,后面缩放层再来一次。每一步都产生完整的 HBM 读写,而实际计算量少得可怜。
我的第一刀是把相邻的 elementwise 算子合并成一个大 kernel,中间结果不落 HBM,留在寄存器和共享内存里直接往下传。batch=24 不变,样本吞吐从 2069 samples/s 涨到了 2680 samples/s。这一步和显存优化完全无关,是实打实的带宽优化。
5.2 第二刀:降低部分 activation 的数据类型
既然带宽是瓶颈,那么“单字节能表达多少计算量”就成了关键。我把一组只在内部传递、对精度不敏感的特征图从 fp32 降到 fp16,读写字节数直接砍半。这里要注意不是无脑全模型降精度,而是用 profiling 工具找出哪些 tensor 对最终精度影响小,哪些不能动。
这一步又带来约 7% 的收益,样本吞吐从 2680 涨到 2860 左右。虽然数字不大,但它是在带宽已经顶到 90% 以上的情况下挤出来的,每一分都值钱。到这一步,带宽利用率稍微松了一点,SM busy 也从 40% 涨到了 55% 上下。
5.3 第三刀:调整 graph 重放策略和 batch 分配
显存优化省下的容量在此时才真正派上用场。带宽压力松动后,把 batch 提到 32 开始有意义了,因为瓶颈不再只有带宽,SM 的并行度也顺带被利用起来。
但在这一步又踩了 CUDA Graph 的坑。graph 在 instantiate 后如果反复重新建图,会因为新图重建触发新的显存预留。正确做法是一次 capture,之后只更新 graph exec 里的参数或输入指针,避免反复实例化。同时我把 graph 私有池的 reserved 上限压缩到 usedMem 的 1.2 倍,把多余预留还给驱动。
最终 batch=32,单 step 耗时涨到 12ms,但样本吞吐直接跳到 3840 samples/s。对比最初 1480 samples/s 的基线,整轮优化的总收益很可观,而且其中大头来自带宽优化和更合理的 batch 分配,并不只是“省显存”的功劳。
5.4 一个容易犯的错:把显存优化做成 graph 节点爆炸
我在中间还试过一种错误方案:为了进一步省显存,把几个大分支改成动态计算,结果导致 capture 时出现大量分支节点和条件判断,graph 节点数暴增,驱动侧遍历图的成本反而拖慢了执行。这个教训让我意识到,CUDA Graph 优化的对象应该是一整条稳定执行流,不要让图里出现运行期才能确定的分支。如果模型本身有动态 shape 或分支逻辑,最好在 capture 前先固定输入维度,保证图结构可预知。
6. 复盘清单:以后再遇到“显存省了但吞吐没变”,我会先查这三件事
6.1 容量不是速度资源,这句话要刻在脑门上
这次项目里最值钱的认知不是某个 API 怎么用,而是把“显存容量”和“显存带宽”彻底分开。有时候你在显存上做了一堆漂亮优化,峰值降了 4GB,但吞吐纹丝不动,因为那张卡缺的根本不是容量,而是访问带宽。在看到 profiler 数据之前,我一度以为“显存占用降低”会自动带来“cache 和分配器压力下降”,最终体现在吞吐上。事实证明这是错觉。
一个简单的判断方法:如果 kernel 执行时 SM busy 低、memory throughput 高,说明是带宽瓶颈;如果是 SM busy 高、算力吃满,说明是 compute 瓶颈;如果时间轴上一大片空白、GPU 利用率低,说明是启动开销或同步问题。省显存只在“容量本身导致 OOM 或者触发换页、抖动”的时候才有意义。
| 现象 | 真正瓶颈 | 正确动作 |
|---|---|---|
| GPU 利用率低,时间轴大片空白 | launch overhead | CUDA Graph、减少 launch 次数 |
| SM busy 低,memory throughput 高 | 带宽瓶颈 | kernel 融合、低精度、减少访存字节 |
| SM busy 高,compute throughput 高 | 计算瓶颈 | kernel 算法优化、算子调优 |
| 显存 OOM,kernel 本身利用率正常 | 容量瓶颈 | 生命周期优化、checkpointing、扩容 |
6.2 CUDA Graph 的显存和普通显存不是一个池子
只要用了 CUDA Graph,就必须把 graph 私有池、普通 tensor 分配、框架 allocator 缓存这三块分开看。一个常见误判是:程序里释放了某个大 tensor,但 graph 对象还握着它;或者全卡显存看着下降了,但 pool 的 reserved 很高,导致你无法扩展新任务。
我在这个项目里学到的标准动作是:capture 完成后查一下 graph pool 的 reserved 和 used;如果 reserved 远大于 used,用池属性把上限压下去。另外,不要在 capture 内部依赖临时分配,最好在 capture 外部预先分配好固定 buffer,让 capture 期间的内存需求稳定、可预测。
6.3 优化顺序的最终建议
如果重新做一遍这个项目,我会调整工作顺序:先看时间轴判断是不是启动瓶颈,再上 CUDA Graph;上完 graph 后用 profiler 看瓶颈标签,确认是 memory-bound 还是 compute-bound;只有真的因为容量 OOM,才去做显存生命周期优化,并且尽量选择不增加访问量的做法。显存优化不是第一位的优化,它给其他优化腾地方,而不是自己直接创造吞吐。
这次经历让我深刻体会到,做性能优化最怕的不是没手段,而是把“资源约束”当成“性能瓶颈”来治。省下 4GB 显存听起来激动人心,但吞吐的数据不会骗人。希望这篇复盘能让你在下次遇到“显存省了但吞吐没提高”的时候,少走一圈我走过的弯路,直接去看带宽和 kernel 的瓶颈标签。