3. 设备管理与枚举
多 GPU 程序的第一步不是写 kernel,而是搞清楚“这台机器上到底有几块 GPU、它们分别是谁、彼此怎么连”。CUDA Runtime 提供了一组设备管理 API,配合 NVML(NVIDIA Management Library)可以同时拿到逻辑设备视图和物理健康视图。本节围绕 cudaGetDeviceCount、设备选择与切换、属性解析、拓扑发现以及 NVML 协同使用展开。
一、设备枚举:cudaGetDeviceCount 与设备标识
任何多 GPU 程序的入口几乎都是先调用 cudaGetDeviceCount(&n),它返回当前进程可见的 CUDA 设备数量。这里的“可见”受环境变量 CUDA_VISIBLE_DEVICES 影响:如果设置为 2,3,那么进程内 cudaGetDeviceCount 返回 2,逻辑编号 0 和 1 分别映射到物理 GPU 2 和 3。这一点在多租户或容器环境中非常关键,逻辑编号与物理编号并不总是一致。
枚举之后,每个设备用逻辑索引 device(0 到 n-1)标识。要获取设备属性,使用 cudaGetDeviceProperties(&prop, device),返回的 cudaDeviceProp 结构体包含名称、计算能力、显存、SM 数量、时钟、PCI 总线信息等。下面是一段典型的枚举代码:
int n = 0;
cudaGetDeviceCount(&n);
for (int i = 0; i < n; ++i) {
cudaDeviceProp prop;
cudaGetDeviceProperties(&prop, i);
printf("GPU %d: %s, CC %d.%d, %.1f GB, %d SMs, PCI %02x:%02x.0\n",
i, prop.name, prop.major, prop.minor,
prop.totalGlobalMem / 1073741824.0,
prop.multiProcessorCount,
prop.pciBusID, prop.pciDeviceID);
}
示例输出(假设环境):GPU 0: NVIDIA A100-SXM4-40GB, CC 8.0, 39.6 GB, 108 SMs, PCI 07:00.0。注意 totalGlobalMem 是字节数,除以 2^30 得到 GiB;pciBusID 和 pciDeviceID 是后续拓扑判断的重要依据。
二、设备选择与切换:cudaSetDevice 与上下文
CUDA 采用“当前设备”模型。每个主机线程有一个当前设备,所有后续的显存分配、kernel 启动、流创建都作用于该设备。切换用 cudaSetDevice(device),查询当前设备用 cudaGetDevice(&cur)。多线程程序中,每个线程独立维护自己的当前设备,因此常见做法是“一个线程绑定一块 GPU”。
需要特别注意的是:cudaSetDevice 只改变当前设备,不会自动迁移已分配的内存或已创建的流。如果先在一块 GPU 上分配了指针,再切到另一块 GPU 去使用它,会导致非法访问。正确做法是“先切换、再分配、再使用”,并在跨设备协作时显式使用 cudaMemcpyPeer 或对等访问。
cudaGetDeviceProperties,因为该调用涉及驱动交互,开销不可忽略。三、设备属性解析:哪些字段真正影响调度
cudaDeviceProp 字段很多,但多 GPU 调度中最常关注以下几类:
- 计算能力:
major/minor,决定可用的指令集与特性(如 Tensor Core、异步拷贝)。 - 资源规模:
multiProcessorCount、maxThreadsPerMultiProcessor、regsPerMultiprocessor、sharedMemPerBlock,用于估算并行度和 occupancy。 - 显存:
totalGlobalMem、memoryClockRate、memoryBusWidth,用于判断能否容纳任务数据。 - 互联能力:
unifiedAddressing、canMapHostMemory、pciBusID、pciDeviceID、pciDomainID,用于拓扑与 P2P 判断。 - 并发能力:
concurrentKernels、asyncEngineCount,决定多流与多设备重叠效果。
例如,若两块 GPU 的 major/minor 不同,就不能假设同一份 cubin 或同一套调优参数通用;若 asyncEngineCount 为 0,则拷贝与计算的重叠能力有限。这些字段应作为调度器选择设备、切分任务、决定是否启用 P2P 的输入。
四、多 GPU 拓扑发现:P2P 与 NVLink
知道有几块 GPU 还不够,还要知道它们之间“怎么连”。CUDA 提供 cudaDeviceCanAccessPeer(&can, dev, peerDev) 判断设备 dev 能否直接访问 peerDev 的显存;若可以,再用 cudaDeviceEnablePeerAccess(peerDev, 0) 启用对等访问。启用后,cudaMemcpyPeer 或 kernel 内的对等指针访问可以绕过主机内存,显著降低延迟。
拓扑结构可以用 cudaDeviceGetP2PAttribute 查询,例如 cudaDevP2PAttrPerformanceRank 给出相对性能排名,数值越小表示路径越快。在 NVLink 机器上,同一 NVLink 域内的 GPU 排名通常为 1,跨 PCIe Switch 或跨 NUMA 的排名会更大。工程上常把“P2P 可达且排名靠前”的设备划为同一通信组,把跨组通信交给主机或 NCCL 处理。
五、NVML 与 CUDA 协同:逻辑视图 + 物理视图
CUDA Runtime 给出的是“逻辑设备视图”,适合编程;NVML 给出的是“物理设备视图”,适合监控与运维。NVML 通过 nvmlInit() 初始化,用 nvmlDeviceGetCount() 获取物理 GPU 数量,用 nvmlDeviceGetHandleByIndex(i) 或 nvmlDeviceGetHandleByUUID() 拿到句柄,再查询温度、功耗、利用率、ECC 错误、显存占用等。
两者协同的关键是“对齐标识”。CUDA 的 cudaDeviceProp 里有 uuid 字段(cudaUUID_t),NVML 也有 UUID 接口。用 UUID 而不是索引来对齐,可以避免 CUDA_VISIBLE_DEVICES 重排导致的错位。典型流程是:先用 CUDA 枚举逻辑设备并读取 UUID,再用 NVML 按 UUID 找到物理句柄,建立“逻辑索引 ↔ 物理句柄”的映射表。之后调度器既能看到“这块 GPU 计算能力 8.0、108 个 SM”,也能看到“它当前温度 72°C、功耗 280W、利用率 85%”,从而做出更合理的任务分配。
nvmlInit() 再枚举 CUDA 设备,以便在 CUDA 上下文创建前就掌握物理健康状态;若某块 GPU 已出现 ECC 错误或温度异常,可提前将其排除在调度池之外。六、一个可复用的设备信息采集片段
把上述能力组合起来,可以在程序启动时生成一张设备信息表,作为后续调度的依据。示例字段包括:逻辑索引、UUID、名称、计算能力、SM 数、显存 GiB、PCI 地址、P2P 可达设备列表、NVML 温度与功耗。下面给出关键调用骨架:
nvmlInit();
int n = 0; cudaGetDeviceCount(&n);
for (int i = 0; i < n; ++i) {
cudaDeviceProp p; cudaGetDeviceProperties(&p, i);
char uuid[64];
snprintf(uuid, sizeof(uuid),
"%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",
p.uuid.bytes[0], p.uuid.bytes[1], /* ... */ p.uuid.bytes[15]);
nvmlDevice_t h;
nvmlDeviceGetHandleByUUID(uuid, &h);
unsigned int temp; nvmlDeviceGetTemperature(h, NVML_TEMPERATURE_GPU, &temp);
// 记录:i, uuid, p.name, p.major, p.minor, p.multiProcessorCount,
// p.totalGlobalMem, p.pciBusID, temp ...
}
for (int i = 0; i < n; ++i)
for (int j = 0; j < n; ++j)
if (i != j) {
int can = 0;
cudaDeviceCanAccessPeer(&can, i, j);
// 记录 P2P 可达性,必要时 cudaDeviceEnablePeerAccess(j, 0)
}
这段代码的产物就是一张“设备能力 + 健康 + 拓扑”的联合表。后续无论是做数据并行、模型并行还是流水线并行,都可以基于这张表决定:哪些 GPU 归为一组、组内是否走 P2P、组间是否经主机中转、是否把任务优先放到温度更低或利用率更低的设备上。设备管理与枚举看似是“准备工作”,但它直接决定了多 GPU 程序能否稳定、高效地跑起来。