4. 显存分配机制

聊到压测中的显存瓶颈,咱们得先搞清楚一个底层问题:显存到底是怎么分配的?

说白了,GPU显存不像CPU内存那样随用随取。它有一套自己的分配逻辑。如果你不了解这套逻辑,压测时就会出现「明明显存还有几百MB,但程序就是报OOM」的诡异现象。我当年第一次遇到这个问题,差点把显卡拔下来重插——后来才发现,是分配策略在搞鬼。

4.1 CUDA显存分配策略

CUDA作为底层接口,它的显存分配策略直接影响上层框架的行为。我个人习惯把CUDA的分配策略分为两种模式:懒惰分配和预分配。

4.1.1 懒惰分配(Lazy Allocation)

这是CUDA的默认行为。你调用cudaMalloc时,驱动并不会立刻在物理显存上划一块给你。它只是给你一个虚拟地址,等到你真正往这块地址写数据时,才触发实际的物理显存分配。

嗯,这里要注意:如果你只是分配了显存但没有使用,nvidia-smi里可能看不到显存占用。我在项目中遇到过这种情况——压测脚本跑完一轮后,显存占用看起来很低,但下一轮突然就OOM了。为什么?因为上一轮分配的内存虽然没写数据,但虚拟地址空间已经被占用了。

核心结论:CUDA的懒惰分配策略,让显存的实际占用和虚拟占用之间存在差异。压测时不能只看nvidia-smi的显存占用,还要关注进程的虚拟内存映射。

4.1.2 预分配(Eager Allocation)

有些场景下,懒惰分配会导致性能抖动。比如你训练一个模型,每轮迭代都要分配新的显存,如果每次都走懒惰分配,那实际分配的开销会分散到计算过程中,导致每轮迭代的时间不稳定。

这时候可以设置环境变量CUDA_CACHE_DISABLE=0或者使用cudaSetDeviceFlags(cudaDeviceScheduleBlockingSync)来改变分配行为。但我个人建议:除非你明确知道自己在做什么,否则别轻易改这个。我曾经为了追求压测的稳定性,强行开启了预分配,结果显存占用直接翻倍,模型都加载不进去了。

分配策略 优点 缺点 适用场景
懒惰分配 节省物理显存,启动快 性能抖动,虚拟地址碎片化 大多数训练和推理场景
预分配 性能稳定,延迟可控 显存浪费,启动慢 实时推理、压测稳定性要求高

4.2 PyTorch显存分配机制

PyTorch在CUDA之上做了一层缓存分配器。说白了,它不会每次需要显存都去调用cudaMalloc,而是自己维护一个显存池。你释放的显存,PyTorch先留着,下次再用。

4.2.1 缓存分配器的工作原理

PyTorch的分配器把显存分成一个个块(block)。当你申请显存时,它先从缓存池里找有没有大小合适的空闲块。如果有,直接复用;如果没有,才去CUDA申请新的显存。

这个机制的好处很明显:减少了cudaMalloc的开销。但坏处也很要命:显存碎片化。你想想看,如果你反复分配和释放不同大小的张量,缓存池里就会留下很多大小不一的空洞。最后可能总空闲显存还有2GB,但最大的连续块只有200MB,一个大张量就分配失败了。

避坑指南:我曾经在压测一个NLP模型时,发现每轮推理后显存占用都在缓慢增长。排查了半天,发现是PyTorch的缓存分配器在作祟——它把上一轮的显存块缓存起来了,并没有真正释放。解决方案是在每轮推理后调用torch.cuda.empty_cache(),强制清空缓存。

4.2.2 显存监控与调试

PyTorch提供了一些工具来查看显存分配情况:

import torch

# 查看当前显存占用
print(torch.cuda.memory_allocated())
# 查看缓存分配器占用的显存
print(torch.cuda.memory_reserved())
# 查看详细的内存快照
print(torch.cuda.memory_summary())

我个人习惯在压测脚本的关键节点打印memory_summary(),这样能清楚地看到哪些张量占用了显存,哪些是缓存碎片。有一次我发现一个张量在循环中反复创建,导致显存泄漏,就是靠这个函数定位的。

4.3 TensorFlow显存分配机制

TensorFlow的显存分配策略和PyTorch完全不同。它默认是霸占型的——启动时就尽可能多地占用显存。我第一次用TensorFlow跑压测时,看到nvidia-smi显示显存占用直接飙到95%,还以为程序出bug了。

4.3.1 显存预占策略

TensorFlow默认使用GPUOptions中的per_process_gpu_memory_fraction来控制显存占用比例。默认值是1.0,也就是占满所有显存。这样做的好处是避免了后续分配的开销,但坏处也很明显:如果你在同一张卡上跑多个进程,它们会互相争抢显存。

注意:在压测场景下,如果你用TensorFlow默认配置,多个压测进程同时启动时,第一个进程会占满显存,后面的进程直接OOM。我曾经在压测一个推荐系统时踩过这个坑,后来改成按比例分配才解决。

4.3.2 动态增长模式

TensorFlow也支持动态增长模式,通过设置allow_growth=True来实现:

import tensorflow as tf

gpus = tf.config.experimental.list_physical_devices('GPU')
if gpus:
    try:
        for gpu in gpus:
            tf.config.experimental.set_memory_growth(gpu, True)
    except RuntimeError as e:
        print(e)

这个模式下,TensorFlow不会一开始就占满显存,而是按需分配。但要注意:它只增不减。也就是说,一旦分配了显存,即使不再使用,也不会释放回系统。我在压测长周期任务时发现,显存占用会随着时间推移缓慢增长,最后稳定在一个较高的水平。

4.4 三种机制的对比与选择

好了,咱们把三种机制放在一起对比一下:

特性 CUDA原生 PyTorch TensorFlow
分配策略 懒惰/预分配 缓存池+按需分配 预占/动态增长
显存碎片化 低(直接操作物理显存) 高(缓存池导致) 中(预占模式碎片少)
多进程友好 一般 较好(可手动释放) 差(默认占满)
压测推荐 底层调试 灵活控制 需配置

我个人在压测时,如果是PyTorch项目,会重点关注缓存分配器的碎片化问题;如果是TensorFlow项目,第一件事就是设置allow_growth或者per_process_gpu_memory_fraction。至于CUDA原生的分配策略,一般只有在排查底层问题时才会直接操作。

一句话总结:显存分配机制决定了压测时的显存使用模式。理解它,你才能准确判断OOM是「真不够」还是「分配策略导致的假象」。
显存分配机制知识体系 CUDA显存分配 PyTorch分配机制 TensorFlow分配机制 懒惰分配 预分配 缓存分配器 碎片化问题 预占策略 动态增长 压测显存瓶颈分析的核心:理解分配策略,避免假性OOM

这张图把三种分配机制的核心要点串起来了。你压测时遇到显存问题,先对照这张图看看:是CUDA层的分配策略问题?还是框架层的缓存碎片问题?定位到具体环节,才能对症下药。

个人经验:我一般压测前会先跑一轮小规模测试,打印出memory_summary()或者nvidia-smi的详细输出,看看显存分配是否符合预期。这一步花不了几分钟,但能避免后面踩大坑。