显存池回收与碎片整理:内存碎片产生原因,延迟回收与合并策略

显存碎片,说白了就是显存被切得七零八落。你明明看到剩余总量还够,但就是分配不出一块连续的大显存。我最早遇到这个问题是在做多卡流水线并行时,模型卡在中间层,报错说显存不足,可一看剩余还有4GB——嗯,全是碎片。

碎片是怎么产生的?

显存碎片主要分两种:内部碎片和外部碎片。内部碎片是分配粒度导致的,比如你申请3GB,但分配器按4GB块给你,多出来的1GB就浪费了。外部碎片更头疼——频繁申请释放后,空闲块被切得支离破碎。

我举个例子。你训练一个模型,前向传播时申请了A、B、C三块显存,反向传播时释放了B。这时候空闲块就不是一整块了,而是被A和C夹在中间。下次你想申请一块比B大的显存,对不起,放不下。

碎片产生的三大元凶:

  • 频繁分配释放:每次迭代都申请临时张量,用完就丢
  • 大小不一的请求:激活值、梯度、优化器状态,尺寸差异巨大
  • 生命周期交错:不同张量的存活时间互相穿插,形成空洞

为什么会这样?你想想看,PyTorch默认的缓存分配器是贪心的——它尽量复用已有块,但不会主动整理。久而久之,显存就像一盘散沙。

延迟回收:给碎片一点时间

我个人的习惯是,不要急着把释放的显存还给CUDA。你释放了,CUDA driver不一定立刻回收。但如果你频繁调用torch.cuda.empty_cache(),反而会打断分配器的缓存策略。

延迟回收的核心思路是:把释放的块留在池子里,等凑够了再合并。就像你收拾房间,不会每扔一张纸就跑一趟垃圾桶,而是攒一堆再倒。

// 伪代码:延迟回收逻辑
class DelayedRecycler {
    queue: Vec<Block>;  // 待回收块队列
    threshold: usize;    // 触发回收的阈值
    
    fn release(block: Block) {
        if block.size < threshold {
            // 小块直接进队列,等合并
            this.queue.push(block);
        } else {
            // 大块立即回收
            cudaFree(block.ptr);
        }
    }
    
    fn try_merge() {
        // 按地址排序,合并相邻块
        this.queue.sort_by_key(|b| b.addr);
        let merged = merge_adjacent(this.queue);
        this.queue = merged;
        
        if this.queue.len() > MAX_PENDING {
            flush_to_cuda();
        }
    }
}

我在项目中遇到过,延迟回收配合水位线机制效果最好。设置两个阈值:低水位和高水位。低于低水位时,尽量不回收;高于高水位时,强制回收一批。这样既避免了频繁的CUDA操作,又不会让待回收队列无限膨胀。

避坑指南:我曾经把延迟队列设得太大,结果显存占用居高不下,OOM了。后来加了超时机制——队列里的块超过一定时间(比如10秒)还没被复用,就强制回收。这个时间值需要根据你的训练步长来调。

合并策略:把碎片拼回去

合并说白了就是找相邻的空闲块,把它们粘成一块大的。但这里有个坑——你怎么知道哪些块是相邻的?

我常用的方法是地址排序+双向合并。每次释放时,检查前后邻居是否也是空闲的。如果是,直接合并。这需要维护一个红黑树或跳表来快速查找邻居。

// 合并逻辑示意
struct Block {
    addr: u64,
    size: u64,
    is_free: bool,
}

fn coalesce(pool: &mut Vec<Block>, idx: usize) {
    let block = &pool[idx];
    
    // 检查前一个块
    if idx > 0 && pool[idx-1].is_free {
        let prev = &mut pool[idx-1];
        if prev.addr + prev.size == block.addr {
            prev.size += block.size;
            pool.remove(idx);
            // 递归检查前一个块是否还能再合并
            coalesce(pool, idx-1);
        }
    }
    
    // 检查后一个块
    if idx < pool.len()-1 && pool[idx+1].is_free {
        let next = &mut pool[idx+1];
        if block.addr + block.size == next.addr {
            block.size += next.size;
            pool.remove(idx+1);
            coalesce(pool, idx);
        }
    }
}

这里有个细节:合并后要更新空闲块列表。我习惯用伙伴系统来管理——把显存按2的幂次分成不同大小的块。释放时,如果两个伙伴块都空闲,就合并成更大的块。这样碎片率能控制在5%以内。

注意:合并操作本身有开销。如果你每释放一个块都做一次完整合并,性能会下降。我建议批量合并——攒够一定数量的释放操作,或者显存碎片率达到阈值(比如30%),再触发一次全局合并。

实战中的碎片整理策略

我整理了一份常用策略对比表,方便你根据场景选择:

策略 适用场景 碎片率控制 性能开销
延迟回收+水位线 训练场景,迭代稳定 10-15% 低
伙伴系统 大小请求均匀 <5% 中
全局压缩 碎片严重,紧急整理 <1% 高(需暂停训练)
分代回收 生命周期差异大 8-12% 中低

我个人最常用的是延迟回收+伙伴系统的组合。延迟回收处理临时张量,伙伴系统管理长期驻留的模型参数和优化器状态。这样既保证了低延迟,又控制了碎片率。

你想想看,如果每次迭代都做全局压缩,训练速度会慢得没法看。但完全不整理,显存利用率可能只有60%。这个平衡点需要根据你的模型和硬件来调。

核心要点总结:

  • 碎片产生是必然的,但可以控制
  • 延迟回收能减少CUDA操作次数,但要注意超时
  • 合并策略要选对数据结构,红黑树或伙伴系统都行
  • 不要追求零碎片,控制在10%以内就够用
显存碎片整理流程 分配请求 检查空闲块列表 未找到 找到 触发碎片合并 直接分配 重试 释放请求 加入延迟队列 检查合并条件 不满足 满足 等 合 更新空闲块列表 分配路径 释放路径 延迟队列 合并检查 触发合并

这张图展示了完整的显存分配与释放流程。注意看释放路径——不是直接还给CUDA,而是先进延迟队列,等条件满足再合并。这样能有效减少碎片。

最后说一句,碎片整理不是银弹。如果你的模型显存需求波动特别大,比如训练GAN或者做强化学习,那碎片率天然就高。这时候可以考虑预分配——在训练开始前就把可能用到的显存都申请好,避免运行时频繁分配。当然,这需要你对模型的内存使用有精确的预估。

嗯,碎片整理这块就聊到这儿。记住一个原则:能不整理就不整理,要整理就批量整理。性能开销和碎片率之间,找到你的平衡点。

可可精品资料库