第 2 章:多GPU架构——NVIDIA多GPU系统拓扑、NVLink与PCIe、统一虚拟地址空间
要把多张 GPU 用好,先得搞清楚它们怎么连在一起的。我见过不少同学,代码写得挺顺,一上多卡就性能崩盘。为什么?根本不了解底层的拓扑结构。这章我就把 NVIDIA 的多 GPU 架构掰开揉碎,聊聊 NVLink、PCIe 还有那个听着很玄的统一虚拟地址空间。
2.1 多 GPU 系统拓扑:树型 vs 混合型
多 GPU 系统,说白了就是把多张卡挂在 CPU 或者交换机上。常见的拓扑有两种,我画出来你们就明白了。
一句话总结拓扑:GPU 之间通信要走几跳?跳数越少,延迟越低。PCIe 树型下跨 Switch 通信可能需要经过 CPU 或 QPI,NVLink 则直接点对点。
实际 NVIDIA DGX 或者 HGX 基板里,往往用的是 NVSwitch —— 一个专门的交换芯片,让所有 GPU 两两之间都有全速 NVLink 通道。我记得有一次调一个 8 卡 A100 集群,拓扑用 nvidia-smi topo -m 一看全是 NVLink 直连,心里就有底了。但如果遇到 PCIe 桥接的老机器,跨卡通信就要谨慎设计。
2.2 NVLink vs PCIe:速度到底差多少?
我直接拿一个表格对比,大家一眼就能看出差距。
| 特性 | PCIe 4.0 x16 | PCIe 5.0 x16 | NVLink 2.0 (V100 双口) | NVLink 3.0 (A100 单口) |
|---|---|---|---|---|
| 单向带宽 | 约 16 GB/s | 约 32 GB/s | 约 50 GB/s | 约 25 GB/s |
| 双向带宽 | 约 32 GB/s | 约 64 GB/s | 约 100 GB/s | 约 50 GB/s |
| 典型延迟 | ≈ 5 μs | ≈ 4 μs | ≈ 0.5 μs | ≈ 0.3 μs |
| 协议开销 | 中等(DMA 描述符 | 中等 | 极低(原子操作) | 极低 |
你看,NVLink 的延迟只有 PCIe 的十分之一。为什么?因为 NVLink 走的是 GPU 内部的高速 SerDes,不需要经过 CPU 的 IOMMU 翻译,也不需要在系统内存里绕一圈。我曾在一个分布式训练任务里,把 all-reduce 从 PCIe 切到 NVLink,时间直接砍掉 40%。这就是架构的红利。
小技巧:用 nvlink-status -s 可以查看当前 NVLink 连接状态和带宽计数器。如果发现某个 link 报错或降速,赶紧查线缆或 firmware 吧。
2.3 统一虚拟地址空间 (UVA) —— 不再手动搬运
CUDA 里有个很优雅的设计:统一虚拟地址空间。意思就是所有 GPU 和 CPU 看到的虚拟地址是同一个范围。你分配一个指针,可以同时在 CPU 和多个 GPU 上解引用。当然,底层数据可能不在同一个地方,但驱动会帮你处理缺页和迁移。
我早期做多 GPU 时,最烦的就是手动维护设备指针数组。用了 UVA 之后,代码清爽多了。举个例子:
// 传统做法:每个 GPU 分配独立指针
float *d_ptr0, *d_ptr1;
cudaSetDevice(0); cudaMalloc(&d_ptr0, N * sizeof(float));
cudaSetDevice(1); cudaMalloc(&d_ptr1, N * sizeof(float));
// UVA 方式:统一分配,所有设备可见
float *uva_ptr;
cudaMallocManaged(&uva_ptr, N * sizeof(float));
// 在 GPU 0 上用
cudaSetDevice(0); kernel<<<...>>>(uva_ptr);
// 在 GPU 1 上用,自动迁移
cudaSetDevice(1); kernel<<<...>>>(uva_ptr);
是不是简单多了?不过要注意,UVA 依赖于 页迁移引擎,如果频繁跨卡访问,反而会触发大量缺页中断。我自己的经验是:对于每 GPU 独立数据的训练,用 cudaMalloc 更好;对于共享只读参数或 AllReduce 缓冲区,UVA 才划算。
2.4 跨卡通信 NVLink + UVA 实战坑
你以为把两张卡用 NVLink 连起来,再开 UVA 就万事大吉了?嗯,这里有个坑:NVLink 直连并不能让 UVA 的缺页变快。缺页本质上还是 CPU 参与。我曾经天真地写了一个全 UVA 的 8 卡 AllReduce,结果性能还不如手动 cudaMemcpy。为什么?因为 UVA 的页迁移默认走 CPU 页面错误,不经过 GPU 的 RDMA 通道。所以最佳实践是:
- 大块传输:显式使用
cudaMemcpyPeer或 NCCL,它们会利用 NVLink 的 P2P 能力。 - 细粒度共享:启动 atomic 或少量数据访问时,用 UVA 避免拷贝开销。
- 一定检查 P2P 是否启用:调用
cudaDeviceCanAccessPeer确认后再用 UVA 指针。
警告:不要对 cudaMallocManaged 分配的数组做跨卡大量随机访问,否则你会看到每秒几十万的缺页中断,性能直接崩到 CPU 级别。启动时记得加 cudaMemAttachGlobal 标志来改善局部性。
写到这里我想起一个真实案例:团队有个同学在 4 卡 V100 上写训练脚本,全用 UVA 分配参数,结果每个 epoch 慢得像蜗牛。我让他打开 nvidia-smi -pm 1 并改用 cudaMemPrefetchAsync 主动预取,速度就正常了。所以工具都是好工具,关键看你怎么用。
2.5 总结几个关键结论
- 拓扑决定带宽天花板:NVLink 直连 > NVSwitch > PCIe 桥接。
- NVLink 延迟极低,但只有特定 GPU 型号支持(V100/A100/H100 等)。
- UVA 降低编程复杂度,但性能需要配合预取和 P2P 使用。
- 生产环境:建议用 NCCL 做集体通信,CUDA 驱动会自动选择最快路径。
下一块内容——嗯,这章讲完架构就到应用层了。大家先把这几个概念印在脑子里,后面做负载均衡和部署时才不会踩底层坑。