2. 显存分配与释放机制

各位同学,今天我们来聊聊显存分配与释放这个话题。说实话,很多做GPU加速的同学,一开始都不太在意这个——不就是调个cudaMalloc吗?但我在实际项目中踩过不少坑,才意识到这里面的门道有多深。

cudaMalloc/cudaFree:看似简单,实则暗藏玄机

先说说最基础的cudaMalloc。它的工作流程是这样的:

// 典型的显存分配
float *d_data;
cudaMalloc(&d_data, size * sizeof(float));
// ... 使用显存 ...
cudaFree(d_data);

看起来很简单对吧?但你知道cudaMalloc背后发生了什么吗?

我个人习惯把cudaMalloc理解为「向GPU驱动申请一块连续地址空间」。驱动会维护一个空闲显存列表,每次分配时去查找合适的空闲块。如果找不到,就会触发一次「隐式同步」——嗯,这里要注意,这个同步操作可能会让你的kernel执行停顿。

我在项目中遇到过这样的情况:一个训练循环里频繁调用cudaMalloc/cudaFree,结果性能直接掉了30%。排查了半天,才发现是显存分配释放导致的隐式同步在作祟。

避坑指南:我曾经在一个实时推理系统中,每帧都分配和释放中间结果的显存。结果帧率从60fps掉到了20fps。后来改成预分配+复用,帧率直接拉回55fps。所以,高频场景下尽量避免频繁的cudaMalloc/cudaFree。

显存池(Memory Pool)概念:为什么需要它?

你想想看,如果每次分配都要跟驱动打交道,那开销得多大?显存池就是来解决这个问题的。

显存池的核心思想很简单:提前申请一大块显存,然后自己管理这块显存的分配和释放。说白了,就是「批发」显存,然后「零售」给程序里的各个模块。

CUDA从11.0开始提供了官方支持的显存池API:

// CUDA 11.0+ 的显存池用法
cudaMemPool_t pool;
cudaMemPoolCreate(&pool, &poolProps);

// 从池中分配
float *d_data;
cudaMallocFromPoolAsync(&d_data, size, pool, stream);

// 释放回池中
cudaFreeAsync(d_data, stream);

// 销毁池
cudaMemPoolDestroy(pool);

为什么推荐用显存池?三个理由:

  • 减少驱动调用开销:分配释放都在用户态完成
  • 避免隐式同步:异步操作不会打断kernel执行
  • 降低碎片化:池内管理更灵活

我记得有一次做吞吐压测,用cudaMalloc分配显存,QPS死活上不去。换成cudaMallocFromPoolAsync后,QPS直接翻了一倍。原因就是去掉了那些烦人的隐式同步。

系统级分配器 vs 自定义分配器

这里有个选择问题:用CUDA自带的分配器,还是自己写一个?

对比维度 系统级分配器 自定义分配器
实现难度 零成本,开箱即用 需要自己实现内存管理逻辑
灵活性 固定策略,无法定制 完全可控,可针对场景优化
性能 通用场景表现良好 特定场景可大幅超越系统级
调试难度 简单,有官方工具支持 需要自己加日志和监控

我个人建议:先用系统级的,性能瓶颈明确后再考虑自定义。别一上来就造轮子,容易翻车。

举个例子,我做过一个自定义分配器,专门针对固定大小的tensor分配:

class FixedSizeAllocator {
private:
    char *pool_base;
    size_t block_size;
    std::vector<bool> free_list;
    
public:
    void* allocate() {
        for (int i = 0; i < free_list.size(); i++) {
            if (free_list[i]) {
                free_list[i] = false;
                return pool_base + i * block_size;
            }
        }
        return nullptr; // 池满了
    }
    
    void deallocate(void* ptr) {
        size_t offset = (char*)ptr - pool_base;
        int idx = offset / block_size;
        free_list[idx] = true;
    }
};

这个分配器在固定batch size的场景下,分配开销几乎为零。但如果是变长分配,那就得用更复杂的伙伴系统或者slab分配器了。

显存碎片化问题:看不见的性能杀手

显存碎片化,说白了就是「显存明明还有空,但就是分配不出连续的大块」。这个问题在长时间运行的推理服务中特别常见。

为什么会碎片化?因为分配和释放的顺序是随机的。比如你分配了A(100MB)、B(200MB)、C(150MB),然后释放了B。这时候空闲显存是200MB,但被分成了两段——A前面和C后面各有一段。如果这时候要分配一个180MB的连续块,就分配不出来。

碎片化的典型症状:
  • cudaMalloc返回out of memory,但nvidia-smi显示显存还有剩余
  • 程序运行时间越长,分配越慢
  • 偶尔出现莫名其妙的cudaErrorMemoryAllocation

我曾经在一个持续运行了72小时的推理服务上,遇到了严重的碎片化问题。排查过程很痛苦——一开始以为是内存泄漏,后来用cuda-memcheck和自定义的显存快照工具一查,才发现是碎片化。

解决碎片化的几个常用手段:

  • 预分配策略:启动时就把所有可能用到的显存都分配好
  • 对象池:复用相同大小的显存块,避免大小混杂
  • 定期整理:在业务低峰期做显存整理(但代价很高)
  • 使用显存池:CUDA的pool内部有碎片管理机制
小技巧:如果你用的是PyTorch,可以试试torch.cuda.memory_stats()来监控显存使用情况。我经常用这个来定位碎片化问题——如果allocated_bytes和reserved_bytes差距很大,那多半是碎片化了。

嗯,关于显存分配与释放,今天就先聊这么多。记住一句话:显存管理不是小事,它直接决定了你的吞吐压测能不能跑起来、跑多快。下次遇到性能问题,不妨先看看显存分配这块有没有优化空间。

显存分配与释放机制知识体系 显存管理 cudaMalloc/cudaFree 向驱动申请/释放显存 触发隐式同步 高频场景性能差 显存池 (Memory Pool) 预分配大块显存 用户态管理分配释放 CUDA 11.0+ 原生支持 分配器对比 系统级 vs 自定义 灵活性 vs 易用性 场景化选择策略 显存碎片化 连续大块分配失败 长时间运行加剧 预分配/对象池解决 核心原则:减少驱动调用,复用显存,避免碎片