这行报错我太熟悉了,凡是折腾过PyTorch多卡训练或者在一台机器上反复切换CUDA环境的人,几乎都被它折磨过。乍一看像是“你的程序里有个cuda2,但另一个东西在cuda0上”,很多人第一反应是去检查机器上有没有插第二张卡,结果发现明明只有一张卡,报错还照样出现,那种心态直接崩掉。
先给结论:这个报错跟“机器上有没有编号为cuda2的显卡”没有必然关系,它真正想表达的是——你的代码里有两个张量(或者模型参数)被放置在不同的CUDA设备上,其中一个在cuda0,而另一个期望在cuda2上,PyTorch不允许这种跨设备运算。这就是一个典型的设备分配不一致问题。搞明白了这一点,你就能少走至少半天弯路。
这篇文章我会从报错原理讲起,然后给你一套完整的排查流程,再给出修复方案和多卡训练下的正确打开方式,最后打包几个我在实际工作中踩过的关联坑,希望能帮你一次性把这问题摁死。
1. 报错解读:这行红字到底在说什么
1.1 一次让人抓狂的报错现场
先还原一下常见场景。你写了个训练脚本,在本地单卡上调得顺风顺水,推送到服务器上准备上多卡或者换一张大显存的卡,结果一跑就弹出来:
RuntimeError: Expected all tensors to be on the same device, but found at least two devices, cuda2 and cuda0!如果你运气差一点,看到的可能是类似“cuda2 but found one of them on device cuda 0”这种被截断的版本。此时你的第一反应通常是:我电脑上根本没有cuda2这块卡啊,程序是不是瞎了?
实际不是。PyTorch里的cuda:2只是一个逻辑编号,它不一定对应物理上的第三块显卡。只要你的程序里有一个张量或者模型参数被放置在编号为2的设备上,而另一个关键张量还在编号为0的设备上,两者一旦发生运算,PyTorch就会立刻拦下来,抛出这个异常。
1.2 设备编号只是逻辑称呼,不是物理顺序
这里需要好好解释一下CUDA设备编号的机制。在没有做任何设置的情况下,cuda:0代表系统当前可见的第一块GPU,cuda:1代表第二块。但只要你设置了环境变量,局面就会完全不一样:
export CUDA_VISIBLE_DEVICES=2,3这样一来,物理上的第3块和第4块显卡,在程序里反而会被认成cuda:0和cuda:1。也就是说,你需要关注的不是“机器上有几块卡”,而是“当前进程里能看到几块卡,以及它们的编号映射关系”。
很多莫名其妙的设备不匹配报错,根源都在这里。比如你在命令行里临时加了CUDA_VISIBLE_DEVICES=2,程序里某个地方写死了torch.device("cuda:1"),这时候进程里其实只有一块可见卡(cuda0),你强行去访问cuda1,轻则直接报“device not found”,重则就是这种云里雾里的设备不一致错误。所以看到报错里出现cuda2,先别急着去机房看有没有第三张卡,先想想你的代码里谁在引用这个编号。
1.3 真正的冲突:张量被放在了不同的“工位”上
用一个生活化的例子帮助理解。你把一批数据张量想象成一组待加工的零件,每个零件都会被放到某个工位上(cuda:0工位、cuda:1工位),而模型就是一台必须同时接触所有零件的机器。现在机器安排在cuda:2工位上,你喂给它的一个零件却在cuda:0工位上,机器自然没办法同时处理这两个不在同一地方的零件,于是报错。
更麻烦的是,这种不一致往往不是显式的。比如你从DataLoader里取一批数据,它们默认放在CPU上,你用.to(device)把它们挪到了cuda:0;但模型的某个子模块因为加载checkpoint时的逻辑问题,留在了cuda:2上。前向传播一执行,两个不匹配的张量一相遇,报错瞬间爆炸。
2. 排查路线:把躲在角落里的那个张量揪出来
面对这种报错,最重要的是不要慌,更不要直接去重装CUDA或者重刷驱动。99%的情况下,是你的代码逻辑在某个环节放错了地方。下面这套排查流程,是我在无数次踩坑后沉淀下来的,按这个顺序走,基本能定位到具体行。
2.1 先搞清楚进程里到底能看见几块卡
第一步先确认环境。你可以在脚本最开头加一段诊断代码:
import torch print("PyTorch版本:", torch.__version__) print("CUDA是否可用:", torch.cuda.is_available()) print("可见GPU数量:", torch.cuda.device_count()) for i in range(torch.cuda.device_count()): print(f" cuda:{i} -> {torch.cuda.get_device_name(i)}") print("当前设备:", torch.cuda.current_device())这一步能帮你判断是不是环境变量把设备编号搞乱了。如果devices_count()返回1,而你的代码里还在用cuda:2,那问题就非常明确了:有人在代码里写死了设备编号。如果返回数量大于1,说明确实有多卡环境,那就进入下一步,查张量到底分布在哪个设备上。
2.2 给每个关键张量“查户口”
报错信息虽然只提到“found at least two devices”,但没告诉你是哪一行代码触发的。不要慌,先做两件事:
第一,仔细看完整堆栈信息,找到第一次发生张量运算的地方,那行代码就是“案发现场”。第二,在“案发现场”附近插入临时打印,把参与运算的每个张量的device属性打出来:
print("input tensor device:", input_tensor.device) print("weight tensor device:", model.fc.weight.device) print("mask tensor device:", mask_tensor.device)记住一条铁律:PyTorch中两个张量进行任何运算,都必须位于同一块设备上。所以你要做的就是在报错触发的那一行之前,逐一确认所有参与运算的张量它device属性是否一致。我遇到过最隐蔽的情况是,一个经过torch.where生成的条件掩码张量,因为之前某个分支把它放到了CPU上,导致后续运算直接炸掉。
2.3 别忘了模型、优化器和Dataloader
除了显式的张量,还有三个容易藏雷的对象:模型参数、优化器状态、数据加载器返回的数据。
- 模型参数:如果只有一部分模块调用了
.to(device),另一部分漏掉了,就会造成模型内部参数散落在多个设备上。检查方法是遍历模型参数,看它们的device是否一致:
devices = set() for name, param in model.named_parameters(): devices.add(param.device) if len(devices) > 1: print(f"参数 {name} 在 {param.device},已有设备集合 {devices}")- 优化器状态:优化器通常会在第一次
step()前根据参数位置创建状态,如果你先to(device)再创建优化器,一般没问题;但如果你顺序反了,或者在to(device)之前已经执行过optimizer.step(),优化器里的动量缓存可能还在CPU或旧设备上。 - Dataloader:如果你在数据集里手动做了张量变换,并且变换后的张量调用了
.cuda(0)或者.to("cuda:0"),而主程序用的设备是cuda:2,那么每个batch都会被送到错误的地方。正确的做法应该是,让DataLoader保持返回CPU张量,在训练循环开头统一.to(device)。
3. 修复方案:从“各玩各的”到“统一工位”
排查出问题在哪之后,修复其实不复杂,但有几个设计层面的习惯一定要养成。我下面的方案按推荐程度从高到低排列。
3.1 最推荐的做法:全程序只维护一个device变量
很多人喜欢在脚本里到处写.cuda(),这算是最容易埋雷的写法。更好的是在配置阶段就定义好全局设备对象,后面所有张量和模型都用它来迁移:
import torch # 专一device变量,不要到处写死 cuda:0 或 cuda:2 device = torch.device("cuda" if torch.cuda.is_available() else "cpu") # 模型 model = MyModel().to(device) # 输入数据 for batch in dataloader: inputs, labels = batch[0].to(device), batch[1].to(device) outputs = model(inputs)这样写有两个好处:一是换机器、换卡时只需要改一行;二是彻底杜绝了“这个张量在cuda0、那个在cuda2”的错位。如果你需要让程序支持多卡,还可以结合环境变量来定义设备:
import os local_rank = int(os.environ.get("LOCAL_RANK", 0)) device = torch.device(f"cuda:{local_rank}" if torch.cuda.is_available() else "cpu")3.2 多卡训练下,DataParallel和DDP的设备分配差异
多卡训练是设备不一致报错的重灾区。PyTorch有两种主流方案:DataParallel(简称DP)和DistributedDataParallel(简称DDP)。
DataParallel用起来很简单:
model = nn.DataParallel(model)它的机制是:把输入batch在dim=0维度上切成多份,分发给所有可见GPU,然后在cuda:0上汇总loss。正因为主卡承担了汇总工作,所以DataParallel要求所有输入数据的batch size必须大于GPU数量,而且主卡(默认cuda:0)的内存占用会明显高于其他卡。如果你在DataParallel的模型外面又手动把输入挪到了cuda:2,就会出现输入在卡2、模型参数分散在所有卡上的诡异局面。
DistributedDataParallel是更推荐的多卡方案,但它对设备分配的要求更严格。它通过torch.distributed.init_process_group初始化进程组,每一个进程负责一块卡,设备必须严格对应当前进程的local_rank。不少人在这里犯的错误是:启动脚本时传了CUDA_VISIBLE_DEVICES=0,1,2,3,但在初始化时却用了torch.device("cuda:0"),导致所有进程都挤到第一块卡上,或者某一次前向传播时张量出现在非当前进程的设备上,触发一堆莫名其妙的错误。
DDP的标准初始化方式我建议直接抄:
import torch.distributed as dist import torch.multiprocessing as mp def worker(local_rank): # 通常由torchrun或launch脚本传入 torch.cuda.set_device(local_rank) device = torch.device(f"cuda:{local_rank}") dist.init_process_group(backend="nccl", rank=local_rank, world_size=world_size) model = MyModel().to(device) model = nn.SyncBatchNorm.convert_sync_batchnorm(model) # 如果需要 ddp_model = DDP(model, device_ids=[local_rank])核心思想就是“一进程一卡,设备编号严格遵循local_rank”。不要在你的代码里用torch.cuda.current_device()或者torch.cuda.device_count()去猜设备,否则多进程环境下极容易错位。
3.3 从checkpoint恢复训练时最容易翻车
我在生产环境里排查过很多次类似的“cuda2 vs cuda0”报错,最后发现它们有个共同的隐藏源头:模型是从checkpoint恢复的,而checkpoint里的参数和设备绑定关系出了问题。
比如说,你之前在一块卡上保存了模型权重,用的是torch.save(model.state_dict(), path),这个没问题,因为state_dict只存了数值,没有绑设备。但如果你用的是torch.save(model, path)直接保存整个模型对象,那么反序列化时,模型参数可能会保持在保存时的设备上。如果你换了机器、换了卡号,加载出来的模型参数就会被还原到已经失效的cuda:2上。
更隐蔽的问题是“先加载后迁移”的顺序搞反了。正确的加载姿势是:
checkpoint = torch.load(path, map_location="cpu") model.load_state_dict(checkpoint["model_state_dict"]) model = model.to(device)注意两个关键点:第一,torch.load一定要加map_location="cpu",先把所有参数加载到CPU,再统一迁移到目标设备。这样能避免“张量在cuda2、模型在cuda0”的尴尬局面。第二,如果checkpoint里保存了优化器状态,也要在模型to(device)之后重新创建优化器或把优化器状态load_state_dict后再to(device),否则优化器状态可能会留在其他设备上。
4. 这些周边坑,排查时一起避开
设备不一致报错往往不是孤立出现的,它通常和你的CUDA环境、显卡驱动、训练框架版本纠缠在一起。我在这里把排查过程中容易一起碰到的坑统一列一下,免得你刚解决完这个,又被下一个绊倒。
4.1 为什么有时候“cuda:1”或者“cuda:2”根本不存在
很多人的误区是:报错里提到了cuda2,那就说明系统里有cuda2可用。但实际上,如果你在启动脚本前设置了CUDA_VISIBLE_DEVICES,物理设备编号和你进程里看到的逻辑编号完全是两码事。
举个例子:
# 物理上有4块卡,但你只想用第1块和第3块 export CUDA_VISIBLE_DEVICES=0,2 python train.py在这个进程里,物理第3块卡对应的逻辑编号是cuda:1,物理第1块卡对应cuda:0。如果你的代码里硬编码了torch.device("cuda:2"),即使物理机上有第3块卡,进程里也访问不到,报错就来了。
我自己的习惯是,在训练脚本里显式打印一下当前进程可见的设备列表,并把它写进日志。这样真正出了问题,一看日志就能追溯到是环境变量的问题、还是代码写死设备的问题。另外,如果你用nvidia-smi看到的卡和PyTorch里device_count()得到的数量对不上,先检查CUDA_VISIBLE_DEVICES,这是最常见的差异来源。
4.2 其他常见CUDA报错速查
除了“cuda2 but found one of them on device cuda 0”,我还经常收到另一类报错,比如:
RuntimeError: CUDA error: device-side assert triggered这种报错通常不是设备分配问题,而是你的代码在GPU上执行了一些非法操作,最常见的就是:标签索引越界、类别数和全连接层输出维度不匹配、某些张量包含NaN或Inf。它之所以在设备分配问题中被一起讨论,是因为很多人第一次遇到它时误以为是CUDA环境坏了。实际排查手段是:先加CUDA_LAUNCH_BLOCKING=1环境变量,把异步执行变成同步执行,这样堆栈信息就能定位到具体代码行,而不是在一个普通Kernel执行完毕后才统一报错。
再比如:
nvidia-smi: unable to determine the device handle for GPU 0000:41:00.0: Unknown Error这通常代表驱动和设备的通信出了问题,常见的诱因是显卡被其他进程占用到濒临崩溃、GPU掉驱动、或者多卡环境下某块卡的PCIe链路异常。遇到这种问题,优先考虑重启机器,或者用nvidia-smi -pm 1打开持久化模式试试,不要急着重装CUDA。
还有一个容易让人误判的:
CUDA error: out of memory这也不用多说,一般就是显存峰值超了。但有一种情况是模型在训练过程中动态创建了某些临时张量,并且没有及时释放,一步步把显存占满。排查时可以用torch.cuda.max_memory_allocated()查看峰值显存,再用torch.cuda.reset_peak_memory_stats()包住某个小循环逐步定位。
4.3 环境层面的兼容性排查:驱动、CUDA、PyTorch三者缺一不可
设备分配报错排查到最后,如果代码里真的找不出毛病,那就得考虑环境本身的兼容性。这里有一个比较实的体会:PyTorch是编译时绑定CUDA版本的,你用什么版本的PyTorch,最好就安装对应版本的CUDA Runtime,虽然PyTorch很多操作是动态加载驱动的,但cuda2这种设备编号能出现在报错里,说明你的CUDA Runtime至少已经正常枚举出了设备。
常见的兼容性问题有这几类:
驱动版本过低:老驱动不支持新版CUDA。
nvidia-smi里能看到驱动版本,torch.version.cuda里能看到PyTorch自带的CUDA版本,如果驱动版本比CUDA最低要求还低,很多奇怪的问题都会冒出。可以先运行python -c "import torch; print(torch.cuda.is_available())",如果返回False,十有八九是驱动和CUDA版本不匹配。多个CUDA版本共存导致路径混乱:一台机器上可能装了CUDA 11.8、CUDA 12.1,
~/.bashrc里的LD_LIBRARY_PATH又写得比较随意,就容易出现“上一个程序跑得好好的,换一个Python环境就崩了”的情况。排查时可以用torch.utils.cpp_extension.CUDA_HOME看看PyTorch认为CUDA在哪,再用nvcc -V看看当前命令行实际用的CUDA是哪一套,保证两者一致。WSL2下的特殊情况:如果是在WSL2里跑,CUDA设备是通过宿主机透传进来的,这里最容易出问题的是驱动必须装Windows侧,而CUDA Toolkit可以装在Linux侧。Windows的驱动和Linux的CUDA Runtime各管一摊,一旦版本对不上或者Path没配对,设备枚举就会出怪事,也可能间接引发设备编号错乱。
5. 最后分享一点排查心得
这一路排查下来,你会发现绝大多数所谓“CUDA设备错误”,其实都不是硬件环境坏了,而是代码里对设备分配的处理不够严谨。我自己在踩过几次坑之后,现在写任何PyTorch训练脚本都会在开头固定好全局device变量,所有张量迁移都通过它,多卡环境一律用DDP并严格绑定local_rank,加载checkpoint统一走map_location="cpu"再迁移。这套组合拳打下来,“cuda2 but found one of them on device cuda 0”这种报错几乎没再出现过。
如果你现在正在被这个报错折磨,建议按文章里的顺序走一遍:先打印可见GPU数量,再逐一张量查设备,最后检查模型和优化器。绝大多数情况下,问题都会在半小时内水落石出。如果真的把所有代码逻辑都排查干净了还是报错,那再去怀疑驱动和CUDA版本也不迟。毕竟,环境问题虽然可怕,但代码逻辑问题永远是最常见的第一嫌疑犯。