☰
显存省了4GB吞吐却不变?揭秘CUDA Graph与带宽瓶颈的真相
2026/10/20 5:12:05 网站建设 项目流程

这个问题我是真被问住了。我们在一台 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.5GB10.8ms1480 samples/s
有 graph / batch=16 / 未优化显存11.2GB9.1ms1758 samples/s
有 graph / batch=16 / 优化显存7.1GB8.9ms1799 samples/s
有 graph / batch=24 / 优化显存9.4GB11.6ms2069 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 overheadCUDA 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 的瓶颈标签。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询