4. 回收与碎片整理:内存碎片产生原因、碎片整理算法、延迟回收机制
好,咱们接着聊显存池的回收。分配搞定了,但光会分不会收,那跟只吃不拉一样,迟早出问题。这一章我重点讲讲回收过程中的那些坑——碎片是怎么来的,怎么整理,以及我为什么强烈建议你搞一套“延迟回收”机制。
4.1 内存碎片:它到底是怎么冒出来的?
说白了,碎片就是“用不了的小空隙”。你想想看,显存是一整块连续的空间,我们不断分配、释放,时间一长,中间就会留下一些大小不一的空洞。
碎片分两种:
- 外部碎片:空闲块的总和够大,但分散在各处,凑不出一块连续的给大请求用。比如你有10个1MB的空闲块,但来了个8MB的请求,对不起,分配失败。
- 内部碎片:分配出去的内存块比实际需求大。比如你申请了100字节,但分配器给了你一个128字节的块,那28字节就浪费了。
我在项目中遇到过最头疼的情况:一个训练任务跑着跑着突然OOM,但一查显存使用率才70%。为什么?因为碎片太多,剩下的30%全是小洞,最大的连续块连一个batch都放不下。嗯,这就是典型的“内存够用但用不了”。
4.2 碎片整理算法:怎么把这些洞填上?
碎片整理,说白了就是把分散的小空闲块合并成大块。我常用的方法有下面几种,各有各的适用场景。
4.2.1 伙伴系统(Buddy System)
这是最经典的算法。把内存按2的幂次分成不同大小的块。释放时,检查相邻的“伙伴”是否也是空闲的,如果是,就合并成更大的块。
// 伪代码示例:伙伴合并
void free_buddy(void *addr, size_t size) {
int order = get_order(size); // 计算2的幂次阶
struct page *page = virt_to_page(addr);
// 标记为空闲
page->flags |= PG_free;
// 尝试合并:检查伙伴是否也空闲
while (order < MAX_ORDER) {
struct page *buddy = find_buddy(page, order);
if (!buddy_is_free(buddy))
break; // 伙伴不空闲,停止合并
// 合并:从当前order链表移除,加入更高order链表
remove_from_list(buddy, order);
order++;
page = merge_pages(page, buddy);
}
add_to_list(page, order);
}
伙伴系统的优点是合并速度快,实现简单。缺点是内部碎片严重——你申请65KB,它给你128KB的块,浪费了将近一半。我个人习惯在显存池的底层用伙伴系统,上层再套一层slab分配器来缓解内部碎片。
4.2.2 紧凑算法(Compaction)
这个更暴力一些。把正在使用的内存块“挤”到一端,让所有空闲空间集中到另一端。就像整理书架——把书都推到左边,右边就空出一大块。
我曾经在一个推理服务里用过这个思路。当时碎片率到了40%,推理延迟开始抖动。我写了个后台线程,每隔一段时间扫描一次,把活跃块往前挪。效果很明显,碎片率降到了5%以下。但代价是:挪动期间,被移动的块不能访问,需要暂停读写。所以这个算法适合离线或低优先级场景。
4.2.3 分代回收(Generational Reclaim)
这个灵感来自垃圾回收。把内存块按“存活时间”分成几代。刚分配的块放年轻代,存活时间长的块升到老年代。回收时优先扫描年轻代,因为大部分块都是“朝生暮死”的。
我建议在训练场景下用这个。训练过程中,临时张量(比如中间激活值)频繁创建和销毁,这些就是年轻代。而模型参数、优化器状态这些常驻内存的,就是老年代。分代回收能大幅减少扫描范围,提升回收效率。
4.3 延迟回收机制:为什么不能“用完就还”?
很多人觉得,显存用完了立刻释放,不是最直接的吗?嗯,理论上没错,但实际中“立刻释放”会带来两个问题:
- 频繁的系统调用开销:每次释放都要跟驱动交互,性能损耗很大。
- 加剧碎片化:释放得越频繁,空闲块越分散,越难合并成大块。
所以我的做法是——延迟回收。说白了就是:用户说“我不要了”,我先不真还,而是把这块内存标记为“待回收”,放进一个缓存池里。等缓存池满了,或者系统真的缺内存了,再统一回收。
具体实现上,我常用一个LRU链表来管理待回收块。每次用户释放时,把块挂到链表尾部。后台线程从链表头部开始检查,如果块已经超时,就真的释放给底层分配器。
// 延迟回收的简化实现
struct delayed_free_pool {
struct list_head lru_list; // LRU链表
int count; // 当前缓存块数量
int max_count; // 最大缓存数量
int timeout_ms; // 超时时间
};
void delayed_free(void *addr, size_t size) {
struct free_block *block = kmalloc(sizeof(*block));
block->addr = addr;
block->size = size;
block->free_time = get_current_time_ms();
// 挂到LRU链表尾部
list_add_tail(&block->node, &pool->lru_list);
pool->count++;
// 如果缓存块太多,触发回收
if (pool->count > pool->max_count)
reclaim_old_blocks(pool);
}
void reclaim_old_blocks(struct delayed_free_pool *pool) {
struct free_block *block, *tmp;
uint64_t now = get_current_time_ms();
// 从链表头部开始检查超时块
list_for_each_entry_safe(block, tmp, &pool->lru_list, node) {
if (now - block->free_time < pool->timeout_ms)
break; // 链表头部是最老的,如果头部都没超时,后面的更不会
// 真的释放给底层分配器
real_free(block->addr, block->size);
list_del(&block->node);
kfree(block);
pool->count--;
}
}
我曾经在一个视频编解码项目里用过这个机制。编码器每帧都会申请和释放大量临时缓冲区。如果每帧都真释放,性能直接掉30%。用了延迟回收后,缓冲区复用率提高了,性能基本没损失。嗯,这就是延迟回收的魅力。
4.4 本章知识体系
下面这张图总结了碎片整理和延迟回收的核心逻辑,我画出来方便你对照理解。
这张图把整个流程串起来了。从分配器开始,碎片产生后,我们有两条路:要么主动整理(碎片整理算法),要么被动缓冲(延迟回收)。实际项目中,我通常两条路都走——后台线程跑碎片整理,同时用户侧的释放走延迟回收。双管齐下,效果最好。
好了,这一章就到这里。碎片整理和延迟回收,说白了就是“怎么把显存用得更省、更快”。下一章我会聊聊更进阶的话题——显存池的监控与调优,到时候咱们再细聊。