3、显存复用策略:时间复用、空间复用、基于优先级的复用
显存复用这个话题,说白了就是「怎么让一块显存干更多的事」。我刚开始做GPU推理优化时,总觉得显存不够用是硬件问题。后来才明白,很多时候是我们没把复用策略用好。
今天咱们就聊聊三种核心策略:时间复用、空间复用、还有基于优先级的复用。这三种策略各有各的适用场景,我一个个说。
3.1 时间复用:错峰出行
时间复用的思路很简单——同一块显存,不同时间给不同人用。就像共享单车,你骑完我骑,谁也别占着不走。
在GPU场景里,典型的就是推理请求的显存复用。多个请求串行执行,每个请求用完就释放显存,下一个请求再申请。
核心思想:显存不归某个请求独有,而是按时间片轮转。
我举个例子。假设你有个BERT模型推理服务,每个请求需要2GB显存。如果同时来10个请求,你难道要准备20GB显存?不用。用时间复用,你只需要2GB就够了——一次只处理一个请求。
代码实现上,其实就是显存池的「借还」机制:
// 伪代码:时间复用显存池
class TimeSharedMemoryPool {
void* allocate(size_t size) {
// 检查是否有空闲块
if (free_block != nullptr) {
return free_block;
}
// 没有空闲块就等
wait_for_free_block();
return free_block;
}
void deallocate(void* ptr) {
// 归还到空闲池
free_block = ptr;
// 通知等待的请求
notify_waiting_requests();
}
};
优点:实现简单,显存利用率高。
缺点:并发能力差,请求多了得排队。
我个人习惯在推理服务里用时间复用。特别是延迟要求不高的场景,比如离线批处理。我曾经在一个OCR服务里用时间复用,显存占用从8GB降到了2GB,效果立竿见影。
3.2 空间复用:分蛋糕
空间复用就不一样了。它把一块显存切成多个小块,同时给多个请求用。你想想看,就像一个大蛋糕,你切一块我切一块,大家同时吃。
在GPU里,空间复用最常见的场景是多模型并行推理。比如你同时跑两个小模型,每个模型只需要1GB显存。用空间复用,你可以在同一块4GB显存上同时跑这两个模型,互不干扰。
核心思想:显存按需分割,多个请求并行使用不同区域。
实现上,我们需要一个「显存分配器」来管理这些小块:
// 伪代码:空间复用显存池
class SpaceSharedMemoryPool {
struct Block {
void* addr;
size_t size;
bool in_use;
};
std::vector<Block> blocks;
void* allocate(size_t size) {
// 找第一个足够大的空闲块
for (auto& block : blocks) {
if (!block.in_use && block.size >= size) {
block.in_use = true;
return block.addr;
}
}
// 没有合适的块,返回null
return nullptr;
}
void deallocate(void* ptr) {
// 标记为空闲
for (auto& block : blocks) {
if (block.addr == ptr) {
block.in_use = false;
break;
}
}
}
};
优点:并发能力强,多个请求同时处理。
缺点:容易产生碎片,管理复杂。
我曾经踩过一个坑:空间复用没处理好碎片问题,跑了一天后显存利用率从90%掉到了40%。后来加了碎片整理才解决。嗯,这里要注意,碎片整理不能频繁做,否则性能会下降。
3.3 基于优先级的复用:谁急谁先用
这个策略就更有意思了。它结合了时间和空间复用,但加了一个维度——优先级。说白了就是:高优先级的请求可以「抢」低优先级的显存。
我举个例子。你在一个GPU上同时跑训练和推理。训练任务优先级低,但占显存多。推理任务优先级高,但占显存少。如果推理任务来了,发现显存不够怎么办?
基于优先级的复用会这么做:
- 检查是否有空闲显存
- 如果没有,看能不能从低优先级任务里「借」
- 能借就借,不能借就等
核心思想:优先级高的请求可以抢占优先级低的显存。
实现上,我们需要一个优先级队列:
// 伪代码:基于优先级的显存复用
class PriorityMemoryPool {
struct Request {
int priority; // 0最高
size_t size;
void* ptr;
};
std::priority_queue<Request> requests;
void* allocate(size_t size, int priority) {
// 先尝试普通分配
void* ptr = try_allocate(size);
if (ptr != nullptr) return ptr;
// 显存不够,尝试抢占低优先级
if (priority == 0) { // 最高优先级
// 找最低优先级的请求,释放它的显存
Request victim = find_lowest_priority();
deallocate(victim.ptr);
return try_allocate(size);
}
// 低优先级请求,排队等待
return nullptr;
}
};
优点:灵活,能应对突发高优先级请求。
缺点:实现复杂,低优先级任务可能被饿死。
我建议在混合负载场景下用这个策略。比如训练+推理混跑,训练优先级设低,推理优先级设高。我曾经在一个推荐系统里这么搞,推理延迟从50ms降到了10ms,训练也没受太大影响。
3.4 三种策略对比
说了这么多,咱们用一张表总结一下:
| 策略 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 时间复用 | 串行推理、离线批处理 | 实现简单,显存利用率高 | 并发能力差 |
| 空间复用 | 多模型并行、小模型推理 | 并发能力强 | 容易产生碎片 |
| 优先级复用 | 混合负载、实时推理 | 灵活,能应对突发 | 实现复杂,可能饿死低优先级 |
3.5 实战中的选择
说实话,实际项目中很少只用一种策略。我一般会组合使用:
- 推理服务:时间复用 + 优先级复用。普通请求时间复用,VIP请求优先级复用。
- 训练任务:空间复用。多个模型同时训练,显存按需分配。
- 混合负载:三种都用。训练用空间复用,推理用时间复用,高优先级推理用优先级复用。
我曾经犯过一个错误:在一个推理服务里只用空间复用,结果碎片越来越多,最后显存利用率不到50%。后来改成时间复用+空间复用组合,才稳定下来。所以,别迷信单一策略,要根据场景灵活搭配。
好了,显存复用策略就聊到这儿。记住一句话:没有最好的策略,只有最合适的策略。你想想看,你的场景适合哪种?