4、预分配策略:固定大小池设计、分级池设计、启动时预分配最佳实践

显存碎片,说白了就是内存被切得七零八落,大块请求来了却找不到连续空间。我见过不少项目,跑着跑着突然OOM,查了半天发现总显存还剩不少,但就是分配不出一个连续块。嗯,这就是碎片化在作祟。

解决这个问题,最直接的办法就是——预分配。在程序启动时,就把显存规划好,后面运行时只从池子里取,不跟系统申请也不释放给系统。这样碎片就控制在我们自己手里了。

固定大小池设计

固定大小池,是最朴素也最有效的策略。说白了,就是预先申请一批大小完全相同的显存块,组成一个池子。每次分配,就从池子里拿一块;释放,就还回去。

我在项目中遇到过这样一个场景:某个推理服务,每次请求的中间激活张量大小是固定的。一开始我们直接调用cudaMalloc,结果跑了几个小时,显存碎片率飙到30%以上。后来改成固定大小池,碎片直接归零。

实现起来其实很简单:

class FixedSizePool {
private:
    void* pool_base;          // 池子基地址
    size_t block_size;        // 每个块的大小
    size_t num_blocks;        // 块数量
    std::vector<bool> free_list; // 空闲标记

public:
    FixedSizePool(size_t blk_sz, size_t num_blk) {
        block_size = blk_sz;
        num_blocks = num_blk;
        // 一次性申请大块显存
        cudaMalloc(&pool_base, block_size * num_blocks);
        free_list.resize(num_blocks, true);
    }

    void* allocate() {
        for (size_t i = 0; i < num_blocks; i++) {
            if (free_list[i]) {
                free_list[i] = false;
                return (char*)pool_base + i * block_size;
            }
        }
        return nullptr; // 池子满了
    }

    void deallocate(void* ptr) {
        size_t idx = ((char*)ptr - (char*)pool_base) / block_size;
        free_list[idx] = true;
    }
};

这个设计的优点很明显:分配和释放都是O(1)复杂度,没有碎片。但缺点也突出——如果请求大小不固定,就浪费了。你想想看,一个块能装下两个小张量,但你只能用一个,另一半就空着。

核心要点:固定大小池适用于请求大小高度一致的场景。比如卷积层的中间结果、固定batch size的推理请求。

分级池设计

如果请求大小变化很大怎么办?那就上分级池。我习惯的做法是:准备多个固定大小池,每个池子负责一个大小级别。比如2KB、8KB、32KB、128KB、512KB……

分配时,根据请求大小找到最合适的池子。比如请求10KB,那就从32KB的池子里拿一块。虽然浪费了22KB,但避免了碎片。

这里有个关键问题:分级粒度怎么定?太粗了浪费多,太细了池子数量多管理复杂。我一般按2的幂次分级,比如2^n。这样查找池子可以用位运算,效率高。

我曾经在一个视频处理项目中用过分级池。视频帧大小不固定,有720p、1080p、4K。我建了三个池子,分别对应这三种分辨率。结果显存使用率从原来的85%降到92%,而且再也没有因为碎片导致分配失败。

分级池的分配逻辑:

class SegregatedPool {
    std::vector<FixedSizePool*> pools;
    std::vector<size_t> pool_sizes; // 每个池子的块大小

public:
    void* allocate(size_t size) {
        // 找到第一个不小于size的池子
        for (size_t i = 0; i < pool_sizes.size(); i++) {
            if (pool_sizes[i] >= size) {
                void* ptr = pools[i]->allocate();
                if (ptr) return ptr;
            }
        }
        // 所有池子都满了,从后备池分配
        return fallback_allocate(size);
    }
};
我的经验:分级池的级数不要超过8个。太多级数会导致管理开销上升,而且每个池子的利用率会下降。我一般用4-6级。

启动时预分配最佳实践

启动时预分配,这个习惯我强烈建议养成。为什么?因为程序启动时显存是干净的,没有碎片。这时候一次性申请大块内存,可以保证连续性。

具体怎么做?我总结了几个要点:

  1. 先估算,再分配。根据模型大小、batch size、序列长度等参数,估算出最大显存需求。然后加20%的余量。
  2. 一次性申请。不要分多次小申请。比如需要2GB,就一次cudaMalloc(2GB)。分10次每次200MB,可能就会产生碎片。
  3. 预留紧急池。我习惯预留5-10%的显存作为紧急池。当常规池子满了,可以从紧急池分配。这个池子只在极端情况下使用。
  4. 记录分配信息。每个预分配的块,都要记录它的用途、大小、地址。方便后续排查问题。

我曾经接手过一个项目,启动时预分配了3GB显存,但实际只用到了2.5GB。后来业务增长,需要2.8GB。因为预分配时留了余量,直接调整一下池子大小就搞定了。如果当时没有预分配,而是按需分配,估计又要跟碎片斗争好几天。

注意:启动时预分配不是越多越好。分配太多会导致其他进程无法使用显存。我建议预分配总量不超过物理显存的80%。剩下的留给系统和其他进程。

三种策略对比

策略 适用场景 碎片率 内存利用率 实现复杂度
固定大小池 请求大小固定 0% 低(可能浪费) 低
分级池 请求大小变化但可分级 接近0% 中 中
启动时预分配 所有场景的基础 可控 高 低

我个人建议:启动时预分配 + 分级池是黄金组合。启动时把显存一次性拿下,然后按大小分级管理。这样既保证了连续性,又兼顾了灵活性。

嗯,最后说一句:预分配策略不是银弹。如果你的请求大小变化极其剧烈,比如从1KB到1GB随机出现,那分级池也救不了你。这时候可能需要考虑更高级的伙伴系统或者slab分配器。不过那是后面章节的内容了。

一句话总结:预分配是显存碎片的第一道防线。固定大小池治标,分级池治本,启动时预分配治根。三管齐下,碎片问题基本可控。
预分配策略体系结构图 预分配策略 固定大小池 分级池设计 启动时预分配 特性 • 分配/释放 O(1) • 零碎片 • 内存利用率低 特性 • 多级固定池组合 • 2的幂次分级 • 平衡碎片与利用率 特性 • 一次性大块申请 • 预留20%余量 • 紧急池机制 黄金组合:启动时预分配 + 分级池

想要更多资料,联系可可