第一讲:显存碎片初探
什么是显存碎片?
显存碎片,说白了就是显存空间被切得七零八落。
想象一下你有一块完整的空地,想停一辆大卡车。结果空地上到处散落着小石子、小沙堆,大卡车就是停不进去。显存碎片也是这个道理——明明总空闲空间够大,但就是凑不出一块连续的区域来分配。
我刚开始做GPU推理优化时,就遇到过这么个怪事。显存占用显示还剩4GB,但一申请个3GB的tensor,直接报OOM。当时我一脸懵,后来才明白,这4GB是碎片化的,最大的连续块可能只有几百MB。
核心定义:显存碎片是指显存中已分配和已释放的内存块交错分布,导致无法找到足够大的连续空闲块来满足新的分配请求。
为什么大模型训练会遇到OOM?
大模型训练,尤其是LLM,对显存的需求是「贪婪」的。一个70B的模型,光参数就要140GB(FP16)。你想想看,这还没算上梯度、优化器状态、激活值。
但真正让OOM频发的,不是总量不够,而是碎片化。我见过一个案例,A100 80GB的卡,模型理论上只需要72GB,但训练到第3个step就炸了。为什么?因为频繁的tensor创建和销毁,把显存切成了「蜂窝煤」。
这里有个关键点:大模型训练中的显存分配是动态的。前向传播时创建激活值,反向传播时释放一部分,优化器更新时又申请新的buffer。一来二去,显存就变成了「碎片拼图」。
避坑指南:我曾经在一个分布式训练项目中,发现某个节点的OOM率比其他节点高30%。排查了三天,最后发现是那个节点的PyTorch版本不同,内存分配器策略有差异。嗯,版本对齐真的很重要。
显存分配的基本单位
GPU显存分配不是按字节来的。CUDA驱动有一套自己的「批发」逻辑。
基本单位是segment(段)和block(块)。
- Segment:GPU驱动从显存中一次性「批发」的大块区域,通常是几MB到几百MB
- Block:从segment中切出来的小块,用于满足具体的tensor分配请求
我习惯把segment比作「整块地皮」,block就是「地皮上的具体地块」。CUDA驱动先批下整块地,然后分配器再切分给各个tensor。
来看个实际例子:
// 伪代码:显存分配过程
cudaMalloc(&ptr1, 1024 * 1024 * 100); // 申请100MB
// 驱动分配一个256MB的segment,从中切出100MB给ptr1
cudaMalloc(&ptr2, 1024 * 1024 * 50); // 申请50MB
// 从同一个segment中切出50MB给ptr2
cudaFree(ptr1); // 释放100MB
// segment中出现了一个100MB的空洞
cudaMalloc(&ptr3, 1024 * 1024 * 80); // 申请80MB
// 空洞只有100MB,但可能因为对齐要求,实际可用只有70MB
// 80MB放不下,只能从新segment中分配
// 旧的100MB空洞就成了碎片
你看,这就是碎片产生的典型过程。释放的块不一定是下一个分配的最佳位置。
| 分配单位 | 大小范围 | 作用 |
|---|---|---|
| Segment | 2MB ~ 512MB | 驱动层面的批量分配 |
| Block | 256B ~ 几十MB | 满足具体tensor请求 |
| Chunk | 自定义 | 框架层面的缓存池 |
个人经验:我建议你在调试显存问题时,先查一下分配粒度。很多框架默认的分配粒度是512B,但如果你频繁申请1KB的tensor,碎片率会飙升。调大分配粒度,比如改成2KB,有时候能立竿见影。
碎片化的直观理解
我画了一张图,帮你理解碎片是怎么产生的:
这张图展示了从连续空闲到碎片化的完整过程。你想想看,如果Tensor C需要300MB,而最大的连续空闲块只有155MB,那OOM就是必然的。
碎片类型
显存碎片主要分两种:
- 外部碎片:已分配块之间的空隙。就像上面图中的空洞,无法被大块请求利用
- 内部碎片:分配块内部未被使用的空间。比如你申请了1000字节,但分配器给了你1024字节,那24字节就是内部碎片
我个人经验是,大模型训练中外部碎片是主要矛盾。内部碎片虽然也存在,但占比通常不到5%。
关键认知:显存碎片不是「显存不够」,而是「显存不连续」。很多OOM问题,加显存并不能解决,反而可能因为更大的分配粒度让碎片更严重。
嗯,这一讲先到这里。碎片是怎么一步步累积的,以及如何用工具去观测,我们后面慢慢聊。