3、推理场景下的显存特征:推理与训练的内存需求差异、动态形状(Dynamic Shape)带来的挑战、KV Cache的碎片化问题

3.1 推理 vs 训练:内存需求的天壤之别

做推理优化这么多年,我最大的感受就是——推理和训练对显存的需求,完全是两码事。

训练时,显存里塞的是什么?是激活值、梯度、优化器状态、参数副本……说白了,训练是“算完还要留着反向传播用”。你想想看,一个 batch 下去,每一层的激活值都得存着,等反向传播时再拿出来算梯度。这显存消耗,跟序列长度、batch size 几乎是线性增长。

但推理不一样。推理是前向传播,算完就扔。我经常跟团队说:推理的显存,核心就三块——模型参数、临时计算缓冲区、KV Cache。

对比维度 训练 推理
激活值存储 需要保留所有层激活值 逐层计算,用完即释放
梯度/优化器状态 需要存储 不需要
参数副本 可能有多份(数据并行) 通常只有一份
KV Cache 通常不需要(或仅用于验证) 核心内存消耗点
显存峰值 高(batch size 大) 相对低,但碎片化严重

我在项目中遇到过好几次这样的情况:训练时显存够用,一到推理部署就 OOM。为什么?因为推理的显存分配模式跟训练完全不同。训练是“一次性分配,长期持有”,推理是“频繁分配,频繁释放”。这种模式,对显存管理的要求更高。

核心结论:推理的显存瓶颈不在总量,而在分配和释放的碎片化问题。

3.2 动态形状(Dynamic Shape)带来的挑战

说到动态形状,我得先吐槽一句:这玩意儿是推理优化的头号杀手。

什么叫动态形状?说白了就是每次请求的输入长度不一样。今天用户问“你好”,明天用户问“请帮我写一篇关于人工智能在医疗领域应用前景的详细分析报告”。这两个请求的序列长度,差了十倍不止。

为什么会带来挑战?我举个例子你就明白了。

假设你给模型分配了一个最大长度 2048 的缓冲区。短请求来了,只用了 10 个 token,剩下 2038 个 token 的空间就浪费了。长请求来了,如果超过 2048,直接截断或报错。你想想看,这显存利用率能高吗?

我见过不少团队的做法是:按最大长度预分配。简单粗暴,但显存浪费严重。另一种做法是动态扩容——请求来了,按实际长度分配,不够了再扩。但这样会带来频繁的内存 realloc,性能损失很大。

我的建议:不要一刀切。可以按请求长度分桶(bucketing),比如 128、256、512、1024、2048 几个档位。短请求进小桶,长请求进大桶。这样既避免了浪费,又减少了动态分配次数。

我曾经在一个项目中,因为动态形状导致推理延迟抖动超过 50%。排查了半天,发现是每次请求长度不同,导致显存分配器频繁触发碎片整理。后来改用分桶策略,延迟抖动降到了 5% 以内。

3.3 KV Cache 的碎片化问题

好,终于说到重点了——KV Cache 的碎片化。这是推理场景下最让人头疼的问题之一。

先简单回顾一下:在自回归生成中,每生成一个 token,都需要用到之前所有 token 的 Key 和 Value。为了不重复计算,我们把它们缓存起来,这就是 KV Cache。

但问题来了:每个请求的序列长度不一样,生成的 token 数也不一样。有的请求生成 10 个 token 就结束了,有的生成 500 个。KV Cache 的大小是动态变化的。

你想想看,显存分配器面对的是什么样的场景?

  • 请求 A 来了,分配 100 个 token 的 KV Cache
  • 请求 A 生成了 50 个 token,释放了
  • 请求 B 来了,需要 200 个 token 的 KV Cache
  • 但刚才释放的 100 个 token 空间不连续,凑不出 200 个

这就是外部碎片。更麻烦的是内部碎片——你给请求分配了 1024 的缓冲区,它只用了 300,剩下的 724 就白白浪费了。

注意:KV Cache 碎片化不仅浪费显存,还会导致分配延迟增加。当显存碎片严重时,分配器可能需要遍历空闲链表、合并相邻块,甚至触发垃圾回收。这些操作都会增加推理延迟。

我记得有一次线上事故,推理服务的 P99 延迟从 200ms 飙升到 2s。查了半天,发现是 KV Cache 碎片化导致显存分配耗时暴增。后来我们做了两件事:

  1. 预分配 + 分桶:按请求长度分桶,每个桶预分配固定大小的 KV Cache 块
  2. 内存池化:请求结束后,KV Cache 不释放回系统,而是放回内存池复用

效果立竿见影,P99 延迟降回了 250ms 以内。

3.4 一张图看懂推理显存特征

下面这张图,是我自己总结的推理显存特征全景。你看完应该能有个整体把握。

推理场景显存特征全景图 推理 vs 训练 • 训练:存激活值/梯度 • 推理:算完即释放 • 训练:一次性分配 • 推理:频繁分配释放 → 碎片化风险高 动态形状挑战 • 输入长度不固定 • 预分配浪费显存 • 动态分配性能差 • 延迟抖动明显 → 分桶策略缓解 KV Cache碎片化 • 外部碎片:不连续 • 内部碎片:浪费 • 分配延迟增加 • 可能触发GC → 内存池化解决 核心优化策略 1. 分桶预分配:按请求长度分档,减少动态分配 2. 内存池化:请求结束后缓存复用,避免碎片 3. 显存整理:定期合并空闲块,降低外部碎片 4. 延迟分配:按需分配,避免预分配浪费

3.5 避坑指南

最后,分享几个我踩过的坑,希望能帮你少走弯路。

坑1:我曾经以为显存总量够大就不会有碎片问题。结果 80GB 的 A100,推理时只用了 30GB 就 OOM 了。原因就是碎片太多,分配不出连续的大块内存。

坑2:动态形状下,不要用默认的 CUDA 分配器。默认分配器对碎片化场景优化不足。我建议用 cudaMallocAsync 或者第三方库如 cub 的分配器,效果会好很多。

坑3:KV Cache 的预分配大小不是越大越好。我见过有人给每个请求预分配 2048 的 KV Cache,结果大部分请求只用了几十个 token。显存利用率不到 10%。按实际需求分桶,才是正道。

嗯,这一章的内容就到这里。显存碎片化是个系统工程问题,不是改一个参数就能解决的。你需要从分配策略、内存管理、请求调度等多个维度去优化。希望这一章能给你一个清晰的思路。

一句话总结:推理场景的显存管理,核心是应对动态性和碎片化。分桶预分配 + 内存池化,是目前最实用的组合拳。