BridgeTensor 与 *_impl
主线位置:算子主线的第二步。
Tensor<2>::matmul做完检查之后,调用离开泛型:交给不泛型的<Float as Numeric>::matmul,它从BridgeTensor里取出DispatchTensor,调Dispatch::float_matmul。本页讲这一步为什么这样切、切在哪里。Dispatch内部怎么按设备match到具体后端,见 Dispatch。
flowchart TB
gen["Tensor 2 matmul 泛型"] --> chk["check 与形状计算"]
chk --> kt["Float matmul 不泛型"]
kt --> parts["into_parts 取出"]
parts --> disp["Dispatch float_matmul"]
disp --> wrap["BridgeTensor float 包回"]
wrap --> newt["Tensor new"]
subgraph down ["下游 crate 单态化"]
gen
chk
newt
end
subgraph up ["burn-tensor 里编译一次"]
kt
parts
wrap
end
贡献者注释说了什么
burn-tensor/src/lib.rs 的 crate 文档末尾有一节写给贡献者的说明:
//! # Note for contributors: `*_impl` helpers
//!
//! Throughout this crate (e.g. in `tensor::api::float`, `tensor::api::int`,
//! `tensor::api::bool`, `tensor::api::cast`, and `tensor::activation`), public
//! generic methods on `Tensor<D, K>` that need to call into `burn_dispatch` are
//! routed through small non-generic helper functions named `*_impl`, grouped
//! together at the bottom of each file under a banner like:
//! ...
//! These helpers take and return only `BridgeTensor` (a type-erased blob — no
//! `DispatchTensor` or other `burn_dispatch` types appear in their
//! signatures). Because the helpers are not generic over `D`, they are
//! compiled once, and the MIR of the public generic methods does not mention
//! any `burn_dispatch` types. Downstream crates that monomorphize the public
//! generic API therefore never have to resolve the cubecl-backed type tree,
//! which drastically cuts compile times for user code.
背景是 Rust 的单态化:Tensor<D, K> 的方法是泛型的,库里只有它们的 MIR,真正的机器码要等用户写下 Tensor<2>、Tensor<3, Int> 时在用户自己的 crate 里生成。泛型方法体里提到的每个类型、调用的每个泛型函数,都会跟着在用户 crate 里实例化一遍。DispatchTensor 背后是 Fusion<CubeBackend>、Autodiff<...>、Flex 等一整棵类型树。注释的意思是:不要让这棵树出现在泛型方法体里。
两种外提
说明里点名的是 *_impl 函数,但源码里把调用从泛型里拿出来的办法有两种。
第一种是种类 trait 的方法。Tensor::matmul 对所有 K: Numeric 都存在,方法体里调的是 K::matmul:
pub fn matmul(self, other: Self) -> Self {
check!(TensorCheck::matmul(&self, &other));
if D >= 3 {
let batch_index = D - 3;
let vector_index = D - 2;
let lhs_dims = &self.shape()[batch_index..D];
let rhs_dims = &other.shape()[batch_index..D];
if let ([_, 1, k1], [1, k2, _]) = (lhs_dims, rhs_dims)
&& k1 == k2
{
return Tensor::new(K::matmul(
self.swap_dims(batch_index, vector_index).primitive,
other.primitive,
))
.swap_dims(batch_index, vector_index);
}
}
// ...
依赖 D 的部分(检查、按秩取形状、把 [..., B, 1, K] @ [..., 1, K, N] 改写成一般矩阵乘)留在泛型方法里,K::matmul 只接收 BridgeTensor。K 只有 Float 和 Int 两个取值,<Float as Numeric>::matmul 是一个具体类型上的普通方法,在 burn-tensor 里编译一次:
fn matmul(lhs: BridgeTensor, rhs: BridgeTensor) -> BridgeTensor {
let (lkind, lhs) = lhs.into_parts();
let (rkind, rhs) = rhs.into_parts();
match (lkind, rkind) {
(BridgeKind::Float, BridgeKind::Float) => {
BridgeTensor::float(Dispatch::float_matmul(lhs, rhs))
}
(BridgeKind::Float, BridgeKind::QFloat) => from_q_primitive(Dispatch::q_matmul(
TensorPrimitive::Float(lhs),
TensorPrimitive::QFloat(rhs),
)),
// ...
_ => panic!("Should be Float primitive kind"),
}
}
第二种是 *_impl 自由函数,用在只对一种种类存在的方法上。Tensor<D>(即 Float)的 erf:
pub fn erf(self) -> Self {
Self::new(erf_impl(self.primitive))
}
erf_impl 和二十来个同类函数排在 api/float.rs 末尾的横幅下面:
// =========================================================================
// Non-generic implementation helpers (outlined from the public generic API).
// See the crate-level docs for the rationale behind this pattern.
// =========================================================================
fn erf_impl(p: BridgeTensor) -> BridgeTensor {
BridgeTensor::float(Dispatch::float_erf(p.into_float()))
}
两种外提之外还有一个变体:方法对三种种类都存在,但实现很重。into_data_async 是一个 async 方法,它的状态机如果在泛型方法里展开,每个 (D, K) 组合都要生成一份。于是 into_data_async_impl 收的不是类型参数,而是上一页讲的运行期值 K::KIND:
/// Non-generic outline of `into_data_async`. The public method just calls this
/// helper, so its monomorphization (per `D`/`K`) is trivial — the heavy async
/// state-machine code lives here, compiled once inside `burn-tensor`.
async fn into_data_async_impl(
primitive: BridgeTensor,
kind: Kind,
) -> Result<TensorData, ExecutionError> {
match kind {
Kind::Float => <Float as BasicOps>::into_data_async(primitive).await,
Kind::Int => <Int as BasicOps>::into_data_async(primitive).await,
Kind::Bool => <Bool as BasicOps>::into_data_async(primitive).await,
}
}
同步的 into_data 再经 try_into_data_sync_impl 用 try_read_sync 阻塞等这个 future(future 怎么被轮询见 tokio 卷的 poll 与 wake)。三种写法的共同点:burn-tensor 里这些函数和 trait 方法都没有 #[inline],下游 crate 只生成一条调用指令,不会把它们的函数体搬过去。
BridgeTensor:一块不说自己装着什么的存储
外提只解决了一半。Tensor 的字段 primitive 会出现在每个泛型方法里(移动、析构、clone),如果这个字段的类型是 DispatchTensor,下游 crate 为了算布局、生成析构代码,还是要把整棵类型树展开。所以 BridgeTensor 的定义里不出现 DispatchTensor:
pub struct BridgeTensor {
blob: bridge_opaque::Opaque,
}
// Aligned, type-erased storage for `BridgeTensorVariant`. See `crate::macros`
// for why this indirection exists (it keeps the dispatch type tree out of
// downstream MIR).
burn_std::obfuscate!(
type: BridgeTensorVariant,
module: bridge_opaque,
derives: [Send, Sync]
);
注释里的 crate::macros 在 0.22.1 里已经不存在了(见下面的冷知识),宏现在来自 cubecl。burn-std 有一行 pub use cubecl_common::*;,burn_std::obfuscate! 就是 cubecl-common 0.11.3 的 obfuscate!。它为每个调用点生成一个私有模块,核心是这个结构体:
const SIZE: usize = ::core::mem::size_of::<$inner>();
const SLOT: usize = ::core::mem::size_of::<*mut ()>();
// ...
const SLOTS: usize = SIZE.div_ceil(SLOT);
// ...
#[repr(C, align($align))]
pub(super) struct Opaque {
data: [::core::mem::MaybeUninit<*mut ()>; SLOTS],
}
SLOTS 是一个常量,在 burn-tensor 编译时就算好了;Opaque 的定义里只有「SLOTS 个指针宽的槽」,不提内部类型。下游 crate 看到的 BridgeTensor 就是一个固定大小、固定对齐的数组,算布局不需要知道里面是什么。
几个细节都在宏的文档里写着理由:
- 槽是
MaybeUninit<*mut ()>而不是u8:内部值里有Arc、Box这样的指针,按字节存会在读回时丢掉指针的来源(provenance),在 Miri 的 Tree Borrows 下是未定义行为;MaybeUninit让枚举里没用到的填充字节可以保持未初始化。 - 不写
align:时对齐固定为 64,宏只断言内部类型的对齐不超过 64。BridgeTensor和Device都没写,所以上一页量到它们按 64 对齐。 new、as_ref、as_mut、into_inner四个方法都是pub(in super::super),只有调用宏的那个模块能用;它们和Drop实现都是具体类型上的非泛型函数,跟着burn-tensor编译一次。- 结构体里有裸指针,默认既不
Send也不Sync,derives: [Send, Sync]是调用方的unsafe断言。
为什么不用 Box<dyn Trait>?宏文档的回答是:要能把具体类型原样取回来(into_inner),也不想每个张量多一次堆分配。
BridgeTensor 对外只暴露种类标签 BridgeKind 和 shape、dtype、rank 这些元数据方法。真正取出 DispatchTensor 的 into_parts / as_parts 和构造用的 float / int / bool / qfloat 都包在 ext_fn! 里:开了 extension feature 是 pub,供写后端扩展的人用;没开是 pub(crate)。
车站的寄存柜每格一样大(SLOTS 个槽、64 字节对齐),格子门上只贴着一张标签(BridgeKind:Float、Int、Bool、QFloat)。管理员(burn-tensor)知道每格里放的是什么箱子(BridgeTensorVariant 和里面的 DispatchTensor),也只有他有钥匙(pub(crate) 的 into_parts)。旅客(下游 crate)搬运、转交、退格(移动、clone、drop)时,只要知道格子多大、交给管理员去开,不用看箱子里的东西,也不用学怎么拆箱子(析构代码)。
比方到这里为止:真的寄存柜格子大小是固定的,Opaque 的大小却是在 burn-tensor 编译时按里面那个枚举算出来的,开的后端 feature 不同,格子就不一样大;而且格子不在别处,它就内联在 Tensor 里跟着走。
Device 用同一个宏包住 DispatchDevice;api/autodiff.rs 包住梯度容器,api/transaction.rs 包住事务。四处 obfuscate! 就是 burn-tensor 里所有可能把 dispatch 类型带进下游的值。
BridgeTensor 先在 #4958(2026-05-15)里出现,那时它就是 pub enum,变体直接装着 DispatchTensor。六天后的 #4977,标题是「perf(core)!: improve compile times via opaque inner types to break dependency chain」,把它改成 blob 字段加一个 crate 私有的 obfuscate_type! 宏,Device 同样处理(提交记录里的条目有「Device blob」「Sanitize outside of generic」「Miri fix」),各个 *_impl 函数和 lib.rs 里那段贡献者说明也是这个 PR 加的。当时宏放在 burn-tensor/src/macros.rs,它的文档写着把内部类型藏起来是为了打断「the chain of type references that would otherwise force downstream crates to monomorphize and resolve the full cubecl-backed type tree just to use a Device or Tensor」,并解释了为什么用指针宽的槽:按字节存曾经在 Miri 下报出「dangling pointer with no provenance」。第二天的 #4997「use obfuscate from burn-std」删掉了这个文件,改用现在的 burn_std::obfuscate!,kind.rs 和 device.rs 里指向 crate::macros 的注释没跟着改。两个 PR 都首次出现在 v0.22.0-pre.1。
实验:同一段用户代码,换成直接持有 DispatchTensor
注释说这样做「drastically cuts compile times」。没有现成的「不带 bridge 的 0.22」可以对照,所以实验做了一个替身:两个只有 lib.rs 的 crate,用户代码一字不差,区别只在用的张量类型。
tensor-bridge-a:用真的burn::tensor::Tensor<D>。tensor-bridge-b:用自己写的MyTensor<D>,唯一的字段是DispatchTensor,泛型方法直接调Dispatch的 op,也就是「没有BridgeTensor和外提时Tensor<D>大致的样子」。
B 的类型和方法(摘要):
pub struct MyTensor<const D: usize> {
inner: DispatchTensor,
}
impl<const D: usize> MyTensor<D> {
fn exp(self) -> Self {
Self { inner: Dispatch::float_exp(self.inner) }
}
fn matmul(self, other: Self) -> Self {
same_shape(&self.inner, &other.inner, "matmul");
Self { inner: Dispatch::float_matmul(self.inner, other.inner) }
}
// log、transpose、sum_dim、mean_dim、swap_dims 同样写法
}
// Add、Sub、Mul、Div、Neg 用宏实现,同样直接调 Dispatch::float_add 等
两边共用的用户代码:16 个泛型函数,每个做 12 个 op,每个函数在 D = 1..4 上各实例化一次:
macro_rules! steps {
($($name:ident),*) => {
$(
fn $name<const D: usize>(a: T<D>, b: T<D>) -> T<D> {
let c = a.clone() + b.clone();
let c = c * b.clone();
let c = c - a.clone();
let c = c.exp();
let c = c.log();
let c = -c;
let c = c / b.clone();
let c = c.matmul(b.clone().transpose());
let c = c.sum_dim(0);
let c = c.mean_dim(0);
let c = c.swap_dims(0, D - 1);
c + a
}
)*
pub fn run(device: &Device) {
$(
let _ = $name::<1>(make(device), make(device));
let _ = $name::<2>(make(device), make(device));
let _ = $name::<3>(make(device), make(device));
let _ = $name::<4>(make(device), make(device));
)*
}
};
}
steps!(s0, s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11, s12, s13, s14, s15);
两个 crate 的依赖、feature、Cargo.lock 完全相同,依赖都已编好。每次 touch src/lib.rs 后 cargo build --lib,只重编叶子 crate,测整条命令的墙钟时间,各跑 5 次:
dev tensor-bridge-a run 1: 1.33 s
dev tensor-bridge-a run 2: 1.39 s
dev tensor-bridge-a run 3: 1.16 s
dev tensor-bridge-a run 4: 0.90 s
dev tensor-bridge-a run 5: 0.86 s
dev tensor-bridge-b run 1: 0.91 s
dev tensor-bridge-b run 2: 0.87 s
dev tensor-bridge-b run 3: 1.13 s
dev tensor-bridge-b run 4: 1.08 s
dev tensor-bridge-b run 5: 1.25 s
release tensor-bridge-a run 1: 2.30 s
release tensor-bridge-a run 2: 1.58 s
release tensor-bridge-a run 3: 2.05 s
release tensor-bridge-a run 4: 1.32 s
release tensor-bridge-a run 5: 2.01 s
release tensor-bridge-b run 1: 8.82 s
release tensor-bridge-b run 2: 5.77 s
release tensor-bridge-b run 3: 4.28 s
release tensor-bridge-b run 4: 4.68 s
release tensor-bridge-b run 5: 6.67 s
时间的波动很大(机器上同时有别的构建),但两种构建的差别一致:debug 下两边差不多,release 下 B 慢 2 到 4 倍。原因要看叶子 crate 生成了什么。用 cargo rustc --lib -- --emit=llvm-ir 导出 LLVM IR,数 define 开头的函数定义,再按符号里出现的 crate 名分类:
debug a: defines=127 drop_glue=4 burn_fusion=0 burn_cubecl(_fusion)=0 burn_router=0 burn_remote=0
release a: defines=48 drop_glue=3 burn_fusion=0 burn_cubecl(_fusion)=0 burn_router=0 burn_remote=0
debug b: defines=166 drop_glue=8 burn_fusion=0 burn_cubecl(_fusion)=0 burn_router=0 burn_remote=0
release b: defines=450 drop_glue=155 burn_fusion=187 burn_cubecl(_fusion)=66 burn_router=112 burn_remote=114
- release 下 B 的叶子 crate 里多出了 155 个析构胶水(drop glue)和几百个
burn_fusion、burn_cubecl、burn_router、burn_remote的泛型实例,例如Dag::new::<Block<RouterGraphExecution<RemoteChannel>>>。MyTensor<D>被移动、丢弃时要析构DispatchTensor,析构一路展开到 fusion 流和远程通道的内部类型。A 的叶子 crate 里一个也没有:析构BridgeTensor只是调用burn-tensor里已经编好的Opaque::drop。 - debug 下两边差不多,是因为 debug 默认开着 share-generics:上游 crate 已经实例化过的泛型(包括
DispatchTensor的析构胶水),下游直接链接上游那一份,不再生成。release 关掉了它,代价才显出来。 - A 的 48 个定义里,提到 cubecl 的只有
cubecl_zspace::Shape的Debug实现,和后端无关。
这个对照有局限:B 是替身,不是真的「0.22 去掉 bridge」,它少了 TensorCheck、广播和 [..., B, 1, K] 的改写,函数体比 A 简单;类型树的大小也取决于开了哪些 feature(这里开了 fusion、remote、autodiff 等一长串)。它能说明的是机制:只要泛型代码里出现 DispatchTensor,release 下析构和相关泛型就会在每个下游 crate 里重新生成一遍;bridge 让这部分留在 burn-tensor 里只编一次。「drastically」到底省多少,取决于用户 crate 的大小和 feature 组合,这里没有测真实项目。
小结
- 泛型方法体里只留依赖
D的东西:检查、形状计算、按秩改写;真正的调用外提到不泛型的种类 trait 方法(K::matmul)或*_impl函数,都只经手BridgeTensor。 K::KIND把类型上的种类变成运行期的值,重的 async 实现因此可以写成一个不泛型的函数。BridgeTensor和Device都用obfuscate!存成「若干指针宽的槽 + 64 字节对齐」,定义里不提内部类型,析构和clone在burn-tensor里编译一次。- 对照实验:直接持有
DispatchTensor的替身在 release 下让叶子 crate 多出 155 个析构胶水和几百个 fusion / router / remote 实例,编译慢 2 到 4 倍;debug 下 share-generics 掩盖了这个差别。