3. Page-Locked Memory

好,咱们接着聊显存优化。上一节我们讲了内存拷贝的基本操作,你可能会想:“不就是把数据从CPU搬到GPU吗?能有多复杂?” 嗯,如果你这么想,那说明你还没踩过坑。我刚开始做CUDA开发时,也以为cudaMemcpy就是个无脑搬运工。直到有一次,我盯着profiler里那惨不忍睹的传输带宽,才意识到——搬运工也有三六九等,而锁页内存,就是那个“VIP通道”。

3.1 缺页中断:数据传输的隐形杀手

先说说为什么普通的内存拷贝会慢。你平时用malloc分配的内存,叫可分页内存(Pageable Memory)。操作系统为了高效利用物理内存,会把不常用的数据换到磁盘上。当你访问一个不在物理内存中的地址时,就会触发缺页中断(Page Fault),CPU得去磁盘把数据捞回来。

这跟GPU传输有什么关系?关系大了。当你用cudaMemcpy从可分页内存拷贝数据到GPU时,CUDA驱动会先发起一个DMA传输。但DMA要求源地址必须是物理连续的,而且不能缺页。所以驱动会偷偷做一件事:它先把你的数据从可分页内存拷贝到一个临时的锁页缓冲区(Pinned Buffer),然后再从这个缓冲区发起DMA到GPU。

你看,这中间多了一次隐式拷贝!而且这个临时缓冲区的大小是有限的,如果数据量大,还得分成多次传输。每次切换缓冲区,都可能触发缺页中断。说白了,你的数据在到达GPU之前,已经在CPU内存里被倒腾了一遍。

核心结论: 可分页内存的cudaMemcpy,实际上包含了“可分页→锁页缓冲区→GPU”两步操作。锁页内存则直接走“锁页内存→GPU”一步到位。

3.2 锁页内存:一步到位的解决方案

锁页内存(Page-Locked Memory,也叫Pinned Memory)就是告诉操作系统:“这块内存你别动,物理页给我锁死了,永远别换出去。” 这样CUDA驱动就能直接拿到物理地址,发起DMA传输,省掉中间那一次隐式拷贝。

分配锁页内存很简单,用cudaHostAlloc代替malloc就行:

float *h_data;
cudaHostAlloc(&h_data, size, cudaHostAllocDefault);
// 使用完后
cudaFreeHost(h_data);

就这么一行改动,传输带宽能提升多少?我实测过,在PCIe 3.0 x16上,可分页内存的带宽大概在6-8 GB/s,而锁页内存能跑到11-12 GB/s,接近理论极限。对于小数据量(比如几百KB),提升可能不明显,但一旦数据量超过几十MB,差距就非常显著了。

内存类型 传输带宽(PCIe 3.0 x16) CPU开销 适用场景
可分页内存 6-8 GB/s 高(隐式拷贝+缺页中断) 小数据量、不频繁传输
锁页内存 11-12 GB/s 低(直接DMA) 大数据量、频繁传输

3.3 锁页内存的代价:别滥用

听到这里,你可能会想:“那我把所有内存都改成锁页的不就行了?” 别急,凡事都有代价。锁页内存有几个坑,我一个个说。

第一,物理内存是有限的。 你把内存锁住了,操作系统就不能把它换到磁盘上。如果你分配了太多锁页内存,其他进程的内存压力会增大,甚至可能导致系统OOM(Out of Memory)。我见过有人一次性分配了8GB锁页内存,结果系统直接卡死。

第二,cudaHostAlloc分配速度慢。 因为它需要分配物理连续的页面,并且锁定它们。相比malloc,cudaHostAlloc的分配时间可能多一个数量级。所以不要在热路径上频繁分配和释放锁页内存。

第三,影响CPU性能。 锁页内存不能被换出,意味着操作系统少了一块可回收的内存。如果系统内存紧张,其他进程的缺页中断会更频繁,整体性能反而下降。

