☰
Windows下cudaMallocHost为何占用显存?WDDM机制解析
2026/10/30 18:57:54 网站建设 项目流程

1. 一个反直觉的显存占用现象

如果你在 Windows 上做 CUDA 开发,尤其是涉及多卡、大页内存或者高频数据传输的场景,大概率用过cudaMallocHost。这个 API 的官方定位很清晰:分配页锁定内存(pinned memory),让主机和设备之间的 DMA 传输跑满 PCIe 带宽,避免分页带来的额外拷贝开销。在 Linux 上,它的行为基本符合直觉——占用的是物理内存,跟显存没有半毛钱关系。

但在 Windows 上,事情变得微妙起来。你可能遇到过这样的场景:任务管理器里 GPU 的专用显存占用莫名其妙涨了一截,而你明明没有调用任何cudaMalloc或cudaMallocAsync。打开nvidia-smi一看,显存确实被吃掉了,而且这部分占用不会随着cudaFreeHost立即释放。更诡异的是,同样的代码在 Linux 上跑,显存纹丝不动。

这不是 bug,也不是驱动抽风,而是 Windows 显示驱动模型(WDDM)在背后做了一套完全不同的内存管理。理解这套机制,能帮你避开很多“显存泄漏”的误判,也能让你在 Windows 上做显存规划时不再拍脑袋。

这篇文章面向的是在 Windows 平台上做 CUDA 开发、推理部署、视频处理或者科学计算的工程师。无论你是刚接触 CUDA 的新手,还是已经踩过几次坑的老手,我都会从 WDDM 的底层逻辑讲起,把cudaMallocHost吃显存的完整链路拆开,再给出可复现的验证方法和实际项目中的应对策略。核心关键词就三个:Windows、cudaMallocHost、显存,外加一个绕不开的背景概念WDDM。

2. WDDM 下的内存管理与 Linux 的本质差异

2.1 Linux 的 pinned memory 为什么“干净”

在 Linux 上,CUDA 驱动直接跟内核的 DMA 子系统打交道。cudaMallocHost最终调用的是get_user_pages加mlock之类的机制,把一段用户态虚拟内存锁在物理页上,确保它不会被换出。GPU 通过 IOMMU 或者直接物理地址访问这段内存,整个过程不涉及显存。

你可以把 Linux 的 pinned memory 理解成“在系统内存里划了一块禁停区”,GPU 的 DMA 引擎直接过来取货,取完就走。显存是显存,内存是内存,两者井水不犯河水。所以你在 Linux 上跑nvidia-smi,无论分配多少 pinned memory,显存占用都不会变。

2.2 Windows 的 WDDM 把内存和显存“搅”在一起

Windows 从 Vista 开始引入 WDDM,核心目标之一是让 GPU 显存像系统内存一样被统一管理。WDDM 把显存分成几个层次:专用显存(Dedicated VRAM)、共享系统内存(Shared System Memory)、非本地显存(Non-Local Memory)。应用程序看到的“可用显存”其实是这三者的总和,而任务管理器里显示的“专用 GPU 内存”才是物理显存条上的容量。

关键点在于:WDDM 允许驱动程序把系统内存当作显存的“溢出区”。当专用显存不够时,资源可以被分页到系统内存;反过来,某些在 Linux 上纯属系统内存的操作,在 Windows 上会被 WDDM 纳入显存资源的管理范畴。

cudaMallocHost分配的 pinned memory 就撞上了这个机制。CUDA 运行时在 Windows 上需要保证这块内存对 GPU 可见且可 DMA,而 WDDM 的资源管理模型要求所有 GPU 可访问的内存都注册为“显存资源”。于是,这部分 pinned memory 会被计入 WDDM 的显存预算,在任务管理器里表现为“共享 GPU 内存”占用,某些情况下甚至会影响“专用 GPU 内存”的可用量。

2.3 一个生活化类比

把 GPU 想象成一个工厂,专用显存是工厂内部的仓库,系统内存是外面的公共停车场。Linux 的做法是:工厂需要临时存放原料时,直接在停车场划一块地,派叉车去取,仓库不受影响。Windows 的做法是:工厂有一个“统一调度中心”(WDDM),它要求所有原料必须先登记为“工厂资源”,哪怕实际存放在停车场,也要在工厂的账本上占一个条目。这个账本条目就是你在任务管理器里看到的显存占用。

