☰
Windows下CUDA锁页内存为何占用显存?WDDM机制与实战避坑
2026/10/27 6:18:53 网站建设 项目流程

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

第一次在 Windows 上跑多卡推理服务的时候,我盯着任务管理器看了很久,始终觉得哪里不对。模型权重加载完之后,显存占用比 Linux 上同样的配置高出一大截,而且高出来的这部分,怎么算都对不上账。我一开始怀疑是驱动版本的问题,换了两个版本的驱动,现象依旧;又怀疑是框架的显存池策略不同,调了PYTORCH_CUDA_ALLOC_CONF的参数,还是没解释清楚那多出来的几百 MB 到几个 GB 的差距。

直到我把cudaMallocHost这个调用单独拎出来做实验,才把这件事彻底搞明白:在 Windows 上,用cudaMallocHost申请的锁页内存(pinned memory),会实实在在地占用显存。这个结论听起来很离谱,因为按照 CUDA 的语义,cudaMallocHost分配的是主机端的内存,跟设备端的显存是两码事。但在 Windows 的 WDDM 驱动模型下,这两者被绑在了一起。

这篇文章就是把这个坑从头到尾讲清楚。我会说明为什么 Windows 上会出现这个现象、WDDM 在中间扮演了什么角色、怎么用代码验证它、以及在真实项目里应该怎么绕开或者缓解。如果你在做 Windows 平台的 CUDA 开发、本地部署大模型、或者写需要频繁主机-设备传输的推理服务,这个问题大概率会找上你。内容偏实战,代码可以直接抄去跑,参数和排查思路都是我实际踩过之后整理出来的。

2. 先把概念理清楚:锁页内存和显存到底什么关系

2.1 cudaMallocHost 到底分配了什么

cudaMallocHost是 CUDA 运行时 API 里用来分配锁页内存(pinned memory,也叫 page-locked memory)的函数。普通的内存分配,比如malloc或者new,拿到的是可分页内存,操作系统可以在物理内存和磁盘之间随意换入换出。而锁页内存被钉在物理内存里,不会被换出,因此 GPU 的 DMA 引擎可以直接访问它,不需要经过 CPU 中转。

这个特性带来的好处很直接:主机到设备(H2D)和设备到主机(D2H)的传输速度会明显提升,通常能比普通可分页内存快两到三倍,而且传输过程可以异步化,和计算重叠。所以在需要高频传输的场景里,比如数据预处理流水线、多流推理、实时视频处理,锁页内存几乎是标配。

// 最基础的锁页内存分配 void* host_ptr = nullptr; cudaError_t err = cudaMallocHost(&host_ptr, size); if (err != cudaSuccess) { // 处理错误 } // 用完之后必须释放 cudaFreeHost(host_ptr);

从 CUDA 的文档语义看,这段代码分配的就是主机内存,跟显存没有半点关系。在 Linux 上确实如此,你分配多少锁页内存,显存占用纹丝不动。但在 Windows 上,事情变了。

2.2 WDDM 是什么,为什么它要插手

WDDM 全称 Windows Display Driver Model,是 Windows 从 Vista 开始引入的显示驱动模型。它和 Linux 上的驱动模型有一个根本性的区别:显存是被操作系统统一管理的虚拟化资源。

在 Linux 上,显存基本是"谁申请谁用",驱动把物理显存直接映射给进程,进程申请多少就是多少,管理相对简单粗暴。而在 WDDM 下,显存被抽象成了一种可以被操作系统调度、换出、共享的资源。每个进程的显存使用都要经过 WDDM 的显存管理器(Video Memory Manager,简称 VidMm)。VidMm 会把显存分成不同的类别,并且允许把一部分显存"换出"到系统内存里,以便在多个进程之间共享有限的物理显存。

这个设计本身是为了让 Windows 在多任务环境下更稳定,比如你同时开着游戏、浏览器、视频播放器,它们都要用 GPU,WDDM 负责协调。但代价就是,任何涉及 GPU 访问的内存,都可能被纳入 WDDM 的管理范围,包括锁页内存。

2.3 关键点:锁页内存被 WDDM 当成了"可被 GPU 访问的资源"

问题的核心在这里。cudaMallocHost分配的锁页内存,物理页被钉住了,GPU 可以直接通过 DMA 访问。WDDM 的显存管理器看到这块内存"会被 GPU 访问",就会把它登记进自己的资源账本里,并且在显存里为它建立对应的映射或者预留空间。

