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.ko | GPU 计算核心,每块 GPU 一个文件 |
| 控制设备 | 195 | /dev/nvidiactl | nvidia.ko | 驱动控制接口,查询 GPU 信息 |
| 内存管理 | 510 | /dev/nvidia-uvm | nvidia-uvm.ko | 统一虚拟内存(CUDA 6.0+) |
| 诊断工具 | 510 | /dev/nvidia-uvm-tools | nvidia-uvm.ko | UVM 性能计数器 |
| MIG 能力 | 511 | /dev/nvidia-caps/* | nvidia.ko | MIG 分区的能力令牌 |
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(硬件上) |
|---|---|
| 0 | 2 |
| 1 | 5 |
| 2 | 7 |
为什么这样设计? 让程序代码保持简洁:总是从 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_DEVICES | CUDA 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* | |
| 多进程看到相同 GPU | CUDA_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 核心要点
-
三层设备抽象
- 硬件层:GPU 芯片
- 驱动层:
/dev/nvidia*设备文件 - 运行时层:NVML、CUDA Runtime
-
设备文件分工
/dev/nvidia[0-N]:各个 GPU 的计算接口/dev/nvidiactl:驱动控制中心,查询信息/dev/nvidia-uvm:统一虚拟内存管理
-
管理工具层次
- NVML:底层管理 API,nvidia-smi 的基础
- CUDA Runtime:简化的计算 API,自动管理 Context
- PyTorch/TensorFlow:更高层封装
-
CUDA_VISIBLE_DEVICES 的作用
- 过滤可见 GPU(软限制)
- 重新编号(虚拟 ID → 物理 ID)
- 必须在 CUDA 初始化前设置
-
与 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 | 限制可见 GPU | 0,2,3 |
CUDA_DEVICE_ORDER | GPU 编号顺序 | PCI_BUS_ID(按 PCIe 顺序) |
CUDA_LAUNCH_BLOCKING | 同步 kernel 启动(调试用) | 1 |
CUDA_CACHE_PATH | PTX 编译缓存路径 | /tmp/cuda_cache |