一、显存池基础概念
大家好,我是这次课程的主讲。今天咱们聊聊显存池,这个在并发推理中绕不开的话题。
说实话,我刚接触显存池的时候,也觉得它就是个“内存池”的翻版。但真正在项目里用起来,才发现里面的门道比想象中多得多。咱们今天就把这三个问题掰开揉碎了讲清楚:什么是显存池、为什么需要它、它和普通显存分配到底有啥区别。
1.1 什么是显存池?
显存池,说白了就是一块预先申请好的、统一管理的GPU显存区域。你可以把它想象成一个“显存仓库”。
我习惯这么理解:
- 普通分配:每次需要显存,就去跟GPU驱动“要一块”。就像每次做饭都去超市买菜。
- 显存池:提前把一周的菜都买好放冰箱。做饭时直接从冰箱拿,不用每次都跑超市。
嗯,这个比喻虽然糙了点,但核心意思到了。显存池就是那个“冰箱”,里面存着各种尺寸的显存块,随用随取。
核心定义:显存池是一个预分配的、可复用的显存块集合。它通过缓存已释放的显存,避免频繁向GPU驱动申请/释放内存。
我在项目中遇到过一种情况:某个模型服务刚启动时,显存池是空的。随着请求进来,池子慢慢“热”起来。等池子热了之后,后续请求的显存分配几乎零开销。这个“热身”过程,就是显存池从空到满的过程。
1.2 为什么需要显存池?
你可能会问:直接用cudaMalloc不香吗?为什么要搞个池子?
原因有三点,我一个个说。
1.2.1 减少显存分配的开销
cudaMalloc这个操作,其实挺重的。它涉及到:
- GPU驱动层的上下文切换
- 页表映射的建立
- 显存物理地址的分配
我做过一个测试:在A100上,单次cudaMalloc 1MB显存,平均耗时约50微秒。看起来不多对吧?但如果你在并发推理中,每个请求都要分配几十次显存,那累积起来就是毫秒级的延迟。对于追求低延迟的推理服务来说,这完全不能忍。
显存池的做法是:一次分配,多次复用。第一次分配慢点没关系,后续的分配直接从池子里拿,几乎零开销。
1.2.2 避免显存碎片
这是个老生常谈的问题,但真的很要命。
你想想看,如果每个请求都随机分配不同大小的显存块,释放的顺序又不一样。时间一长,显存里就会出现很多“小洞”——也就是碎片。这些碎片单个看都不大,但加起来可能占了几百MB甚至几个GB。
我记得有一次排查线上问题,发现显存明明还剩4GB,但就是分配不出一个2GB的连续块。查了半天,发现就是碎片搞的鬼。后来上了显存池,这个问题就再没出现过。
注意:显存碎片在长时间运行的推理服务中尤其严重。我曾经见过一个服务跑了72小时后,显存利用率从95%掉到了60%,就是因为碎片太多,大块显存分配不出来。
1.2.3 支持动态batch和显存复用
并发推理场景下,请求的batch size是动态变化的。今天可能batch size=1,明天可能batch size=32。如果每次都按最大batch size分配显存,那大部分时间都在浪费。
显存池可以做到:
- 按需分配不同大小的显存块
- 请求结束后,显存块回收到池中
- 下一个请求可以复用这些块
说白了,就是让显存流动起来,而不是死板地固定分配。
1.3 显存池与普通显存分配的区别
这个区别,我用一个表格来对比,会更直观一些。
| 对比维度 | 普通显存分配(cudaMalloc) | 显存池分配 |
|---|---|---|
| 分配速度 | 慢(涉及驱动层操作) | 快(从池中直接获取) |
| 碎片控制 | 差(容易产生碎片) | 好(通过合并策略减少碎片) |
| 内存复用 | 不支持(释放后还给驱动) | 支持(释放后回收到池中) |
| 适用场景 | 一次性分配、长期持有 | 高频分配、短生命周期 |
| 实现复杂度 | 低(直接调用API) | 高(需要管理池子) |
嗯,这个表格基本把核心区别说清楚了。但我还想补充一点:普通分配是“用完即弃”,显存池是“用完回收”。这个思维转变很重要。
我曾经犯过一个错误:在显存池里分配了一块显存,用完之后直接调了cudaFree。结果池子里的指针变成了野指针,后续再用就崩了。后来才意识到,显存池的显存不能直接释放,要还给池子管理。
1.4 显存池的核心结构
为了让你更直观地理解显存池,我画了一张结构图。
这张图展示了显存池的核心结构。左边是空闲链表,存放着可用的显存块;右边是已分配链表,记录着正在使用的块。分配器负责在两者之间调度。
我习惯把分配器比作“仓库管理员”。有人来领显存,管理员就去空闲链表里找合适的块;用完了还回来,管理员就把块从已分配链表移到空闲链表。
小技巧:在实际实现中,空闲链表通常按块大小排序,这样分配时可以用“最佳适配”或“首次适配”策略。我个人更推荐“最佳适配”,虽然查找慢一点,但能有效减少碎片。
1.5 一个简单的显存池示例
光说不练假把式。我写一个极简的显存池示例,帮你理解它的工作流程。
// 伪代码:显存池分配流程
void* pool_alloc(size_t size) {
// 1. 在空闲链表中查找合适大小的块
block_t* block = find_free_block(size);
if (block != NULL) {
// 2. 找到空闲块,从空闲链表移除
remove_from_free_list(block);
} else {
// 3. 没找到,向GPU申请新显存
block = malloc_from_gpu(size);
}
// 4. 加入已分配链表
add_to_allocated_list(block);
return block->address;
}
void pool_free(void* ptr) {
// 1. 从已分配链表找到对应块
block_t* block = find_allocated_block(ptr);
// 2. 从已分配链表移除
remove_from_allocated_list(block);
// 3. 加入空闲链表(等待复用)
add_to_free_list(block);
// 4. 可选:合并相邻空闲块
merge_adjacent_free_blocks();
}
这段代码虽然简单,但把显存池的核心逻辑说清楚了。分配时优先复用,释放时回收等待。注意最后一步的合并操作,这是减少碎片的关键。
我曾经在实现中漏掉了合并这一步,结果跑了一天后,空闲链表里全是小碎片,大块分配全都要重新向GPU申请。加了合并之后,显存复用率从60%提升到了95%以上。
1.6 什么时候不该用显存池?
说了这么多显存池的好处,我也得泼点冷水。显存池不是万能的,有些场景下它反而会添乱。
- 一次性大块分配:如果模型只需要分配一次显存,并且一直持有到结束,那用cudaMalloc就够了。显存池的管理开销反而多余。
- 显存极度紧张:显存池会预留一部分显存作为缓存。如果显存本来就捉襟见肘,再搞个池子可能会加剧压力。
- 单次推理场景:如果只是跑一次推理就退出,显存池的“热身”优势体现不出来,反而增加了启动时间。
嗯,说白了就是:高频、短生命周期、大小不一的显存分配,才是显存池的用武之地。
好了,这一章的内容就到这里。显存池的基础概念,咱们算是讲清楚了。下一章我会深入讲显存池的实现细节,包括如何设计分配策略、如何处理并发访问等等。到时候见。
本章要点回顾:
- 显存池是预分配的显存块集合,用于复用显存
- 核心优势:减少分配开销、避免碎片、支持动态复用
- 与普通分配的区别:分配速度、碎片控制、复用机制
- 核心结构:空闲链表 + 已分配链表 + 分配器
- 适用场景:高频、短生命周期、大小不一的分配