1. 为什么说 VMM 是“虚拟内存革命”而不是又一个显存分配接口
如果你在多卡节点上做大模型训练,大概率经历过这种尴尬:卡 0 的显存已经红到发紫,卡 1 却还空着 20GB;你想把其中一块中间结果挪过去,却发现所有指针都要重建,所有访问路径都要改写。CUDA 的 VMM API 就是冲着这个痛点来的:它把 GPU 显存重新拆成“虚拟地址空间 + 物理内存 + 访问权限”三个维度,让多 GPU 下的跨设备协同第一次有了可编程、细粒度的控制手段。
很多同学看到“虚拟内存”四个字,第一反应是 Linux 的 swap 分区、Windows 的虚拟内存设置,或者“为什么我的 CUDA 安装总提示 gzip: stdin: invalid compressed data”。这些并不是一回事。操作系统的虚拟内存解决的是进程地址空间和物理 RAM 之间的映射,而 CUDA VMM API 解决的是 GPU 显存里的地址空间和物理显存页之间的映射。虽然原理相似,但 VMM 的设计目标更像一个“显存上的 mmap 体系”,而不是“显存版 swap”。
1.1 cudaMalloc 时代被隐藏的三个真相
cudaMalloc用起来太舒服了,以致于很多同学根本意识不到它把显存管理的复杂度藏在了哪里。我梳理了一下,至少有三个真相是被它隐藏掉的。
第一,分配即绑定。cudaMalloc返回一个指针时,虚拟地址和物理资源已经被绑成一个整体。你拿到的地址背后是哪一段物理映射、粒度如何、是否适合跨设备共享,上层一概不知。一旦你想做更精细的操作,比如只映射某一块显存给另一张卡,cudaMalloc帮不上忙。
第二,地址空间默认为设备私有。在传统模型里,device 0 的显存地址只在 device 0 的上下文里有效。想从 device 0 给 device 1 分享数据,几乎只有cudaMemcpyPeer一条路,而且拷贝是“复制”而非法“共享”。你要么接受复制开销,要么去写绕开驱动约束的取巧代码。
第三,物理页提交过于粗放。分配器为了性能,往往倾向于一次性提交足够多的物理页。如果一个应用只需要稀疏访问一块很大的显存区域,传统 API 很难把“虚拟地址范围”和“物理页实际占用”分开表达。
VMM API 把这三层全部解耦了。你可以先预留地址,再创建物理内存,然后决定什么时刻映射、允许哪张卡访问、什么时候解映射并释放物理内存。这种解耦在单卡上看起来只是“更麻烦”,但在多 GPU 场景里,恰恰是协同设计的基础。
1.2 VMM API 在 CUDA 生态里的定位
从 CUDA 10.2 开始,driver API 提供了一组cuMem*接口,也就是俗称的 CUDA VMM API。它并不是用来完全替代cudaMalloc的高级分配器,而是更底层的内存管理原语。就像malloc之上可以有各种内存池,cudaMalloc之上也可以继续封装,但 VMM 允许你直接控制分配过程中的每一个环节。
这套接口的平台支持属性可以通过下列 device attribute 查询:CU_DEVICE_ATTRIBUTE_VIRTUAL_ADDRESS_MANAGEMENT_SUPPORTED、CU_DEVICE_ATTRIBUTE_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR_SUPPORTED、CU_DEVICE_ATTRIBUTE_HANDLE_TYPE_WIN32_HANDLE_SUPPORTED等。如果你的环境不支持,调用相关函数大概率会直接返回CUDA_ERROR_NOT_SUPPORTED。
这里还要区分 VMM 与受管内存。cudaMallocManaged/cuMemAllocManaged提供的是统一虚拟地址空间,让你异步迁移数据;VMM API 并没有自动迁移能力,它把物理位置、映射、访问权限全部交给你。严格来说,VMM 更像绘图的画布,而受管内存更像一个会自动调整布局的智能相册。
1.3 多卡场景真正稀缺的资源
很多优化文章说“多卡时代显存总容量很富余”,这句话对了一半。真正稀缺的不是单卡显存总量,而是“可被多张卡高效访问的数据布局”。VMM 把这个问题拆成了两个清晰的问题:为什么样的物理内存可以被哪张卡访问,以及什么时候把虚拟地址更新到新的物理内存。
举个例子。Transformer 推理里,KV Cache 的大小随序列长度动态变化。传统做法是给每块卡预留一个最大长度对应的显存,结果序列一短,大片显存被浪费。VMM 可以先把虚拟地址段保留出来,再按需提交物理页;当序列长度增长时,只补映射即可。这种思路一旦放到多卡环境里,就自然延伸成“一张卡显存不够,可以借旁边卡的空闲显存做二级缓存”。这才是标题里“跨设备协同设计哲学”的真实含义。
2. 四个原子操作:把显存拆成可跨设备传递的对象
VMM API 和传统分配接口最大的区别,在于它把一次分配拆成了四个可以独立操作的阶段:cuMemAddressReserve、cuMemCreate、cuMemMap、cuMemSetAccess。我习惯把这四个操作称为“显存里的原子操作”,因为它们分别对应虚拟地址预留、物理内存创建、映射建立、访问权限授予。
这四个阶段不是简单的流程,而是四种不同的语义资产。下面逐个讲透。
2.1 cuMemAddressReserve:先占地址,后装物理页
cuMemAddressReserve负责在进程的地址空间里预留一段虚拟地址,不绑定任何物理显存。它和cudaMalloc的最大区别在于:预留地址几乎不消耗显存,只是占用了地址空间。这对多卡协同非常重要,因为你可以提前规划好一套稳定的指针,后续物理内存怎么换,指针都不变。
这个接口有两个关键参数需要关注:granularity和addr。granularity必须从cuMemGetAllocationGranularity查询,不能自己拍脑袋写 4096。常见的 granularity 有 64KB、2MB 等,具体取决于平台和分配类型。addr用于“地址固定”,如果你想在某个指定地址上分配,可以传入候选地址,并配合CU_MEM_ADDRESS_RESERVE_GRANULARITY等 flag 使用。
我曾经在项目里跳过 granularity 查询,直接按 4KB 对齐预留地址,结果cuMemMap阶段反复报错。排查半天才发现,VMM 要求映射地址和大小都要按 allocation granularity 对齐,而不是普通 host 页的 4KB 对齐。这是非常典型的 VMM 入门坑。
2.2 cuMemCreate:物理内存从分配到对象
cuMemCreate负责创建真正的物理内存,返回值不是指针,而是一个CUmemGenericAllocationHandle句柄。这一步已经把物理内存从“地址”中抽离出来,变成了一个可以传递、导入导出、重新映射的资源对象。
创建时需要指定CUmemAllocationProp,其中最重要的是 location。如果你想在 device 0 上创建物理内存,就要把 location 的类型设为CU_MEM_LOCATION_TYPE_DEVICE,id设为 0。如果是 host pinned memory,则用CU_MEM_LOCATION_TYPE_HOST。CUmemAllocationProp里还有一个requestedHandleTypes字段,用于声明这个句柄未来是否能被导出成文件描述符或 Windows handle。跨设备、跨进程共享时,这个字段必须提前设好。
有一个容易被忽略的点:VMM 创建的物理内存可能是惰性提交的。也就是说,cuMemCreate返回句柄时,驱动不一定立刻把物理页全部钉住,而是在实际访问或映射时才逐步提交。这既是优势也是风险:优势是稀疏场景可以节省显存;风险是显存占用看起来比cudaMalloc更加“飘忽”,你用cudaMemGetInfo观察到的可用显存可能比预想的多,也可能在某次访问瞬间掉下来。
2.3 cuMemMap / cuMemUnmap:映射才是真正“用起来”
cuMemMap把一个CUmemGenericAllocationHandle映射到一段虚拟地址区间。至此,物理内存和虚拟地址才发生绑定。cuMemUnmap负责解除绑定,但解绑后物理内存并不一定被释放,还要通过cuMemRelease释放句柄。
映射阶段真正体现了“跨设备”的可能性。你可以在 device 0 上创建物理内存,却把它映射到 device 1 需要的地址区间;也可以在后续某次重新映射时,把新的物理内存挂到同一段虚拟地址上。也就是说,指针本身不是“属于某块卡”的,它只是一个稳定门牌号,而门牌号后面的房子可以换。
习惯上,cuMemMap之后必须配合cuMemSetAccess,否则即便映射成功,当前设备也不一定有权访问。这个细节我见过很多人踩坑。
// 查询推荐粒度 size_t granularity = 0; cuMemGetAllocationGranularity(&granularity, &prop, CU_MEM_ALLOC_GRANULARITY_RECOMMENDED); // 在 device 0 上创建物理内存句柄 CUmemGenericAllocationHandle handle; CUmemAllocationProp prop = {}; prop.type = CU_MEM_ALLOCATION_TYPE_PINNED; prop.location.type = CU_MEM_LOCATION_TYPE_DEVICE; prop.location.id = 0; cuMemCreate(&handle, size, &prop, 0); // 预留一段虚拟地址 CUdeviceptr va; cuMemAddressReserve(&va, size, granularity, 0, 0); // 建立映射 cuMemMap(va, size, 0, handle, 0); // 授权给 device 1 访问 CUmemAccessDesc accessDesc = {}; accessDesc.location.type = CU_MEM_LOCATION_TYPE_DEVICE; accessDesc.location.id = 1; accessDesc.flags = CU_MEM_ACCESS_FLAGS_PROT_READWRITE; cuMemSetAccess(va, size, &accessDesc, 1);代码逻辑并不复杂,但每一步的错误码都必须认真检查。VMM API 不像cudaMalloc那样失败后会给你一个“NULL 指针”,而是会给出一堆你去查表才知道意思的 CUDA 错误码。
2.4 cuMemSetAccess:跨设备访问的权限开关
cuMemSetAccess是把一段已映射地址的访问权限授予某个或某几个设备。它接受一个CUmemAccessDesc数组,每个元素描述一个目标 location 和访问 flag。CU_MEM_ACCESS_FLAGS_PROT_READWRITE是最常用的权限。
这一步是跨设备协同里最容易被忽视的。很多同学以为只要用cuMemMap把显存映射到地址空间,其他卡就能自动访问了。实际上,VMM 的默认行为并不保证跨设备可见。如果 device 1 要访问 device 0 的物理内存,你必须在cuMemSetAccess中显式授予 device 1 访问权限。如果两张卡之间没有 P2P 能力,驱动也会在这里拒绝执行。
我在调一台旧 PCIe 显卡时遇到过CUDA_ERROR_NO_PEER_ACCESS。一开始以为是驱动问题,后来检查发现两张卡确实不支持 peer-to-peer,VMM 并不会替你兜底。因此,做跨设备授权前,先查cudaDeviceCanAccessPeer或用两卡通信相关的样本验证底层能力,比直接写业务代码要靠谱得多。
3. 跨设备协同的三种玩法:迁移、映射和缓冲池
理解了四个原子操作,下面聊实际使用形态。在我接触过的工程里,跨设备协同基本可以归纳成三种模式,分别对应不同需求:迁移、映射、缓冲池。
这三种模式没有优劣之分,只是适用的性能模型不同。下面展开说。
3.1 跨设备迁移:地址不变,物理资源换位置
第一种玩法是“让数据从卡 A 搬到卡 B,但上层看到的指针不变”。这非常适合多卡训练中的显存均衡:卡 A 显存紧张,卡 B 空闲,把一部分权重或中间结果挪到卡 B,同时保持原指针仍然可用。
具体流程大致是:先把旧的 physical handle 从虚拟地址段上cuMemUnmap,然后通过cuMemCreate在 device B 上创建新的物理内存,再将同一段 VA 映射到新 handle,最后用cudaMemcpyAsync把数据拷过去。由于虚拟地址始终没变,上层所有 kernel 里的 pointer 都不需要改动,唯一变化的只是“这个地址后面到底指向哪块物理显存”。
这个模式的实际收益要看你能否忍受一次 PCIe 或 NVLink 的拷贝开销。如果数据本身就是低频访问的 embedding 表,迁移成本可以被后续的显存释放收益轻松覆盖;如果每个 step 都要来回迁移,那还是要谨慎评估。
3.2 跨设备映射:让远端显存像本地内存一样访问
第二种玩法是“数据不用搬,直接把远端显存映射成本地可访问”。这依赖底层 P2P 能力,但不是传统cudaMemcpyPeer那种“显式拷贝”,而是通过cuMemSetAccess把物理内存的访问权直接授予另一张卡。
举例来说,你在 device 0 上创建了一块显存,映射到 VA 后,通过cuMemSetAccess把这块 VA 对应的访问权授予 device 1。只要两张卡之间支持 P2P,device 1 的 kernel 就能像访问本地显存一样访问这块远端显存。
这种模式对多卡推理很有用。例如大模型推理时,某些层只存在于卡 A,卡 B 需要访问它算 attention 时,不用先跑到卡 A 拷一份,再回本地算。访问远端显存的带宽和延迟虽然不如本地,但节省了一次完整拷贝。对于 KV Cache 这类“读得多、写得多但不需要永久驻留两份”的数据,跨设备映射的价值非常直观。
3.3 基于 VMM 构建跨 GPU 缓冲池
第三种玩法是把 VMM 当作资源池的底座。你可以在一开始用cuMemCreate预先创建一批大块 physical handle,分别挂在不同的 device 上,然后维护一个空闲句柄列表。上层需要显存时,只需要 reserve VA + map + set access,而不是走一次完整的cudaMalloc/cudaFree。
这样做最大的好处是分配开销稳定。cudaMalloc本身有全局分配器的加锁和状态管理,频繁调用的延迟在一些框架里甚至能成为瓶颈。VMM 让你把“创建物理内存”和“建立虚拟地址映射”拆成两件可以预执行的事,于是快速路径上只剩cuMemMap和cuMemSetAccess。
从资源池角度设计时,通常会引入一个通用MemoryBuffer对象,内部记录:设备 id、physical handle、虚拟地址、映射大小、是否已授予某卡访问权限。上层框架只需要持有这个对象,而不需要关心底层是卡上的 local memory 还是 peer memory。跨设备协同在设计层面就完成了。
4. 只有实操才体会到的细节:稀疏分配、粒度分配和生命周期管理
如果只看官方文档,VMM 看起来就是四个函数的组合,难在读懂每个参数。但真正用起来,你会发现文档不会告诉你工程里的几个关键细节。
4.1 预留大段虚拟地址比预分配显存便宜得多
VMM 能把“虚拟地址”和“物理显存”分开,这意味着你可以预留一个很大的 VA 段来容纳可能的最大内存需求,然后按需创建物理内存。在稀疏数据场景下,这个特性可以显著减少显存占用的虚高。
我做过一个 CUDA 样本级验证:预留 1TB 的虚拟地址段,但只往里面映射几十 MB 的物理显存。只要不访问未映射区域,整个过程完全合法。这和操作系统里mmap之后不实际写入内存是一样的道理。对多卡推理服务来说,这给了上层框架一种优雅的“显存配额”实现方式:先告诉系统“我可能需要这么多地址空间”,真正占用的物理显存按实际访问逐步提交。
当然,cuMemAddressReserve预留过大的地址空间也有代价。最直接的是cudaMemGetInfo无法反映真实占用量,做容量规划时会比较困惑。建议在工程里额外维护一个“已创建物理句柄总大小”的统计值。
4.2 粒度:别猜,去查
VMM 对地址和映射的粒度要求非常严格。不同驱动、不同 GPU、不同分配类型下,granularity 都可能不一样。我强烈建议每次初始化时都调用cuMemGetAllocationGranularity去查一次 recommended granularity,而不是把 2MB 写死在代码里。
因为映射粒度直接影响地址对齐和大小对齐。如果大小不是粒度的整数倍,cuMemMap可能返回CUDA_ERROR_INVALID_VALUE。很多人在 4096 对齐上栽跟头,是因为默认用 host page 对齐思维去理解 GPU 显存管理。
另外,如果上层需要频繁分配小于粒度的小对象,直接走 VMM 会浪费显存。实践上我一般先向 VMM 申请一个 2MB 或更大级别的 chunk,然后在上层用 buddy allocator 或 slab allocator 做细粒度切分。这样既享受 VMM 的跨设备特性,又不至于因为粒度太粗浪费显存。
4.3 句柄、映射与释放次序
VMM 的生命周期顺序是一个容易埋雷的点。cuMemAddressReserve得到的地址段,cuMemCreate得到的 handle,cuMemMap建立的映射,三者各有独立的生命周期,而且相互依赖。正确释放顺序一般是:先cuMemUnmap解除映射,再cuMemRelease释放 handle,最后cuMemAddressFree释放地址段。
如果不按顺序来,比如先释放地址段再 unmap,轻则报 INVALID_VALUE,重则导致句柄泄漏。另外需要注意,一个 handle 可能被多个地址段映射,只有所有映射都解除后,才能安全释放物理内存对象。
我在一次跨进程共享显存的代码里,因为提前释放了 handle,导致另一个进程访问时直接触发CUDA_ERROR_INVALID_RESOURCE_HANDLE。排查时看了半天地址映射代码,最后才发现是对端进程尚未完成 detach,本进程就把底层 handle 扔掉。VMM 的句柄机制在这里很像 shared_ptr,你需要明确“谁持有句柄、谁负责释放”。
5. 排错记录:VMM 项目最常见的几个“为什么”
说几个我在实际项目里踩过的坑。这些坑不是因为 VMM 文档少,而是因为错误信息太容易让人误判。
5.1 CUDA_ERROR_INVALID_RESOURCE_HANDLE:句柄生命周期没理清
这个错误最常见的场景:一个 handle 被映射到 VA 后,你在某个分支里提前cuMemRelease了,然后另一段代码还在尝试访问。本质上,VMM 中的句柄是所有者,VA 只是映射方。物理内存必须至少被一个映射或一个句柄持有。
排查思路很简单:全局搜索cuMemRelease的调用点,检查它是否在所有cuMemUnmap之后才执行。如果涉及跨进程导入,还要检查cuMemImportFromShareableHandle后是否复制过句柄引用。
5.2 CUDA_ERROR_INVALID_VALUE:地址和大小没对齐
这个错误在 VMM 里非常高频。多数情况下,是因为忘记按cuMemGetAllocationGranularity返回的 granularity 对齐地址或大小。少数情况是在cuMemMap时把 offset 写错。
排查时我习惯打印三个值:VA 起始地址、大小、granularity。然后看地址和大小是否都是 granularity 的整数倍。Python 里写个va % granularity很低成本,但能解决大量玄学问题。CUDA 官方常用的computeSanity工具在采购时很有用,但真到自己代码里,还是要把它封装成断言带进项目。
5.3 CUDA_ERROR_NO_PEER_ACCESS:P2P 能力不支持或权限未设置
这个错误出现得也很频繁。一种是两张卡之间确实不支持 P2P;另一种是支持 P2P,但你忘了在cuMemSetAccess数组里加上目标设备。第二种情况最常见,特别是目标设备不止一张时。
排查思路分两步:第一步,用cudaDeviceCanAccessPeer确认硬件能力。第二步,确认CUmemAccessDesc数组包含了所有需要访问这段 VA 的设备,而不仅仅是创建物理内存的设备。有人会问“为什么要这么显式”,这就是 VMM 的授权模型:所有访问都必须显式声明,默认没有权限。
5.4 环境检查:版本、属性和样本代码
还有一个容易忽略的点:如果你的 CUDA 版本低于 10.2,或者驱动太老,VMM API 可能并不存在。即便如此,编译时也不一定报错,因为头文件里的函数声明可能被宏包住了。此时调用会返回CUDA_ERROR_NOT_FOUND或无法解析符号。
遇到这种情况,我一般会跑一下官方 CUDA sample 里的simpleVMM或simpleIPC。如果 sample 能跑通,说明环境没问题;如果 sample 都报错,问题就在驱动、CUDA 版本或虚拟化环境。很多云主机和高性能计算节点会把 P2P 能力屏蔽,导致跨设备 VMM 限制表现得很诡异,但这个限制并不是 VMM 本身的错误,而是底层硬件拓扑不允许。
6. 这个 API 背后的设计哲学,以及它教给上层框架的事
技术细节看完了,最后聊聊 VMM 的设计哲学。这才是它在多 GPU 时代真正值得关注的地方。
6.1 显式是跨设备协同的前提
VMM API 和传统cudaMalloc最大的哲学差异是“显式性”。传统分配器把你当用户,VMM 把你当内存管理器。你在 VMM 里的每一次操作都必须明确回答:物理内存在哪、虚拟地址在哪、映射到哪、谁可以访问。
这种显式化看似增加负担,却是多设备协同得以成立的前提。当你只有一块卡时,隐式行为是方便;当你有多块卡、多个进程、多种访问路径时,隐式行为就成了不确定性来源。VMM 把每个决策都暴露出来,反而让上层框架可以在这个清晰的模型上做可靠调度。
6.2 所有权从设备手里转移到用户代码手里
传统观点里,显存地址天然属于某块卡;VMM 把这个观点改成了“物理内存是一个资源对象,而设备只是它当前的 location”。所有权被显式地交到用户代码手里,因此你既可以把 handle 从一个进程导出给另一个进程,也可以把 handle 挂到另一张卡的地址空间。
这个转变的意义在于:上层框架不再需要为每张卡单独设计一套内存管理逻辑。你可以用统一的对象模型去描述“显存资源”,把设备差异延迟到物理创建和访问授权阶段。对于多 GPU 训练、推理调度、显存池化这类问题,这种抽象是刚需。
6.3 它启发的不止是内存池,还有调度
VMM 让我重新理解了资源调度。以前做显存池,本质上是“一块显存用完还给池子”;VMM 之后可以做到“一段地址空间不变,但物理内存动态切换”。这不只是性能优化,更是设计思路的升级。
比如端侧推理引擎里,KV Cache 的容量随并发请求动态变化。用 VMM 预留一个很大的 VA 缓存区,再按需映射物理显存;当多卡部署时,甚至可以把一部分 cache 放到旁边空闲卡的显存里。这样的系统设计能从“硬性预分配”走向“软性、按需、跨设备弹性分配”。
就我个人的实践体会来说,真正学会 VMM 的标志不是会用cuMemMap,而是设计内存生命周期时习惯性先问三个问题:物理资源归谁?虚拟地址是否稳定?跨设备访问谁有权?这三个问题想清楚,多 GPU 内存协同的大多数难题都能提前化解。