一个推理服务把批大小从 8 提到 16,报 CUDA out of memory。日志显示"已用 18.2G / 24G,剩余 5.8G"——而"要分配的那块"只有 1.2G。原因是显存碎片:剩余的空间分散在很多小块里,凑不出"连续的一块"。
显存与内存的差别在"没有交换空间、也没有整理机制"。这个"直接失败"的性质很关键:内存碎片只是"分配变慢",而显存碎片是"分配不了"——因为内存有"整理(compaction)"与"换出",而显存两者都没有。
于是"碎片"在这里不是"性能问题",而是"直接失败"。
1. 显存分配的三种形态
| 形态 | 特点 |
|---|---|
| 预分配(caching allocator) | 大块预留,内部切分复用 |
按需分配(cudaMalloc) |
每次向驱动申请(慢) |
| 统一内存(UVM) | 可分页到主机(慢但不易 OOM) |
第一项是 PyTorch 默认:它向驱动要一大块,然后自己切第一项的"实现细节"是"按块大小分桶"(small/large pool):于是"小块"与"大块"从不同的池子取——这解释了"为什么小块能取到、大块取不到"。
——于是"OOM"往往发生在"缓存分配器内部",而不是驱动层。
第三项的"典型损失"是"数十倍的延迟":一次"换页"可能几百微秒,而"一次算子"只有几十微秒——于是它在推理场景几乎不可用。
第三项"不易 OOM"的代价是"带宽下降"(数据在主机与显存之间搬),于是"能跑但更慢"。
2. 三种"看起来有空闲却分配失败"
| 原因 | 表现 |
|---|---|
| 外部碎片 | 剩余总量够,连续块不够 |
| 缓存未释放 | reserved 远大于 allocated |
| 其它进程占用 | 同卡上有别的进程 |
第二项最容易被误判:torch.cuda.memory_reserved() 高而 memory_allocated() 低第二项的"判断"很干脆:reserved - allocated 就是"缓存池里没在用的部分"——它占着显存但不服务任何张量。
,说明"缓存池占着但没在用"——empty_cache() 能还回去。
第三项的"排查"要"看到进程级":nvidia-smi --query-compute-apps=pid,used_memory --format=csv 能列出"哪个 PID 占了多少"。
第三项在多用户/多服务共卡时常见:nvidia-smi 显示占用,但你的进程看不到"是谁"。
// 一次"显存账本"的完整读取(四行,解释 OOM)
print(torch.cuda.memory_allocated() / 2**30, "allocated") # 张量实际占用
print(torch.cuda.memory_reserved() / 2**30, "reserved") # 缓存池占用
print(torch.cuda.max_memory_allocated() / 2**30, "peak") # 峰值
torch.cuda.empty_cache() # 把空闲缓存还给驱动
// 判据:reserved - allocated 很大 → 缓存没释放;两者都小但 OOM → 碎片或外部占用
3. "碎片"为什么在推理里更严重
| 机制 | 影响 |
|---|---|
| 变长序列 | 每次要的块大小不同 |
| 批内 padding | 峰值按最长者算 |
| 反复分配/释放 | 空洞累积 |
第一项与第二项叠加:**"变长 + padding"让"每次请求的块大小都不一样"**第二项的"前提"是"批内按最长者分配":于是"一个长请求"会把"整批"的显存抬到"长者的水平"——而它同时降低"可并发的批数"。
——于是"小洞越来越多",而"新请求要的块越来越大"。
这个对比说明"碎片"是"动态形状"的产物:同样的代码在"固定 batch + 固定长度"下几乎不出碎片——于是"复现"常常在"简化脚本里不出来"。
对比"训练":训练的形状固定,于是"分配模式稳定",碎片少。
4. 四种缓解手段
| 手段 | 效果 |
|---|---|
| 固定形状(padding 到固定长度) | 最有效(牺牲算力) |
| 预分配池(按"几种尺寸"分桶) | 有效 |
定期 empty_cache |
缓解(有性能代价) |
| 降批大小/降并发 | 绕过(牺牲吞吐) |
第一项是"用算力换稳定":**把"变长"变成"固定几档"**第一项的"收益"是"可预测":分配模式固定 → 峰值显存固定 → 容量规划可算——而"可预测"在"多租户共卡"时比"省一点算力"更重要。
(如 512/1024/2048),于是"分配模式"可预测。
第三项的代价要清楚:empty_cache() 会让"下一次分配"重新向驱动申请,于是"频繁调用"会变慢——它适合"任务间隙",不适合"每个请求"。
// 分桶预分配(按"档位"申请,减少碎片)
档位:512 / 1024 / 2048 / 4096 token
每个档位预留一块 KV 空间,请求按"上取整"落到某个档
// 代价:短请求浪费(用 512 的块跑 100 token)
// 收益:不再有"大小不一"的分配 → 碎片基本消失
"OOM 复现"要"最小化到能触发",否则修完也不知道有没有用:
// 复现脚本的四要素(缺一项就可能复现不出来)
1 同样的"长度分布"(不是"平均长度",而是"P99 长度 + 混合比例")
2 同样的"批策略"(动态批 vs 固定批)
3 同样的"并发数"(并发决定"同时存在的请求数")
4 至少 100 轮(碎片累积需要时间)
// 判据:脚本能稳定复现 → 修法可验证
5. 怎么"看见"碎片
| 手段 | 得到 |
|---|---|
torch.cuda.memory_summary() |
分块直方图 |
nvidia-smi 的 memory.used |
总占用(看不出碎片) |
PYTORCH_CUDA_ALLOC_CONF 日志 |
分配/释放轨迹 |
| 复现脚本(同序列反复跑) | 是否随轮次恶化 |
第一项最直接:它列出"每个块大小的数量"——于是"小洞多不多"一眼可见。
第四项的"区分"很关键:"逐轮上升后稳定"是碎片(池子达到稳态);"一直上升"是泄漏(有张量没释放)——两者修法不同。
第四项是"低成本验证":同一批请求跑 100 轮,看"峰值显存"是否逐轮上升——上升说明"有泄漏或碎片累积"。
"分配配置"可以直接调,而它常被忽略:
// PyTorch 分配器配置(通过环境变量)
export PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:128,garbage_collection_threshold:0.8
// max_split_size_mb:限制"块被切分的最大尺寸"→ 减少小块碎片
// gc threshold:缓存占用超阈值时主动回收
// 注意:它只缓解,不解决"变长导致的形状不一"
6. 排错:五种"显存 OOM"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 剩余够但分配失败 | 碎片 | 固定形状/分桶 |
reserved 远大于 allocated |
缓存未释放 | empty_cache |
| 逐轮峰值上升 | 泄漏/碎片累积 | 复现脚本 + 直方图 |
| 单请求也 OOM | 模型+KV 超容量 | 降批/量化 |
| 换批大小后突然 OOM | 峰值按最长者 | 限长度差 |
第五项与"批内 padding"是同源问题:"批里混进一个长请求"会同时抬高"峰值显存"与"计算时间"。
最后一个"多租户"视角:共卡的多个服务会让"碎片"从"自己的问题"变成"互相的问题"。
// 共卡时的两种隔离(从弱到强)
弱:各自跑,显存靠"余量"协商 → 一方的峰值会挤掉另一方
强:MPS / MIG 硬隔离 → 各自有固定显存,互不影响
// 判据:是否"多个服务共卡且都要求稳定延迟"
7. 边界:什么情况不必操心碎片
| 场景 | 为什么 | 做法 |
|---|---|---|
| 固定形状(图像固定分辨率) | 分配模式稳定 | 不管 |
| 单请求推理(批=1) | 无批内差异 | 不管 |
| 显存远大于需求(余量 > 50%) | 不易触发 | 不管 |
| 用 UVM(可换页) | 不易 OOM | 只关心带宽 |
判断口径一句话:"每次请求要的显存块大小是否一致"。最后一条与"容量"有关的边界:"留 20% 余量"在"变长"场景里常常不够——因为"峰值"由"最长请求"决定,而不是"平均"。所以余量要按"P99 长度"算,而不是按均值。
一致(固定形状),碎片风险低;不一致(变长序列、动态批),就必须"分桶或固定档位"。
8. 自测
- 显存与内存的三个差别是什么?为什么"碎片"在这里是"直接失败"?
reserved远大于allocated说明什么?empty_cache()的代价是什么?- 为什么"变长序列 + padding"会让碎片更严重?训练为什么碎片少?
- "分桶预分配"的代价与收益分别是什么?
- 怎么"低成本验证"碎片累积?"逐轮峰值上升"说明什么?
- 判断"是否需要操心碎片"的那个问题是什么?