账本条目本身不占物理显存,但它会影响工厂的“可用资源”计算。当账本条目太多,调度中心会认为工厂资源紧张,进而触发一些你意想不到的行为,比如限制新的分配、触发分页,甚至让某些操作变慢。

3. cudaMallocHost 在 Windows 上的完整分配链路

3.1 从 API 调用到 WDDM 资源注册

当你调用cudaMallocHost(&ptr, size)时,CUDA 运行时在 Windows 上大致经历以下步骤:

  1. 用户态内存分配:运行时先通过 Windows 的虚拟内存 API 保留一段地址空间,通常是VirtualAlloc系列。
  2. 物理页锁定:调用MmProbeAndLockPages或等价机制,把这段虚拟内存对应的物理页锁定,防止被换出。
  3. GPU 可见性注册:这是 Windows 特有的步骤。CUDA 驱动需要向 WDDM 子系统注册这块内存,使其对 GPU 可见。注册过程会创建一个 WDDM 资源对象,这个对象会被计入进程的显存预算。
  4. 映射与返回:驱动返回一个 GPU 可访问的地址,同时把主机虚拟地址给到应用程序。

第 3 步是问题的根源。在 Linux 上,这一步只是建立 DMA 映射,不涉及显存账本;在 Windows 上,它变成了一个正式的 WDDM 资源分配。

3.2 为什么任务管理器里看到的是“共享 GPU 内存”

WDDM 把显存资源分为“本地”和“非本地”。本地资源驻留在专用显存上,非本地资源驻留在系统内存上。cudaMallocHost分配的内存在物理上位于系统内存,所以它被归类为非本地资源,在任务管理器里显示为“共享 GPU 内存”。

但这里有个容易混淆的地方:任务管理器的“共享 GPU 内存”是一个动态值,它反映的是当前被 WDDM 纳入管理的系统内存总量,而不是实际被 GPU 使用的量。你分配了 1GB 的 pinned memory,共享 GPU 内存可能涨 1GB,但这 1GB 仍然在系统内存条上,没有被搬到显存里。

3.3 显存预算与“可用显存”的缩水

真正影响你的是 WDDM 的显存预算机制。每个进程在创建 CUDA 上下文时,WDDM 会根据专用显存大小、系统内存大小、当前系统负载等因素,给这个进程分配一个显存预算。这个预算决定了进程最多能“登记”多少显存资源。

cudaMallocHost占用的 WDDM 资源会计入这个预算。如果你分配了大量 pinned memory,预算被大量消耗,后续的cudaMalloc就可能失败,或者 CUDA 运行时被迫使用更保守的分配策略。表现出来就是:明明nvidia-smi显示显存还有富余,但你的程序就是分配不出显存了。

我实测过一个极端场景:在一台 8GB 显存的机器上,先分配 6GB 的cudaMallocHost,然后再尝试cudaMalloc2GB。结果cudaMalloc直接返回cudaErrorMemoryAllocation。释放掉 pinned memory 后,同样的cudaMalloc立刻成功。这就是 WDDM 预算机制在起作用。

4. 复现与验证:用代码把现象钉死

4.1 最小复现代码

下面这段代码可以在 Windows 上直接编译运行,用来观察cudaMallocHost对显存占用的影响。建议在运行前打开任务管理器的“性能”标签页,切换到 GPU 视图,观察“共享 GPU 内存”和“专用 GPU 内存”的变化。

#include <cuda_runtime.h> #include <iostream> #include <vector> #include <thread> #include <chrono> void printMemInfo(const char* tag) { size_t freeMem = 0, totalMem = 0; cudaMemGetInfo(&freeMem, &totalMem); std::cout << tag << " | Free: " << freeMem / 1024 / 1024 << " MB" << " | Total: " << totalMem / 1024 / 1024 << " MB" << std::endl; } int main() { printMemInfo("Initial"); const size_t allocSize = 1024ULL * 1024 * 1024; // 1GB std::vector<void*> pinnedPtrs; for (int i = 0; i < 4; ++i) { void* ptr = nullptr; cudaError_t err = cudaMallocHost(&ptr, allocSize); if (err != cudaSuccess) { std::cout << "cudaMallocHost failed at iteration " << i << ": " << cudaGetErrorString(err) << std::endl; break; } pinnedPtrs.push_back(ptr); std::cout << "Allocated pinned memory #" << (i + 1) << std::endl; printMemInfo("After cudaMallocHost"); std::this_thread::sleep_for(std::chrono::seconds(2)); } // 尝试分配设备内存 void* devPtr = nullptr; cudaError_t err = cudaMalloc(&devPtr, 2ULL * 1024 * 1024 * 1024); if (err != cudaSuccess) { std::cout << "cudaMalloc failed: " << cudaGetErrorString(err) << std::endl; } else { std::cout << "cudaMalloc succeeded" << std::endl; cudaFree(devPtr); } for (void* p : pinnedPtrs) { cudaFreeHost(p); } printMemInfo("After free all pinned"); return 0; }

