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")
#[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
/// 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。
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 的要点):
| dtype | CPU | Metal |
|---|---|---|
| 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。
问: 既然 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。