2. 显存生命周期

显存管理这事儿,说简单也简单,说复杂真能让人头秃。我刚开始做CUDA开发那会儿,总觉得cudaMalloc和cudaFree就是配对使用就完事了。直到有一次,一个训练任务跑了三天,第四天突然OOM崩溃——嗯,从那以后我再也不敢小看显存生命周期管理了。

cudaMalloc/cudaFree的最佳实践

先说说最基本的。cudaMalloc分配显存,cudaFree释放显存,这俩是铁打的搭档。但怎么用,里面门道不少。

核心原则:能复用就别重复分配,能延迟释放就别急着释放。

我个人习惯的做法是这样的:

// 不好的做法:频繁分配释放
for (int i = 0; i < 1000; i++) {
    float *d_data;
    cudaMalloc(&d_data, N * sizeof(float));
    // 处理数据...
    cudaFree(d_data);
}

// 好的做法:一次分配,多次复用
float *d_data;
cudaMalloc(&d_data, N * sizeof(float));
for (int i = 0; i < 1000; i++) {
    // 处理数据...
}
cudaFree(d_data);

为什么这样好?你想想看,每次cudaMalloc都要走驱动层,跟操作系统要内存,这开销可不小。我在项目中遇到过,一个频繁分配释放的循环,光分配释放就占了总时间的15%。

小技巧:如果实在不确定需要多大显存,可以稍微多分配一点。比如你需要N个float,那就分配N+1024个。多出来的这点空间,能帮你避免很多边界问题。

显存池化技术:让显存管理更优雅

说白了,显存池化就是提前申请一大块显存,然后自己管理这块内存的分配和回收。这样就不用每次都跟驱动层打交道了。

我给大家画个图,看看池化前后的区别:

显存池化前后对比 无池化 应用层(频繁请求/释放) CUDA驱动层 物理显存 有池化 应用层(从池中获取/归还) 显存池(预分配大块内存) 内部管理分配/回收 物理显存 无池化:每次请求都要经过驱动层,开销大 有池化:预分配后,应用层直接操作池内内存,速度快

实现一个简单的显存池,其实不复杂:

class MemoryPool {
private:
    char *pool_base;          // 池基地址
    size_t pool_size;         // 池大小
    size_t offset;            // 当前分配偏移
    
public:
    MemoryPool(size_t size) {
        cudaMalloc(&pool_base, size);
        pool_size = size;
        offset = 0;
    }
    
    void* allocate(size_t bytes) {
        // 对齐到256字节
        size_t aligned = (bytes + 255) & ~255;
        if (offset + aligned > pool_size) {
            return nullptr;  // 池空间不足
        }
        void *ptr = pool_base + offset;
        offset += aligned;
        return ptr;
    }
    
    void reset() {
        offset = 0;  // 重置分配位置
    }
    
    ~MemoryPool() {
        cudaFree(pool_base);
    }
};

注意:显存池不是万能的。如果你的程序需要频繁分配不同大小的内存块,池化反而可能造成浪费。我见过有人为了用池化而用池化,结果池内碎片率高达40%。

内存碎片整理:给显存做个"瘦身"

显存碎片这个问题,说实话挺隐蔽的。你跑一个程序,看着显存占用不大,但就是分配不出新的内存块。为什么?因为碎片太多了。

我曾经遇到过一个案例:一个推理服务跑了两个月,显存占用显示只有3GB(总共8GB),但就是分配不出一个100MB的tensor。查了半天,发现是碎片导致的。

碎片整理的核心思路就两个:

  1. 合并空闲块:把相邻的小空闲块合并成大块
  2. 压缩整理:把已分配的内存挪到一起,腾出连续空间

这里有个简单的碎片整理策略:

// 碎片整理伪代码
void defragment() {
    // 1. 暂停所有分配请求
    lock_pool();
    
    // 2. 扫描空闲块列表
    for (auto &block : free_blocks) {
        // 检查相邻块是否也是空闲的
        if (block.next_is_free()) {
            // 合并
            block.merge_with_next();
        }
    }
    
    // 3. 如果碎片率超过阈值,执行压缩
    if (fragmentation_rate > 0.3) {
        compact_memory();
    }
    
    // 4. 恢复分配
    unlock_pool();
}

实战建议:碎片整理不是越频繁越好。我一般设置两个阈值:碎片率超过30%时触发整理,但两次整理间隔至少5分钟。这样既不会太频繁影响性能,又能及时处理碎片问题。

说到碎片率怎么算,其实很简单:

指标 公式 说明
碎片率 1 - (最大连续空闲块 / 总空闲空间) 越接近1,碎片越严重
利用率 已分配空间 / 总显存 超过80%就要警惕了
分配成功率 成功分配次数 / 总请求次数 低于90%说明有问题

嗯,说到这儿我想起来一个事儿。有一次我优化一个图像处理管线,发现每次处理完一张图,显存占用就涨一点,但释放后也不降。查了半天,原来是cudaFree没有真正把内存还给系统,而是留在了CUDA的内部缓存里。这种情况,用池化技术反而更好控制。

总结一下我的经验:

  • 小内存频繁分配释放 → 用池化
  • 大内存偶尔分配 → 直接cudaMalloc
  • 长期运行的服务 → 定期检查碎片率
  • 调试阶段 → 用cudaMemGetInfo监控显存

显存管理这事儿,说白了就是"预则立,不预则废"。提前规划好分配策略,比出了问题再手忙脚乱强得多。我现在的习惯是,写代码之前先画个显存使用图,标清楚每个阶段需要多少显存、什么时候释放。这样基本不会出大问题。