
这个问题我是真被问住了。我们在一台 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 在“发命令”上的消耗反而一点不比“干活”少。基线数据是这样的batch16一个 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 就把整张图交给 GPUCPU 不再需要挨个发指令。更关键的是正式执行时可以用 device-side launch 或者 graph instantiation 后的 exec 对象反复重放CPU 侧的每 kernel 开销被摊薄成每图一次。我一般用“火车站调度”来类比原来每趟列车都要单独发一条指令现在把整本时刻表交给调度员调度员自己按图发车。对这个项目来说graph 是最小改动、最大收益的路径。当时的目标是让 SM 利用率至少回到 85% 以上这个预期后来也确实达到了。1.3 显存的“隐性占用”是怎么发现的优化前的 batch16 已经跑到峰值显存 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 / batch16 / 未优化显存10.5GB10.8ms1480 samples/s有 graph / batch16 / 未优化显存11.2GB9.1ms1758 samples/s有 graph / batch16 / 优化显存7.1GB8.9ms1799 samples/s有 graph / batch24 / 优化显存9.4GB11.6ms2069 samples/s数据摆出来问题很刺眼。同样 batch16显存从 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。显存优化明明没有增加任何大 tensorgraph 反而让它多出了 0.7GB。这就是 Graph 最容易被忽视的问题stream capture 期间的 cudaMalloc 调用会被 graph context 接管分配内存不再进入普通 allocator 池而是进入一个和 graph 绑定的私有内存池。更重要的是graph 里保存的不只是 kernel 启动参数还有 kernel 之间的依赖关系。普通流式执行时一个中间 tensor 用完就能释放但在 capture 后的 graph 对象里这个 tensor 可能作为后续节点的依赖被一直保留到整图执行结束。这就导致一个常见局面代码里看着可以释放的 buffer被 graph “冻结”了。结果就是12GB 的卡原本 OOM 风险很高的 batch16加上 graph 后更是到 11.2GB离天花板只有不到 1GB。显存优化把 batch16 的占用拉到 7.1GB 后你以为手里有 4GB 以上可支配空间实际上 graph 私有池仍然抱着一大块内存不肯放。3.2 怎么把 graph 内存池的占用挖出来这一步要用显存 timeline 和 CUDA 提供的内存池属性查询接口。核心看两个字段reservedMem 代表这个池子从驱动手里预留了多少显存usedMem 代表当前实际用了多少。问题通常就出在两者差距很大。我在优化后的配置里看到graph 私有池的 reserved 大概 3GB而 used 只有不到 2GB说明有 1GB 以上是被“占着不用”的。如果把这个预留上限压下来batch24 甚至能有机会往上再探一点。具体做法可以调池子的 maxPoolSize 上限或者提前在 capture 外分配好固定工作 buffer让 capture 期间尽量不触发新的池子扩张。调完之后batch24 的峰值显存从 9.4GB 降到 8.3GB可吞吐依然没有任何变化。这个结果反而很有价值它证明“显存不够”已经不是当前配置的主要矛盾。就算把显存问题全部解决吞吐也动不了。3.3 但这也解释了“省了显存却没完全省”的错觉很多做优化的人有一个习惯看到全卡显存数字降了就以为这些显存可以自由支配。实际上框架的 allocator 会缓存最近释放的空闲块graph 的私有池又锁住了一部分中间结果这两个机制叠加之后真正能拿去做新分配的空间远没有表面上看起来那么多。我后来在类似项目里养成了一个习惯看显存不能只看全卡峰值要看“普通分配池 graph 私有池 allocator 缓存”三段各自的占用。尤其在和 CUDA Graph 打交道时如果不对 graph pool 做限制它很容易吃掉你辛苦优化出来的容量。但注意这一层只解决了“容量错觉”还没有解决“吞吐为什么不动”的问题。4. 排查第二关省下的显存和推高吞吐之间隔着一堵叫“带宽”的墙4.1 用 profiler 看 kernel 的瓶颈标签光靠目测没有说服力。我用性能分析工具逐 kernel 采样看两个核心指标SM busy计算单元忙不忙和 memory throughput显存带宽用到多少。不看不知道一看吓一跳。这个模型里最耗时的几个 kernelMemory 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相当于你每次发车装的箱子多了但装车时间也成比例变长出货率并没有真正提升。实测数据能直接验证这一点。主卷积层在 batch16 时 kernel 耗时 3.2msachieved bandwidth 91%batch24 时 kernel 耗时 4.7msachieved 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留在寄存器和共享内存里直接往下传。batch24 不变样本吞吐从 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 倍把多余预留还给驱动。最终 batch32单 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 算法优化、算子调优显存 OOMkernel 本身利用率正常容量瓶颈生命周期优化、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 的瓶颈标签。