编译命令(假设你已安装 CUDA Toolkit):

nvcc -o test_pinned test_pinned.cu

4.2 观察到的现象与解读

在 Windows 上运行这段代码,你会看到类似下面的输出(具体数值因机器而异):

Initial | Free: 7500 MB | Total: 8192 MB Allocated pinned memory #1 After cudaMallocHost | Free: 7500 MB | Total: 8192 MB Allocated pinned memory #2 After cudaMallocHost | Free: 7500 MB | Total: 8192 MB ... cudaMalloc failed: out of memory After free all pinned After free all pinned | Free: 7500 MB | Total: 8192 MB

注意cudaMemGetInfo返回的 Free 值可能没有明显变化,但cudaMalloc就是失败了。这是因为cudaMemGetInfo反映的是专用显存的空闲量,而 WDDM 的预算消耗不在这个统计里。你需要看任务管理器的“共享 GPU 内存”才能看到真实情况。

在 Linux 上跑同样的代码,cudaMalloc大概率会成功,因为 pinned memory 不占显存预算。

4.3 用 nvidia-smi 和任务管理器交叉验证

nvidia-smi在 Windows 上显示的是专用显存的使用情况,对共享内存的反映有限。要完整观察,建议同时打开:

  • 任务管理器 -> 性能 -> GPU:看“共享 GPU 内存”和“专用 GPU 内存”两条曲线。
  • nvidia-smi -l 1:看专用显存的实时变化。
  • GPU-Z:看更细粒度的内存控制器负载。

我实测下来,任务管理器的“共享 GPU 内存”对cudaMallocHost的响应最敏感,基本是线性增长。每分配 1GB pinned memory,共享 GPU 内存涨约 1GB。释放后,这个值不会立刻回落,而是由 WDDM 在后台异步回收,通常需要几秒到几十秒。

5. 实际项目中的影响与应对策略

5.1 推理部署:pinned memory 池不能无限扩

在做推理服务时,很多人会用 pinned memory 做主机到设备的数据中转,比如预处理后的图像、token embedding 等。在 Linux 上,你可以放心地开一个大的 pinned memory 池,比如 4GB 或 8GB,只要系统内存够。但在 Windows 上,这个池会直接吃掉 WDDM 预算,导致模型权重加载不进去。

我的建议是:在 Windows 上,pinned memory 池的大小不要超过专用显存的 30%。比如 8GB 显存的机器,pinned 池控制在 2GB 以内。如果数据吞吐量确实大,可以考虑用cudaMallocManaged替代,或者改用分批传输,减少单次 pinned 分配的量。

另外,cudaHostAlloc的cudaHostAllocMapped标志在 Windows 上也要慎用。映射后的内存同样会注册为 WDDM 资源,而且因为要建立 GPU 地址映射,开销比普通 pinned memory 更大。

5.2 多卡场景:每张卡都会“分账”

如果你有多张 GPU,每张卡都会独立计算 WDDM 预算。cudaMallocHost分配的内存在默认情况下是绑定到当前设备的,但如果你用了cudaHostAllocPortable,这块内存会对所有设备可见,同时也会被所有设备的 WDDM 预算“分账”。

这意味着在多卡机器上,一块 2GB 的 portable pinned memory 可能消耗掉每张卡各 2GB 的预算。四卡机器就是 8GB 的总预算消耗。这个坑我在一个视频处理项目里踩过:四张 RTX 3090,每张 24GB 显存,按理说很宽裕,但因为开了 6GB 的 portable pinned 池,每张卡的可用预算被砍掉一大块,导致其中一个模型加载失败。

解决办法很简单:能不用 portable 就不用。如果确实需要多卡共享,考虑用cudaHostRegister按需注册,用完就cudaHostUnregister,而不是长期持有一大块 portable 内存。

5.3 与 WDDM 预算相关的注册表调优

