4. 动态内存复用:即时分配、碎片整理、缓存机制
好,咱们来聊聊动态内存复用。说实话,这是显存管理里最刺激、也最容易翻车的一块。静态复用你可以在编译期就规划好,但动态复用——嗯,它得在运行时跟系统抢资源、跟碎片做斗争。
我个人习惯把动态内存复用拆成三个核心能力:即时分配、碎片整理、缓存机制。这三者缺一不可,配合好了,你的模型就能在有限显存里跑得飞起;配合不好,OOM 就是家常便饭。
4.1 即时分配:别等,要抢
什么叫即时分配?说白了就是“用到的时候再分配,不用就立刻还回去”。这听起来简单,但实际做起来坑很多。
我在项目中遇到过这样一个场景:训练一个超大模型,显存只有 24GB。如果提前把所有中间结果都分配好,显存直接爆掉。怎么办?只能动态分配——算到哪一层,才给哪一层分配临时缓冲区。
核心思路:延迟分配 + 按需回收。不要提前占坑,也不要占着茅坑不拉屎。
举个例子,PyTorch 的 torch.cuda.empty_cache() 就是个典型的即时回收机制。但要注意,它只是把缓存还给 CUDA 的缓存分配器,不是真的还给操作系统。你想想看,如果每次释放都真的还给 OS,那下次分配时又要重新向内核申请,性能会差很多。
# 伪代码:即时分配器核心逻辑
class DynamicAllocator:
def __init__(self):
self.pool = {} # key: size, value: list of free blocks
self.active = {} # key: tensor_id, value: block
def allocate(self, size):
# 先从池子里找合适大小的块
for block in self.pool.get(size, []):
self.pool[size].remove(block)
self.active[id(block)] = block
return block
# 找不到就真的向 CUDA 申请
return cuda_malloc(size)
def free(self, tensor):
block = self.active.pop(id(tensor), None)
if block:
# 不真的释放,放回池子
self.pool.setdefault(block.size, []).append(block)
我的经验:即时分配的关键是“粒度”。块太大浪费显存,块太小碎片多。我一般建议以 256KB 为最小粒度,大块按 2MB 对齐。
4.2 碎片整理:内存的“大扫除”
动态分配用久了,一定会产生碎片。这就像你搬家,东西乱塞一气,最后想放个大箱子进去,发现哪儿都塞不下。
为什么会这样?因为分配和释放的顺序是乱的。比如你先分配了 A(4MB)、B(8MB)、C(4MB),然后释放了 A 和 C,剩下 B 在中间。这时候你想分配一个 6MB 的块——明明总空闲有 8MB,但就是没有连续 6MB 的空间。
碎片整理就是解决这个问题的。我见过最粗暴的做法是:把所有活跃块都搬到一起,腾出一大块连续空间。
注意:碎片整理是有代价的。搬数据需要时间,而且搬的时候所有 GPU 计算都得停下来。我曾经在一个推理服务里做碎片整理,结果导致延迟抖动从 5ms 飙到了 50ms——用户直接投诉。
那怎么办?我建议的策略是:
- 阈值触发:只有当碎片率超过 30% 时才做整理
- 增量整理:每次只整理一小块区域,分摊开销
- 避开计算热点:在 kernel 启动间隙做整理
碎片率怎么算?很简单:
碎片率 = 1 - (最大连续空闲块大小 / 总空闲大小)
如果这个值超过 0.3,就该动手了。
4.3 缓存机制:别重复造轮子
动态分配最怕什么?频繁分配和释放。每次 cudaMalloc 和 cudaFree 都是昂贵的操作,尤其是小块的分配。
所以,缓存机制就派上用场了。说白了就是:用过的块别扔,洗干净下次再用。
CUDA 自带的缓存分配器就是这么干的。你调用 cudaFree 时,它并没有真的把内存还给 OS,而是放到了一个“空闲列表”里。下次 cudaMalloc 时,先查这个列表,有合适的就直接拿。
| 缓存策略 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 大小匹配缓存 | 固定大小的 tensor | 命中率高,零开销 | 灵活性差 |
| 大小近似缓存 | 大小变化不大的场景 | 兼顾灵活性和性能 | 可能浪费空间 |
| 分桶缓存 | 大小分布离散 | 碎片少,分配快 | 实现复杂 |
我个人比较推荐分桶缓存。把内存按大小分成几个桶:小桶(<1MB)、中桶(1-16MB)、大桶(>16MB)。每个桶内部用 LRU 或 FIFO 策略管理。这样既能控制碎片,又能保证分配速度。
避坑指南:我曾经在某个项目中,缓存了太多大块内存,结果其他进程申请不到显存,直接报错。后来我加了一个“最大缓存上限”——总缓存不超过显存的 20%。这样既保证了复用效率,又不会饿死别人。
4.4 三者如何配合?
即时分配、碎片整理、缓存机制,这三者不是孤立的。它们需要协同工作:
- 分配时:先查缓存,命中就直接用;没命中就即时分配
- 释放时:不真释放,放回缓存池
- 碎片整理时:把缓存池里的块合并成大块,减少碎片
我画了一张流程图,帮你理解这个协作关系:
你看,这个流程里,缓存机制是第一道防线,即时分配是兜底方案,碎片整理是最后的“清道夫”。三者环环相扣,缺一不可。
我的建议:如果你在写自己的内存管理器,先把缓存机制做好。它能解决 80% 的性能问题。碎片整理可以晚点再加,但一定要有阈值保护,别让它成为性能瓶颈。
好了,动态内存复用就聊到这儿。记住三个关键词:即时、整理、缓存。下次你遇到显存不够或者分配变慢的问题,先想想这三个方向,大概率能找到解法。
一句话总结:动态内存复用不是玄学,是工程。把分配、整理、缓存三个环节管好了,你的 GPU 显存就能“变”出更多空间来。
想要更多资料,联系可可