Rust in Everything
基于 candle-core 0.11.0

DType 与 Device

主线位置:算子主线的第三步。Storage::matmul 在 match (self, rhs) 按设备对分派之前,先做两道检查:self.same_device(rhs, "matmul")? 和 self.same_dtype(rhs, "matmul")?。本页讲这两道检查比的是什么(Device、DType),用户怎么在设备和 dtype 之间搬数据,以及各个 dtype 在 CPU 和 Metal 上实际能做哪些运算。分派本身见 Storage 与后端 trait。

flowchart TB
  op["Storage matmul"] --> sd{"same_device"}
  sd -->|否| e1["DeviceMismatchBinaryOp"]
  sd -->|是| dt{"same_dtype"}
  dt -->|否| e2["DTypeMismatchBinaryOp"]
  dt -->|是| m{"设备对"}
  m -->|Cpu Cpu| cpu["CpuStorage matmul"]
  m -->|Metal Metal| mtl["MetalStorage matmul"]
  cpu --> m2{"dtype 支持"}
  mtl --> m2
  m2 -->|否| e3["UnsupportedDType 或 bail"]

实验 crate:exp-candle/tensor-exp(features metal accelerate)、exp-candle/tensor-noaccel(去掉 accelerate);M2 Max、macOS 27、rustc 1.99.0,release。CUDA 后端只读了源码,没有实测。

DType

DType 是一个不带数据的枚举:

#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum DType {
    // Unsigned 8 bits integer.
    U8,
    // Unsigned 32 bits integer.
    U32,
    // Signed 16 bits integer.
    I16,
    // Signed 32 bits integer.
    I32,
    // Signed 64 bits integer.
    I64,
    // Brain floating-point using half precision (16 bits).
    BF16,
    // Floating-point using half precision (16 bits).
    F16,
    // Floating-point using single precision (32 bits).
    F32,
    // Floating-point using double precision (64 bits).
    F64,
    // 8-bit floating point with 4-bit exponent and 3-bit mantissa.
    F8E4M3,
    /// 6-bit float with 2 exponent bits and 3 mantissa bits (MX6 format)
    F6E2M3,
    /// 6-bit float with 3 exponent bits and 2 mantissa bits (MX6 format)
    F6E3M2,
    /// 4-bit float (MX4 format)
    F4,
    /// 8-bit float with 8 exponent bits and 0 mantissa bits
    F8E8M0,
}

前十个是能计算的类型。最后四个来自 dummy_dtype.rs,文件开头说它们是「placeholder types for experimental floating-point formats that are defined in the safetensors spec but not yet fully implemented」:safetensors 文件里出现这几种 dtype 时,CPU 上能把原始字节读进 CpuStorage::F4(..) 之类的变体里存着,但不能计算。size_in_bytes 对 F6E2M3、F6E3M2、F4 返回 0(注释写「6 bits」「4 bits」)。dummy 实验在 CPU 上对它们各试四件事:

F6E2M3 size_in_bytes=0 zeros=Err("unsupported dtype F6E2M3 for op zeros") add=Err("unsupported dtype F6E2M3 for op zeros") to_f32=Err("unsupported dtype F6E2M3 for op zeros") from_f32=Err("unsupported dtype F6E2M3 for op to_dtype")
...
F8E8M0 size_in_bytes=1 zeros=Err("unsupported dtype F8E8M0 for op zeros") add=Err("unsupported dtype F8E8M0 for op zeros") to_f32=Err("unsupported dtype F8E8M0 for op zeros") from_f32=Err("unsupported dtype F8E8M0 for op to_dtype")

连 zeros 都建不出来,也不能从 F32 转过去。

DType 实现了 FromStr,接受 "u8"、"bf16"、"f8e4m3" 这样的小写名字(mamba、mamba2 两个例子程序用它解析命令行参数),不认识的返回 DTypeParseError:

"bf16".parse() = Ok(BF16), "float".parse() = Err("cannot parse 'float' as a dtype")
📝NOTE
冷知识:加了 #[non_exhaustive]

PR #3412(「feat: add #[non_exhaustive] to DType enum」,首次进入 0.10.0)之前,DType 是普通枚举,下游 crate 可以对它写穷尽的 match。提交说明给的理由:「Adding new variants to a public enum is a breaking change for downstream crates that use exhaustive match statements」,标成 non_exhaustive 之后以后加变体不必发不兼容版本。这个改动本身就是一次不兼容:工作区里受影响的外部 match 在 candle-pyo3,PR 给它补了一个返回「unsupported dtype」错误的通配分支(candle-onnx 也改了两行)。它前面一次加变体正是 PR #3195(「Add dummy dtypes」,首个正式版 0.10.0),一次加了 I16、I32 和上面四个占位类型。

