GPU 显存管理:分配器、碎片与 OOM 排查树
Kubernetes 能限制 Pod 用多少内存,却管不住它用多少显存。这不是配置遗漏,是显存根本不经过内核的内存管理。
阅读位置:上一章《CUDA 执行模型》 · 下一章《互连与集合通信》。
GPU 基础认知给出了“算力是软约束,显存是硬约束”的一阶结论;本章把显存这条硬约束拆到底:分配路径、框架行为、碎片机制、OOM 排查。它是HAMi显存配额(nvidia.com/gpumem)的计量口径、vLLM显存水位线策略与MIG硬边界的共同地基。
铁律:显存绕过 cgroup
K8s 的 memory.limit 经 cgroup 管主机内存。显存则通过 NVIDIA 驱动的设备节点(/dev/nvidia0、/dev/nvidia-uvm 等)分配,内核对此没有记账:
容器视角
├── 主机内存 7.9Gi / limit 8Gi ← cgroup 可见,超限则 OOMKilled
└── 显存 79Gi / 80Gi ← cgroup 不可见,再涨即 CUDA OOM于是:给了 Pod 一块 GPU,它就能吃光整卡显存,原生 K8s 无任何手段限制。这正是共享方案(HAMi 的 gpumem 配额、vGPU 的显存分区、MIG 的物理切分)存在的根本原因:三种机制分别在API 层、驱动层、硬件层补这个洞。
分配为什么慢,以及人人都有“缓存分配器”
驱动级分配(cudaMalloc/cudaFree)是微秒到毫秒级操作,且释放还可能同步整个设备。训练循环每步分配/释放张量的话,时间全耗在驱动上。因此所有 DL 框架内建了缓存分配器(PyTorch 的 CUDACachingAllocator),思路与 jemalloc/tcmalloc 一致:
- 按大小分池:小块(<1MB 请求,2MB 段)、大块(≥1MB,20MB 段);
- 释放的张量不还给驱动,留在池内等待复用;
torch.cuda.empty_cache()才把空闲段还给驱动,它是诊断工具,生产循环里调用通常是 bug。
由此得到平台必背的一对指标:
reserved(nvidia-smi 看到的) = allocated(真实张量) + 缓存 + 碎片nvidia-smi 的进程显存“只涨不跌”不是泄漏,是分配器的设计。把它当成泄漏去杀 Pod,是运维最常见的误操作之一。
碎片:还有内存却 OOM
外部碎片:池里有 10GB 空闲,但都是 1GB 的洞,来一个 5GB 请求 → 放置失败 → OOM。识别特征是报错信息里“reserved but unallocated”数值很大。对症配置:
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True # 虚拟地址连续映射,基本消灭外部碎片
PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:512 # 保守方案:禁止大切小内部碎片:大小取整(2MB/20MB 段、推理引擎的 KV 块取整)造成的零头,单看小、量大时可观。分页 KV 的块大小选择就是这个问题在推理层的重现(见LLM 推理的物理层)。
与软切分配额的口径联动
HAMi 一类软切分方案在分配调用上拦截并记账(见数据平面谱系)。这意味着两件事:
- 计的是 reserved 口径(分配量),不是活跃张量,框架缓存会被当成“已用”;
- 跑在配额 Pod 里的 PyTorch 若不调
PYTORCH_CUDA_ALLOC_CONF,可能在“真实张量没用满”时就撞注入的 OOM。
软切分 Pod 模板应默认带分配器配置,这是显存配额从“能声明”到“能用”的临门一脚。
两棵 OOM 排查树
| Pod OOMKilled(exit 137) | 进程内 CUDA out of memory | |
|---|---|---|
| 资源 | 主机内存(cgroup) | 显存(绕过 cgroup) |
| 常见元凶 | pinned host buffer、NCCL 共享内存、数据加载 | 权重+KV+ 激活超预算、碎片、配额口径 |
| 排查入口 | kubectl describe pod、容器内存曲线 | nvidia-smi 总量 → 进程 reserved−allocated → 内存快照 |
| 修复方向 | 调 memory limit / 减少主机侧缓存 | 容量公式 / expandable_segments / 配额口径 |
CUDA OOM 一侧的树状顺序:先看卡上是否真有空(共享卡则查邻居与配额);再看 reserved−allocated(大 → 碎片,走分配器配置);再看 allocated 本身(用 torch.cuda.memory._record_memory_history() + memory_viz 定位张量来源);最后核对容量公式(权重 + KV 池 + 每进程上下文底噪 0.3–0.8GB + 框架开销)。
精度是一份工程契约
内存预算的另一根调节旋钮是精度。FP32 提供数值范围和熟悉的参照;FP16 与 BF16 减少内存、可提升 Tensor Core 吞吐,但二者在指数范围和稳定性行为上不同;FP8 及更低比特格式能在受支持的架构上解锁更高吞吐和容量,但需要缩放、校准、kernel 与质量验证。
| 格式 | 适用角色 | 需要的验证 |
|---|---|---|
| FP32 | 参考运算与敏感操作 | 数值基线与性能对比 |
| TF32 | 加速的兼容 FP32 风格训练路径 | 框架默认值与架构支持 |
| FP16 / BF16 | 训练与推理 | 损失缩放、范围、累加、质量 |
| FP8 | 现代训练/推理 | 缩放策略、算子覆盖、回归测试 |
| INT8 | 量化推理 | 校准与任务精度 |
| FP4 / 微缩放 | 新兴低比特 AI 路径 | 精确的 Blackwell 时代软件与模型支持 |
正确的问题不是“可用的最低精度是什么”,而是“在这套架构和软件栈上,满足应用质量与稳定性目标的最低精度是什么”。对显存预算而言,精度直接改写占用表里的权重、激活与 KV 缓存三项,这也是LLM 推理的物理层中量化提速的内存侧解释。
常见误区
| 误区 | 真相 |
|---|---|
| 调大 Pod memory limit 能解决 CUDA OOM | 无关,显存不经 cgroup |
empty_cache() 能防 OOM | 只释放空闲缓存;循环里调用反而拖慢 |
| nvidia-smi 显示 79GB 就是模型有 79GB | 含缓存与碎片,真实张量看 allocated |
| OOM 一定是模型太大 | 多数是碎片、水位线或配额口径问题 |
| 多个小进程共用 GPU 更灵活 | 每进程 0.3–0.8GB 上下文底噪,且无隔离 |
总结
显存是 GPU 治理的第一硬约束,四个事实各有推论:
- 绕过 cgroup:所以需要配额层
- 框架持有缓存:所以 reserved ≠ allocated
- 会碎片化:所以有“还有内存却 OOM”
- 有每进程底噪:所以进程数本身是成本
这四个事实分别决定了软切分的计量口径、vLLM的水位线设计、MIG 硬边界的价值,以及 OOM 排障的第一步永远是“分清哪棵树”。