避坑指南: 我曾经在一个服务器上同时跑4个GPU任务,每个任务都分配了2GB锁页内存。结果系统内存被吃光,其他进程频繁swap,最终所有任务都变慢了。后来我改成只对传输频繁的缓冲区使用锁页内存,其他数据用普通内存,问题就解决了。

3.4 锁页内存的进阶用法:映射与写入组合

除了cudaHostAllocDefault,CUDA还提供了几种标志位,可以玩出更多花样。

  • cudaHostAllocPortable: 让锁页内存能被所有CUDA上下文访问。默认情况下,锁页内存只对分配它的设备可见。如果你有多GPU,这个标志很有用。
  • cudaHostAllocMapped: 把锁页内存映射到GPU地址空间。这样GPU可以直接读取CPU内存,不需要显式拷贝。适合零拷贝(Zero-Copy)场景,比如嵌入式设备或集成显卡。
  • cudaHostAllocWriteCombined: 使用写合并(Write-Combining)内存。这种内存对CPU读操作很慢,但对GPU写操作很快。适合CPU只写、GPU只读的场景,比如把输入数据从CPU传到GPU。

举个例子,如果你有一个数据流,CPU不断生成数据,GPU不断消费,可以用cudaHostAllocMapped实现零拷贝:

float *h_mapped;
cudaHostAlloc(&h_mapped, size, cudaHostAllocMapped);

float *d_mapped;
cudaHostGetDevicePointer(&d_mapped, h_mapped, 0);

// CPU写数据到h_mapped
// GPU直接从d_mapped读取,无需cudaMemcpy

嗯,这里要注意:零拷贝虽然省了拷贝时间,但GPU访问CPU内存的速度远低于访问显存。所以它只适合数据量小、或者访问模式是“一次读取、多次使用”的场景。否则,你省下的拷贝时间,可能全浪费在慢速的PCIe访问上了。

3.5 知识体系:锁页内存的核心逻辑

为了让你更直观地理解,我画了一张流程图,展示锁页内存和可分页内存的传输路径差异:

锁页内存 vs 可分页内存:数据传输路径对比 CPU可分页内存 缺页中断风险 临时锁页缓冲区 DMA传输 GPU显存 CPU锁页内存 直接DMA传输 GPU显存 VS 两步 vs 一步 可分页内存:CPU内存 → 临时缓冲区 → GPU(两次拷贝) 锁页内存:CPU内存 → GPU(一次DMA,无中间拷贝) 锁页内存省去隐式拷贝,但占用物理内存不可换出

3.6 实践建议:什么时候用锁页内存?

根据我的经验,以下场景强烈建议使用锁页内存:

  • 频繁的Host到Device传输: 比如训练数据加载、实时推理的输入输出。每次传输都走锁页,累积节省的时间非常可观。
  • 大数据块传输: 超过10MB的数据,锁页内存的优势就很明显了。小于1MB的数据,用可分页内存也差不了多少。
  • 流式传输(Stream): 配合CUDA Stream做异步传输时,锁页内存是必须的。因为异步传输要求源地址在传输完成前不能被修改,锁页内存能保证物理地址不变。

而以下场景,我建议你谨慎使用:

  • 系统内存紧张: 比如只有4GB内存的机器,分配1GB锁页内存可能让系统崩溃。
  • 一次性分配后很少使用: 如果只是偶尔传一次数据,用锁页内存反而浪费了宝贵的物理内存。
  • CPU频繁读取锁页内存: 特别是Write-Combined内存,CPU读它慢得像蜗牛。如果你需要CPU也频繁读写,用cudaHostAllocMapped或者干脆用普通内存。
我的个人习惯: 我会在程序初始化时,预先分配一块足够大的锁页内存池。运行时需要传输数据,就从池子里取一块用,用完归还。这样既避免了频繁分配的开销,又控制了锁页内存的总量。你可以试试这个模式,效果不错。