文章

LLM GPU 显存碎片治理生产实战:用 Memory Snapshot、Allocator 调优与 OOM 门禁稳住推理服务

大模型推理服务常在显存尚未耗尽时因碎片化触发 OOM。本文结合 Memory Snapshot、动态 Shape、分配器调优和 vLLM 显存预算,给出可观测、可验证、可回滚的生产治理方案。

背景: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、执行工作区和安全余量之和已经接近显卡总容量。此时无论如何整理碎片,都无法容纳新的峰值请求。

典型信号是 allocatedreserved 都长期接近上限,而且 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 时重点看三件事:

  1. OOM 前是否存在大量 inactive block;
  2. 新 Shape 是否不断创建略大于旧块的新 Segment;
  3. 是否有本应随请求结束释放的 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_mbroundup_power2_divisionsgarbage_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-sminvidia-smi 无法区分 Tensor 正在使用的内存、PyTorch 缓存、NCCL 分配与其他进程占用。必须结合 allocatedreserved、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 和外部显存的预算。
  • 已对 allocatedreserved、inactive split 和 external bytes 建立监控。
  • 已用真实 Prompt/Output Length 分布完成至少数小时混合流量回放。
  • 已覆盖最小、典型、突发和超长 Shape。
  • 已验证 allocator 配置对吞吐、P99 和 CUDA Graph 的影响。
  • 已设置并发 Token、Max Model Length 和多模态输入门禁。
  • 已确认单请求 OOM 不会直接杀死整个服务进程。
  • 已设计 Worker 排空、重启和配置回滚流程。
  • 已确认 Snapshot 的采集窗口、大小限制和敏感路径处理。
  • 已固定 PyTorch、CUDA、vLLM 和驱动版本,避免参数语义漂移。

参考资料

  1. PyTorch — CUDA semantics / Memory management
    https://docs.pytorch.org/docs/2.13/notes/cuda.html
  2. PyTorch — Understanding CUDA Memory Usage
    https://docs.pytorch.org/docs/2.13/torch_cuda_memory.html
  3. PyTorch Blog — Understanding GPU Memory: Visualizing All Allocations over Time
    https://pytorch.org/blog/understanding-gpu-memory-1/
  4. NVIDIA CUDA Runtime API — Memory Pools
    https://docs.nvidia.com/cuda/cuda-runtime-api/group__CUDART__MEMORY__POOLS.html
  5. vLLM — Engine Arguments
    https://docs.vllm.ai/en/latest/configuration/engine_args/

常见问题

nvidia-smi 还有空闲显存,为什么推理服务仍会 CUDA OOM?
OOM 不只取决于总空闲量,还取决于分配器能否找到满足本次请求的可用块。动态批次、不同序列长度和临时工作区可能留下无法复用的碎片;此外 NCCL 或直接 CUDA 分配也可能不在 PyTorch Snapshot 中。
torch.cuda.empty_cache() 能解决显存碎片吗?
它只能把完全空闲的缓存 Segment 归还给 CUDA,不能释放仍被 Tensor 占用的显存,也不能保证修复所有碎片。把它放进每个请求的热路径通常会破坏缓存复用并增加延迟。
expandable_segments 或 cudaMallocAsync 应该直接在线上开启吗?
不应该盲开。两者需要基于真实 Shape 分布、inactive split block、吞吐和尾延迟做 A/B 验证;部分原生分配器参数在 cudaMallocAsync 后端下会被忽略。
OOM 后继续服务是否安全?
取决于框架是否能完整清理失败请求的状态。单请求 OOM 可捕获并不意味着所有自定义算子、通信状态或 CUDA Graph 都已恢复。连续出现 OOM 时,应把 Worker 标记为不接新流量,排空后重启。