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)复杂度,没有碎片。但缺点也突出——如果请求大小不固定,就浪费了。你想想看,一个块能装下两个小张量,但你只能用一个,另一半就空着。
分级池设计
如果请求大小变化很大怎么办?那就上分级池。我习惯的做法是:准备多个固定大小池,每个池子负责一个大小级别。比如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);
}
};
启动时预分配最佳实践
启动时预分配,这个习惯我强烈建议养成。为什么?因为程序启动时显存是干净的,没有碎片。这时候一次性申请大块内存,可以保证连续性。
具体怎么做?我总结了几个要点:
- 先估算,再分配。根据模型大小、batch size、序列长度等参数,估算出最大显存需求。然后加20%的余量。
- 一次性申请。不要分多次小申请。比如需要2GB,就一次cudaMalloc(2GB)。分10次每次200MB,可能就会产生碎片。
- 预留紧急池。我习惯预留5-10%的显存作为紧急池。当常规池子满了,可以从紧急池分配。这个池子只在极端情况下使用。
- 记录分配信息。每个预分配的块,都要记录它的用途、大小、地址。方便后续排查问题。
我曾经接手过一个项目,启动时预分配了3GB显存,但实际只用到了2.5GB。后来业务增长,需要2.8GB。因为预分配时留了余量,直接调整一下池子大小就搞定了。如果当时没有预分配,而是按需分配,估计又要跟碎片斗争好几天。
三种策略对比
| 策略 | 适用场景 | 碎片率 | 内存利用率 | 实现复杂度 |
|---|---|---|---|---|
| 固定大小池 | 请求大小固定 | 0% | 低(可能浪费) | 低 |
| 分级池 | 请求大小变化但可分级 | 接近0% | 中 | 中 |
| 启动时预分配 | 所有场景的基础 | 可控 | 高 | 低 |
我个人建议:启动时预分配 + 分级池是黄金组合。启动时把显存一次性拿下,然后按大小分级管理。这样既保证了连续性,又兼顾了灵活性。
嗯,最后说一句:预分配策略不是银弹。如果你的请求大小变化极其剧烈,比如从1KB到1GB随机出现,那分级池也救不了你。这时候可能需要考虑更高级的伙伴系统或者slab分配器。不过那是后面章节的内容了。
想要更多资料,联系可可