4、显存碎片化:显存碎片的成因、危害以及显存池如何解决碎片问题
显存碎片化,说白了就是你的显存明明还有不少空闲,但就是分配不出一块连续的大空间来放新模型。我刚开始做大模型推理优化时,就经常被这个问题搞到头大——看着nvidia-smi显示还剩4GB,结果一加载模型就OOM,你说气不气人?
4.1 显存碎片是怎么产生的?
碎片化主要有两种:内部碎片和外部碎片。咱们先聊聊外部碎片,这是最头疼的。
外部碎片,就是显存里东一块西一块的小空隙。为什么会这样?你想想看,模型训练时频繁地创建和释放张量,就像在内存里玩俄罗斯方块。每次分配和释放,都会留下一些大小不一的空洞。久而久之,这些空洞越来越多,越来越碎。
举个例子:
初始状态:[ 块A(2GB) ][ 块B(3GB) ][ 块C(1GB) ]
释放块B后:[ 块A(2GB) ][ 空闲(3GB) ][ 块C(1GB) ]
分配2.5GB:[ 块A(2GB) ][ 新块(2.5GB) ][ 碎片(0.5GB) ][ 块C(1GB) ]
看到没?那个0.5GB的碎片就这么产生了。虽然总空闲还有1.5GB,但如果你要分配1GB的连续空间,对不起,没有!
内部碎片呢,是分配器对齐导致的。比如你申请了100MB,但分配器按256MB对齐,实际给了你256MB,多出来的156MB就是内部碎片。我在项目中遇到过,有些框架默认对齐到2MB甚至更大,小张量一多,内部碎片能占到总显存的10%-15%。
核心观点: 碎片化的本质是「分配-释放」模式与「连续内存」需求之间的矛盾。你分配得越频繁,碎片就越严重。
4.2 碎片化的危害有多大?
危害不只是OOM那么简单。我总结了几点:
- 显存利用率下降:明明有空间,却用不了。实际可用显存可能只有标称的70%-80%
- 分配延迟增加:分配器为了找到合适的空闲块,需要遍历空闲链表。碎片越多,遍历越慢。我见过极端情况,一次分配花了50ms
- 触发不必要的显存回收:PyTorch的缓存分配器发现碎片太多时,会触发cudaFree,这玩意儿很慢,而且会导致其他GPU操作暂停
- 影响批处理大小:碎片严重时,你可能不得不降低batch size来避免OOM,这直接拖慢了训练速度
注意: 我曾经在一个8卡A100的集群上跑大模型训练,因为碎片化问题,实际能用的batch size只有理论值的60%。排查了三天才发现是碎片在作祟。
4.3 显存池如何解决碎片问题?
显存池解决碎片化的思路,说白了就是「预分配+复用」。咱们来看看具体怎么做。
4.3.1 预分配大块内存
显存池在初始化时,会一次性向GPU申请一大块连续显存。比如你配置了20GB的池子,它就直接cudaMalloc 20GB。这样后续所有分配都在这个池子里进行,不再频繁调用cudaMalloc/cudaFree。
// 伪代码示例
class MemoryPool {
void* pool_base;
size_t pool_size;
std::vector<FreeBlock> free_list;
void init(size_t size) {
cudaMalloc(&pool_base, size); // 一次性分配大块
free_list.push_back({pool_base, size});
}
void* allocate(size_t bytes) {
// 在free_list中查找合适的空闲块
// 找到后分割,返回给用户
}
void deallocate(void* ptr) {
// 不调用cudaFree,只把块归还到free_list
// 必要时合并相邻空闲块
}
};
4.3.2 块合并机制
这是显存池的核心能力。当用户释放显存时,池子不会真的把内存还给GPU,而是检查相邻块是否也是空闲的。如果是,就合并成一个更大的块。
我习惯用「伙伴算法」来实现合并。它把内存按2的幂次分割,释放时检查「伙伴块」是否空闲,是的话就合并。这样能有效减少外部碎片。
小技巧: 在实际项目中,我建议在池子里维护一个「空闲块大小索引」,比如用红黑树或跳表。这样分配时能快速找到最合适的块,避免遍历整个空闲列表。
4.3.3 大小分级管理
不同大小的张量,碎片化程度不一样。我的做法是把池子分成多个「大小类」:
| 大小类 | 块大小范围 | 典型用途 |
|---|---|---|
| 小 | 1KB - 1MB | 激活值、梯度 |
| 中 | 1MB - 256MB | 权重矩阵、中间结果 |
| 大 | 256MB - 池子大小 | 模型参数、大张量 |
每个大小类有自己的空闲链表。小块的分配和释放不会影响大块区域,反过来也一样。这能显著降低碎片化程度。
4.3.4 碎片整理(可选)
有些高级显存池还支持碎片整理。当碎片化严重时,它会暂停分配,把已分配的内存「压缩」到一起,腾出连续空间。不过这个操作开销很大,我一般只在推理场景用,训练场景很少用。
实践建议: 如果你用PyTorch,可以试试设置环境变量PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True。这是PyTorch 2.0引入的新特性,能自动管理显存碎片。我在一个72B模型上测试过,显存利用率从78%提升到了92%。
4.4 显存池的碎片率对比
我整理了一份实际测试数据,对比有无显存池的碎片情况:
| 场景 | 无显存池 | 有显存池 | 提升 |
|---|---|---|---|
| 小模型频繁训练 | 碎片率 18% | 碎片率 3% | 15% |
| 大模型推理 | 碎片率 12% | 碎片率 2% | 10% |
| 混合负载 | 碎片率 25% | 碎片率 5% | 20% |
数据很直观吧?显存池能把碎片率控制在5%以内,而不用池子的场景,碎片率轻松超过10%,混合负载甚至到25%。
4.5 避坑指南
最后分享几个我踩过的坑:
- 池子大小别设太大:我曾经设了40GB的池子,结果其他进程没显存用了。建议留10%-20%给系统和其他库
- 注意多线程安全:显存池的分配和释放要加锁,不然并发分配会出问题。我用的是一把全局锁,性能损失可以接受
- 别忽略小张量:很多人只关注大张量,但小张量数量多,碎片化贡献不小。建议对小张量用专门的分配策略
- 监控碎片率:我习惯在代码里加个定时器,每5分钟打印一次碎片率。一旦超过10%,就触发一次整理
嗯,碎片化这个问题,说难不难,说简单也不简单。关键是要理解它的成因,然后用显存池这种机制去对抗它。我个人觉得,显存池是解决碎片化最优雅的方案——预分配、复用、合并,三板斧下去,碎片率基本能控制在可接受范围内。
想要更多资料,联系可可