☰
模型总跑第0张卡?CUDA_VISIBLE_DEVICES不生效的排障全攻略
2026/10/3 8:02:40 网站建设 项目流程

1. 问题现象:指定了显卡,模型却还是跑到了第0张卡上

先说结论:这个问题我在生产环境里踩过不止一次,而且每次的原因都不太一样。最典型的表现是,你在代码里明明写了CUDA_VISIBLE_DEVICES=2,或者在脚本里设置了device_ids=[2],但程序跑起来之后用nvidia-smi一看,模型权重和中间激活全挤在了第0张显卡上。

这个现象在 transformer 类模型(包括 Bert、GPT、ViT 这些)上出现得尤其频繁,因为这类模型库往往封装得很深,硬件初始化、分布式逻辑、模型并行这些环节都帮你包掉了。很多新手会把锅甩给“transformer 库有 bug”,但我负责任地说,绝大多数情况下是环境变量、库封装和代码优先级这三层之间打架,跟模型本身关系不大。

这篇文章会完整拆解我排查这个问题的全过程,包括底层原因、快速验证手段、可落地的修复方案,以及一些你以后大概率还会遇到的相关坑。适合自己搭过 transformer 训练或推理、被多卡环境折磨过的同学参考。

注意:以下所有操作都基于 Linux 环境 + PyTorch + HuggingFace transformers 库,NVIDIA 驱动和 CUDA 已正常安装。如果你的环境是 Windows 或 AMD 显卡,排查思路类似,但部分命令和路径会不一样。

2. 为什么“指定了”不等于“生效了”

2.1 系统层面:CUDA_VISIBLE_DEVICES 是第一步,但不是全部

很多人在代码里做文章,比如torch.device('cuda:0'),然后以为这就是“指定了显卡”。实际上,cuda:0这个编号是逻辑编号,不是物理编号。它指向的是 CUDA 运行时可见的显卡列表里的第一块,而这个可见列表是由环境变量CUDA_VISIBLE_DEVICES决定的。

举个例子,如果机器上有4块卡,物理编号是 0、1、2、3。你只设置了CUDA_VISIBLE_DEVICES=2,那么在这个进程里,物理第2块卡会被重新映射为逻辑编号0。此时你写cuda:0,实际用的就是物理第2块卡。但如果你的代码里先执行了某些操作,把 CUDA 上下文提前初始化了,或者你用某个上层框架启动程序时,它自动设置了别的CUDA_VISIBLE_DEVICES,那么你后续再import torch的时候看到的显卡编号可能就不是你以为的那个了。

我遇到过最典型的场景:用deepspeed或accelerate启动训练脚本,它们内部会根据--num_gpus或者配置文件重新设置可见显卡列表。如果你之前在脚本里手动export CUDA_VISIBLE_DEVICES=2,进到框架里却被它覆盖成别的值,就会发生“我明明指定了,为什么没生效”的错觉。

2.2 框架层面:transformer 库加载模型的默认逻辑

HuggingFace transformers 库在加载模型时,默认行为是对齐 PyTorch 的device_map。如果你没有显式传入device_map,模型权重会被加载到默认设备上——通常是 CPU,然后再移动到某个 CUDA 设备。这个“某个”,在很多实现里写的是cuda:0。

注意,这个cuda:0是逻辑编号。如果你已经正确设置了CUDA_VISIBLE_DEVICES=2,那么它其实去的是物理第2块卡,这是没问题的。但问题恰恰在于,你设置了环境变量,但设置的位置不对,导致整个进程根本就没接收到这个变量。

另外还有一个点,HuggingFace 从某个版本开始,如果检测到环境里有多张显卡,且你没有明确指定device_map,它可能会默认启用auto模式,自动把某些层分配到不同的卡上。如果你期望的是“全部放在物理第2块卡”,但库给你自动分配到了别的地方,表现上就是你指定了但没生效。

2.3 代码层面:device 指定顺序和上下文初始化

更深层的问题在于 CUDA 上下文的初始化时机。PyTorch 中,一旦执行了任何 CUDA 操作(比如torch.zeros(1).cuda(),或者加载一个已经在 GPU 上的 tensor),当前进程就会绑定当前可见的显卡列表。如果在这之前你没有正确设置环境变量,后续再设置也来不及了。

我见过有人这样写:

import torch import os os.environ['CUDA_VISIBLE_DEVICES'] = '2' model = model.cuda()

这段代码从表面看没问题,但如果在你import torch之前,系统或某个第三方库已经执行过 CUDA 初始化,那么这个环境变量就晚了。严格来说,CUDA_VISIBLE_DEVICES必须在进程启动的最早期设置,最好是在import torch之前,甚至是在import any_cuda_related_library之前。

更隐蔽的一个坑:os.environ['CUDA_VISIBLE_DEVICES'] = '2'只是在当前 Python 进程里设置了环境变量,但如果你是用subprocess或multiprocessing启动的子进程,这个设置不会自动继承,除非你显式传给子进程。

