Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

11. 【GPU】设备管理:从/dev/nvidia到CUDA Runtime

$ ls /dev/nvidia*
/dev/nvidia0  /dev/nvidia1  /dev/nvidiactl  /dev/nvidia-uvm  /dev/nvidia-uvm-tools

一台双卡机器,为什么有 5 个设备文件?它们分别是干什么的?

当你执行 nvidia-smi 或运行 CUDA 程序时,这些设备文件、驱动层、运行时库是如何协作的?


1. GPU 在 Linux 中的三层抽象

核心问题:从按下回车到 GPU 执行计算,中间经过了哪些层次?

一个 CUDA 程序访问 GPU 需要穿越三层抽象:

应用层(PyTorch/TensorFlow)
    ↓ 调用CUDA API
CUDA Runtime(libcudart.so)
    ↓ 调用驱动API
NVIDIA驱动(nvidia.ko)
    ↓ 读写设备文件
硬件层(GPU芯片)

每一层的职责

层次代表主要功能操作对象
硬件层GPU 芯片执行计算寄存器、显存
内核驱动层nvidia.ko设备抽象、内存管理/dev/nvidia*
用户态库NVML, CUDA Driver API设备查询、上下文管理文件描述符
运行时库CUDA Runtime简化 API、自动管理抽象句柄
应用层PyTorch张量操作Tensor 对象

关键洞察:每层都在隐藏下层的复杂性。理解这些层次,才能明白:

  • 为什么 nvidia-smi 能看到 8 卡,CUDA 程序只能看到 2 卡
  • 为什么容器里 CUDA_VISIBLE_DEVICES=0 不一定是物理 GPU 0
  • 为什么两个进程能同时用同一块 GPU

2. 设备文件层:/dev/nvidia 的分工

核心问题:5 个设备文件,各自负责什么?

2.1 设备文件全家福

$ ls -l /dev/nvidia*
crw-rw-rw- 1 root root 195,   0 Oct 21 10:00 /dev/nvidia0
crw-rw-rw- 1 root root 195,   1 Oct 21 10:00 /dev/nvidia1
crw-rw-rw- 1 root root 195, 255 Oct 21 10:00 /dev/nvidiactl
crw-rw-rw- 1 root root 510,   0 Oct 21 10:00 /dev/nvidia-uvm
crw-rw-rw- 1 root root 510,   1 Oct 21 10:00 /dev/nvidia-uvm-tools

$ ls -l /dev/nvidia-caps/
crw-rw-rw- 1 root root 511, 1 Oct 21 10:00 nvidia-cap1  # MIG能力设备
crw-rw-rw- 1 root root 511, 2 Oct 21 10:00 nvidia-cap2

三类设备,三种驱动:

