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 处理。

CUDA 设备管理:枚举 → 属性 → 拓扑 → 调度决策 ① 设备枚举 cudaGetDeviceCount CUDA_VISIBLE_DEVICES 逻辑编号 0..n-1 ② 属性解析 cudaGetDeviceProperties CC / SM / 显存 / 时钟 pciBusID / asyncEngine ③ 拓扑发现 cudaDeviceCanAccessPeer cudaDeviceGetP2PAttribute NVLink / PCIe / NUMA 设备选择与切换 cudaSetDevice / cudaGetDevice 每线程独立当前设备 · 先切换再分配 NVML 协同(物理健康视图) nvmlDeviceGetHandleByIndex / UUID 温度 · 功耗 · 利用率 · ECC · 显存占用 调度决策输入 选设备 · 分组 · 是否启用 P2P 任务切分 · 流与拷贝重叠策略 逻辑视图(CUDA Runtime)与物理视图(NVML)互补,共同支撑多 GPU 调度

五、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%”,从而做出更合理的任务分配。

NVML 与 CUDA 的初始化顺序没有强制要求,但建议先 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 程序能否稳定、高效地跑起来。