3. 快速验证:三行命令定位问题

在动手改代码之前,先用几个命令确认当前的可见显卡和实际占用情况。

3.1 查看物理显卡和当前占用

nvidia-smi

这个命令展示的是物理显卡的实时状态,包括显存占用、温度、进程等。你可以看到物理编号和当前占用。如果某个进程占的是物理0号卡,而你期望它在2号卡,说明指定失败了。

3.2 确认进程看到的可见显卡

CUDA_VISIBLE_DEVICES=2 python -c "import torch; print(torch.cuda.device_count()); print(torch.cuda.current_device())"

如果输出是1和0,说明环境变量生效了,进程只看到一块卡(映射为逻辑0)。如果输出是4或更大的数字,说明环境变量根本没传到这个进程里。

3.3 确认模型参数所在设备

import torch from transformers import AutoModel model = AutoModel.from_pretrained('bert-base-uncased') print(model.device)

如果输出是cpu,说明模型还在 CPU 上,后面调用.cuda()或者.to()才会移动。如果你没移动就做前向推理,PyTorch 会报错“tensor must be on the same device”。如果你移动了,可以通过下面的方式确认:

print(next(model.parameters()).device)

这个输出能精确告诉你模型权重到底在哪个逻辑设备上。

4. 三层修复:从环境变量到代码写法

4.1 第一层:在进程启动前正确设置环境变量

最稳妥的方式,是在运行命令之前直接设置环境变量,保证 Python 进程一开始就看到它:

export CUDA_VISIBLE_DEVICES=2 python train.py

如果你是在脚本里写,那么必须放在所有import torch之前,而且最好的做法是放在文件最开头:

import os os.environ['CUDA_VISIBLE_DEVICES'] = '2' import torch

注意,这里不能调换顺序。一旦import torch完成,CUDA 相关的运行时可能已经被初始化(尽管很多情况下是懒加载,但最安全的做法是环境变量先行)。

如果你用 PyTorch 的torch.cuda.set_device(2),那指定的是逻辑编号,不是物理编号。物理编号只能通过CUDA_VISIBLE_DEVICES来控制。这也是很多人搞混的点。

4.2 第二层:在 transformers 库加载时显式指定

HuggingFace transformers 从 4.30 左右开始,模型加载支持device_map参数。你可以直接指定:

from transformers import AutoModel model = AutoModel.from_pretrained( 'bert-base-uncased', device_map={'': 0} )

device_map={'': 0}的意思是,所有没有明确映射到某层的模块(''代表根模块)都放到逻辑设备0上。如果你的环境变量已经正确设置,逻辑0就是物理目标卡。

如果机器显存足够,不想让模型分散到多卡,还可以这样:

from transformers import AutoModel model = AutoModel.from_pretrained( 'bert-base-uncased', device_map='cuda:0' )

按我实际测试,device_map='cuda:0'在大部分情况下等效于{'': 0},但前者更简洁,适合 rapid prototype。如果你需要精确控制哪些层放到哪张卡,可以用字典形式。

另外,还有一个容易忽视的地方:from_pretrained加载时,如果模型文件很大且本地没有缓存,会先下载到磁盘,再加载到内存,最后移动到设备。这个过程如果耗时很长,你可能误以为“卡住了”,其实是正常的。

4.3 第三层:手动移动模型到目标设备

如果环境变量和device_map都没问题,但你依然发现某些子模块或 tensors 在其他设备上,那是模型内部有些 buffer 没有被.to()方法一起带过去。比较少见,但确实存在。

这种情况下,可以在 model 加载后,手动把所有参数和 buffer 移动到目标设备:

device = torch.device('cuda:0') model = model.to(device) # 强制所有 buffer 也过去 for buffer in model.buffers(): buffer.data = buffer.data.to(device)

这个方法比较粗暴,但在某些第三方自定义模型里能救命。

5. 常见问题与排查技巧实录

5.1 设了 CUDA_VISIBLE_DEVICES 但 nvidia-smi 看不到进程

这个问题很怪,但也常见。如果你设在环境变量里,但进程是通过nohup或者某些任务调度系统(比如 Slurm)启动的,可能环境变量没被传递。解决方式是,在启动命令里显式使用CUDA_VISIBLE_DEVICES前缀:

CUDA_VISIBLE_DEVICES=2 nohup python train.py > train.log 2>&1 &

或者在你的 Python 脚本里,把 device 信息打印出来,确认实际看到的编号:

print(torch.cuda.device_count(), torch.cuda.current_device())

5.2 accelerate / deepspeed 环境下指定无效

如果你用accelerate launch或deepspeed启动,配置文件里的num_processes、gpu_ids会覆盖环境变量。最省心的办法是直接改配置:

# accelerate config num_processes: 1 gpu_ids: [2]

deepspeed 的话,在启动命令里加--include=localhost:2,明确指定物理卡号。

