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内存里被倒腾了一遍。
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性能。 锁页内存不能被换出,意味着操作系统少了一块可回收的内存。如果系统内存紧张,其他进程的缺页中断会更频繁,整体性能反而下降。
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 知识体系:锁页内存的核心逻辑
为了让你更直观地理解,我画了一张流程图,展示锁页内存和可分页内存的传输路径差异:
3.6 实践建议:什么时候用锁页内存?
根据我的经验,以下场景强烈建议使用锁页内存:
- 频繁的Host到Device传输: 比如训练数据加载、实时推理的输入输出。每次传输都走锁页,累积节省的时间非常可观。
- 大数据块传输: 超过10MB的数据,锁页内存的优势就很明显了。小于1MB的数据,用可分页内存也差不了多少。
- 流式传输(Stream): 配合CUDA Stream做异步传输时,锁页内存是必须的。因为异步传输要求源地址在传输完成前不能被修改,锁页内存能保证物理地址不变。
而以下场景,我建议你谨慎使用:
- 系统内存紧张: 比如只有4GB内存的机器,分配1GB锁页内存可能让系统崩溃。
- 一次性分配后很少使用: 如果只是偶尔传一次数据,用锁页内存反而浪费了宝贵的物理内存。
- CPU频繁读取锁页内存: 特别是Write-Combined内存,CPU读它慢得像蜗牛。如果你需要CPU也频繁读写,用cudaHostAllocMapped或者干脆用普通内存。