第2章:CUDA内存管理基础:cudaMalloc与cudaFree的工作原理,虚拟内存与物理内存的映射

好,咱们正式开始啃显存管理这块硬骨头。这一章,我会带你搞清楚两个最基础的问题:cudaMalloc到底干了什么?cudaFree又干了什么? 以及背后那个绕不开的概念——虚拟内存与物理内存的映射。

说实话,我见过不少做AI训练的同学,写了半年CUDA代码,对cudaMalloc的理解还停留在「申请一块显存」这个层面。嗯,这其实挺危险的。你想想看,显存碎片问题,根源往往就出在这里。

2.1 cudaMalloc:不只是「分配一块内存」那么简单

先看一个最基础的调用:

float *d_ptr;
cudaMalloc((void**)&d_ptr, 1024 * sizeof(float));

这行代码,表面上是「给我1024个float的空间」。但背后,GPU驱动做了三件事:

  1. 在虚拟地址空间中预留一段地址 —— 注意,只是预留,还没真正分配物理显存。
  2. 建立虚拟地址到物理地址的映射关系 —— 这一步是懒加载的,也就是用到的时候才真正分配物理页。
  3. 返回用户态可用的虚拟地址 —— 也就是d_ptr的值。

我个人习惯把cudaMalloc比作「在虚拟地址簿上画了个圈」。圈是画好了,但物理显存还没真正占上。什么时候占?等你第一次往里面写数据的时候。

核心要点: cudaMalloc返回的是虚拟地址,不是物理地址。物理显存的分配是延迟发生的。

2.2 cudaFree:释放的到底是什么?

cudaFree的调用更简单:

cudaFree(d_ptr);

但这里有个坑,我踩过。cudaFree做了两件事:

  • 解除虚拟地址到物理地址的映射
  • 回收物理显存页 —— 注意,是回收给驱动管理,不是还给操作系统。

我曾经在项目里遇到一个诡异的问题:cudaFree之后,用nvidia-smi看显存占用,数字没降下来。当时我以为是内存泄漏,查了半天。后来才发现,cudaFree只是把物理页标记为「空闲」,但驱动为了性能,不会立刻把物理显存归还给全局堆。说白了,驱动手里攒着一批「热备用」的物理页,下次cudaMalloc可以直接复用。

避坑指南: 我曾经因为频繁cudaMalloc/cudaFree导致显存碎片率飙升到40%。后来改用cudaMallocManaged + 手动预分配池,才把碎片率压到5%以下。记住:频繁的分配释放是碎片的温床。

2.3 虚拟内存与物理内存的映射:一张「地址翻译表」

为什么GPU要用虚拟内存?说白了,就是为了让每个进程觉得自己独占整个显存空间。你想想看,如果没有虚拟内存,两个进程同时申请显存,物理地址冲突了怎么办?

GPU的虚拟内存映射机制,和CPU的MMU非常像。核心是一张页表:

虚拟地址范围 物理地址范围 页大小 状态
0x7f00_0000 - 0x7f00_1000 0x1000_0000 - 0x1000_1000 4KB 已映射
0x7f00_1000 - 0x7f00_2000 未分配 4KB 未映射
0x7f00_2000 - 0x7f00_3000 0x2000_0000 - 0x2000_1000 4KB 已映射

这张表,驱动维护在显存里。每次GPU访问一个虚拟地址,硬件MMU就去查表,找到对应的物理地址。如果查不到,就触发缺页中断,驱动再分配物理页并更新页表。

小技巧: 你可以用 cudaPointerGetAttributes() 来查看一个指针的虚拟地址、物理地址(如果支持)、以及它属于哪个设备。这在调试显存泄漏时特别有用。

2.4 一张图看懂整个流程

下面这张SVG图,是我自己画的,把cudaMalloc到cudaFree的完整生命周期串起来了:

cudaMalloc / cudaFree 生命周期 步骤1:cudaMalloc 虚拟地址预留 步骤2:首次访问 触发缺页中断 步骤3:物理分配 更新页表 GPU页表(MMU维护) 虚拟地址 0x7f00_0000 → 物理地址 0x1000_0000 虚拟地址 0x7f00_1000 → 未映射 (缺页) 步骤4:cudaFree 解除映射 + 回收物理页 注意:物理页回收后进入驱动热备用池,不立即归还全局堆

2.5 为什么理解这个对显存碎片分析很重要?

好,现在咱们把前面这些知识串起来。显存碎片是怎么产生的?

  • 外部碎片: 频繁cudaMalloc/cudaFree,导致物理显存被切割成很多小块。新申请一个大块时,找不到连续空间。
  • 内部碎片: 申请了4KB,实际只用1KB,剩下的3KB浪费了。这在页表层面表现为「映射了但没用」。

我记得有一次,一个训练任务跑着跑着突然OOM了。用nvidia-smi一看,显存还剩2GB,但cudaMalloc就是失败。为什么?因为那2GB被分割成了几百个4KB的小碎片,没有一个连续的大块能满足新申请的需求。

关键洞察: 虚拟地址空间是连续的,但物理显存不一定是连续的。cudaMalloc成功,只代表虚拟地址连续,不代表物理地址连续。但GPU硬件访问时,需要物理地址连续的场景(比如DMA传输),就会触发额外的内存整理操作,进一步加剧碎片。

2.6 实战建议:如何用好cudaMalloc/cudaFree

基于上面的理解,我给你几条实战建议:

  1. 尽量复用显存,不要频繁分配释放。 我习惯用一个显存池,提前申请一大块,然后自己管理内部的小块分配。
  2. 注意对齐。 cudaMalloc默认按256字节对齐,但如果你申请的大小不是页大小的整数倍,容易产生内部碎片。
  3. 用cudaMalloc3D替代cudaMalloc。 对于多维数组,cudaMalloc3D能更好地保证内存布局的连续性,减少碎片。
  4. 监控碎片率。 可以用 cudaMemGetInfo 获取空闲显存和总显存,但更精确的碎片分析需要自己写工具遍历页表。

个人习惯: 我在项目里会写一个简单的显存池类,预分配256MB,内部用buddy system管理。这样cudaMalloc/cudaFree的次数从每秒几千次降到几次,碎片率从30%降到5%以下。效果立竿见影。

好了,这一章的内容就到这里。cudaMalloc和cudaFree看似简单,但背后涉及的虚拟内存映射机制,是理解显存碎片的基石。下一章,我们会深入显存分配器的内部实现,看看驱动到底是怎么管理这些物理页的。

可可精品资料库