5.3 用 Docker 运行时容器内编号错乱

Docker 容器里看到的是NVIDIA_VISIBLE_DEVICES环境变量,不是CUDA_VISIBLE_DEVICES。如果你在宿主机设置了CUDA_VISIBLE_DEVICES,但没传进容器,容器内以为自己看到了所有卡。

正确做法:

docker run --gpus '"device=2"' -e NVIDIA_VISIBLE_DEVICES=2 ...

这样容器里只看到物理第2块卡,逻辑编号也是0。

5.4 模型加载到 GPU 但推理时依然报 CUDA error

这种情况往往是显存不足。transformer 模型加载时,会把所有权重从 CPU 拷贝到 GPU,如果显存不够,会在.cuda()那一步直接报CUDA out of memory。我之前在 8G 显存的卡上跑 Bert-large,参数 3.4 亿,大概占 1.3G,但加上优化器和中间变量轻松上 4G。如果你的模型加载后 nvidia-smi 看到显存占用接近上限,就别折腾指定显卡了,先解决显存问题。

提示:用torch.cuda.memory_summary()可以打印当前显存分配情况,排查哪些变量占了大头,非常实用。

6. 踩坑总结:这些细节最容易忽略

6.1 环境变量作用域问题

os.environ['CUDA_VISIBLE_DEVICES'] = '2'只影响当前 Python 进程。如果你在 Jupyter Notebook 里设置,它只对当前 kernel 生效;如果你在脚本 A 里调用脚本 B,B 不会自动继承。这一点在多人共用服务器、分模块开发时非常致命。

6.2 多进程训练时的传播性

如果你用torch.multiprocessing或DataLoader的多进程模式,子进程会继承父进程的环境变量,但前提是父进程在fork之前已经设置好了。如果用spawn方式,那环境变量不一定继承,建议在主函数入口重新设置或通过参数传递。

6.3 配置文件和硬编码冲突

很多训练框架(比如 transformers 自带的TrainingArguments)有一个no_cuda参数,默认 False。如果你手动设置了CUDA_VISIBLE_DEVICES,但框架里还有个配置项是use_cpu=True,模型会强行跑到 CPU 上。排查时先确认框架配置里没有类似的开关。

6.4 有时不指定,反而是最佳选择

在单进程 + 单卡的情况下,你完全可以不指定显卡,让 CUDA 默认走逻辑0。只要你确定机器上只有一块物理卡,或者只有一块卡被你可见,那写不写device_map没区别。我以前用 4 卡机器调试某个小模型,图省事直接不指定,结果模型的每一层被自动分布到4张卡上,显存占用倒是均匀了,但通信开销非常大,推理速度反而更慢。这种问题不算“加载错卡”,但同样让人摸不着头脑。

7. 终极方案:把设备选择抽成工具函数

经过多次踩坑,我现在习惯把设备选择逻辑封装成一个小工具,所有项目都复用。核心思路是:环境变量优先,框架参数覆盖,最后检查设备数。

import os import torch def setup_device(target_gpu_id=None): """选择目标 GPU 并用环境变量锁定可见显卡。 Args: target_gpu_id: 物理显卡编号。如果为 None,则使用逻辑0. """ if target_gpu_id is not None: os.environ['CUDA_VISIBLE_DEVICES'] = str(target_gpu_id) # 二次确认 print(f'Visible GPUs: {torch.cuda.device_count()}') print(f'Current CUDA device: {torch.cuda.current_device()}') print(f'Device name: {torch.cuda.get_device_name()}') if torch.cuda.device_count() > 1: # 显式指定,避免深度框架自动分布 return torch.device('cuda:0') return torch.device('cuda:0' if torch.cuda.is_available() else 'cpu')

这个函数确保在任何import torch之前完成环境变量设置,并且通过打印设备信息,让你一眼看出当前进程到底看到了几张卡。调用方式:

device = setup_device(target_gpu_id=2) model.to(device)

如果你用的是 HuggingFace transformers 的高层封装(如Trainer),直接把device对应的逻辑编号写进TrainingArguments的device_ids=[0]即可,因为经过环境变量过滤后,逻辑0就是物理目标卡。

8. 我的最终建议

说实话,显卡指定问题 90% 以上是环境变量没设对,或者被上层框架覆盖了。解决问题的关键不是背一堆 API,而是先建立“物理设备、可见设备、逻辑设备”三个概念,然后从最外层环境变量开始排查,逐层向内。

如果你排查一天还没搞定,不妨把模型换小一点,先确认流程能跑通,再逐步放大模型。我之前有段时间一直以为指定显卡的代码有问题,结果发现是一个离线的第三方库在import时执行了 CUDA 初始化,把环境变量读早了。这种问题单靠看代码很难定位,需要耐心加日志。

希望这篇文字能让你少踩几个坑。如果你也遇到过类似的奇怪现象,并找到了不一样的查法,欢迎日后在评论里聊。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询