最近把一个在 Linux 上跑了一年多的数据处理管线搬到 Windows,结果第一天就被显存占用吓了一跳。任务管理器里 GPU 的“专用显存”使用量比预期多出几百兆,而代码里真正往显存分配的只有几个小 buffer。排查到最后,问题居然落在一个用cudaMallocHost分配的主机端缓冲上。当时我就懵了:按文档说法,cudaMallocHost分配的是主机锁页内存,数据不进入显存,怎么 Windows 下反而能“吃显存”?
这个问题其实比想象中常见。做 Windows 下 CUDA 开发,或者经常在 Windows 上跑本地大模型的朋友,十有八九会踩到类似的坑。今天就把这种现象掰开揉碎讲清楚——它不是玄学,背后是驱动模式、GPU 地址空间和系统内存记账方式三重因素叠加的结果。搞清楚之后,你至少能少白蹲半天的 debug 时间。
1. 先把现象说清楚:到底是“吃显存”还是“被显存记账吃掉了”
1.1 一个最小复现实验
我的复现代码非常简单,本质就是分配一块主机端 pinned memory,再观察显存变化:
#include <cuda_runtime.h> #include <cstdio> #include <cstdlib> int main() { int deviceCount = 0; cudaGetDeviceCount(&deviceCount); cudaSetDevice(0); size_t freeMem, totalMem; cudaMemGetInfo(&freeMem, &totalMem); printf("Before: free %.2f MB / total %.2f MB\n", freeMem / 1024.0 / 1024.0, totalMem / 1024.0 / 1024.0); float* hostBuf = nullptr; // 分配 4GB 主机锁页内存 cudaMallocHost((void**)&hostBuf, 4ULL * 1024 * 1024 * 1024); cudaDeviceSynchronize(); size_t freeMem2, totalMem2; cudaMemGetInfo(&freeMem2, &totalMem2); printf("After cudaMallocHost: free %.2f MB / total %.2f MB\n", freeMem2 / 1024.0 / 1024.0, totalMem2 / 1024.0 / 1024.0); getchar(); cudaFreeHost(hostBuf); return 0; }在 Linux 上运行,这个程序的free基本不变,系统内存占用却会上升。但在 Windows 上,我实测的结果是:4GB 的锁页内存分配后,cudaMemGetInfo报出的显卡可用显存直接少了 300 多 MB。也就是说,CUDA runtime 确实在设备侧扣掉了一部分“额度”。
更直观的是任务管理器。切到“性能-GPU”页面,你会看到“专用 GPU 内存”这一项挂着“共享 GPU 内存”。真正常见的情况是:cudaMallocHost执行后,“共享 GPU 内存”涨上去,这部分本质还是系统内存,不是显存。但别忘了,nvidia-smi 里的“内存使用”列也会跟着涨——很多朋友看到那一列就慌了。
1.2 不同工具的记账口径不一样
要判断“显存被吃”是真吃还是虚报,关键得区分三套账本:
- nvidia-smi 的显存列:读的是 NVIDIA 驱动的设备显存租用情况,专指设备上的物理显存和 mappable 系统内存段。
- cudaMemGetInfo:CUDA runtime 向驱动要到的显存剩余量,反映 CUDA 上下文可见的设备内存。
- 任务管理器 GPU 专用显存:Windows 在 WDDM 模式下统计的 Dedicated GPU Memory,同样只算物理显存,但不等于 nvidia-smi 的口径。
我做的另一个对比测试:把cudaMallocHost换成普通malloc后,再跑同样的 copy 流程,nvidia-smi 里“内存使用”就基本纹丝不动,只有任务管理器里系统内存涨了。这个差异直接暴露了cudaMallocHost在 Windows 下的特殊之处。
1.3 为什么日常本地部署更容易踩中
Windows 上跑本地大模型,比如 16G 显存的显卡推理 Qwen 或者各种 llama.cpp 变体,很多人会习惯性用 pinned memory 来加速数据搬运。尤其做视频流、实时语音这类场景,输入 buffer 动不动就几个 G,副作用就是显存“莫名”多占几百 MB。而 16G 显存本来就不宽裕,主模型加载后剩余空间紧巴巴,KV cache 再被压缩,显存压力一下子上来了。
所以问题的核心不是cudaMallocHost错误地把数据写进了显存,而是 Windows 的驱动为了“让 GPU 能访问锁页内存”,在显存里额外做了一套映射和记账。下面进入正题。
2. 为什么一块主机内存会牵扯到显存:cudaMallocHost 的真实分工
2.1 锁页内存是拿来干吗的
先回到基本概念。CPU 默认用虚拟内存管理,普通malloc分配的内存有可能被操作系统换出到磁盘,物理页面是不固定的。GPU 要通过 PCIe 总线做 DMA 传输时,需要拿到稳定的物理地址;如果页面不锁定,底层驱动还得临时把所有涉及的内存页锁住、搬运、再解锁,效率非常低。
cudaMallocHost的作用就是让系统为你锁定一块物理连续或至少驻留不换出的主机内存。锁定之后,GPU 可以用 DMA 直接访问这块内存,省掉中间 staging buffer 的拷贝。这也是为什么做cudaMemcpyAsync和 zero-copy 时,大家普遍推荐使用 pinned memory。
但这里有个容易被忽略的点:仅“锁定”还不够。GPU 有自己的一套虚拟地址空间,为了能访问主机内存,驱动还要建立“设备虚拟地址到主机物理地址”的映射。在统一寻址架构(UVA)模式下,CUDA 会给所有锁定内存分配一段 GPU 地址空间,然后把这套地址映射关系写进 GPU 的页面管理表里。
2.2 映射的代价:GPU 页表也是要占显存的
打个比方:主机内存是一栋大楼的房间,GPU 想去访问,不能直接凭直觉走进去,手里必须拿一张图纸。这张图纸就是 GPU 页表。锁定的房间越多,图纸就越厚。
关键点在于:这张“图纸”必须放在设备侧,也就是说,哪怕数据本身仍然住在系统内存里,用于描述“怎么找到这些数据”的目录结构,实实在在占用了显存。
具体大小怎么估?我遇到过一种情况:4GB 数据、4KB 一页,就是 1048576 个page table entry。GPU 页表条目按 8 字节计算,光这层就要 8MB;再加上多级页表和驱动预留的 mapping pool,驱动实际划走 300MB 左右并不夸张。如果你恰好用的是 2MB 大页,页表项数量会大幅减小,但 Windows 下cudaMallocHost并不会自动帮你开大页,默认还是按普通页来。
2.3 除了页表,还有固定映射的“工作区”
另一个隐性成本是驱动为固定内存预留的“映射工作区”。WDDM 模式下,驱动不可能每次 DMA 都现场建映射再销毁,那样性能太差。通常 driver 会一次性预留多份固定映射资源,这部分也计入设备内存。预留多少跟分配次数有关,不一定跟总分配量成正比——这也是很多人发现“明明只分配了 100MB 锁页内存,显存却涨了 150MB”的原因,因为 driver 还提前把映射表、同步对象、上下文工作区一起预留好了。
在这个机制下,cudaMallocHost看起来就是在“吃显存”,实际上吃的是 GPU 用来管理“访问锁页主机内存”这件事的管理平面。搞清楚这一层后,Windows 和 Linux 的差异就很好解释了。
3. Windows 特别坑在哪:WDDM 模式带来的一连串连锁反应
3.1 WDDM 与 Linux 下 TCC/原生驱动的本质区别
Linux 下 NVIDIA 驱动默认使用 TCC(Tesla Compute Cluster)模式,或者是普通图形驱动配合 IOMMU,做 DMA 映射时一般依赖系统 IOMMU 的地址转换表。这个地址转换表在 CPU 侧的北桥/IOMMU 里,不需要占用显存。GPU 看到的是经过 IOMMU 转换后的“设备地址”,而不会单独为每个主机内存页维护一份庞大的设备页表。
Windows 则走 WDDM 图形驱动模型。NVIDIA 在 WDDM 下把 CUDA context 当成一个图形上下文来管,设备访存的页表、地址映射、上下文的资源记账统统要在设备内存里。这就导致同样一个cudaMallocHost,在 Linux 下几乎不碰显存额度,到了 Windows 下就被 WDDM 的账本记上了一笔。
你还能观察到一个现象:Linux 下cudaSetDevice之后如果只分配锁页内存,nvidia-smi显示的进程显存占用基本为 0。Windows 下只要创建了 CUDA context,哪怕什么显存都不分配,驱动也会先烧掉几十 MB 显存用于上下文管理。继续叠 lock 内存,占用继续往上走。这就是 WDDM 在背后“操盘”的痕迹。
3.2 任务管理器里“共享 GPU 内存”的误导
Windows 任务管理器里有一项叫“共享 GPU 内存”,很多人把它误当成显存。它其实表示系统内存中允许被 GPU 使用的部分,并不是设备显存。cudaMallocHost分配出来的锁页内存,在任务管理器里往往会被归到“共享 GPU 内存”或者系统内存里,这个数字上涨不代表显存物理占用上涨。
但 nvidia-smi 那列不同。它统计的是驱动“记账”的显存分配,会把 mapping pool、页表、固定内存映射标记都折算进去。所以正确姿势是看 nvidia-smi,不是只看任务管理器。
还有个小坑:Windows 的 WDDM 模块图里,“帧缓冲内存”和“专用 GPU 内存”是分开的。有些工具,比如 GPU-Z、Process Explorer,会把“Driver reserved memory”也显示出来。如果你只盯着进程的“专用字节”,很容易漏掉 driver reserved 这一层。
3.3 cudaMalloc 与 cudaMallocHost 混用时的“联动效应”
Windows 下另一个不容易察觉的问题:锁页内存的映射一旦建立起来,它会挂在当前 CUDA context 上。这个 context 在你程序整个生命周期里都会持续保留这些映射。如果你在程序里创建多个 context(比如用多线程或加载了不同 CUDA 库),每个 context 都可能复制一套映射信息,显存占用就可能翻倍。
我见过一个实际案例:一个 Windows 服务同时加载了 OpenCV 的 CUDA 模块和自研 CUDA 模块,两个模块各自初始化了独立的 context,又都在启动阶段分配了几百 MB pin 内存做预取缓冲,结果显存里的“管理开销”直接从 300MB 涨到 900MB。这就不是单纯的页表数学题了,而是多个 context 各自建账的叠加效应。
遇到这种情况,最佳策略是尽量减少并发 context 数量,或者尽量让所有框架共用同一个 CUDA context。在 Windows 上,频繁创建释放 CUDA context 比 Linux 更亏,因为每个 context 的生命周期里都有设备侧映射资源的隐性开销。
4. 怎么确认你的显存是被锁页内存吃掉的
4.1 一套两分钟能跑完的诊断流程
排查时按顺序做这几步就行:
- 打开
cmd,运行nvidia-smi,记录当前显存占用。 - 运行你的程序,看 nvidia-smi 里进程的“内存使用”列增长了多少。
- 关闭程序,把代码里的
cudaMallocHost改成malloc,其他逻辑不变,再跑一次,观察同样的指标。 - 如果第二次显存增长大幅减少,基本可以确定是固定内存映射/页表开销导致的。
注意第 3 步里,malloc后做cudaMemcpy时需要驱动额外做 staging buffer,性能会差一些,但这是为了排查问题临时做的,不影响判断。
还可以配合一个小命令循环观察:
nvidia-smi --query-gpu=memory.total,memory.used,memory.free --format=csv -l 14.2 用 CUDA API 得到更精确的数字
用cudaMemGetInfo可以度量当前 CUDA context 看到的显存剩余量。这是最接近“驱动记账”底层的指标,比任务管理器准得多。
在cudaMallocHost前后各打一次点,同时注意,分配完cudaDeviceSynchronize(),否则分配动作可能还在异步队列里没生效。
真正的判断标准是:同样大小的分配,malloc 时free下降 0,cudaMallocHost 时free下降几百 MB,那就可以锤死是固定内存的映射开销。
4.3 看 Nsight Systems 的内存图
Windows 下用 Nsight Systems 也能看到比较细的内存分配时间线。打开nsys profile抓一段分配和拷贝过程,左侧 Memory 视图里可以看到对应的Pinned Memory分配项。它虽然不会直接显示“显存被占用几 MB”,但能帮你验证分配时机和代码逻辑的关系,避免误伤。
另外,如果机器上有两张显卡,可以留意一下:在 Windows 下,cudaMallocHost可能把锁页内存映射到所有可见设备的地址空间。也就是说,你只在一张卡上做运算,另一张卡的显存“记账”也可能抖动一下。多卡环境尤其要用 nvidia-smi 逐卡确认。
5. 实用规避方案:让锁页内存回归主机内存的本来定位
5.1 先问自己:真的需要 pinned memory 吗
不是所有 CUDA 程序都需要cudaMallocHost。我列了一个判断表,按需求决策:
| 使用场景 | 是否需要 cudaMallocHost | 建议 |
|---|---|---|
大量小数据频繁cudaMemcpy | 可不用 | 用普通 malloc,让驱动内部调度即可 |
流式数据复制,使用cudaMemcpyAsync | 建议用 | 配合 pinned memory 才能异步生效 |
| zero-copy 核函数直接访问主机内存 | 必须用 | 使用cudaHostAllocMapped并获取映射地址 |
| Windows 下本地模型推理 | 尽量避免大块 pin 内存 | 把 pin 内存池限制在 512MB 以内 |
| 多 context / 多模块混用 | 尽量避免 | 全局共享一个固定的 pin 池 |
很多人在 Windows 上跑本地 LLM,为了加速 prompt 处理,用 pinned memory 缓存所有 token 输入,动辄几个 G。实际上对于单次 prompt 传输来说,256MB pin 池已经绰绰有余,再多就是给显存记账页面添柴火。控制好 pin 池大小,显存占用立刻降下来。
5.2 内心计算过 pin 池大小再动手
如果需要使用 pinned memory,我习惯先粗算一下。复制缓冲区如果是 256MB,pin 池就设 256MB 或略大;如果程序里同时存在 4 个 256MB 的流式缓冲,那分配时尽量把 4 块并成 1 个 1GB 的整形 buffer。为什么要合并?因为驱动为“一次大分配”建的映射比“多次小分配”少得多,而且打散的小块容易让驱动保留更多映射工作区。
代码层面可以这样:
// 分配前先查一次默认池大小,避免臆测 size_t freeMem, totalMem; cudaMemGetInfo(&freeMem, &totalMem); size_t targetSize = 256 * 1024 * 1024; if (targetSize > freeMem / 2) { targetSize = freeMem / 2; } float* hostBuf = nullptr; cudaMallocHost((void**)&hostBuf, targetSize);这个freeMem / 2的保障很粗暴,主要是防止 Windows 下 driver 预留映射后,数据显存不够用。实际项目的保守比例可以按业务峰值流量来定。
5.3 cudaHostAlloc 的旗标值得再看一眼
cudaMallocHost本质上等价于cudaHostAlloc(ptr, size, cudaHostAllocDefault)。但如果你细看 cudaHostAlloc 的旗标,有几个能直接影响显存映射开销:
cudaHostAllocPortable:让这块锁页内存在多设备间共享。Windows 下如果只需要当前卡,不要乱加这个旗标,否则会在所有设备上建立映射信息。cudaHostAllocMapped:明确要求把主机内存映射到设备地址空间,供核函数直接访问。如果你只需要cudaMemcpy的输入缓冲,不需要核函数直接读写,就别加这个,省得一整块都变成设备可访问映射。cudaHostAllocWriteCombined:适合写多读少、CPU 往 GPU 单向流式传输的数据。它会使用 write-combined 内存,走映射时页表管理开销略有不同。注意,读取该内存的 CPU 速度会变慢,不能乱用在双向场景。
选择时遵循一个原则:尽量使用所需的最少映射能力。不需要设备访问的 pin buffer,用默认旗标即可;需要设备访问的,再考虑 mapped。
5.4 替代方案:如果只是想异步拷贝,用 managed memory 要谨慎
cudaMallocManaged也能在主机和设备间共享数据,但它的行为更复杂。默认情况下,managed memory 可以根据访问位置在 CPU 和 GPU 间迁移;Windows 下如果启用了 compute preemption,驱动可能在显存里保留多份副本的元信息,反而更容易造成显存占用。所以我的建议是:
- 明确知道每个内存对象属于谁,用
cudaMalloc+ 显式 copy。 - 数据传输频繁但单次量不大,用
cudaMallocHost+cudaMemcpyAsync。 - 数据访问模式高度动态,再考虑
cudaMallocManaged,并且定期调用cudaMemAdvise调节落点。
我测试过一个视频编码管线,把所有中间帧 buffer 换成 managed memory 后,nvidia-smi 显存占用比原来的 pinned memory 方案多出约 20%,拷贝延迟还高了。Windows 下 managed memory 的迁移开销并不像文档写得那么轻巧,非必要不建议大规模替换。
5.5 针对本地模型部署的额外建议
如果你是在 Windows 上做 16G 显存下的本地模型推理,还想让显存利用率更高,可以考虑这几件事:
- 把 tokenizer 结果和 prompt 输入从 pinned memory 改成普通内存,只在真正进入 GPU 计算前用 staging 小缓冲拷贝。
- 模型的 KV cache 不要和 pinned memory 抢显存。优先给 KV cache 预留固定空间,再考虑 pin 池。
- 如果使用 llama.cpp 的 CUDA 后端,可以留意它的
--mmap和--mlock参数。--mlock会锁定主机内存,Windows 下同样会带来显存映射开销,除非真的需要防止换页,否则开着就是在和显存过不去。 - 配合 Windows 关闭图形硬件加速调度等不必要功能,有时能降低 WDDM 的上下文开销,但不保证所有驱动都有效。
6. 常见问题速查表
| 现象 | 可能原因 | 对策 |
|---|---|---|
cudaMallocHost后 nvidia-smi 显存增加几百 MB | WDDM 驱动的映射管理开销 | 减少分配次数,合并 buffer,控制 pin 池 |
| 任务管理器中“共享 GPU 内存”上涨 | 锁页内存被映射进 GPU 地址空间 | 不必在意,重点看专用显存和 nvidia-smi |
| 多个 CUDA context 共存,显存账单叠加 | 每个 context 独立建立映射 | 尽量复用单个 context,避免频繁创建销毁 |
| Linux 正常,Windows 异常 | 驱动模式差异(IOMMU vs WDDM) | 按 Windows 特性重新评估 pin 池,不要对比预期 |
使用 PyTorchpin_memory=True后显存升高 | DataLoader 的 pin 缓冲池变大 | 调小 batch_size,或手动控制 pin_pool 上限 |
| 使用 managed memory 后显存不降反升 | managed 内存迁移元信息开销 | 回归显式 copy,临时缓冲用默认 pinned |
7. 一些来自实操的补充观察
最后分享一个我自己的习惯:在 Windows 上做 CUDA 项目时,我会在开发机顶部挂一个nvidia-smi常驻窗口,每次跑回归测试时先截一张初始显存,再截一张结束显存。别信“程序退出后显存一定归零”这种假设,WDDM 模式下的驱动有时会把 context 保留一段时间,尤其是进程异常退出时。遇到显存占用只增不减,先杀进程,再等几十秒看驱动是否自动回收。
另一个小经验是,无论cudaMallocHost还是cudaHostAlloc,分配完务必在合适时机cudaFreeHost。很多人只记得cudaFree设备内存,忘了主机锁页内存同样需要显式释放。泄漏的 pin buffer 会一直占着系统内存,同时里面的映射条目也一直挂着显存记账,整个系统越跑越臃肿。
在 Windows 上踩过这个坑之后,我现在写跨平台 CUDA 代码时会专门做一层封装:Linux 上使用 pinned memory 加速流式拷贝,Windows 上自动退化成普通malloc,只保留少量 staging pinned buffer。两边性能差距在大多数业务场景中可以接受,但显存账本的稳定性好很多。如果你正被这个“隐形显存杀手”困扰,照上面几节的方法先确认原因,再按需裁剪 pin 池,多半能很快解决问题。