Rust in Everything
基于 burn 0.22.1

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)。

💡TIP
打个比方:寄存柜的格子

车站的寄存柜每格一样大(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 类型带进下游的值。

📝NOTE
冷知识:这块存储是在 0.22 的预发布阶段才加上的

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 掩盖了这个差别。

💬 关联讨论

发起讨论
加载中...