显存复用核心技术实战 - 第2节

显存分配与回收机制:从cudaMalloc到显存池

大家好,我是你们的老朋友。今天咱们聊聊显存分配与回收这个看似基础、实则暗藏玄机的话题。

说实话,我刚开始做GPU加速时,觉得显存分配不就是调个cudaMalloc、用完再cudaFree嘛,有啥好讲的?直到有一次,我负责的一个训练任务跑了整整两天,突然在第三个epoch挂了——cudaMalloc返回了out of memory。我检查了所有张量释放逻辑,都没问题。后来一查,原来是显存碎片化惹的祸。嗯,从那以后,我再也不敢小看显存管理了。

一、CUDA显存分配API:cudaMalloc与cudaFree

1.1 cudaMalloc:显存分配的入口

cudaMalloc是CUDA编程中最基础的显存分配函数。它的签名很简单:

cudaError_t cudaMalloc(void** devPtr, size_t size);

你传一个指针地址和大小,它给你分配一块显存。但这里有个坑——cudaMalloc是同步操作。什么意思?就是它调用完,GPU上的内存分配就完成了,不会异步返回。

关键点:cudaMalloc分配的是全局内存(global memory),生命周期从分配到显式释放为止。它不会自动回收。

我在项目中遇到过一个问题:频繁调用cudaMalloc分配小块内存,性能会急剧下降。为什么?因为每次分配都要和驱动层交互,开销不小。你想想看,如果每层网络都单独分配一个张量,那光分配时间就够你喝一壶的。

1.2 cudaFree:释放的艺术

cudaError_t cudaFree(void* devPtr);

cudaFree释放由cudaMalloc分配的内存。但注意,它也是同步的——释放操作会等待所有对该内存的访问完成。

警告:千万不要在GPU还在读写这块内存时就调用cudaFree。我曾经见过一个同事,在kernel还没执行完就释放了显存,结果数据全乱了。调试了整整一天才发现是释放时机的问题。

另外,cudaFree(NULL)是安全的,但cudaFree一个已经释放过的指针,会返回cudaErrorInvalidValue。所以我的习惯是:释放后立即把指针置为NULL。

1.3 分配与释放的性能开销

我做过一个测试:分配1000次1MB的显存,和分配1次1GB的显存,时间差了多少?

操作耗时(ms)说明
1000次cudaMalloc(1MB)~45ms每次都有驱动交互
1次cudaMalloc(1GB)~0.8ms一次分配大块
1000次cudaFree(1MB)~38ms同样有开销

看到了吧?小内存频繁分配的开销,比大内存一次分配高得多。这就是为什么我们需要显存池。

二、显存池(Pool)的基本原理

2.1 为什么需要显存池?

说白了,显存池就是一个预分配机制。你一次性从GPU申请一大块显存,然后自己管理这块显存的使用。需要小内存时,从池子里切一块出去;用完了,还回池子里,而不是直接还给GPU。

这样做的好处很明显:

  • 减少驱动交互:不用每次分配都调用cudaMalloc
  • 降低碎片化:统一管理,可以合并空闲块
  • 提升分配速度:池内分配是纯用户态操作,毫秒级变微秒级

2.2 显存池的核心设计

一个简单的显存池,通常包含这几个部分:

class MemoryPool {
    // 空闲块列表,按大小排序
    std::map<size_t, std::vector<void*>> free_blocks;
    // 已分配块记录
    std::unordered_map<void*, size_t> allocated_blocks;
    // 总池大小
    size_t pool_size;
    // 从GPU申请的原始内存
    void* base_ptr;
};

分配时,从free_blocks里找大小合适的块;释放时,把块放回free_blocks,并尝试合并相邻的空闲块。

我的经验:实际项目中,我一般用buddy system或者slab allocator。buddy system适合大小不固定的分配,slab适合固定大小的分配。PyTorch的CachingAllocator用的就是类似buddy system的思路。

2.3 主流框架的显存池实现

PyTorch的CachingAllocator是我见过最成熟的显存池之一。它的核心思想是:

  • 按2的幂次分桶(1KB、2KB、4KB...)
  • 分配时向上取整到最近的桶
  • 释放时先放回桶里,不立即还给GPU
  • 当总空闲内存超过阈值时,才调用cudaFree归还

TensorFlow的BFC Allocator也是类似,但它用的是best-fit策略,找最接近需求大小的空闲块。

三、显存碎片化问题

3.1 碎片化是怎么产生的?

显存碎片化,说白了就是显存被切成了很多小块,虽然总空闲空间够,但找不到一块连续的大空间来满足分配请求。

我举个例子:你分配了A(100MB)、B(200MB)、C(100MB),然后释放了B。这时空闲空间是200MB,但被分成了两块100MB。如果你要分配150MB,就会失败——因为没有连续150MB的空闲块。

核心问题:碎片化导致明明有足够显存,却分配不出大块内存。这是很多OOM错误的真正元凶。

3.2 碎片化的类型

碎片化分两种:

  • 内部碎片:分配的内存比实际需求大。比如你要100MB,但池子只提供128MB的块,多出来的28MB就是内部碎片
  • 外部碎片:空闲块分散在各处,无法合并成连续大块

内部碎片好解决,用更细的粒度分桶就行。外部碎片就麻烦了,需要做内存整理。

3.3 如何缓解碎片化?

我总结了几条实战经验:

  1. 预分配大块:启动时一次性分配足够大的显存池,减少动态分配
  2. 对齐分配:所有分配按256字节或512字节对齐,减少内部碎片
  3. 延迟释放:释放的内存先缓存起来,等积累到一定量再合并
  4. 内存整理:在空闲时做defragmentation,移动已分配块来合并空闲空间

避坑指南:我曾经在一个推理服务中,因为频繁创建和销毁不同大小的中间张量,导致显存碎片化严重。后来我改用固定大小的预分配池,把中间张量都复用起来,碎片率从30%降到了不到5%。

四、知识体系总览

下面这张图,是我自己整理的显存管理核心逻辑。你看一遍就能明白整个体系:

显存分配与回收核心知识体系 API层 cudaMalloc cudaFree cudaMallocManaged 显存池层 Buddy System Slab Allocator BFC Allocator 碎片化问题 内部碎片(分配粒度问题) 外部碎片(空闲块分散) 解决方案:预分配 + 对齐 + 延迟释放 + 内存整理

这张图把显存管理的四个层次串起来了。从最底层的API调用,到中间的池化策略,再到碎片化问题,最后到解决方案。你顺着箭头看,就能理解整个体系是怎么运作的。

五、实战建议

最后,我给大家几条实战建议:

  • 别裸用cudaMalloc:除非你只分配一两次,否则一定要用显存池
  • 监控碎片率:我习惯在代码里加个hook,定期打印空闲块数量和大小分布。碎片率超过20%就要警惕了
  • 复用张量:能复用的中间结果尽量复用,别频繁创建销毁
  • 调大池子:如果显存够用,把池子预分配大一点,能有效减少碎片

小技巧:用cudaMemGetInfo(&free, &total)可以查看当前显存使用情况。我经常在关键节点打印这个值,用来排查显存泄漏和碎片化问题。

好了,这一章的内容就到这里。显存管理看似简单,但真正做好并不容易。希望我的这些经验能帮你少踩一些坑。

想要更多资料,联系可可