WithDType:Rust 类型到 DType

用户代码里写的是 f32、half::bf16 这样的 Rust 类型,WithDType 把它们和 DType 连起来:

pub trait WithDType:
    Sized
    + Copy
    + num_traits::NumAssign
    + std::cmp::PartialOrd
    + std::fmt::Display
    + 'static
    + Send
    + Sync
    + std::any::Any
    + crate::cpu::kernels::VecOps
{
    const DTYPE: DType;

    fn from_f64(v: f64) -> Self;
    fn to_f64(self) -> f64;
    fn to_scalar(self) -> crate::scalar::Scalar;
    fn cpu_storage_ref(data: &[Self]) -> CpuStorageRef<'_>;
    fn to_cpu_storage_owned(data: Vec<Self>) -> CpuStorage;
    // ...
    fn cpu_storage_as_slice(s: &CpuStorage) -> Result<&[Self]>;
    fn cpu_storage_data(s: CpuStorage) -> Result<Vec<Self>>;
}

关联常量 DTYPE 给出对应的枚举值;to_cpu_storage_owned 把 Vec<Self> 装进 CpuStorage 对应的变体(建张量时用);cpu_storage_as_slice 反过来从 CpuStorage 里取出 &[Self],变体不对就返回 UnexpectedDType(读回数据时用)。实现由 with_dtype! 宏给十个类型生成:u8、u32、i16、i32、i64、half::f16、half::bf16、f32、f64、float8::F8E4M3;四个占位类型由 dummy_dtype.rs 里另一个宏实现。在它之上还有两个标记 trait:FloatDType(五个浮点类型,Tensor::rand / randn 要求它)和 IntDType(五个整数类型)。

所以 Tensor::new(&[1f32, 2.], &dev) 的 dtype 是编译期从 f32 定下来的,t.to_vec1::<f64>() 读一个 F32 张量则要到运行期才发现不对:

to_vec2::<f64> on f32: unexpected dtype, expected: F64, got: F32

Device 与 DeviceLocation

Device 和 DeviceLocation:

/// A `DeviceLocation` represents a physical device whereas multiple `Device`
/// can live on the same location (typically for cuda devices).
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
pub enum DeviceLocation {
    Cpu,
    Cuda { gpu_id: usize },
    Metal { gpu_id: usize },
}

/// Cpu, Cuda, or Metal
#[derive(Debug, Clone)]
pub enum Device {
    Cpu,
    Cuda(crate::CudaDevice),
    Metal(crate::MetalDevice),
}

Device 带着设备的句柄(MetalDevice 里有命令队列、缓冲区池、内核缓存,见 Metal 后端),DeviceLocation 只是一个可以比较、可以哈希的位置。没开 cuda / metal feature 时,CudaDevice / MetalDevice 是 dummy_*_backend.rs 里的空壳,变体仍然存在,构造函数返回 NotCompiledWith*Support 错误。

两者的区别体现在「是不是同一个设备」的判断上。Device::same_device 转给各后端,Metal 的实现比较的是每次 MetalDevice::new 从全局计数器取的 DeviceId,而 location 用的是 GPU 的 registry_id。所以两次 Device::new_metal(0) 得到两个「不同」的设备:

metal == metal2? same_device=false location Metal { gpu_id: 4294968354 } Metal { gpu_id: 4294968354 }

运算前的 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
        };

每个 MetalDevice 有自己的命令队列;两个张量在不同的队列上,一个内核读另一个队列还没写完的缓冲区就会出错。CUDA 那边同一块 GPU 的设备共用一个流,比 DeviceLocation 就够了(注释如此说,本卷没有实测 CUDA)。

构造 Metal 设备的 Device::new_metal(ordinal) 返回 Result,但序号越界时不是 Err:MetalDevice::new 第一行是 Device::all().swap_remove(ordinal)。在只有一块 GPU 的机器上调 new_metal(1):

thread 'main' (3704777) panicked at /rustc/b940084d7eb6a299eb4bfeb8e34901bc051e7ac4/library/alloc/src/vec/mod.rs:2258:13:
swap_remove index (is 1) should be < len (is 1)