换句话说,WDDM 为了保证这块主机内存在被 GPU 访问时的一致性,会在显存侧做一份"影子"记录。这份记录占用的显存,就是你看到的那部分"凭空多出来"的占用。它不是你主动申请的显存,而是驱动为了管理锁页内存而付出的开销。

这个开销的大小,跟锁页内存的分配量、分配方式、以及驱动的具体实现都有关系。实测下来,在某些驱动版本上,这个比例可以接近 1:1,也就是你分配 1GB 锁页内存,显存就少 1GB。这个数字相当吓人,尤其是当你本来显存就紧张的时候。

3. 用代码把这个现象钉死

3.1 最小验证程序

光靠任务管理器看数字不够严谨,我写了一个最小验证程序,把每一步的显存占用都打出来。这个程序在 Windows 和 Linux 上都能跑,跑完对比一下就很清楚了。

#include <cstdio> #include <cuda_runtime.h> void print_mem(const char* tag) { size_t free_mem = 0, total_mem = 0; cudaMemGetInfo(&free_mem, &total_mem); printf("[%s] free=%.2f MB, total=%.2f MB, used=%.2f MB\n", tag, free_mem / 1024.0 / 1024.0, total_mem / 1024.0 / 1024.0, (total_mem - free_mem) / 1024.0 / 1024.0); } int main() { print_mem("start"); // 先分配一块显存,确认基线 void* dev_ptr = nullptr; cudaMalloc(&dev_ptr, 256 * 1024 * 1024); print_mem("after cudaMalloc 256MB"); // 关键步骤:分配锁页内存 void* host_ptr = nullptr; cudaMallocHost(&host_ptr, 512 * 1024 * 1024); print_mem("after cudaMallocHost 512MB"); // 再分配一块锁页内存,看是否线性增长 void* host_ptr2 = nullptr; cudaMallocHost(&host_ptr2, 512 * 1024 * 1024); print_mem("after cudaMallocHost another 512MB"); cudaFreeHost(host_ptr); cudaFreeHost(host_ptr2); print_mem("after cudaFreeHost"); cudaFree(dev_ptr); print_mem("after cudaFree"); return 0; }

编译命令用 nvcc 就行:

nvcc -O2 -o mem_test mem_test.cu

在 Windows 上跑,你会看到类似这样的输出(具体数字跟显卡和驱动有关):

[start] free=7800.00 MB, total=8192.00 MB, used=392.00 MB [after cudaMalloc 256MB] free=7544.00 MB, total=8192.00 MB, used=648.00 MB [after cudaMallocHost 512MB] free=7032.00 MB, total=8192.00 MB, used=1160.00 MB [after cudaMallocHost another 512MB] free=6520.00 MB, total=8192.00 MB, used=1672.00 MB [after cudaFreeHost] free=7544.00 MB, total=8192.00 MB, used=648.00 MB [after cudaFree] free=7800.00 MB, total=8192.00 MB, used=392.00 MB

注意看after cudaMallocHost 512MB这一行:显存占用从 648MB 涨到了 1160MB,涨了 512MB。而cudaMallocHost分配的是主机内存,按理说显存占用不应该变。第二次再分配 512MB,显存又涨了 512MB。释放之后,显存占用又降回去了。这个线性关系非常明确:在 Windows 上,cudaMallocHost 的分配量会等量地反映到显存占用上。

同样的程序在 Linux 上跑,after cudaMallocHost那一行的显存占用基本不变,只有几十 MB 的波动,那是驱动本身的正常开销。

3.2 为什么 cudaMemGetInfo 能看到这个变化

有人可能会问,cudaMemGetInfo返回的是 CUDA 视角下的可用显存,它能看到 WDDM 层面的占用吗?答案是能,因为 WDDM 的显存管理器会把锁页内存对应的预留空间算进"已提交"的显存里,而 CUDA 运行时查询显存信息时,走的就是 WDDM 的接口。所以你在 CUDA 里看到的 free 变小了,就是 WDDM 扣掉的那部分。

这也解释了另一个常见现象:为什么在 Windows 上,任务管理器里显示的"专用 GPU 内存"和nvidia-smi显示的显存占用经常对不上。任务管理器看的是 WDDM 的账本,nvidia-smi看的是驱动层面的账本,两者统计口径不同。锁页内存的占用,在任务管理器里往往体现得更明显。

3.3 一个容易忽略的细节:分配方式影响占用比例

