Rust in Everything
基于 candle-core 0.11.0

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。

📝NOTE
冷知识:两次 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。
  • 设备建一次,克隆着传下去。

💬 关联讨论

发起讨论
加载中...