《CUDA显存碎片与TensorRT分配策略》

4、显存碎片化概念:内部碎片与外部碎片、碎片产生的原因

在CUDA程序运行过程中,显存分配器面对的是一个动态变化的请求序列:张量创建、算子临时缓冲区申请、显存池复用、释放回CUDA Runtime。当这些请求的大小与释放时机不匹配时,显存空间会被切割成大量不连续的空闲块,这就是显存碎片化。碎片化并不直接导致显存溢出,但它会让“明明总量够用,却分配不出一个连续大块”的尴尬局面反复出现。要理解TensorRT的分配策略,必须先厘清碎片化的两种基本形态:内部碎片与外部碎片。

4.1 内部碎片:分配粒度造成的“隐形浪费”

内部碎片是指已经分配给某个请求、但实际未被利用的显存空间。它产生于分配器的对齐规则与最小分配单元机制。CUDA Runtime和主流显存分配库(如cudaMalloc、RMM、CUB)通常按256字节或512字节对齐,且最小分配块为2MB(针对cudaMalloc的VMM路径)或更大。当一个请求只需要1KB时,分配器仍会返回一个2MB的块,剩余的约2MB-1KB空间就是内部碎片。

更典型的场景是显存池(memory pool)的块大小分级。TensorRT的显存池通常预分配若干固定大小的块(如1MB、4MB、16MB、64MB),当请求大小为3MB时,分配器从4MB块中切出3MB,剩余的1MB虽然仍属于该块,但无法再被其他请求使用,除非原请求释放。这种“块内剩余”就是内部碎片。

内部碎片计算公式:

内部碎片 = 分配块大小 - 实际请求大小
碎片率 = 内部碎片 / 分配块总大小

例如:请求 3000 字节,分配器按 4096 字节对齐,则内部碎片为 1096 字节,碎片率约 26.7%。

内部碎片是“确定性”的——只要分配器策略固定,碎片大小可精确计算。它不随程序运行时间增长,但会持续占用显存,降低有效容量。

4.2 外部碎片:空闲块不连续导致的“假性不足”

外部碎片是指显存中存在足够总和的空闲空间,但无法满足一个连续大块分配请求的状态。它产生于不同生命周期对象的交错分配与释放。例如:张量A(64MB)与张量B(64MB)相邻分配,A先释放,B后释放,此时空闲区为两个不连续的64MB块,若新请求需要96MB连续显存,分配器无法满足,尽管总空闲空间为128MB。

外部碎片是CUDA显存分配中最棘手的难题,因为它具有动态性和不可预测性。其严重程度取决于三个因素:

  • 请求大小分布:大小差异越大,碎片越严重。若所有请求集中在少数几个固定大小,碎片率低;若请求从1KB到1GB均匀分布,碎片率极高。
  • 生命周期交错度:短期对象与长期对象混存时,短期对象释放后留下的空洞难以被后续大请求利用。
  • 分配/释放顺序:后进先出(LIFO)顺序最友好,先进先出(FIFO)顺序最易产生碎片。
显存碎片化示意:内部碎片 vs 外部碎片 已分配(使用中) 内部碎片(块内浪费) 空闲块(可复用) 外部碎片(不连续空洞) 内部碎片(分配粒度导致) 请求 2KB 浪费 请求 3MB 浪费 请求 1MB 废 4KB 对齐块 4MB 池块 外部碎片(生命周期交错导致) 张量A 64MB 空闲 32MB 张量B 64MB 空闲 32MB 权重 48MB 空闲 16MB 激活值 64MB 空闲 48MB 新请求:需要 96MB 连续空间 结论:总空闲 = 32+32+16+48 = 128MB > 96MB,但无连续块 ≥ 96MB → 分配失败 外部碎片 = 128MB - 最大连续空闲块(48MB) = 80MB

4.3 碎片产生的根本原因

碎片化不是单一机制造成的,而是分配策略、请求模式、生命周期管理三者共同作用的结果。从CUDA Runtime到TensorRT,每一层都有其“贡献”。