想在没有 Metal 时退回 CPU,用 Device::metal_if_available。

❗IMPORTANT
动脑筋:两次 new_metal(0)
let metal = Device::new_metal(0)?;
let metal2 = Device::new_metal(0)?;
let a = Tensor::ones((2, 2), DType::F32, &Device::Cpu)?;
let r = &a.to_device(&metal)? + &a.to_device(&metal2)?;

r 是 Ok 还是 Err?如果是 Err,报错里的 lhs 和 rhs 两个位置一样吗?

to_device 与 to_dtype

to_device 同一设备时原样返回 self.clone()(id 都不变),否则按(源 storage,目标设备)配对:

            let storage = match (&*self.storage(), device) {
                (Storage::Cpu(storage), Device::Cuda(cuda)) => {
                    Storage::Cuda(cuda.storage_from_cpu_storage(storage)?)
                }
                (Storage::Cpu(storage), Device::Metal(metal)) => {
                    Storage::Metal(metal.storage_from_cpu_storage(storage)?)
                }
                (Storage::Cuda(storage), Device::Cpu) => Storage::Cpu(storage.to_cpu_storage()?),
                (Storage::Metal(storage), Device::Cpu) => Storage::Cpu(storage.to_cpu_storage()?),
                (Storage::Cuda(storage), Device::Cuda(cuda)) => {
                    // can't clone storage if it's the same device because of the underlying device ptr
                    let dst_storage = storage.transfer_to_device(cuda)?;
                    Storage::Cuda(dst_storage)
                }
                (Storage::Cpu(storage), Device::Cpu) => Storage::Cpu(storage.clone()),
                _ => {
                    bail!(
                        "not implemented yet, self.device: {:?}, device: {:?}",
                        self.device(),
                        device
                    )
                }
            };

几点:

  • CPU 和 GPU 之间直接搬;CUDA 到另一个 CUDA 设备有专门的 transfer_to_device;Metal 到另一个 Metal 设备落进最后的 bail!,Metal 和 CUDA 之间也是。实测:

    metal.to_device(metal2): not implemented yet, self.device: Metal(MetalDevice(DeviceId(1))), device: Metal(MetalDevice(DeviceId(2)))
    

    所以上面两个 new_metal(0) 造出来的设备之间,连搬数据都不行,只能经 CPU 中转。

  • 拷贝的是整块 storage,布局原样保留(layout: self.layout.clone()):一个大张量的小视图搬到 GPU,搬的是整个底层缓冲区。

  • 结果带 Op::ToDevice,梯度能跨设备传回去,见 backward 与 GradStore。

to_dtype 同 dtype 时也是 self.clone(),否则交给后端的类型转换,结果是新的连续 storage。

跨 dtype、跨设备的运算

candle 不做隐式类型提升,也不做隐式搬运。dtype 检查在 Storage::same_dtype,和设备检查一样在每个二元 Storage 方法开头调用:

f32 + f16: dtype mismatch in add, lhs: F32, rhs: F16
f32.matmul(f16): dtype mismatch in matmul, lhs: F32, rhs: F16
cpu + metal: device mismatch in add, lhs: Cpu, rhs: Metal { gpu_id: 4294968354 }
cpu.matmul(metal): device mismatch in matmul, lhs: Cpu, rhs: Metal { gpu_id: 4294968354 }
index_select f32 ids: unsupported dtype F32 for op index-select
scalar add a + 1.0: [[2.0, 2.0], [2.0, 2.0]]

报错里的设备用的是 DeviceLocation,不是 Device,所以看不出是哪个 MetalDevice 实例。索引类操作(index_select、gather、embedding)要求下标是整数类型,在后端按下标的 dtype 分派时报 UnsupportedDTypeForOp。和标量运算不受 dtype 限制:&a + 1.0 走的是 impl Add<f64> for Tensor,变成 affine(1., 1.0),标量以 f64 传进去,由后端转成张量的类型。

各 dtype 在 CPU 和 Metal 上能做什么

dtypes 实验对前十个 dtype,在 CPU 和 Metal 上各建一个 (2, 2) 张量,试八件事:zeros、t + t、t.matmul(&t)、t.exp()、t.sum_all()、转成 F32、从 F32 转过来、转置后 contiguous()。逐项原文在实验输出里,汇总如下(✓ 成功;P 是 panic;其余是 Err 的要点):