Windows 允许通过注册表调整 WDDM 的显存预算行为,但我不建议普通用户去动。这些键值主要面向驱动开发和特殊场景,改错了可能导致系统不稳定。如果你确实遇到了预算严重不足的情况,优先考虑:

  • 减少 pinned memory 的使用量。
  • 升级到更大的专用显存。
  • 在 Linux 上部署对显存预算敏感的任务。

有一个相对安全的调整是关闭 Windows 的“硬件加速 GPU 计划”(HAGS)。在“设置 -> 系统 -> 显示 -> 图形 -> 更改默认图形设置”里可以找到。HAGS 会让 WDDM 更激进地管理显存资源,关闭后某些场景下预算会宽松一些。但这个因驱动版本和系统版本而异,需要你自己实测。

6. 几个容易误判的排查场景

6.1 “显存泄漏”其实是 WDDM 延迟回收

很多人看到任务管理器里共享 GPU 内存居高不下,第一反应是显存泄漏。实际上,WDDM 对非本地资源的回收是异步的。你调用了cudaFreeHost,CUDA 运行时释放了内存,但 WDDM 资源对象的销毁可能滞后。在资源管理器里,这个滞后可能持续几秒到几十秒。

判断方法:写一个循环,反复分配和释放 pinned memory,观察共享 GPU 内存是否持续增长。如果增长到某个值后稳定,说明是正常的缓存行为;如果无限增长,那才是真泄漏。

6.2 cudaMemGetInfo 在 Windows 上的局限性

cudaMemGetInfo返回的是专用显存的空闲量,不包含 WDDM 预算信息。所以你不能用它来判断“还能分配多少 pinned memory”。在 Windows 上做资源规划时,要同时参考任务管理器的共享 GPU 内存和专用 GPU 内存。

我通常的做法是:在程序启动时记录一个基线,然后在关键分配点打印cudaMemGetInfo和任务管理器的读数(可以通过 Windows 性能计数器 API 获取)。两者结合,才能得到完整的画面。

6.3 与 DirectX 应用的相互影响

如果你的 Windows 机器上同时跑着 DirectX 应用(比如游戏、视频播放器、浏览器硬件加速),这些应用也会占用 WDDM 预算。CUDA 进程的预算会被压缩。表现出来就是:关掉浏览器,CUDA 程序突然能分配更多显存了。

这个现象在显存较小的机器上特别明显。做 CUDA 开发时,建议关掉不必要的图形应用,或者至少在排查显存问题时保持环境干净。

7. 我个人的几条实操经验

第一,在 Windows 上做 CUDA 开发,永远不要把 pinned memory 当成“免费”的资源。它在 Linux 上是免费的,在 Windows 上是要记账的。写代码时就要有预算意识,而不是等到分配失败再回头找原因。

第二,用cudaHostRegister替代长期持有的cudaMallocHost。如果你的 pinned memory 是间歇性使用的,比如只在数据传输阶段需要,那就用cudaHostRegister临时注册,传完就cudaHostUnregister。这样 WDDM 资源对象的生命周期更短,对预算的占用是脉冲式的,而不是持续性的。

第三,在 CI/CD 或自动化测试里,不要用 Windows 的显存读数做硬性断言。WDDM 的行为受系统负载、驱动版本、其他进程影响太大,同样的代码在不同时间跑出来的显存占用可能不一样。如果要做回归测试,建议在 Linux 上跑,或者用相对值而不是绝对值。

第四,遇到“显存不足”但nvidia-smi显示有富余时,先查 pinned memory。这是 Windows 上最容易被忽略的显存消耗源。打开任务管理器,看共享 GPU 内存是不是异常高。如果是,检查代码里有没有大的 pinned 分配没有及时释放。

第五,驱动版本对 WDDM 行为有影响。我遇到过某个版本的驱动对 pinned memory 的 WDDM 注册特别激进,升级驱动后行为就变了。如果你被这个问题困扰,不妨试试更新到最新的 Studio 驱动或 Game Ready 驱动,看看现象是否缓解。

最后分享一个排查小技巧:在 Windows 上可以用cuda-memcheck的--leak-check选项来检查 pinned memory 泄漏,但它对 WDDM 资源对象的回收滞后无能为力。更可靠的方法是用 Windows 的性能计数器监控“GPU 非本地内存”这个指标,它能直接反映 WDDM 对系统内存的登记量。把这个指标和你的代码分配日志对齐,就能精确定位是哪一次分配导致了预算消耗。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询