2、显存分配机制:CUDA显存分配器原理、cudaMalloc与cudaFree的底层行为、PyTorch缓存分配器(caching allocator)的工作机制
显存分配,听起来好像就是「申请一块内存,用完释放」这么简单。但实际做深度学习训练时,你会发现事情远没那么单纯。我见过不少团队,模型结构没问题,数据加载也没错,就是训练到一半突然报 CUDA out of memory。查来查去,发现是显存碎片化在作祟。
要理解碎片化,得先搞清楚显存是怎么分配的。今天我们就从底层往上,一层层拆开来看。
2.1 CUDA 显存分配器:底层的那位「管家」
CUDA 驱动层维护着一个显存分配器。它的职责很简单:管理 GPU 上的物理显存页。
当你调用 cudaMalloc 时,驱动会做两件事:
- 从空闲页列表中找一块足够大的连续物理显存
- 如果没有合适的,就触发一次「回收」或「整理」
这里有个关键点:CUDA 驱动分配的最小单位是「页」,通常是 64KB 或 2MB(取决于 GPU 架构和分配大小)。
核心概念:CUDA 驱动分配显存时,是按「页」对齐的。你申请 1 字节,它可能给你 64KB。这本身就会造成内部碎片。
我个人习惯把 CUDA 驱动层的分配器想象成一个「粗放型管家」。它只管大块分配,不太关心小块内存的复用。你申请 100 次 1MB,它可能给你 100 个不同的 2MB 页,中间留下大量空洞。
2.2 cudaMalloc 与 cudaFree 的底层行为
咱们直接看代码。这是最基础的用法:
float* d_ptr;
cudaMalloc(&d_ptr, 1024 * sizeof(float)); // 申请 4KB
// ... 使用 ...
cudaFree(d_ptr); // 释放
看起来简单吧?但底层发生的事情,比你想象的多:
- cudaMalloc 调用时:驱动会检查当前进程的显存上下文。如果空闲列表里有合适大小的连续块,直接返回。如果没有,驱动会尝试从系统预留的显存池中切一块出来。如果还不够,就会触发一次「同步等待」,等之前提交的 kernel 执行完,回收它们占用的显存。
- cudaFree 调用时:驱动不会立即把显存还给系统。它会把这块内存标记为「空闲」,放回空闲列表。这样做是为了下次
cudaMalloc能快速复用。
注意:频繁的 cudaMalloc 和 cudaFree 是性能杀手。每次调用都有内核态切换的开销,而且容易导致显存碎片化。我曾经在一个项目中,每步训练都动态分配和释放中间结果张量,结果训练速度直接掉了 30%。
为什么会这样?因为 cudaMalloc 和 cudaFree 是同步操作。它们会阻塞 CPU 线程,直到 GPU 完成所有未完成的工作。你想想看,每次分配都要等 GPU 同步,这得多慢。
2.3 PyTorch 缓存分配器(Caching Allocator)
PyTorch 的设计者当然知道 cudaMalloc 的痛点。所以他们搞了一个「缓存分配器」,也叫 Caching Allocator。说白了,就是在 CUDA 驱动之上又加了一层「智能管家」。
它的工作机制,我总结为三步:
- 预分配:当你第一次在 GPU 上创建张量时,PyTorch 会调用
cudaMalloc申请一大块显存(比如 2GB)。这块显存被缓存分配器管理,不会轻易还给驱动。 - 内部复用:后续你创建张量时,缓存分配器会从自己的「空闲块列表」中找一块大小合适的显存。如果找不到,它会尝试合并相邻的空闲块,或者从预分配的大块中切出一块。
- 延迟释放:当你删除张量(调用
del或超出作用域)时,缓存分配器不会调用cudaFree,而是把这块显存标记为「空闲」,放回自己的池子里。
这个机制的好处很明显:减少了 cudaMalloc 和 cudaFree 的调用次数,避免了频繁的内核态切换。同时,通过复用显存块,也降低了碎片化的概率。
小技巧:你可以通过 torch.cuda.memory_summary() 查看当前缓存分配器的状态。它会显示已分配块、空闲块、以及碎片化程度。我每次调优显存时,第一件事就是跑这个命令。
但缓存分配器也不是万能的。它有一个「阿喀琉斯之踵」:当分配大小不一时,容易产生外部碎片。
举个例子:你分配了 100MB、200MB、300MB 三个张量,然后释放了中间的 200MB。这时空闲列表里有一个 200MB 的空洞。如果你接下来要分配一个 250MB 的张量,缓存分配器发现没有合适的连续块,就会触发一次「整理」——尝试合并相邻的空闲块。但如果 200MB 空洞两边的块都还在使用,那就合并不了。最终,缓存分配器只能调用 cudaMalloc 向驱动申请新的显存。
这就是碎片化的典型场景。
2.4 一张图看懂显存分配流程
下面我用一张 SVG 图来展示整个流程,从 PyTorch 张量创建到物理显存分配:
2.5 避坑指南
基于上面的原理,我给你几个实战建议:
- 尽量复用张量:不要频繁创建和销毁中间结果。用
torch.zeros_like或原地操作(inplace=True)来复用显存。 - 注意分配大小:尽量让张量大小对齐到 512 字节或 1KB。这能减少内部碎片。
- 监控碎片化程度:用
torch.cuda.memory_stats()查看allocated_bytes.all.peak和reserved_bytes.all.peak的比值。如果比值接近 1,说明碎片化严重。
我曾经踩过的坑:在一个 NLP 项目中,我用了动态 batch 大小,每个 step 的序列长度都不一样。结果显存碎片化严重,训练到第 1000 步就 OOM 了。后来我改成固定序列长度 + padding,碎片化问题直接消失。你想想看,固定大小的张量分配,缓存分配器几乎 100% 命中,效率能不高吗?
嗯,到这里你应该对显存分配机制有了清晰的认识。从 CUDA 驱动的页式管理,到 cudaMalloc 的同步开销,再到 PyTorch 缓存分配器的复用策略,每一层都有自己的脾气。理解了这些,你就能在实战中避开那些「莫名其妙」的显存错误了。
一句话总结:显存分配不是简单的 malloc/free,而是一场「复用 vs 碎片」的博弈。PyTorch 的缓存分配器帮你赢了大半,但剩下的那部分,得靠你自己来优化。
想要更多资料,联系可可