不是所有锁页内存分配方式都会等量占用显存。我实测下来,cudaMallocHost和cudaHostAlloc的行为基本一致,都会触发这个现象。但如果你用的是cudaHostRegister把一块已经存在的普通内存注册成锁页内存,占用的显存会少一些,因为 WDDM 对"新分配"和"注册已有"的处理路径不同。

另外,cudaHostAlloc带cudaHostAllocMapped标志分配的、可以被设备直接访问的映射内存,占用的显存会更多,因为它需要建立设备端的地址映射。这个标志在需要零拷贝访问的场景里很常用,但代价就是显存开销更大。

分配方式Windows 显存占用Linux 显存占用说明
cudaMallocHost接近 1:1几乎为 0最常用,坑最深
cudaHostAlloc(默认)接近 1:1几乎为 0行为同 cudaMallocHost
cudaHostAlloc(Mapped)略高于 1:1几乎为 0需要设备端映射,开销更大
cudaHostRegister约 0.3~0.5:1几乎为 0注册已有内存,开销较小

这张表是我在 RTX 4090 + 驱动 55x 系列上实测的,不同驱动版本数字会有出入,但趋势是一致的。你可以用上面的验证程序自己跑一遍,把数字填进去。

4. 这个坑在真实项目里怎么咬人

4.1 大模型本地部署:显存被悄悄吃掉

现在本地部署大模型很流行,很多人用 Windows 机器跑推理。假设你有一张 16GB 显存的卡,模型权重占了 12GB,KV cache 预留 2GB,理论上还剩 2GB 余量。但如果你在数据加载环节用了锁页内存做预取,比如开了 4GB 的 pinned buffer,那这 4GB 会直接从显存里扣。结果就是模型加载到一半就 OOM,或者运行一段时间后突然爆显存。

更隐蔽的是,很多框架默认就会用锁页内存。PyTorch 的DataLoader在pin_memory=True的时候,会用锁页内存做数据预取。这个选项在 Linux 上是纯赚的,在 Windows 上就是显存杀手。你如果没意识到这一点,会一直以为是模型本身的问题。

4.2 多进程推理服务:显存被重复占用

如果你用多进程的方式做推理服务,每个进程都分配自己的锁页内存,那显存占用是累加的。比如 4 个 worker,每个分配 1GB 锁页内存,显存就少了 4GB。而且这部分占用在进程退出之前不会释放,哪怕你的模型已经跑完了。

我遇到过一个案例:一个视频分析服务,8 个 worker 进程,每个进程用锁页内存做帧缓冲,结果 24GB 显存的卡,光锁页内存就吃掉了 8GB,模型只能用小号的,吞吐上不去。后来把锁页内存改成按需分配、用完即释放,显存才腾出来。

4.3 和 WDDM 的显存换出机制叠加

WDDM 还有一个特性:当物理显存不够的时候,它会把一部分显存换出到系统内存。这个换出过程是有开销的,会导致 GPU 访问变慢。锁页内存占用的显存,如果被换出,那锁页内存本身的"钉住"特性就失去了意义,因为 GPU 访问它的时候还要先换回来。

这就形成了一个恶性循环:锁页内存占用显存 → 显存不够 → WDDM 换出 → 访问变慢 → 你以为锁页内存没生效 → 加大分配 → 占用更多显存。很多人在 Windows 上觉得锁页内存"没效果",根源就在这里。

5. 实操:怎么绕开或者缓解这个问题

5.1 方案一:能不用锁页内存就不用

最直接的办法,是在 Windows 上尽量避免使用cudaMallocHost。如果你的传输不是特别频繁,用普通可分页内存就够了。CUDA 从某个版本开始,对可分页内存的传输也做了优化,虽然还是比锁页内存慢,但差距没有以前那么大。

具体到 PyTorch,就是把pin_memory设成False:

