device mismatch in add
主线位置:算子主线的第三步。
Tensor::matmul查完形状,取两边 storage 的读锁交给Storage::matmul,它先检查两边在同一个设备上,再按(Cpu, Cpu)/(Metal, Metal)匹配后端(见 Storage 与后端 trait)。
flowchart TB
a["CPU 张量"] --> chk{"same_device"}
b["Metal 张量"] --> chk
chk -->|"Metal 比 DeviceId"| m{"同一个 MetalDevice"}
chk -->|"其他比 location"| loc{"location 相同"}
m -->|否| err["device mismatch"]
loc -->|否| err
m -->|是| ok["进入后端"]
loc -->|是| ok
实验 crate exp-candle/pa-pit,bin p02_device、p02_ordinal,debug;M2 Max(一块 GPU)、macOS 27、rustc 1.99.0。
现象
第一种很直白:一个张量在 CPU 上,一个在 Metal 上。
cpu + metal: Err(device mismatch in add, lhs: Cpu, rhs: Metal { gpu_id: 4294968354 })
cpu.matmul(metal): Err(device mismatch in matmul, lhs: Cpu, rhs: Metal { gpu_id: 4294968354 })
第二种不直白:模型加载函数里 Device::new_metal(0)? 了一次,生成输入的地方又 Device::new_metal(0)? 了一次。
let metal = Device::new_metal(0)?;
let b = Tensor::ones((2, 2), DType::F32, &metal)?;
// a helper that opens "the GPU" again
let other = Device::new_metal(0)?;
let c = Tensor::ones((2, 2), DType::F32, &other)?;
show("metal + other", &b + &c);
show("b.to_device(&other)", b.to_device(&other));
metal.same_device(&other) = false
metal + other: Err(device mismatch in add, lhs: Metal { gpu_id: 4294968354 }, rhs: Metal { gpu_id: 4294968354 })
b.to_device(&other): Err(not implemented yet, self.device: Metal(MetalDevice(DeviceId(1))), device: Metal(MetalDevice(DeviceId(2))))
报错里左右两边一字不差,看不出差在哪;想用 to_device 搬过去也不行。
第三种:机器上只有一块 GPU,代码里写了 new_metal(1)。不是 Err,是 panic:
thread 'main' (3925833) panicked at /rustc/b940084d7eb6a299eb4bfeb8e34901bc051e7ac4/library/alloc/src/vec/mod.rs:2258:13:
swap_remove index (is 1) should be < len (is 1)
出处
Storage::same_device 对 Metal 单独处理:
let same_device = if self.device().is_metal() {
// On metal, we require the device to be exactly the same rather than
// having the same location. In cuda this is not necessary as all CudaDevice on the
// same GPU will use the same cuda stream.
lhs_device.same_device(&rhs_device)
} else {
lhs == rhs
};
if !same_device {
Err(Error::DeviceMismatchBinaryOp { lhs, rhs, op }.bt())
} else {
Ok(())
}
MetalDevice 的 same_device 是 self.id == rhs.id,id 是进程内自增的 DeviceId;每次 new_metal 都建一个新的命令队列、新的缓冲池和内核缓存(见 Metal 后端),两个实例之间没有执行顺序的保证,所以不让混用。报错却打印 location(),Metal 的 location 是 gpu_id(GPU 的 registryID),两个实例当然相同。
Tensor::to_device 只认四种搬法:CPU 到 CUDA / Metal、CUDA / Metal 到 CPU、CUDA 到 CUDA、CPU 到 CPU;Metal 到另一个 Metal 落进 _ 分支的 bail!("not implemented yet, ..")。
new_metal(1) 的 panic 来自 BackendDevice::new 的第一行 Device::all().swap_remove(ordinal):序号越界,Vec::swap_remove 直接 panic,没有转成 Err。
new_metal(0) 曾经算同一个设备PR #1932(「Really unique identifier for metal device ids.」)之前,MetalDevice::same_device 比较的是 self.device.registry_id(),Storage::same_device 对所有设备都只比 location(),两次 new_metal(0) 的张量可以直接相加,尽管它们各有各的命令队列。这个 PR 给 MetalDevice 加了 id: DeviceId 字段,字段注释写明了原因:「the registryID is not sufficient as it identifies the GPU rather than the device itself」;Storage::same_device 加上了上面那段 Metal 分支和注释。Debug 输出也从 registryID 换成了 DeviceId,所以 to_device 的报错里能看到 DeviceId(1)、DeviceId(2),而 DeviceMismatchBinaryOp 用的仍是 location()。本地仓库最早的 tag 是 0.5.1,git tag --contains 只能查到它。
修法
设备建一次,到处克隆。 Device 是 Clone,MetalDevice 的字段除了 id 和设备句柄全是 Arc,id 原样复制,克隆出来的是同一个设备:
let shared = metal.clone();
let d = Tensor::ones((2, 2), DType::F32, &shared)?;
show("metal + clone", &b + &d);
show("a.to_device(&metal) + b", a.to_device(&metal)?.add(&b));
metal.same_device(&shared) = true
metal + clone: Ok [2, 2] on Metal { gpu_id: 4294968354 }
a.to_device(&metal) + b: Ok [2, 2] on Metal { gpu_id: 4294968354 }
把 &Device 作为参数传进加载函数和取样函数,而不是在里面各自 new_metal。CPU 上的数据(token id、mask)用 to_device(&device) 搬过去再参与运算。
已经有两个 Metal 设备上的张量要合到一起时,只能经 CPU 中转:
b via Cpu to other: Ok [2, 2] on Metal { gpu_id: 4294968354 }
(b.to_device(&Device::Cpu)?.to_device(&other),一次读回、一次上传。)
序号越界会 panic 而不是返回错误,单 GPU 的 Mac 上只能用 0。别指望 candle_core::utils::metal_is_available() 帮忙:它的实现是 cfg!(feature = "metal"),只说明编译时开没开 feature,不查机器上有几块 GPU。
怎么确认没中招
- 报错两边都是
Metal { gpu_id: N }且N相同:一定是两个MetalDevice实例。全局搜new_metal,只应该出现一次。 - 不确定时打印
t.device()的Debug:Metal(MetalDevice(DeviceId(1)))里的编号才是区分实例的东西;或者直接a.device().same_device(b.device())。 - burn 在同一种情形下的表现(
cat跨设备)见 burn 卷的这一页。
小结
- candle 不做隐式搬运,CPU 和 Metal 的张量不能混算,要
to_device。 - 「同一个 Metal 设备」看的是进程内的
DeviceId,两次new_metal(0)是两个设备;报错只打印gpu_id,两边看起来一样。 - Metal 到 Metal 的
to_device没有实现,要经 CPU;越界的序号 panic。 - 设备建一次,克隆着传下去。