Backend trait
主线位置:算子主线的「契约」。
Tensor的方法最后都变成Dispatch上的某个FloatTensorOps/IntTensorOps/ … 方法,Dispatch再把同名方法转给Flex、Cube(GPU)或Autodiff<B>。这些方法的签名就是本页讲的 trait,定义在burn-backend。Dispatch怎么转见 Dispatch,CPU 上的实现见 Flex。
flowchart TB bt["BackendTypes"] --> dev["Device: DeviceOps"] bt --> prim["四种张量原语"] bt --> gprim["GraphPrimitive"] be["Backend"] --> bt be --> fops["FloatTensorOps"] be --> iops["IntTensorOps"] be --> bops["BoolTensorOps"] be --> qops["QTensorOps"] be --> mops["ModuleOps"] be --> aops["ActivationOps"] be --> tops["Transaction 和 Distributed"] ad["AutodiffBackend"] --> be
burn-backend 是 0.20 开发中从 burn-tensor 拆出来的(PR #4111「Move backend traits and types to burn-backend」)。它不依赖 burn-tensor,不认识 Tensor:后端作者实现它,burn-dispatch 和 burn-autodiff 用它,用户代码在 0.22 里一般碰不到它。本页的代码都在 crates/burn-backend/src/backend/ 下,base.rs 589 行,ops/ 下的各个 trait 加起来约 1.1 万行,大部分是文档注释。
BackendTypes:后端的类型表
BackendTypes 只有关联类型,没有方法:
pub trait BackendTypes: Clone + Send + Sync + core::fmt::Debug + 'static {
/// Device type.
type Device: DeviceOps;
/// Tensor primitive to be used for all float operations.
type FloatTensorPrimitive: TensorMetadata<Device = Self::Device> + 'static;
/// Tensor primitive to be used for all int operations.
type IntTensorPrimitive: TensorMetadata<Device = Self::Device> + 'static;
/// Tensor primitive to be used for all bool operations.
type BoolTensorPrimitive: TensorMetadata<Device = Self::Device> + 'static;
/// Tensor primitive to be used for all quantized operations.
type QuantizedTensorPrimitive: TensorMetadata<Device = Self::Device> + 'static;
// ...
type GraphPrimitive: Clone + Send + Sync + core::fmt::Debug + 'static;
}
四种张量原语分开声明,但没要求它们不同。三个后端的取法:
| 后端 | FloatTensorPrimitive | Int… / Bool… | Quantized… | GraphPrimitive |
|---|---|---|---|---|
Flex | FlexTensor | 同一个 FlexTensor | FlexQTensor | GraphUnsupported |
Dispatch | DispatchTensor | 同一个 DispatchTensor | 同一个 DispatchTensor | DispatchGraph |
Autodiff<B> | AutodiffTensor<B> | 直接用 B 的 | 直接用 B 的 | GraphUnsupported |
Flex 用一个按字节存储、带 dtype 字段的张量装所有种类,所以浮点、整数、布尔是同一个类型;Dispatch 更进一步,连量化张量也是同一个枚举。这里的「种类」只是 trait 方法签名上的约定,类型系统并不区分:把一个整数 DispatchTensor 传给 float_add 能通过编译,错误要等运行期才暴露。用户层面防住这件事的是 Tensor<D, K> 的 K(见 Tensor 与 Kind)。
GraphUnsupported 是一个没有变体的枚举。不支持图捕获的后端把它填进 GraphPrimitive,graph_stop_capture 的默认实现永远返回 Err,于是这个类型的值不可能出现,graph_replay 也就不可能被调用。用类型表达「这条路走不通」,不用运行期再查一次。
每个原语都要实现 TensorMetadata:dtype、shape、device,以及 can_mut。最后一个问的是「这个句柄是不是独占底层缓冲」,独占时后端可以原地写;不知道就必须答 false。Flex 上它就是 Arc::strong_count == 1,见 Flex。
Backend:op trait 的合集
Backend 本身没有算子方法,算子全在它的 supertrait 里:
pub trait Backend:
BackendTypes
+ FloatTensorOps<Self>
+ BoolTensorOps<Self>
+ IntTensorOps<Self>
+ ModuleOps<Self>
+ ActivationOps<Self>
+ QTensorOps<Self>
+ TransactionOps<Self>
+ DistributedOps<Self>
+ Clone
+ Default
+ Sized
+ Send
+ Sync
+ core::fmt::Debug
+ 'static
{
所有 op 都是关联函数,没有 self:张量原语按值传进去、按值返回,设备从张量上取或作为参数传入。Backend 自己的方法是设备级的:name、seed、sync、flush、profile 一组、graph_* 一组、memory_* 一组、supports_dtype / dtype_usage、device_count。其中只有 name、seed、flush、dtype_usage、device_count 没有默认实现。
按 trait 数一下 0.22.1 里的方法(只数 trait 体内的 fn):
| trait | 文件 | 必须实现 | 有默认实现 |
|---|---|---|---|
FloatTensorOps | ops/tensor.rs | 78 | 50 |
IntTensorOps | ops/int_tensor.rs | 65 | 42 |
BoolTensorOps | ops/bool_tensor.rs | 27 | 12 |
QTensorOps | ops/qtensor.rs | 9 | 71 |
ModuleOps | ops/modules/base.rs | 18 | 42 |
ActivationOps | ops/activation.rs | 0 | 14 |
TransactionOps | ops/transaction.rs | 0 | 1 |
DistributedOps | distributed/ops.rs | 0 | 7 |
合计 197 个必须实现、239 个有默认实现。量化(QTensorOps)几乎全靠默认:先反量化成浮点、调浮点 op,再按设备设置里的 QuantPropagation 决定量化回去还是留成浮点,后端只需提供量化、反量化和搬运这几个原语。ActivationOps 全是默认,一个后端什么都不写也有 relu、gelu、sigmoid。TransactionOps::tr_execute 的默认实现逐个 await 每个张量的 *_into_data,GPU 后端覆盖它,把多次读回合成一次。
默认实现:用别的 op 拼
默认实现只能用同一个 trait 体系里的其它方法,所以它们读起来像一小段用 op 写的程序。三个例子:
float_neg 是乘以 −1:
fn float_neg(tensor: FloatTensor<B>) -> FloatTensor<B> {
Self::float_mul_scalar(tensor, (-1f32).into())
}
relu 是一次比较加一次按掩码填值,掩码的 bool dtype 从设备设置里取:
fn relu(tensor: FloatTensor<B>) -> FloatTensor<B> {
let bool_dtype = get_or_init_device_settings::<B>(&tensor.device()).bool_dtype;
let mask = B::float_lower_equal_elem(tensor.clone(), 0f32.into(), bool_dtype);
B::float_mask_fill(tensor, mask, 0f32.into())
}
float_sort 干脆把数据读回主机,在 CPU 上排好再建回去:
fn float_sort(tensor: FloatTensor<B>, dim: usize, descending: bool) -> FloatTensor<B> {
let device = tensor.device();
sort::<B, _, _, _>(
tensor,
dim,
descending,
device,
|tensor| {
let msg = "Failed to synchronously read tensor data. This operation is not supported until this backend has a GPU sorting implementation.";
try_read_sync(B::float_into_data(tensor))
.expect(msg)
.expect(msg)
},
|data, device, _dtype| B::float_from_data(data, device),
)
}
哪些后端覆盖了它们(git grep "fn <名字>(",Dispatch 和 burn-router 是转发层,不计):
| 默认方法 | 拼法 | Flex | CubeBackend | Fusion<B> | Autodiff<B> |
|---|---|---|---|---|---|
float_neg | mul_scalar(-1) | 覆盖 | 用默认 | 用默认 | 覆盖 |
relu | lower_equal_elem + mask_fill | 覆盖 | 用默认 | 用默认 | 覆盖 |
float_sort | 读回主机排序 | 覆盖 | 用默认 | 用默认 | 覆盖 |
float_max_dim | argmax + gather | 覆盖 | 覆盖 | 覆盖 | 覆盖 |
float_prod | exp(sum(log x)) | 覆盖 | 覆盖 | 覆盖 | 覆盖 |
几种取舍各有理由:
- Flex 几乎全覆盖。 CPU 上每个 op 是一次独立的遍历,
neg走默认就是「建一个 −1 标量、做一次乘法」,自己写一遍循环更省。relu走默认要先生成一个布尔掩码张量再遍历一次,覆盖后一次遍历搞定。 - GPU 两层常用默认。
Cube在开了fusionfeature 时是Fusion<CubeBackend>(burn-cubecl/src/lib.rs),neg变成一个mul_scalar、relu变成比较加mask_fill,都会被记进融合流,和前后的逐元素 op 合成一个内核(见 Fusion 后端)。拆成通用小 op 反而方便融合。 float_sort在 GPU 上真的走默认。burn-cubecl和burn-fusion都没有覆盖它,所以 Metal 上的x.sort(0)会把张量读回主机、在 CPU 上排序、再上传。报错信息里那句「until this backend has a GPU sorting implementation」说的就是这个现状。在 wasm 上try_read_sync等不到结果,这个默认实现会直接 panic。- Autodiff 几乎全覆盖,理由和速度无关。 默认实现由几个 op 拼成,在
Autodiff<B>上走默认就会在图里记几个节点;覆盖之后只记一个节点、配一个专门的反向(relu的反向是B::relu_backward),内层算前向时再交给B::relu,B自己的快实现照样用得上。详见一个算子的反向。 float_prod的默认实现对负数不成立:负数的log是 NaN。树内的后端全都覆盖了它;自己写后端时如果不覆盖,这个默认值是错的。
问: Dispatch 自己不算任何东西,为什么 burn-dispatch/src/ops/tensor.rs 要把有默认实现的方法也一个个写出来(float_neg、float_transpose 都在里面)?让它们走默认不是更省代码吗?
答: 默认实现是在实现者那一层展开的。Dispatch 不写 float_neg,调用就会落到 FloatTensorOps<Dispatch> 的默认体:Dispatch::float_mul_scalar(tensor, -1),再按设备分派到 Flex::float_mul_scalar。Flex 自己那个一次遍历的 float_neg 永远不会被调用,Autodiff 也会多记一个乘法节点。转发层必须把每个方法都转发一遍,具体后端的覆盖才看得见。
AutodiffBackend
AutodiffBackend 在 Backend 之上加三类方法:backward / grad / grad_remove / grad_replace 管梯度;inner / from_inner(及 int_ / bool_ / q_ 版本)在「带图的张量」和「内层后端的张量」之间换;set_distributed_params 一组给分布式训练打标记。它要求 InnerBackend: Backend<Device = Self::Device>,即外层和内层用同一种设备类型。
Autodiff<B> 实现它,InnerBackend = B。Dispatch 也实现它,但 InnerBackend = Dispatch:在 Dispatch 层,「是否带图」不是类型的区别,而是 DispatchTensor 上的一个运行期标记,inner 只是把标记清掉。这些方法做什么、图怎么建,属于Autodiff 装饰器;本页只需要知道,0.22 的用户代码通过 Tensor::backward、Tensor::grad 间接用到它们。
DeviceOps 与设备设置
BackendTypes::Device 要实现 DeviceOps:
pub trait DeviceOps: Clone + Default + PartialEq + Send + Sync + core::fmt::Debug + Device {
/// Returns the device id.
fn id(&self) -> DeviceId {
self.to_id()
}
/// Returns the default settings for this device.
fn defaults(&self) -> DeviceSettings;
}
supertrait Device 来自 cubecl 的 cubecl-common(经 burn-std 重新导出),只有两个方法:from_id 和 to_id,DeviceId 是两个 u16(type_id、index_id)。burn 和 cubecl 用同一套设备编号,CPU 后端也一样:FlexDevice 是个零大小的结构体,to_id 固定返回 DeviceId::new(0, 0)。
defaults 返回 DeviceSettings:默认的 float / int / bool dtype 和量化配置。Flex 的是 F32、I32、Bool(Native)。这组设置按 (DeviceId, TypeId) 存在一个全局表里(get_or_init_device_settings),第一次用到某个设备时从 defaults() 初始化,set_default_dtypes 能换掉默认值,但只有一次机会,而且要赶在表项初始化之前:它先检查 dtype 在该设备上 supports_dtype,再往表项的 OnceLock 里写,写不进去就返回 DeviceError。实验 crate 里依次试了三次(NdArray 设备还没用过,Flex 设备已经建过一个张量):
let fresh = Device::ndarray().as_dispatch().clone();
println!("before first use: {:?}", set_default_dtypes::<Dispatch>(&fresh, FloatDType::F16, IntDType::I32, BoolDType::Native));
println!("second call: {:?}", set_default_dtypes::<Dispatch>(&fresh, FloatDType::F32, IntDType::I32, BoolDType::Native));
let used = Device::flex();
let _t = Tensor::<1>::from_floats([1.0, 2.0], &used);
println!("after first use: {:?}", set_default_dtypes::<Dispatch>(used.as_dispatch(), FloatDType::F16, IntDType::I32, BoolDType::Native));
before first use: Err(UnsupportedDType { device: "NdArray(Cpu)", dtype: F16 })
second call: Ok(())
after first use: Err(AlreadyInitialized { device: "Flex(Cpu)" })
第一次因为 NdArray 不支持 F16 被拒,表项仍是空的,第二次设 F32 成功;Flex 设备建张量时已经按 defaults() 初始化了表项,再设就是 AlreadyInitialized。上面 relu 默认实现里的 get_or_init_device_settings::<B>(..).bool_dtype 就是查这张表。用户面的 Device::configure / Device::settings 见 Device。
0.22 开发早期,BackendTypes 还有三个关联类型 FloatElem、IntElem、BoolElem,AutodiffBackend 的约束写成 InnerBackend: Backend<Device = Self::Device, FloatElem = Self::FloatElem, IntElem = Self::IntElem>,Flex 为了套进这个形状,被定义成 pub struct Flex<E = f32, I = i32>,带两个 PhantomData。PR #5000(「refactor(backends)!: remove associated element types & replace with device defaults」,首次进入 v0.22.0-pre.1)把三个关联类型删掉,给 DeviceOps 加了 fn defaults(&self) -> DeviceSettings,Flex 变成今天的 pub struct Flex {}。默认 dtype 从「编译期的类型」变成「每个设备一份的运行期设置」,这和 0.22 把后端也从类型里拿掉是同一个方向。
dtype 支持:DTypeUsage
dtype_usage 返回一个 DTypeUsage 的集合,三个标志:Storage(能存、能转换)、Arithmetic(能做一般运算)、Accelerated(有硬件加速路径,比如矩阵乘的专用单元)。supports_dtype 的默认实现要求前两个都有。区分出 Accelerated 是因为有的类型只在矩阵乘这类专用路径上可用,不能拿来当默认 dtype。
Flex::dtype_usage 是一张写死的表:所有浮点、整数类型都是 Storage | Arithmetic,Bool(U32) 和大部分量化类型是空集。GPU 上的答案由 cubecl 按设备能力给出。
F64 吗?Dispatch::name 打印什么?实验 crate backend-explore(burn =0.22.1,features std flex ndarray metal autodiff fusion autotune),对 Device::flex() 和 Device::metal(DeviceKind::DefaultDevice) 各调一次 Dispatch::name 和 Dispatch::dtype_usage:
for (name, device) in [("flex", Device::flex()), ("metal", Device::metal(DeviceKind::DefaultDevice))] {
let d = device.as_dispatch();
println!("{name}: Dispatch::name = {}", Dispatch::name(d));
for dt in [DType::F64, DType::F32, DType::F16, DType::BF16, DType::I64, DType::I32, DType::U8] {
println!(" {dt:?}: usage={:?} supports={}", Dispatch::dtype_usage(d, dt), Dispatch::supports_dtype(d, dt));
}
}
先猜 F64 那一行,再看页尾答案。
为什么 float_into_data 是 async
几乎所有 op 都是同步签名,读数据的 float_into_data 例外:
fn float_into_data(
tensor: FloatTensor<B>,
) -> impl Future<Output = Result<TensorData, ExecutionError>> + Send;
int_into_data、bool_into_data、q_into_data、tr_execute 也是同样的形状。原因在 GPU:op 只是把命令提交给设备,读数据要等设备算完、再把缓冲映射回主机,这是一次真正的等待。在原生平台上可以阻塞线程等;在浏览器里(WebGPU 跑在 wasm 上)主线程不允许阻塞,只能交出控制权、等回调。把签名定成 future,两种平台用同一份后端代码:原生上由调用方阻塞,wasm 上由调用方 .await。future 怎么被 poll、什么叫交出控制权,见 tokio 卷的 poll 与 wake。
签名写成返回位置的 impl Future + Send,实现方可以直接写 async fn。CPU 后端没有什么可等的,Flex::float_into_data 的 future 第一次 poll 就完成:
async fn float_into_data(tensor: FloatTensor<Flex>) -> Result<TensorData, ExecutionError> {
Ok(tensor.into_data())
}
Result 来自另一处改动:GPU 上前面某个 op 的执行错误(比如设备丢失)往往要等到读数据时才浮出来,读数据是报告它的地方。
同步的 Tensor::into_data 最终调用 try_read_sync(cubecl-environment,经 burn-std 导出):
pub fn try_read_sync<F: Future<Output = T>, T>(f: F) -> Option<T> {
#[cfg(target_family = "wasm")]
{
use core::task::Poll;
match embassy_futures::poll_once(f) {
Poll::Ready(output) => Some(output),
_ => None,
}
}
#[cfg(not(target_family = "wasm"))]
{
Some(super::block_on(f))
}
}
原生平台上 block_on 是 futures_lite::future::block_on,在当前线程上一直 poll 到完成。wasm 上只 poll 一次:Flex 的 future 当场完成,同步 API 照常可用;GPU 的 future 还没好,返回 None,Tensor::into_data 随即 panic,提示改用 into_data_async。这也是上面 float_sort 默认实现在 wasm 上会 panic 的原因。
ReaderPR #1936(「Consistent sync/async handling, allow more functions to be async for wasm.」,首次进入 v0.14.0)之前,这个方法的签名是 fn float_into_data<const D: usize>(tensor: FloatTensor<B, D>) -> Reader<TensorData>:Reader 是 burn 自己的类型,能同步读也能异步读。float_sort 等需要读回数据的默认实现前面挂着 #[cfg(any(feature = "wasm-sync", not(target_family = "wasm")))],在 wasm 上整段不编译。这个 PR 把返回值改成 impl Future<Output = TensorData> + Send,删掉了那些 cfg,改由今天的 try_read_sync 在运行期判断能不能等。后来返回值又包了一层 Result,签名里的 const D 也随着秩从后端 trait 里拿掉而消失。
动脑筋答案
cargo run --offline(debug),M2 Max、macOS 27、rustc 1.99.0:
flex: Dispatch::name = dispatch<flex>
F64: usage=EnumSet(Storage | Arithmetic) supports=true
F32: usage=EnumSet(Storage | Arithmetic) supports=true
F16: usage=EnumSet(Storage | Arithmetic) supports=true
BF16: usage=EnumSet(Storage | Arithmetic) supports=true
I64: usage=EnumSet(Storage | Arithmetic) supports=true
I32: usage=EnumSet(Storage | Arithmetic) supports=true
U8: usage=EnumSet(Storage | Arithmetic) supports=true
metal: Dispatch::name = dispatch<fusion<cubecl<wgpu<msl>>>>
F64: usage=EnumSet() supports=false
F32: usage=EnumSet(Storage | Arithmetic | Accelerated) supports=true
F16: usage=EnumSet(Storage | Arithmetic | Accelerated) supports=true
BF16: usage=EnumSet(Storage | Arithmetic | Accelerated) supports=true
I64: usage=EnumSet(Storage | Arithmetic) supports=true
I32: usage=EnumSet(Storage | Arithmetic) supports=true
U8: usage=EnumSet(Storage | Arithmetic) supports=true
- Metal 上
F64是空集,连Storage都没有:Metal 着色语言没有 64 位浮点类型。同一份用f64的代码在 Flex 上能跑,换到 Metal 设备就得先改 dtype。 F32、F16、BF16在 Metal 上多一个Accelerated,Flex 上一个也没有:Flex 不把任何路径标成「硬件加速」,gemm再快也只是普通算术。name把层次全打了出来:Dispatch包着Fusion,Fusion包着CubeBackend,运行时是 wgpu,内核编成 MSL。每一层的name都是「自己的名字 + 内层的名字」,Dispatch层的写法见Backend for Dispatch。
小结
BackendTypes定义设备和四种张量原语,Backend是九个 op trait 的合集加设备级方法;op 都是不带self的关联函数,张量按值进出。- 0.22.1 里共 197 个必须实现的方法、239 个默认实现。默认实现用别的 op 拼:
neg是乘 −1,relu是比较加填值,sort是读回主机排序。 - CPU 后端几乎全覆盖;GPU 的两层常留着默认,把它们拆成能融合的小 op;Autodiff 覆盖是为了一个 op 只记一个节点;
Dispatch必须逐个转发,否则内层的覆盖会被绕过。 DeviceOps::defaults给出每个设备的默认 dtype,存进全局设置表;dtype_usage用三个标志说明一个 dtype 在设备上能做什么,Metal 上F64不可用。float_into_data返回 future,原生平台阻塞等待,wasm 上只 poll 一次;Flex 的 future 当场完成。