
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 在多任务环境下更稳定比如你同时开着游戏、浏览器、视频播放器它们都要用 GPUWDDM 负责协调。但代价就是任何涉及 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] free7800.00 MB, total8192.00 MB, used392.00 MB [after cudaMalloc 256MB] free7544.00 MB, total8192.00 MB, used648.00 MB [after cudaMallocHost 512MB] free7032.00 MB, total8192.00 MB, used1160.00 MB [after cudaMallocHost another 512MB] free6520.00 MB, total8192.00 MB, used1672.00 MB [after cudaFreeHost] free7544.00 MB, total8192.00 MB, used648.00 MB [after cudaFree] free7800.00 MB, total8192.00 MB, used392.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行为同 cudaMallocHostcudaHostAllocMapped略高于 1:1几乎为 0需要设备端映射开销更大cudaHostRegister约 0.3~0.5:1几乎为 0注册已有内存开销较小这张表是我在 RTX 4090 驱动 55x 系列上实测的不同驱动版本数字会有出入但趋势是一致的。你可以用上面的验证程序自己跑一遍把数字填进去。4. 这个坑在真实项目里怎么咬人4.1 大模型本地部署显存被悄悄吃掉现在本地部署大模型很流行很多人用 Windows 机器跑推理。假设你有一张 16GB 显存的卡模型权重占了 12GBKV cache 预留 2GB理论上还剩 2GB 余量。但如果你在数据加载环节用了锁页内存做预取比如开了 4GB 的 pinned buffer那这 4GB 会直接从显存里扣。结果就是模型加载到一半就 OOM或者运行一段时间后突然爆显存。更隐蔽的是很多框架默认就会用锁页内存。PyTorch 的DataLoader在pin_memoryTrue的时候会用锁页内存做数据预取。这个选项在 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设成Falsefrom torch.utils.data import DataLoader loader DataLoader( dataset, batch_size32, num_workers4, pin_memoryFalse, # 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_guardstd::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_guardstd::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::vectorvoid* 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显示的对不上。排查顺序是这样的先确认有没有用锁页内存。搜代码里的cudaMallocHost、cudaHostAlloc、pin_memory全部找出来。用第 3 节的验证程序单独测一下锁页内存的占用比例。检查 CUDA context 本身的开销。Windows 上 CUDA context 占用的显存比 Linux 大通常在 300MB 到 500MB 之间这是正常的。检查 WDDM 的换出情况。如果显存被换出任务管理器里的数字会虚高。6.2 锁页内存分配失败怎么办cudaMallocHost返回cudaErrorMemoryAllocation不一定是显存不够也可能是主机内存不够或者 WDDM 的显存预留失败。排查步骤先看主机内存余量锁页内存是钉在物理内存里的主机内存不够也会失败。再看显存余量WDDM 需要为锁页内存预留显存显存不够也会失败。检查是不是分配量太大。Windows 上单个进程的锁页内存有上限超过上限会失败可以分成多次小量分配。6.3 为什么释放了锁页内存显存没马上回来cudaFreeHost之后显存占用可能不会立刻下降因为 WDDM 的释放是异步的。等几秒再看或者调用cudaDeviceSynchronize强制同步。如果一直不回来可能是别的进程或者别的线程还持有引用。6.4 常见问题速查表现象可能原因排查方法解决方向显存占用比预期高几百 MBCUDA context 开销空程序跑一下看基线正常现象预留余量显存占用随锁页内存线性增长WDDM 预留用验证程序确认减少锁页内存用量锁页内存分配失败主机内存或显存不足分别查两者余量减小分配量或分批分配释放后显存不降WDDM 异步释放等待或同步加延时或强制同步锁页内存性能没提升显存被换出看任务管理器换出量早期分配或减少用量多进程显存累加每进程独立占用统计进程数共享内存池或减少 worker7. 几个我踩过的坑和实操心得第一个坑是以为任务管理器不准。我一开始看到任务管理器的显存数字比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可能会有同步开销。NVMLNVIDIA 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 或者 WPAWindows Performance Analyzer抓 WDDM 的显存分配事件看看锁页内存对应的预留具体是在哪个阶段发生的、占用了哪个显存池。这个分析比较重一般项目用不上但如果你要写驱动层面的优化这是绕不开的。我目前的做法还是停留在应用层规避把锁页内存的用量控制住配合监控和告警实际项目里已经够用了。