一、显存池基础概念

大家好,我是这次课程的主讲。今天咱们聊聊显存池,这个在并发推理中绕不开的话题。

说实话,我刚接触显存池的时候,也觉得它就是个“内存池”的翻版。但真正在项目里用起来,才发现里面的门道比想象中多得多。咱们今天就把这三个问题掰开揉碎了讲清楚:什么是显存池、为什么需要它、它和普通显存分配到底有啥区别。

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 显存池的核心结构

为了让你更直观地理解显存池,我画了一张结构图。

显存池核心结构图 显存池(Memory Pool) 空闲链表(Free List) 块1: 64MB (地址0x1000) 块2: 128MB (地址0x2000) 块3: 256MB (地址0x3000) 已分配链表(Allocated List) 块A: 32MB (请求1) 块B: 64MB (请求2) 块C: 128MB (请求3) 分配器(Allocator) 查找空闲块 记录已分配 空闲块 已分配块

这张图展示了显存池的核心结构。左边是空闲链表,存放着可用的显存块;右边是已分配链表,记录着正在使用的块。分配器负责在两者之间调度。

我习惯把分配器比作“仓库管理员”。有人来领显存,管理员就去空闲链表里找合适的块;用完了还回来,管理员就把块从已分配链表移到空闲链表。

小技巧:在实际实现中,空闲链表通常按块大小排序,这样分配时可以用“最佳适配”或“首次适配”策略。我个人更推荐“最佳适配”,虽然查找慢一点,但能有效减少碎片。

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就够了。显存池的管理开销反而多余。
  • 显存极度紧张:显存池会预留一部分显存作为缓存。如果显存本来就捉襟见肘,再搞个池子可能会加剧压力。
  • 单次推理场景:如果只是跑一次推理就退出,显存池的“热身”优势体现不出来,反而增加了启动时间。

嗯,说白了就是:高频、短生命周期、大小不一的显存分配,才是显存池的用武之地。

好了,这一章的内容就到这里。显存池的基础概念,咱们算是讲清楚了。下一章我会深入讲显存池的实现细节,包括如何设计分配策略、如何处理并发访问等等。到时候见。

本章要点回顾:

  • 显存池是预分配的显存块集合,用于复用显存
  • 核心优势:减少分配开销、避免碎片、支持动态复用
  • 与普通分配的区别:分配速度、碎片控制、复用机制
  • 核心结构:空闲链表 + 已分配链表 + 分配器
  • 适用场景:高频、短生命周期、大小不一的分配

可可精品资料库