背景:OOM 不一定意味着显存真的”用完了”
大模型推理进程里的 GPU 显存通常同时承载 模型权重、KV Cache、临时激活与算子工作区、CUDA Graph 私有内存池、通信缓冲区。当请求的批次大小、输入长度、输出长度或多模态尺寸持续变化时,分配器需要反复申请和复用不同大小的块。
此时常见的故障并不是总容量绝对不足,而是:
memory_reserved很高,但真正被 Tensor 使用的memory_allocated较低;- 缓存 Segment 内出现大量不能满足新请求的零散空洞;
- 某次 Prefill 或临时 Workspace 需要一个较大的连续分配,最终触发 OOM;
- PyTorch Snapshot 看起来仍有余量,但 NCCL、第三方 Kernel 或直接 CUDA API 占用了不可见显存。
因此,生产治理不能只盯 nvidia-smi,也不能把 OOM 简化成”把 gpu_memory_utilization 调低一点”。正确顺序是:先分类,再定位,然后调分配器和请求形状,最后建立 OOM 门禁与恢复策略。
核心原理:先区分四类显存问题
1. 真实容量不足
权重、KV Cache、执行工作区和安全余量之和已经接近显卡总容量。此时无论如何整理碎片,都无法容纳新的峰值请求。
典型信号是 allocated 与 reserved 都长期接近上限,而且 OOM 与最大并发 Token、最长上下文或最大图片尺寸高度相关。
2. 生命周期泄漏
某些 Tensor、请求上下文、Hook 或异步 Future 被意外持有,导致活跃内存随请求数持续增长而不回落。它与碎片不同:泄漏表现为 active allocation 单调上涨,而不是空闲块越来越零散。
3. 分配器碎片
PyTorch 使用缓存分配器避免频繁设备同步。已释放的块会保留在进程内供后续复用,因此 nvidia-smi 看到的占用通常更接近 reserved,而不是当前 Tensor 的 allocated。
当动态 Batch 从 N、N-1、N+1 不断切换时,不同层产生的分配尺寸也会变化。旧 Segment 末尾可能留下很多无法拼接利用的碎片,最终出现”总空闲量看似足够,但没有合适块”的情况。
4. 分配器外显存
PyTorch Memory Snapshot 只能看到通过 PyTorch 分配器管理的内存。NCCL、某些自定义 CUDA 扩展、驱动上下文或第三方推理库直接申请的显存,可能不会显示在 Snapshot 中。
如果设备总占用明显高于 PyTorch reserved,需要把差值作为 external GPU memory 单独监控,不能误判成 PyTorch 泄漏。
工程落地一:建立显存观测基线
上线前至少同时采集以下指标:
torch.cuda.memory_allocated()与峰值;torch.cuda.memory_reserved()与峰值;memory_stats()中 inactive split block 的数量和字节数;- GPU 设备总占用与 PyTorch
reserved的差值; - 请求侧 Batch、Prompt Token、Expected Output Token、模态尺寸;
- OOM 时的请求类型、并发 Token、实例运行时长和最近一次 Shape 变化。
建议定义两个派生指标:
allocator_slack = reserved_bytes - allocated_bytes
external_bytes = device_used_bytes - reserved_bytes
allocator_slack 很高不等于一定碎片,但如果同时出现大量 inactive split blocks,并且 OOM 发生在大块申请阶段,就值得进一步分析。
工程落地二:用 Memory Snapshot 找到分配时间线
PyTorch 提供 Memory Snapshot,用于记录 allocation、free、segment allocation 与 OOM 事件及其调用栈。它适合在预发布压测、故障副本或短时间诊断窗口内使用。
import torch
def run_probe() -> None:
# 替换为具有真实长度分布和并发模式的推理回放
...
torch.cuda.memory._record_memory_history(max_entries=100_000)
try:
run_probe()
except torch.cuda.OutOfMemoryError:
torch.cuda.memory._dump_snapshot("/tmp/llm-oom-snapshot.pickle")
raise
finally:
torch.cuda.memory._record_memory_history(enabled=None)
分析 Snapshot 时重点看三件事:
- OOM 前是否存在大量 inactive block;
- 新 Shape 是否不断创建略大于旧块的新 Segment;
- 是否有本应随请求结束释放的 active allocation 长期存活。
Snapshot 记录量可能很大,不应在所有生产副本上无限期启用。更稳妥的做法是由诊断开关控制,限制事件数、运行时长和输出目录。
工程落地三:先治理 Shape,再调 Allocator
分配器调优不能替代请求治理。对推理服务而言,最有效的第一步通常是减少无限离散的 Shape:
- 将 Batch Size 约束到有限档位;
- 对 Prompt Length、Max Output Tokens 和多模态分辨率做 Bucket;
- 给超长请求单独路由,避免与普通请求混跑;
- 用并发 Token 而不是请求数做 Admission Control;
- 对预估峰值工作区较大的模型或 Kernel 保留额外 Headroom。
Shape Bucket 不是越少越好。桶过粗会产生 Padding 浪费,桶过细又会降低内存块复用率。应使用真实流量回放,在 Padding 成本、缓存复用和尾延迟 之间找平衡。
工程落地四:按证据选择分配器参数
PyTorch 当前支持原生缓存分配器和 CUDA 异步分配器。参数应单独试验,而不是一次性全部打开。
方案 A:动态 Batch 明显,评估 expandable_segments
export PYTORCH_CUDA_ALLOC_CONF="backend:native,expandable_segments:True"
expandable_segments 的目标是让 Segment 随需求扩展,减少动态 Batch 在多层网络中留下大量尾部碎片。它仍属于实验能力,必须验证 GPU 型号、PyTorch 版本、CUDA Graph 与第三方算子的兼容性。
方案 B:inactive split blocks 很高,最后再试 max_split_size_mb
export PYTORCH_CUDA_ALLOC_CONF="backend:native,max_split_size_mb:256"
该参数限制大块被继续拆分,可能缓解边界型 OOM,但性能影响取决于分配模式。官方文档明确建议把它作为最后手段,并结合 memory_stats() 和 memory_summary() 调整。
方案 C:评估 cudaMallocAsync
export PYTORCH_CUDA_ALLOC_CONF="backend:cudaMallocAsync"
CUDA 异步分配器基于 Stream Ordered Memory Allocator,需要兼容的 CUDA 版本。切换后,部分只针对 native backend 的统计和参数不再有意义,例如 max_split_size_mb、roundup_power2_divisions 和 garbage_collection_threshold。
A/B 验证维度
每种方案都应分别做 A/B,不要把”未 OOM”当作唯一成功标准。还要比较:
| 维度 | 说明 |
|---|---|
| P50/P95/P99 TTFT 与 TPOT | 首 Token 延迟与每输出 Token 延迟 |
| 吞吐与 GPU 利用率 | 整体服务容量是否下降 |
| reserved/allocated 差值 | 缓存效率变化 |
| 长时间混合流量后的碎片趋势 | 是否随时间恶化 |
| CUDA Graph 捕获与重放 | 是否增加额外私有池 |
| Worker 重启频率和单请求失败率 | 稳定性指标 |
工程落地五:为 vLLM 预留不可见和突发显存
vLLM 的 --gpu-memory-utilization 是单实例预算,不是整个 GPU 的全局协调器。当前开发文档默认值为 0.92,但生产环境应按固定版本、模型、并行度和共卡进程重新测定。
如果需要精确控制 KV Cache,可以使用 --kv-cache-memory-bytes,它会覆盖基于 gpu_memory_utilization 的自动推断。无论采用哪一种方式,都不要把剩余显存全部交给 KV Cache,还应预留:
- Prefill 与采样临时工作区;
- CUDA Graph 私有池;
- NCCL 或自定义算子显存;
- 突发 Shape 和诊断工具开销;
- 驱动、上下文以及同卡其他进程的变化。
示例启动参数:
vllm serve /models/your-model \
--gpu-memory-utilization 0.86 \
--max-model-len 32768 \
--max-num-batched-tokens 16384
这里的数值只是配置形式示例,不是通用推荐值。正确数值必须来自目标硬件上的容量测试和真实流量回放。
OOM 门禁:让单个异常请求失败,而不是拖垮 Worker
生产系统应在请求进入调度器前估算峰值成本。一个简单模型可以写成:
request_peak ≈ kv_growth + prefill_workspace + multimodal_encoder_workspace + safety_margin
admit only if request_peak <= reclaimable_headroom
估算不需要一开始就非常精确,但必须保守,并持续用 OOM 样本校准。建议至少实现:
- 超出 Token、图片数量或分辨率预算时提前拒绝;
- OOM 只失败当前请求,记录完整请求 Shape;
- 连续 OOM 或碎片指标持续恶化时停止接流并排空 Worker;
- Worker 重启作为最终恢复手段,而不是日常清理策略;
- 不在每个请求后调用
empty_cache()。
PyTorch 新版分配器配置还提供面向推理服务的 OOM 抛出选项,使框架有机会捕获单请求 OOM 并继续服务;是否采用需结合当前 PyTorch 与 Serving 框架版本验证。
适用场景
这套方法适合:
- 动态 Batch 与长短请求混跑的在线推理;
- 文本和图片、视频输入混合的多模态服务;
- CUDA Graph、Tensor Parallel 或自定义 Kernel 共同占用显存的进程;
- 多模型或多个 Serving 实例共享同一 GPU;
- 运行数小时后才逐步出现 OOM 的服务。
如果模型权重本身已经超过物理显存,优先处理量化、并行、CPU Offload 或模型选择,Allocator 调优无法解决根本容量不足。
常见误区
| 误区 | 正确做法 |
|---|---|
只看 nvidia-smi | nvidia-smi 无法区分 Tensor 正在使用的内存、PyTorch 缓存、NCCL 分配与其他进程占用。必须结合 allocated、reserved、Snapshot 和设备级差值。 |
把 reserved - allocated 全部当成碎片 | 这部分包含可正常复用的缓存。只有当块分布、inactive split 和失败申请共同指向无法复用时,才能判断为碎片问题。 |
每次 OOM 都 empty_cache() | empty_cache() 只能归还完全空闲的 Segment,无法释放活跃 Tensor。频繁调用还会增加重新分配和同步开销。 |
把 gpu_memory_utilization 调到接近 1 | 推理引擎之外仍有执行工作区、通信库、Graph 私有池和外部分配。过度压缩 Headroom 会把偶发峰值变成系统性 OOM。 |
| 一次性打开所有 allocator 参数 | 参数之间存在后端约束,且效果依赖 Shape 分布。没有单变量 A/B,就无法判断收益来源,也无法安全回滚。 |
上线检查清单
- 已记录模型权重、KV Cache、工作区、Graph Pool 和外部显存的预算。
- 已对
allocated、reserved、inactive split 和 external bytes 建立监控。 - 已用真实 Prompt/Output Length 分布完成至少数小时混合流量回放。
- 已覆盖最小、典型、突发和超长 Shape。
- 已验证 allocator 配置对吞吐、P99 和 CUDA Graph 的影响。
- 已设置并发 Token、Max Model Length 和多模态输入门禁。
- 已确认单请求 OOM 不会直接杀死整个服务进程。
- 已设计 Worker 排空、重启和配置回滚流程。
- 已确认 Snapshot 的采集窗口、大小限制和敏感路径处理。
- 已固定 PyTorch、CUDA、vLLM 和驱动版本,避免参数语义漂移。
参考资料
- PyTorch — CUDA semantics / Memory management
https://docs.pytorch.org/docs/2.13/notes/cuda.html - PyTorch — Understanding CUDA Memory Usage
https://docs.pytorch.org/docs/2.13/torch_cuda_memory.html - PyTorch Blog — Understanding GPU Memory: Visualizing All Allocations over Time
https://pytorch.org/blog/understanding-gpu-memory-1/ - NVIDIA CUDA Runtime API — Memory Pools
https://docs.nvidia.com/cuda/cuda-runtime-api/group__CUDART__MEMORY__POOLS.html - vLLM — Engine Arguments
https://docs.vllm.ai/en/latest/configuration/engine_args/