设备类型主设备号文件名驱动模块作用
计算设备195/dev/nvidia[0-N]nvidia.koGPU 计算核心,每块 GPU 一个文件
控制设备195/dev/nvidiactlnvidia.ko驱动控制接口,查询 GPU 信息
内存管理510/dev/nvidia-uvmnvidia-uvm.ko统一虚拟内存(CUDA 6.0+)
诊断工具510/dev/nvidia-uvm-toolsnvidia-uvm.koUVM 性能计数器
MIG 能力511/dev/nvidia-caps/*nvidia.koMIG 分区的能力令牌

2.2 /dev/nvidia[0-N]:计算设备

作用:代表单块 GPU 的计算资源。

打开后可以做什么

int fd = open("/dev/nvidia0", O_RDWR);  // 获得GPU 0的句柄

// 通过ioctl发送命令
ioctl(fd, NVIDIA_IOC_ALLOC_MEMORY, ...);  // 分配显存
ioctl(fd, NVIDIA_IOC_SUBMIT_GPFIFO, ...); // 提交GPU任务
ioctl(fd, NVIDIA_IOC_WAIT_FENCE, ...);    // 等待任务完成

关键特性

  • 每次 open() 创建一个独立的上下文(Context)
  • 上下文是 GPU 资源隔离的基本单位(显存分配、kernel 调度独立)
  • 同一个进程可以打开多次,创建多个 Context

实验:直接打开设备文件

import os

# 打开GPU 0
fd = os.open('/dev/nvidia0', os.O_RDWR)
print(f"文件描述符: {fd}")

# 可以执行ioctl操作(需要特权)
# 这里只是演示打开成功,实际ioctl需要驱动API
os.close(fd)

注意:普通 CUDA 程序不直接操作这些设备文件,而是通过 CUDA Runtime 间接访问。

2.3 /dev/nvidiactl:控制中心

作用:驱动的"总控台",负责:

  • 查询有多少块 GPU
  • 获取 GPU 属性(型号、显存大小、CUDA 能力)
  • 创建跨 GPU 的资源(如 P2P 通信)

为什么独立出来? 设想如果没有 nvidiactl,要查询系统有几块 GPU,就得尝试打开 /dev/nvidia0, /dev/nvidia1... 直到打开失败。有了控制设备,一次查询搞定:

int ctl_fd = open("/dev/nvidiactl", O_RDWR);
ioctl(ctl_fd, NVIDIA_IOC_QUERY_DEVICE_COUNT, &count);
printf("系统有 %d 块GPU\n", count);

关键点

  • nvidia-smi 主要通过 nvidiactl 查询信息
  • CUDA Runtime 初始化时必须打开 nvidiactl
  • 即使只用一块 GPU,也需要访问 nvidiactl

验证:用 strace 追踪 nvidia-smi

$ strace -e openat nvidia-smi 2>&1 | grep nvidia
openat(AT_FDCWD, "/dev/nvidiactl", O_RDWR) = 3      # 首先打开控制设备
openat(AT_FDCWD, "/dev/nvidia0", O_RDWR) = 4        # 然后打开各个GPU
openat(AT_FDCWD, "/dev/nvidia1", O_RDWR) = 5

2.4 /dev/nvidia-uvm:统一虚拟内存

作用:让 GPU 和 CPU 共享统一的虚拟地址空间(Unified Memory)。

没有 UVM 的时代(CUDA 5.x 及之前):

// 程序员手动管理两侧内存
float *h_data = malloc(size);              // CPU内存
float *d_data;
cudaMalloc(&d_data, size);                 // GPU内存
cudaMemcpy(d_data, h_data, size, H2D);    // 显式拷贝
kernel<<<...>>>(d_data);                   // GPU计算
cudaMemcpy(h_data, d_data, size, D2H);    // 拷贝回来

有 UVM 之后(CUDA 6.0+):

// 自动按需迁移
float *data;
cudaMallocManaged(&data, size);  // 分配统一内存
data[0] = 1.0;                   // CPU访问 → 数据在CPU
kernel<<<...>>>(data);           // GPU访问 → 自动迁移到GPU
printf("%f", data[0]);           // CPU再访问 → 自动迁移回CPU

UVM 设备的工作原理

1. cudaMallocManaged() 通过UVM设备分配内存
   ├─ 内存初始状态:未绑定物理位置
   └─ CPU和GPU都能访问同一地址

2. 首次访问触发page fault
   ├─ CPU访问 → UVM驱动分配CPU侧内存
   └─ GPU访问 → UVM驱动分配GPU侧内存并迁移

3. 驱动自动同步
   ├─ CPU kernel启动前 → 同步数据到GPU
   └─ GPU kernel结束后 → 同步数据到CPU(按需)

实验:UVM 的性能影响

import torch
import time

size = 1000000000  # 10亿个float(4GB)

# 方法1:手动管理(无UVM)
x = torch.randn(size, device='cpu')
start = time.time()
x_gpu = x.cuda()  # 显式H2D拷贝
result = x_gpu.sum()
print(f"手动管理: {time.time() - start:.3f}秒")

# 方法2:统一内存(UVM)
# PyTorch默认不使用UVM,但CUDA程序可以
# 这里演示概念,实际需要C++代码

UVM 的代价

  • 好处:编程简单,按需迁移节省显存
  • 代价:page fault 开销,频繁迁移会慢于手动管理
  • 适用场景:数据访问模式不规则、显存不足时的 fallback

2.5 设备权限与安全

默认权限问题

$ ls -l /dev/nvidia0
crw-rw-rw- 1 root root 195, 0 ...  # 所有用户可读写(危险!)

为什么是 666(rw-rw-rw-)? NVIDIA 驱动安装脚本为了方便,给所有用户 GPU 访问权限。这在单用户工作站可以接受,但多用户服务器有安全隐患:

  • 任何用户都能读取其他用户的显存数据(信息泄漏)
  • 恶意用户可以占满 GPU(拒绝服务)

生产环境建议

# 创建gpu用户组
sudo groupadd gpu

# 修改设备权限
sudo chown root:gpu /dev/nvidia*
sudo chmod 660 /dev/nvidia*  # rw-rw----

# 将授权用户加入gpu组
sudo usermod -aG gpu alice

验证

$ groups alice
alice gpu

$ ls -l /dev/nvidia0
crw-rw---- 1 root gpu 195, 0 ...  # 只有gpu组成员能访问

3. NVML 层:nvidia-smi 的秘密

核心问题nvidia-smi 如何获取 GPU 温度、功耗、进程列表?

答案是NVML(NVIDIA Management Library)——NVIDIA 提供的管理 API 库。

3.1 NVML 是什么?

NVML 是对 /dev/nvidiactl/dev/nvidia* 的高层封装,提供 C 语言 API 用于:

  • 查询 GPU 硬件信息(型号、序列号、BIOS 版本)
  • 监控运行状态(温度、风扇转速、功耗、利用率)
  • 获取进程信息(哪些进程在用 GPU、占用多少显存)
  • 控制配置(设置功耗限制、计算模式)

库文件位置

$ ls -l /usr/lib/x86_64-linux-gnu/libnvidia-ml.so*
lrwxrwxrwx ... libnvidia-ml.so -> libnvidia-ml.so.1
lrwxrwxrwx ... libnvidia-ml.so.1 -> libnvidia-ml.so.535.183.01
-rw-r--r-- ... libnvidia-ml.so.535.183.01  # 实际库文件

3.2 nvidia-smi 的实现原理

$ nvidia-smi
+-----------------------------------------------------------------------------+
| NVIDIA-SMI 535.183.01   Driver Version: 535.183.01   CUDA Version: 12.2  |
|-------------------------------+----------------------+----------------------+
| GPU  Name        Persistence-M| Bus-Id        Disp.A | Volatile Uncorr. ECC |
| Fan  Temp  Perf  Pwr:Usage/Cap|         Memory-Usage | GPU-Util  Compute M. |
|   0  NVIDIA A100-SXM4-40GB On | 00000000:00:04.0 Off |                    0 |
| N/A   32C    P0    45W / 400W |   1024MiB / 40960MiB |      5%      Default |
+-------------------------------+----------------------+----------------------+

背后的 NVML 调用(简化):

#include <nvml.h>

int main() {
    nvmlInit();  // 初始化NVML(打开/dev/nvidiactl)

    // 获取GPU数量
    unsigned int deviceCount;
    nvmlDeviceGetCount(&deviceCount);

    for (int i = 0; i < deviceCount; i++) {
        nvmlDevice_t device;
        nvmlDeviceGetHandleByIndex(i, &device);  // 获取GPU句柄

        // 查询型号
        char name[64];
        nvmlDeviceGetName(device, name, sizeof(name));

        // 查询温度
        unsigned int temp;
        nvmlDeviceGetTemperature(device, NVML_TEMPERATURE_GPU, &temp);

        // 查询内存使用
        nvmlMemory_t memory;
        nvmlDeviceGetMemoryInfo(device, &memory);

        // 查询功耗
        unsigned int power;
        nvmlDeviceGetPowerUsage(device, &power);  // 单位:毫瓦

        printf("GPU %d: %s, %d°C, %.0fW, %lluMB / %lluMB\n",
               i, name, temp, power/1000.0,
               memory.used/1024/1024, memory.total/1024/1024);
    }

    nvmlShutdown();
}

编译运行

$ gcc -o my_smi my_smi.c -lnvidia-ml
$ ./my_smi
GPU 0: NVIDIA A100-SXM4-40GB, 32°C, 45W, 1024MB / 40960MB
GPU 1: NVIDIA A100-SXM4-40GB, 30°C, 42W, 0MB / 40960MB

3.3 Python 绑定:pynvml

安装

pip install nvidia-ml-py3

实战脚本:查询所有 GPU 的状态

import pynvml

# 初始化NVML
pynvml.nvmlInit()

# 获取GPU数量
device_count = pynvml.nvmlDeviceGetCount()
print(f"检测到 {device_count} 块GPU\n")

for i in range(device_count):
    handle = pynvml.nvmlDeviceGetHandleByIndex(i)

    # 基本信息
    name = pynvml.nvmlDeviceGetName(handle)
    uuid = pynvml.nvmlDeviceGetUUID(handle)

    # 运行状态
    temp = pynvml.nvmlDeviceGetTemperature(handle, pynvml.NVML_TEMPERATURE_GPU)
    power = pynvml.nvmlDeviceGetPowerUsage(handle) / 1000.0  # 转换为瓦
    fan = pynvml.nvmlDeviceGetFanSpeed(handle)  # 百分比

    # 内存信息
    mem_info = pynvml.nvmlDeviceGetMemoryInfo(handle)
    mem_used_mb = mem_info.used / 1024 / 1024
    mem_total_mb = mem_info.total / 1024 / 1024
    mem_percent = 100 * mem_info.used / mem_info.total

    # 利用率
    util = pynvml.nvmlDeviceGetUtilizationRates(handle)
    gpu_util = util.gpu  # GPU计算利用率
    mem_util = util.memory  # 显存带宽利用率

    print(f"GPU {i}: {name}")
    print(f"  UUID: {uuid}")
    print(f"  温度: {temp}°C  风扇: {fan}%  功耗: {power:.1f}W")
    print(f"  显存: {mem_used_mb:.0f}MB / {mem_total_mb:.0f}MB ({mem_percent:.1f}%)")
    print(f"  利用率: GPU {gpu_util}%  显存带宽 {mem_util}%\n")

# 清理
pynvml.nvmlShutdown()

输出示例

检测到 2 块GPU

GPU 0: NVIDIA A100-SXM4-40GB
  UUID: GPU-12345678-1234-1234-1234-123456789abc
  温度: 32°C  风扇: 30%  功耗: 45.2W
  显存: 1024MB / 40960MB (2.5%)
  利用率: GPU 5%  显存带宽 2%

GPU 1: NVIDIA A100-SXM4-40GB
  UUID: GPU-87654321-4321-4321-4321-cba987654321
  温度: 30°C  风扇: 30%  功耗: 42.0W
  显存: 0MB / 40960MB (0.0%)
  利用率: GPU 0%  显存带宽 0%

3.4 查询 GPU 进程

NVML 能获取哪些进程在使用 GPU

import pynvml

pynvml.nvmlInit()
device_count = pynvml.nvmlDeviceGetCount()

for i in range(device_count):
    handle = pynvml.nvmlDeviceGetHandleByIndex(i)

    # 获取计算进程(CUDA kernels)
    compute_procs = pynvml.nvmlDeviceGetComputeRunningProcesses(handle)

    # 获取图形进程(OpenGL/Vulkan)
    graphics_procs = pynvml.nvmlDeviceGetGraphicsRunningProcesses(handle)

    print(f"\nGPU {i} 的进程:")
    for proc in compute_procs:
        print(f"  PID {proc.pid}: 使用显存 {proc.usedGpuMemory / 1024 / 1024:.0f}MB")

        # 可以用psutil获取进程名
        import psutil
        try:
            p = psutil.Process(proc.pid)
            print(f"    进程名: {p.name()}  命令: {' '.join(p.cmdline()[:3])}")
        except:
            pass

pynvml.nvmlShutdown()

输出示例

GPU 0 的进程:
  PID 12345: 使用显存 2048MB
    进程名: python  命令: python train.py --model resnet50
  PID 23456: 使用显存 1024MB
    进程名: python  命令: python inference.py --batch-size 32

关键点:这就是 nvidia-smi 下半部分"Processes"表格的数据来源。


4. CUDA Runtime 层:设备抽象与 Context 管理

核心问题:CUDA 程序如何发现和使用 GPU?

4.1 CUDA Runtime 的初始化流程

当你在 Python 中首次调用 CUDA:

import torch
print(torch.cuda.device_count())  # 这一行触发了什么?

背后的完整流程

1. 加载CUDA Runtime库
   dlopen("libcudart.so")

2. CUDA Runtime初始化
   cudaGetDeviceCount()
   ├─ 打开 /dev/nvidiactl
   ├─ ioctl查询GPU数量
   ├─ 逐个打开 /dev/nvidia0, /dev/nvidia1, ...
   └─ 查询每块GPU的属性(计算能力、显存大小)

3. 返回可见GPU数量
   (可能被CUDA_VISIBLE_DEVICES过滤,见第5节)

4.2 设备查询 API

基础查询

#include <cuda_runtime.h>

int main() {
    // 查询GPU数量
    int deviceCount;
    cudaGetDeviceCount(&deviceCount);
    printf("检测到 %d 块GPU\n", deviceCount);

    for (int i = 0; i < deviceCount; i++) {
        cudaDeviceProp prop;
        cudaGetDeviceProperties(&prop, i);

        printf("\nGPU %d: %s\n", i, prop.name);
        printf("  计算能力: %d.%d\n", prop.major, prop.minor);
        printf("  显存: %.0f MB\n", prop.totalGlobalMem / 1024.0 / 1024.0);
        printf("  SM数量: %d\n", prop.multiProcessorCount);
        printf("  时钟频率: %.2f GHz\n", prop.clockRate / 1e6);
        printf("  显存带宽: %.0f GB/s\n",
               2.0 * prop.memoryClockRate * (prop.memoryBusWidth / 8) / 1e6);
    }
}

Python 版本(PyTorch):

import torch

print(f"可见GPU数量: {torch.cuda.device_count()}")

for i in range(torch.cuda.device_count()):
    props = torch.cuda.get_device_properties(i)
    print(f"\nGPU {i}: {props.name}")
    print(f"  计算能力: {props.major}.{props.minor}")
    print(f"  显存: {props.total_memory / 1024**3:.1f} GB")
    print(f"  多处理器数量: {props.multi_processor_count}")

4.3 设备选择:cudaSetDevice()

单 GPU 程序

import torch

torch.cuda.set_device(0)  # 选择GPU 0
x = torch.randn(1000, 1000, device='cuda')  # 分配在GPU 0

多 GPU 程序

# 在GPU 0上创建张量
torch.cuda.set_device(0)
x = torch.randn(1000, 1000, device='cuda')

# 在GPU 1上创建张量
torch.cuda.set_device(1)
y = torch.randn(1000, 1000, device='cuda')

# 跨GPU操作需要显式拷贝
y_on_0 = y.cuda(0)  # 拷贝到GPU 0
z = x + y_on_0

cudaSetDevice()的底层行为

1. 记录"当前设备"到线程局部变量
   thread_local int current_device = device_id;

2. 后续CUDA操作默认使用这个设备
   cudaMalloc() → 在current_device上分配
   kernel<<<>>>() → 在current_device上执行

3. 每个线程有独立的current_device
   线程A: cudaSetDevice(0)
   线程B: cudaSetDevice(1)
   两者互不影响

4.4 CUDA Context:GPU 资源的容器

什么是 Context? Context 是 GPU 上的一个执行环境,包含:

  • 已分配的显存
  • 已编译的 kernel 代码
  • 流(Stream)和事件(Event)
  • 纹理和表面绑定

类比:Context 就像操作系统的进程地址空间

  • 进程 1 和进程 2 的内存相互隔离
  • GPU Context A 和 Context B 的显存相互隔离

Context 的创建时机

import torch

# 第一次CUDA操作时,自动创建Context
x = torch.randn(100, device='cuda')  # ← 这里创建了Context

# 同一个Python进程后续操作复用这个Context
y = torch.randn(200, device='cuda')  # ← 复用已有Context

验证:用 nvidia-smi 查看进程

$ nvidia-smi
+-----------------------------------------------------------------------------+
| Processes:                                                                  |
|  GPU   GI   CI        PID   Type   Process name                  GPU Memory |
|        ID   ID                                                   Usage      |
|=============================================================================|
|    0   N/A  N/A     12345      C   python                           1024MiB |
+-----------------------------------------------------------------------------+
  • Type = C:表示 Compute Context(CUDA 计算)
  • GPU Memory Usage:该 Context 占用的显存

多进程的 Context 隔离

# 进程A
import torch
x = torch.randn(1000, 1000, device='cuda')  # 占用4MB显存

# 进程B(独立的Python进程)
import torch
y = torch.randn(1000, 1000, device='cuda')  # 也占用4MB显存

两个进程各自有独立的 Context,显存互不可见:

  • 进程 A 的显存地址 0x7f0000 和进程 B 的 0x7f0000 指向不同物理位置
  • 一个进程崩溃不会影响另一个进程的 GPU 内存

5. CUDA_VISIBLE_DEVICES:环境变量的魔法

核心问题:如何让程序"看不见"某些 GPU?

答案是环境变量 CUDA_VISIBLE_DEVICES——CUDA Runtime 在初始化时读取这个变量,过滤可见的 GPU。

5.1 基本用法

示例 1:只用 GPU 2

$ CUDA_VISIBLE_DEVICES=2 python train.py

# train.py内部
import torch
print(torch.cuda.device_count())  # 输出: 1
print(torch.cuda.get_device_name(0))  # 输出: NVIDIA A100(实际是物理GPU 2)

示例 2:使用 GPU 1 和 3

$ CUDA_VISIBLE_DEVICES=1,3 python train.py

# 程序看到2块GPU
# torch.cuda.device(0) → 物理GPU 1
# torch.cuda.device(1) → 物理GPU 3

示例 3:禁用所有 GPU

$ CUDA_VISIBLE_DEVICES= python train.py  # 空字符串

import torch
print(torch.cuda.is_available())  # False

5.2 虚拟编号映射

关键机制:CUDA Runtime 会重新编号可见的 GPU。

实验:8 卡机器,只用 GPU 2,5,7

$ CUDA_VISIBLE_DEVICES=2,5,7 python -c "
import torch
for i in range(torch.cuda.device_count()):
    print(f'虚拟GPU {i}: {torch.cuda.get_device_properties(i).name}')
"

输出

虚拟GPU 0: NVIDIA A100-SXM4-40GB  # 物理GPU 2
虚拟GPU 1: NVIDIA A100-SXM4-40GB  # 物理GPU 5
虚拟GPU 2: NVIDIA A100-SXM4-40GB  # 物理GPU 7

映射表

虚拟 ID(程序中)物理 ID(硬件上)
02
15
27

为什么这样设计? 让程序代码保持简洁:总是从 GPU 0 开始编号,而不需要关心物理拓扑。

# 代码永远是这样写
device = torch.device('cuda:0')  # 使用"第一块可见GPU"
# 而不是 torch.device('cuda:2')  # 硬编码物理GPU

5.3 工作原理

CUDA Runtime 的初始化逻辑(简化):

void cudaInit() {
    // 1. 读取环境变量
    const char *visible_devices = getenv("CUDA_VISIBLE_DEVICES");

    // 2. 查询物理GPU数量
    int physical_count = queryPhysicalDeviceCount();  // 例如:8

    // 3. 解析可见设备列表
    int visible_list[MAX_DEVICES];
    int visible_count;
    if (visible_devices) {
        parseDeviceList(visible_devices, visible_list, &visible_count);
        // 例如:"2,5,7" → [2, 5, 7]
    } else {
        // 未设置:所有GPU可见
        for (int i = 0; i < physical_count; i++)
            visible_list[i] = i;
        visible_count = physical_count;
    }

    // 4. 建立映射表
    for (int i = 0; i < visible_count; i++) {
        device_map[i] = visible_list[i];
        // 虚拟ID i → 物理ID visible_list[i]
    }

    // 5. 打开可见的设备文件
    for (int i = 0; i < visible_count; i++) {
        int physical_id = device_map[i];
        char path[64];
        sprintf(path, "/dev/nvidia%d", physical_id);
        device_fd[i] = open(path, O_RDWR);
    }
}

后续操作

cudaError_t cudaSetDevice(int virtual_id) {
    int physical_id = device_map[virtual_id];  // 查映射表
    current_device = device_fd[virtual_id];    // 切换到对应fd
}

5.4 与 cgroups 的关系

两层隔离机制

机制生效层强制性绕过可能性
CUDA_VISIBLE_DEVICESCUDA Runtime软限制可以(不用 CUDA)
cgroups devices内核硬限制不可以

示例:cgroups 限制 + 环境变量

# 1. 用cgroups限制只能访问GPU 0,1(硬限制)
echo "c 195:0 rwm" > /sys/fs/cgroup/devices/my_cgroup/devices.allow
echo "c 195:1 rwm" > ...
echo $$ > .../cgroup.procs

# 2. 用环境变量进一步限制只用GPU 1(软限制)
CUDA_VISIBLE_DEVICES=1 python train.py

# 结果:程序看到1块GPU(物理GPU 1)

如果尝试绕过

# 设置环境变量看GPU 2(但cgroups不允许)
CUDA_VISIBLE_DEVICES=2 python -c "import torch; print(torch.cuda.device_count())"
# 输出: 0(CUDA Runtime初始化失败,因为无法打开/dev/nvidia2)

关键洞察

  • CUDA_VISIBLE_DEVICES 是应用层的"君子协定"
  • cgroups 是内核层的"铁律"
  • 生产环境建议两者结合:cgroups 保证安全隔离,环境变量提供灵活性

5.5 PyTorch 和 TensorFlow 如何读取

PyTorch

# PyTorch调用CUDA Runtime,自动遵守CUDA_VISIBLE_DEVICES
import torch
print(torch.cuda.device_count())  # 已经过滤

# 也可以在Python内修改(但必须在首次CUDA调用之前)
import os
os.environ['CUDA_VISIBLE_DEVICES'] = '0,1'
import torch  # 现在才生效

TensorFlow 2.x

import os
os.environ['CUDA_VISIBLE_DEVICES'] = '2,3'

import tensorflow as tf
print(tf.config.list_physical_devices('GPU'))
# [PhysicalDevice(name='/physical_device:GPU:0', device_type='GPU'),  # 物理GPU 2
#  PhysicalDevice(name='/physical_device:GPU:1', device_type='GPU')]  # 物理GPU 3

TensorFlow 的额外控制

# TensorFlow还可以在代码内设置可见设备
gpus = tf.config.list_physical_devices('GPU')
tf.config.set_visible_devices(gpus[1:], 'GPU')  # 只用第2块往后

6. 多层抽象的交互:一次 CUDA 调用的完整旅程

场景:执行 torch.cuda.is_available() 时发生了什么?

6.1 调用栈展开

应用层:
torch.cuda.is_available()
    ↓
PyTorch C++扩展:
THCState_getNumDevices()
    ↓
CUDA Runtime API:
cudaGetDeviceCount(&count)
    ↓ 读取环境变量
CUDA_VISIBLE_DEVICES = "2,5" → 只尝试GPU 2和5
    ↓
CUDA Driver API:
cuInit() → 初始化驱动
cuDeviceGetCount() → 查询设备数
    ↓
Linux系统调用:
open("/dev/nvidiactl", O_RDWR)
ioctl(fd, NVIDIA_IOC_QUERY_DEVICE_COUNT, &count)
    ↓ 检查cgroups规则
内核devices cgroup:
检查进程是否有权限访问 195:255(nvidiactl)
检查进程是否有权限访问 195:2, 195:5
    ↓
NVIDIA内核驱动(nvidia.ko):
查询硬件寄存器,返回实际GPU数量
    ↓
硬件层:
GPU通过PCIe返回设备信息

6.2 时序图

应用  |  Runtime  |  Driver  |  Kernel  |  Hardware
------|-----------|----------|----------|----------
  1.调用
is_available()
      |   2.读环境变量
      |   CUDA_VISIBLE_DEVICES
      |           3.打开设备
      |           open(/dev/nvidiactl)
      |                   4.检查cgroups
      |                   devices.allow?
      |                   ✓ 允许
      |                           5.查询硬件
      |                           ioctl(...)
      |                                   6.读PCIe
      |                                   [GPU信息]
      |                           7.返回结果
      |                   8.返回
      |           9.返回
      |   10.应用过滤
      |   count=2 (物理2,5)
  11.返回
True

6.3 失败案例分析

案例 1:cgroups 拒绝

# cgroups只允许GPU 0
echo "c 195:0 rwm" > .../devices.allow

# 环境变量要求GPU 1
CUDA_VISIBLE_DEVICES=1 python -c "import torch; print(torch.cuda.is_available())"

失败点:步骤 3,open("/dev/nvidia1") 返回 EACCES 结果torch.cuda.is_available() 返回 False

案例 2:驱动未加载

$ lsmod | grep nvidia
# 无输出

$ python -c "import torch; torch.cuda.is_available()"

失败点:步骤 3,/dev/nvidia* 文件不存在 结果torch.cuda.is_available() 返回 False


7. 实战:多进程 GPU 分配策略

场景:4 个训练任务,8 块 GPU,如何分配?

7.1 策略 1:每进程独占 GPU

#!/bin/bash
# run_distributed.sh

# 进程1:GPU 0,1
CUDA_VISIBLE_DEVICES=0,1 python train.py --task task1 &

# 进程2:GPU 2,3
CUDA_VISIBLE_DEVICES=2,3 python train.py --task task2 &

# 进程3:GPU 4,5
CUDA_VISIBLE_DEVICES=4,5 python train.py --task task3 &

# 进程4:GPU 6,7
CUDA_VISIBLE_DEVICES=6,7 python train.py --task task4 &

wait  # 等待所有进程完成

优点:隔离性好,互不干扰 缺点:需要手动分配,不灵活

7.2 策略 2:动态分配(使用 GPUtil)

# dynamic_allocate.py
import GPUtil
import subprocess
import sys

# 获取空闲GPU(利用率<10%,显存使用<10%)
available_gpus = GPUtil.getAvailable(
    order='memory',           # 按显存排序
    limit=2,                  # 需要2块GPU
    maxLoad=0.1,              # 最大利用率10%
    maxMemory=0.1             # 最大显存占用10%
)

if len(available_gpus) < 2:
    print("没有足够的空闲GPU")
    sys.exit(1)

# 设置环境变量并启动训练
gpu_ids = ','.join(map(str, available_gpus))
subprocess.run([
    'python', 'train.py',
    '--task', sys.argv[1]
], env={**os.environ, 'CUDA_VISIBLE_DEVICES': gpu_ids})

使用

python dynamic_allocate.py task1 &
python dynamic_allocate.py task2 &
python dynamic_allocate.py task3 &
python dynamic_allocate.py task4 &

7.3 策略 3:Slurm 自动调度

# 提交作业(Slurm自动分配GPU)
sbatch --gres=gpu:2 --job-name=task1 train_task1.sh
sbatch --gres=gpu:2 --job-name=task2 train_task2.sh
sbatch --gres=gpu:2 --job-name=task3 train_task3.sh
sbatch --gres=gpu:2 --job-name=task4 train_task4.sh

# Slurm自动:
# 1. 分配空闲GPU
# 2. 设置CUDA_VISIBLE_DEVICES
# 3. 配置cgroups隔离

7.4 进程监控脚本

# monitor_gpu_processes.py
import pynvml
import psutil
from collections import defaultdict

pynvml.nvmlInit()
device_count = pynvml.nvmlDeviceGetCount()

# 统计每个GPU的进程
gpu_processes = defaultdict(list)

for i in range(device_count):
    handle = pynvml.nvmlDeviceGetHandleByIndex(i)
    procs = pynvml.nvmlDeviceGetComputeRunningProcesses(handle)

    for proc in procs:
        try:
            p = psutil.Process(proc.pid)
            gpu_processes[i].append({
                'pid': proc.pid,
                'name': p.name(),
                'cmdline': ' '.join(p.cmdline()[:5]),
                'gpu_mem_mb': proc.usedGpuMemory / 1024 / 1024
            })
        except:
            pass

# 打印报告
for gpu_id, procs in gpu_processes.items():
    print(f"\n{'='*60}")
    print(f"GPU {gpu_id}: {len(procs)} 个进程")
    print(f"{'='*60}")
    for p in procs:
        print(f"  PID {p['pid']} | {p['name']:<15} | {p['gpu_mem_mb']:>8.0f}MB")
        print(f"    {p['cmdline']}")

pynvml.nvmlShutdown()

输出示例

============================================================
GPU 0: 2 个进程
============================================================
  PID 12345 | python          |   2048MB
    python train.py --model resnet50 --task task1
  PID 12346 | python          |   1024MB
    python inference.py --batch-size 64

============================================================
GPU 1: 1 个进程
============================================================
  PID 23456 | python          |   4096MB
    python train.py --model gpt2 --task task2

8. 调试技巧与常见问题

8.1 诊断清单

症状可能原因检查命令
torch.cuda.is_available() = False驱动未安装/未加载nvidia-smi
CUDA_VISIBLE_DEVICES 设置为空echo $CUDA_VISIBLE_DEVICES
cgroups 拒绝访问cat /proc/self/cgroup
RuntimeError: CUDA out of memory显存不足nvidia-smi 查看占用
有僵尸进程占用fuser -v /dev/nvidia*
多进程看到相同 GPUCUDA_VISIBLE_DEVICES 继承了父进程检查启动脚本
nvidia-smi 挂起驱动崩溃dmesg | grep nvidia 查看内核日志
GPU 硬件故障nvidia-smi -q 详细诊断

8.2 调试技巧

技巧 1:追踪设备文件访问

# 监控程序打开了哪些GPU设备
strace -e openat,ioctl python train.py 2>&1 | grep nvidia

# 输出示例:
# openat(AT_FDCWD, "/dev/nvidiactl", O_RDWR) = 3
# openat(AT_FDCWD, "/dev/nvidia0", O_RDWR) = 4
# ioctl(3, NVIDIA_IOC_QUERY_DEVICE_COUNT, ...) = 0

技巧 2:检查设备权限

# 测试能否打开设备文件
python -c "
import os
for i in range(8):
    try:
        fd = os.open(f'/dev/nvidia{i}', os.O_RDONLY)
        os.close(fd)
        print(f'✅ GPU {i}: 可访问')
    except PermissionError:
        print(f'❌ GPU {i}: 权限拒绝')
    except FileNotFoundError:
        break
"

技巧 3:对比 NVML 和 CUDA 的可见性

import pynvml
import torch

# NVML看到的GPU(接近硬件真实状态)
pynvml.nvmlInit()
nvml_count = pynvml.nvmlDeviceGetCount()
print(f"NVML可见GPU: {nvml_count}")

# CUDA看到的GPU(受CUDA_VISIBLE_DEVICES影响)
cuda_count = torch.cuda.device_count()
print(f"CUDA可见GPU: {cuda_count}")

# 如果两者不一致,说明环境变量生效了
pynvml.nvmlShutdown()

技巧 4:清理僵尸进程

# 查找占用GPU的进程
nvidia-smi --query-compute-apps=pid --format=csv,noheader

# 或使用fuser
fuser -v /dev/nvidia0

# 强制结束(谨慎!)
kill -9 <PID>

技巧 5:测试 P2P 通信

import torch

# 检查GPU间是否支持P2P
for i in range(torch.cuda.device_count()):
    for j in range(i+1, torch.cuda.device_count()):
        can_access = torch.cuda.can_device_access_peer(i, j)
        print(f"GPU {i} → GPU {j}: {'✅ 支持P2P' if can_access else '❌ 不支持'}")

9. 总结

9.1 核心要点

  1. 三层设备抽象

    • 硬件层:GPU 芯片
    • 驱动层:/dev/nvidia* 设备文件
    • 运行时层:NVML、CUDA Runtime
  2. 设备文件分工

    • /dev/nvidia[0-N]:各个 GPU 的计算接口
    • /dev/nvidiactl:驱动控制中心,查询信息
    • /dev/nvidia-uvm:统一虚拟内存管理
  3. 管理工具层次

    • NVML:底层管理 API,nvidia-smi 的基础
    • CUDA Runtime:简化的计算 API,自动管理 Context
    • PyTorch/TensorFlow:更高层封装
  4. CUDA_VISIBLE_DEVICES 的作用

    • 过滤可见 GPU(软限制)
    • 重新编号(虚拟 ID → 物理 ID)
    • 必须在 CUDA 初始化前设置
  5. 与 cgroups 的配合

    • cgroups:内核级硬限制,无法绕过
    • CUDA_VISIBLE_DEVICES:应用层软限制,灵活方便
    • 生产环境建议两者结合

9.2 适用场景对比

场景推荐工具原因
单机多任务CUDA_VISIBLE_DEVICES简单快速
容器环境Docker --gpus + cgroups自动化 + 强隔离
HPC 集群Slurm + cgroups资源调度 + 隔离
共享服务器cgroups + CUDA_VISIBLE_DEVICES安全 + 灵活
开发调试手动设置环境变量快速切换

9.3 最佳实践

开发环境

# 指定GPU,避免占用别人的卡
export CUDA_VISIBLE_DEVICES=0,1
python train.py

生产环境

# 使用容器 + cgroups自动隔离
docker run --gpus '"device=0,1"' my-training-image

# 或使用Slurm调度
sbatch --gres=gpu:2 train.sh

调试技巧

# 打印完整的GPU可见性信息
import os
import torch
print(f"环境变量: {os.getenv('CUDA_VISIBLE_DEVICES', '未设置')}")
print(f"CUDA可见: {torch.cuda.device_count()} 块GPU")
for i in range(torch.cuda.device_count()):
    print(f"  GPU {i}: {torch.cuda.get_device_name(i)}")

9.4 进阶学习

深入理解

  • CUDA Driver API 文档:比 Runtime 更底层的接口
  • NVML 文档:完整的管理功能
  • NVIDIA GPU 架构白皮书:硬件工作原理

实践项目

  • 实现简化版的 nvidia-smi
  • 编写 GPU 资源监控守护进程
  • 开发自动化 GPU 分配工具

相关技术

  • MIG(Multi-Instance GPU):A100 支持的 GPU 物理分区
  • MPS(Multi-Process Service):多进程共享 GPU Context
  • GPUDirect:GPU 间、GPU-网卡间的直接通信

附录:快速参考

常用命令

# 查看GPU设备文件
ls -l /dev/nvidia*

# 检查驱动是否加载
lsmod | grep nvidia

# 查看GPU状态
nvidia-smi

# 查看详细GPU信息
nvidia-smi -q

# 查看GPU拓扑(PCIe连接)
nvidia-smi topo -m

# 监控GPU占用(每2秒刷新)
watch -n 2 nvidia-smi

# 查找占用GPU的进程
nvidia-smi pmon -c 1

# 设置功耗限制(需要root)
nvidia-smi -i 0 -pl 200  # 限制GPU 0为200W

Python 快速查询

# 基础查询
import torch
print(torch.cuda.device_count())        # GPU数量
print(torch.cuda.get_device_name(0))    # GPU名称
print(torch.cuda.is_available())        # CUDA是否可用

# NVML详细查询
import pynvml
pynvml.nvmlInit()
handle = pynvml.nvmlDeviceGetHandleByIndex(0)
mem = pynvml.nvmlDeviceGetMemoryInfo(handle)
print(f"显存: {mem.used/1024**3:.1f}/{mem.total/1024**3:.1f} GB")
pynvml.nvmlShutdown()

# 环境变量
import os
print(os.getenv('CUDA_VISIBLE_DEVICES', '未设置'))

环境变量速查

环境变量作用示例
CUDA_VISIBLE_DEVICES限制可见 GPU0,2,3
CUDA_DEVICE_ORDERGPU 编号顺序PCI_BUS_ID(按 PCIe 顺序)
CUDA_LAUNCH_BLOCKING同步 kernel 启动(调试用)1
CUDA_CACHE_PATHPTX 编译缓存路径/tmp/cuda_cache