dtypeCPUMetal
U8、U32、I64除 matmul(unsupported dtype U8 for op matmul)和 exp(P)外全 ✓除 matmul(mlx matmul doesn't support U8)和 exp(Metal contiguous unary uexp U8 not implemented)外全 ✓
I16、I32同上只有 zeros、上传、下载 ✓;加法 Error while loading function: badd_i16,求和、转换、复制都是 not implemented
BF16除 matmul(unsupported dtype BF16 for op matmul)外全 ✓全 ✓
F16开 accelerate:matmul 报 the accelerate backend does not support f16 matmul,其余 ✓;不开:全 ✓全 ✓
F32全 ✓全 ✓
F64全 ✓同 I16
F8E4M3除 matmul 外全 ✓同 I16

整数的 exp 在 CPU 上不是错误而是 panic:

U8: ... exp=PANIC(not yet implemented: no unary function for u8) ...

来自 op.rs 里 unary_op! 宏给整数类型生成的 todo!("no unary function for u8")。neg、sqrt 这些同一个宏生成的一元运算也一样。

几条结论:

  • CPU 上 matmul 只支持 F16、F32、F64(不开 accelerate 时,gemm 那一支开头就是这个检查);开了 accelerate 只剩 F32、F64。BF16 在 CPU 上能存、能逐元素算,不能乘。
  • Metal 上完整可用的只有 F32、F16、BF16。U8、U32、I64 能做逐元素和归约(足够做掩码、下标),I16、I32、F64、F8E4M3 实际上只能上传和下载(down 实验把 [0, 1, 2, 3] 上传再下载,四种都原样回来)。
  • Device::supports_bf16 对 CPU 返回 false,bf16_default_to_f32 据此在 CPU 上选 F32、在 Metal 上选 BF16。几个例子程序(phi、mixtral、flux 等)用它选模型的 dtype;它说的「支持」指的是能不能拿这个 dtype 跑模型,而 CPU 上卡住 BF16 的正是 matmul。
📝NOTE
没有蠢问题

问: 既然 same_dtype 在 Storage 层统一检查,为什么上表里有的组合报 Err、有的 panic、有的报「Error while loading function」?

答: 三类失败发生在三个不同的地方。same_dtype、same_device 只保证两边一致,不保证后端实现了这个 dtype。CPU 后端按 CpuStorage 的变体 match,没实现的组合在 Map1 / Map2 这类辅助 trait 里返回 UnsupportedDTypeForOp,但一元运算的整数分支是宏生成的 todo!(),于是 panic。Metal 后端按 dtype 拼出内核名(badd_i16),去已编译的内核库里找函数,库里没有这个名字时 newFunctionWithName 返回空,candle-metal-kernels 把它包成 LoadFunctionError;没拼出内核名的组合则在 Rust 一侧 bail!("... not implemented")。

动脑筋答案

metal + metal2: device mismatch in add, lhs: Metal { gpu_id: 4294968354 }, rhs: Metal { gpu_id: 4294968354 }

是 Err,两个位置完全一样。same_device 对 Metal 比的是 DeviceId(1 和 2),不相等就报错;报错时却打印 DeviceLocation,两个设备在同一块 GPU 上,registry_id 相同。读到这样的报错,就该去查是不是在两处各调了一次 Device::new_metal(0):整个程序应该只建一个 Device,需要的地方 clone(克隆出来的 MetalDevice 带着同一个 DeviceId)。

小结

  • DType 有 14 个变体,#[non_exhaustive](0.10.0 起);后四个是 safetensors 规范里的占位类型,CPU 上能读进来存着,不能建、不能算、不能转换。
  • WithDType 把十个 Rust 类型和 DType 一一对应,负责和 CpuStorage 的变体互相装箱拆箱;读回时类型不对是运行期的 UnexpectedDType。
  • Device 是 Cpu / Cuda / Metal 的枚举,DeviceLocation 是可比较的物理位置;Metal 判断同一设备看 DeviceId,两次 new_metal(0) 是两个设备,互相不能运算,to_device 也不行。
  • 运算前 Storage 先查设备、再查 dtype,不做隐式提升和搬运;to_device / to_dtype 在已经满足时原样返回,否则整块复制。
  • Metal 上能完整使用的只有 F32、F16、BF16;CPU 上 matmul 只支持 F16、F32、F64(accelerate 下没有 F16),整数的一元运算会 panic。

💬 关联讨论

发起讨论
加载中...