碎片类型 直接原因 深层机制 典型场景
内部碎片 对齐规则(256B/512B/2MB) 分配器按固定粒度切块,无法精确匹配请求 小张量(<1KB)频繁创建
内部碎片 显存池块大小分级 池块大小固定(如1/4/16/64MB),请求只能向上取整 TensorRT的workspace池
外部碎片 生命周期交错 短期对象(临时buffer)与长期对象(权重)混存 推理时每帧创建临时激活值
外部碎片 请求大小分布广 大小差异大,释放后空洞难以被后续请求匹配 多模型动态batch推理
外部碎片 分配/释放顺序 非LIFO顺序导致空洞无法合并 多流并发执行

从CUDA Runtime层面看,cudaMalloc每次分配都会向驱动申请独立的虚拟地址区间,释放时虽然会归还给驱动,但驱动并不保证相邻空闲区自动合并。从TensorRT层面看,其显存池(memory pool)机制虽然减少了cudaMalloc的调用次数,但池内块的管理策略直接决定了碎片率。TensorRT默认使用best-fit分配算法,即从空闲块列表中选择“最小且能满足请求”的块。这种算法在请求大小分布均匀时表现良好,但在大小差异悬殊时,容易产生大量“剩余小块”——这些小块单独无法满足任何大请求,成为外部碎片。

4.4 案例:一次典型的碎片化故障

某推理服务部署一个BERT-Large模型,显存总量16GB。模型权重约1.2GB,但运行时显存占用达到14.8GB,且偶尔出现CUDA out of memory错误。通过nvidia-smi查看显存使用,发现已分配块数量超过2000个,但最大空闲连续块仅约300MB。

进一步分析发现:

  1. 模型每层的前向计算会创建临时激活值(约几十MB),这些激活值在层间切换时释放。
  2. 动态batch导致请求大小在8MB到128MB之间波动。
  3. TensorRT的workspace池预分配了2GB,但池内碎片严重,实际可用连续空间不足500MB。

最终解决方案是:将TensorRT的workspaceSize从2GB调整为1.5GB,并启用cudaMemPoolSetAttribute设置池的cudaMemPoolAttrReleaseThreshold,让空闲块在超过阈值时主动归还给CUDA Runtime,从而减少池内碎片堆积。

常见误区:很多开发者认为“显存碎片化只是性能问题,不会导致功能失败”。实际上,当外部碎片严重时,即使总显存充足,cudaMalloc或TensorRT的enqueueV2也会直接返回cudaErrorMemoryAllocation,导致推理中断。碎片化是功能性故障,不只是性能问题。

4.5 碎片化度量与监控

要量化碎片化程度,可以使用以下指标:

  • 碎片率(Fragmentation Ratio):1 - (最大连续空闲块 / 总空闲空间)。碎片率接近0表示空闲空间连续,接近1表示极度碎片化。
  • 内部碎片占比:内部碎片总量 / 已分配总量。可通过对比cudaMalloc请求总大小与实际占用显存估算。
  • 分配失败次数:记录cudaErrorMemoryAllocation出现的频率。

使用cudaMemGetInfo可以获取当前空闲显存总量,但无法直接获取最大连续块大小。要获取连续块信息,需要借助CUDA的cuMemGetAddressRange遍历虚拟地址空间,或使用NVIDIA的compute-sanitizer工具(带--tool memcheck选项)分析分配模式。

实用监控代码片段:

size_t free_bytes, total_bytes;
cudaMemGetInfo(&free_bytes, &total_bytes);
// 碎片率估算:通过多次分配不同大小块测试最大连续块
size_t max_contiguous = 0;
for (size_t size = free_bytes; size > 1MB; size /= 2) {
    void* ptr;
    cudaError_t err = cudaMalloc(&ptr, size);
    if (err == cudaSuccess) {
        max_contiguous = size;
        cudaFree(ptr);
        break;
    }
}
float frag_ratio = 1.0f - (float)max_contiguous / (float)free_bytes;

理解内部碎片与外部碎片的本质区别,是后续掌握TensorRT显存池配置、cudaMemPool调优、以及setDeviceMemPool策略的基础。碎片化不是“玄学”,而是分配器行为与请求模式共同决定的确定性结果——只要掌握了规律,就能通过调整分配策略、池大小、释放阈值等手段有效控制。