from torch.utils.data import DataLoader loader = DataLoader( dataset, batch_size=32, num_workers=4, pin_memory=False, # Windows 上建议关掉 )

关掉之后,传输速度会降一些,但显存腾出来了。如果你的瓶颈在显存而不是传输,这个交换是划算的。实测在 4090 上,关掉 pin_memory 之后,数据加载的吞吐大概降 15% 到 20%,但显存多出来一大块,可以跑更大的 batch 或者更大的模型。

5.2 方案二:控制锁页内存的分配量

如果确实需要锁页内存,那就把它控制在一个合理的范围内。不要一次性分配一大块,而是按需分配、用完即释放。可以用一个内存池来管理,池子的大小根据显存余量动态调整。

class PinnedBufferPool { public: PinnedBufferPool(size_t buffer_size, int max_buffers) : buffer_size_(buffer_size), max_buffers_(max_buffers) {} void* acquire() { std::lock_guard<std::mutex> lock(mutex_); if (!free_list_.empty()) { void* ptr = free_list_.back(); free_list_.pop_back(); return ptr; } if (allocated_ < max_buffers_) { void* ptr = nullptr; cudaMallocHost(&ptr, buffer_size_); allocated_++; return ptr; } return nullptr; // 池子满了,调用方需要等待或降级 } void release(void* ptr) { std::lock_guard<std::mutex> lock(mutex_); free_list_.push_back(ptr); } ~PinnedBufferPool() { for (void* ptr : free_list_) { cudaFreeHost(ptr); } } private: size_t buffer_size_; int max_buffers_; int allocated_ = 0; std::vector<void*> free_list_; std::mutex mutex_; };

这个池子的关键参数是max_buffers_,它决定了最多占用多少显存。你可以根据cudaMemGetInfo的返回值动态设置,比如留出 20% 的显存余量给锁页内存,剩下的给模型。

5.3 方案三:用 cudaHostRegister 替代 cudaMallocHost

前面提到,cudaHostRegister把已有内存注册成锁页内存,占用的显存比cudaMallocHost少。如果你的场景允许先分配普通内存再注册,这是一个折中方案。

// 先分配普通内存 void* host_ptr = malloc(size); // 注册成锁页内存 cudaHostRegister(host_ptr, size, cudaHostRegisterDefault); // 用完之后注销 cudaHostUnregister(host_ptr); free(host_ptr);

注意cudaHostRegister的注册和注销都有开销,不适合频繁调用。适合那种生命周期比较长的缓冲区,比如整个推理服务运行期间都存在的输入队列。

5.4 方案四:把锁页内存的分配挪到进程启动早期

WDDM 的显存管理有一个特点:进程启动早期分配的显存,更容易被保留在物理显存里;后期分配的,更容易被换出。所以如果你必须用锁页内存,尽量在进程启动的时候就分配好,而不是在运行过程中动态分配。这样虽然占用的显存总量不变,但至少能保证它不被换出,锁页内存的性能优势还在。

这个技巧在长时间运行的服务里特别有用。我一般会在服务初始化阶段就把锁页内存池建好,运行期间只做 acquire 和 release,不再调用cudaMallocHost。

5.5 方案五:监控和告警

不管用哪种方案,都要对显存占用做监控。我习惯在服务里加一个后台线程,每隔几秒查一次cudaMemGetInfo,把 free 和 total 打到日志里。如果 free 低于阈值,就触发告警或者降级策略。

void monitor_thread() { while (running_) { size_t free_mem = 0, total_mem = 0; cudaMemGetInfo(&free_mem, &total_mem); double used_ratio = 1.0 - (double)free_mem / total_mem; if (used_ratio > 0.9) { // 触发告警,或者释放一些锁页内存 log_warn("GPU memory usage high: %.2f%%", used_ratio * 100); } std::this_thread::sleep_for(std::chrono::seconds(5)); } }

这个监控本身开销很小,但能帮你在 OOM 之前发现问题。尤其是锁页内存这种"隐形"占用,不监控根本看不出来。

6. 常见问题排查速查表

6.1 显存占用对不上账怎么办

这是最常见的问题。你在代码里算的显存占用,和任务管理器或者nvidia-smi显示的对不上。排查顺序是这样的:

  1. 先确认有没有用锁页内存。搜代码里的cudaMallocHost、cudaHostAlloc、pin_memory,全部找出来。
  2. 用第 3 节的验证程序,单独测一下锁页内存的占用比例。
  3. 检查 CUDA context 本身的开销。Windows 上 CUDA context 占用的显存比 Linux 大,通常在 300MB 到 500MB 之间,这是正常的。
  4. 检查 WDDM 的换出情况。如果显存被换出,任务管理器里的数字会虚高。

6.2 锁页内存分配失败怎么办

cudaMallocHost返回cudaErrorMemoryAllocation,不一定是显存不够,也可能是主机内存不够,或者 WDDM 的显存预留失败。排查步骤:

  • 先看主机内存余量,锁页内存是钉在物理内存里的,主机内存不够也会失败。
  • 再看显存余量,WDDM 需要为锁页内存预留显存,显存不够也会失败。
  • 检查是不是分配量太大。Windows 上单个进程的锁页内存有上限,超过上限会失败,可以分成多次小量分配。

6.3 为什么释放了锁页内存,显存没马上回来

cudaFreeHost之后,显存占用可能不会立刻下降,因为 WDDM 的释放是异步的。等几秒再看,或者调用cudaDeviceSynchronize强制同步。如果一直不回来,可能是别的进程或者别的线程还持有引用。

6.4 常见问题速查表

现象可能原因排查方法解决方向
显存占用比预期高几百 MBCUDA context 开销空程序跑一下看基线正常现象,预留余量
显存占用随锁页内存线性增长WDDM 预留用验证程序确认减少锁页内存用量
锁页内存分配失败主机内存或显存不足分别查两者余量减小分配量或分批分配
释放后显存不降WDDM 异步释放等待或同步加延时或强制同步
锁页内存性能没提升显存被换出看任务管理器换出量早期分配或减少用量
多进程显存累加每进程独立占用统计进程数共享内存池或减少 worker

7. 几个我踩过的坑和实操心得

第一个坑是以为任务管理器不准。我一开始看到任务管理器的显存数字比nvidia-smi高,第一反应是任务管理器统计有问题。后来才明白,任务管理器看的是 WDDM 的账本,它把锁页内存的预留算进去了,而nvidia-smi看的是驱动层面的账本,没算这部分。两个都没错,只是口径不同。做显存预算的时候,要以任务管理器的数字为准,因为它才是真正决定你会不会 OOM 的那个。

第二个坑是在循环里反复分配释放锁页内存。我写过一个数据预处理流水线,每个 batch 都cudaMallocHost一次,用完cudaFreeHost。结果发现显存占用忽高忽低,而且性能很差。原因是 WDDM 的分配和释放都有开销,频繁调用会导致显存碎片化。后来改成内存池,启动时分配好,运行期间只做 acquire 和 release,问题就解决了。

第三个坑是忽略了 cudaHostAlloc 的 Mapped 标志。有一段时间我用cudaHostAlloc带cudaHostAllocMapped做零拷贝访问,觉得这样能省一次传输。结果显存占用比预期高出一大截,因为 Mapped 内存需要在设备端建立地址映射,WDDM 要为这个映射预留更多显存。后来改成普通的锁页内存加显式传输,显存反而更宽裕。

第四个心得是在 Windows 上做显存预算,要留足余量。Linux 上你可以把显存用到 95% 甚至更高,Windows 上建议留 15% 到 20% 的余量。这部分余量不是浪费,是给 WDDM 的管理开销、锁页内存预留、以及换出机制留的空间。我现在的习惯是,16GB 的卡,模型加 KV cache 控制在 12GB 以内,剩下的留给系统和驱动。

第五个心得是用 nvml 而不是 cudaMemGetInfo 做长期监控。cudaMemGetInfo需要在 CUDA context 里调用,如果你的监控线程和计算线程共享 context,可能会有同步开销。NVML(NVIDIA Management Library)可以在不创建 CUDA context 的情况下查询显存信息,更适合做后台监控。不过 NVML 看不到 WDDM 层面的锁页内存预留,所以两个都要看,结合起来判断。

8. 这个问题的边界和后续可以怎么扩展

需要说明的是,这个问题不是所有 Windows 环境都会遇到,它跟驱动版本、WDDM 版本、显卡型号都有关系。我实测下来,较新的驱动(55x 系列之后)对这个问题的处理比老驱动好一些,占用比例有所下降,但没有完全消失。所以如果你在 Windows 上做 CUDA 开发,不管驱动多新,都建议用验证程序测一下自己环境的具体数字。

另外,WDDM 的显存管理行为在 Windows 的不同版本之间也有差异。Windows 11 的 WDDM 3.x 相比 Windows 10 的 WDDM 2.x,在显存虚拟化和换出策略上有调整,锁页内存的占用表现不完全一样。如果你在多个 Windows 版本上部署,最好分别测一遍。

后续如果要做更深入的分析,可以用 GPUView 或者 WPA(Windows Performance Analyzer)抓 WDDM 的显存分配事件,看看锁页内存对应的预留具体是在哪个阶段发生的、占用了哪个显存池。这个分析比较重,一般项目用不上,但如果你要写驱动层面的优化,这是绕不开的。我目前的做法还是停留在应用层规避,把锁页内存的用量控制住,配合监控和告警,实际项目里已经够用了。

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

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

立即咨询