2、显存池核心架构:内存块管理、空闲链表设计、分配器策略

好,咱们直接切入正题。显存池这东西,说白了就是给GPU提前划好一块地,然后自己当包租公。你想想看,每次调用cudaMalloc都去跟驱动打交道,那开销可不是一般的大。我早年做图像处理管线的时候,就吃过这个亏——帧率死活上不去,一查profile,全是内存分配在拖后腿。

所以今天咱们聊聊显存池的核心架构。我把它拆成三个部分:内存块怎么管、空闲链表怎么设计、分配器用什么策略。这三块搞明白了,你的显存池基本就稳了。

2.1 内存块管理:大块切小块,小块要记账

显存池的第一步,是向GPU申请一大块连续显存。比如我习惯一次性申请1GB,然后自己切。这块大内存我们叫它池底(pool base)。

然后呢?你得把它切成小块。每个小块就是一个内存块(memory block)。我见过不少新手直接拿链表一个个挂,结果碎片化严重。我的做法是:

  • 块头结构:每个块前面加一个header,记录块大小、是否空闲、前后块指针。
  • 对齐要求:所有块地址按256字节对齐。为什么?因为GPU的global memory访问,对齐了效率高很多。
  • 最小块大小:我一般设成512字节。小于这个的请求,直接向上取整。不然碎片太多,你想想看,一个4字节的int占一个512字节的块,多浪费。

核心要点:内存块管理的关键是「快」和「省」。快是指分配和释放要O(1)或接近O(1);省是指内部碎片和外部碎片都要控制住。

这里我画了一张图,帮你理解内存块在池子里的布局:

显存池底层(1GB连续显存) Header 数据区(512B) Header 数据区(1024B) Header 数据区 块1(512B) 块2(1024B) 块3(512B) 空闲链表:块1 → 块2 → 块3 → NULL 已分配 用户数据 ↑ 用户拿到的是数据区起始地址,Header对用户透明

2.2 空闲链表设计:别让查找成了瓶颈

空闲链表,就是记录哪些块是空闲的。设计得好,分配就是O(1);设计得不好,分配就是O(n),n大了直接卡死。

我见过三种主流设计,咱们一个个说:

链表类型 分配策略 时间复杂度 适用场景
单链表(无序) 遍历找第一个大小合适的 O(n) 块数少(<100)
双链表(按大小排序) 从合适大小的位置开始找 O(log n) ~ O(n) 块数中等(100~1000)
伙伴系统(Buddy) 按2的幂次切分,合并也方便 O(log n) 块数多,且大小规律

我个人最常用的是双链表 + 大小桶。什么意思呢?就是把空闲块按大小分到不同的桶里。比如:

  • 桶1:512B ~ 1KB
  • 桶2:1KB ~ 4KB
  • 桶3:4KB ~ 16KB
  • 桶4:16KB ~ 64KB
  • 桶5:64KB以上

分配时,先算请求大小落在哪个桶,然后从那个桶的链表里取一个。如果桶空了,就去下一个更大的桶借。这样查找基本是O(1)的。

小技巧:每个桶内部用双链表,这样释放时合并相邻空闲块特别方便。我曾经在项目里只用单链表,结果释放后碎片越来越多,最后池子废了。后来改成双链表,问题就解决了。

2.3 分配器策略:选对策略,性能翻倍

分配器策略,说白了就是「当用户要一块内存时,我从哪个空闲块给他」。策略不同,碎片率和分配速度天差地别。

我整理了几种常见策略,你感受一下:

策略名称 做法 优点 缺点
First Fit 从头遍历,找到第一个够大的 简单,速度快 容易产生小碎片
Best Fit 找大小最接近的 碎片少 遍历慢,O(n)
Worst Fit 找最大的块,切一块给用户 大块保留少 容易浪费空间
Buddy System 按2的幂切分,伙伴合并 合并快,碎片可控 内部碎片最多50%

我自己的经验是:没有银弹。不同的业务场景,策略完全不同。

举个例子,我做深度学习训练框架时,tensor大小基本是固定的(比如1MB、4MB、16MB)。这时候用Buddy System就特别香——分配和释放都是O(log n),而且合并非常干净。

但做图形渲染管线时,内存请求大小变化很大,从几十字节到几兆字节都有。这时候我改用大小桶 + First Fit。每个桶内部用First Fit,因为桶内块大小差不多,First Fit和Best Fit差别不大,但First Fit更快。

注意:千万别在GPU上做复杂的链表遍历。GPU的线程是并行的,一个线程在遍历链表,其他线程可能就在改链表。我见过有人把CPU上的分配器直接搬到GPU上,结果死锁加数据竞争,调试了三天。记住:GPU分配器要尽量无锁,或者用原子操作。

2.4 避坑指南:我踩过的几个坑

最后分享几个我实际项目中遇到的坑,你遇到了可以少走弯路:

  • 坑一:忘了对齐。我曾经有一个块地址没做256字节对齐,结果在Volta架构上跑,带宽直接掉了一半。后来加上对齐,性能就回来了。
  • 坑二:释放时没合并相邻块。分配释放几次后,池子里全是小碎片,连一个1MB的连续块都找不到。后来我加了「释放时检查前后块是否空闲,是则合并」的逻辑。
  • 坑三:多线程竞争。多个CPU线程同时调显存池的分配和释放,不加锁的话,链表指针乱飞。我后来用了一个全局的spinlock,虽然简单但有效。
  • 坑四:池子太小。一开始只申请了256MB,结果业务一上来就爆了。后来改成动态扩展——池子不够时,自动再向cudaMalloc申请一块新的。

嗯,核心架构就这些。你把这些搞明白了,显存池的基本骨架就有了。剩下的就是根据你的业务场景,微调参数和策略。

一句话总结:内存块管理要「快和省」,空闲链表要「分桶+双链」,分配器策略要「看场景选」。这三板斧下去,你的显存池性能不会差。

可可精品资料库