Rust in Everything
基于 burn 0.22.1

Tensor 与 Kind

主线位置:算子主线的起点。a.matmul(b) 里的 a、b 就是本页的 Tensor<2>:本页讲它的类型参数各管什么、里面装着什么、为什么 matmul 拿走 a 的所有权。matmul 的方法体怎么把调用交出去见 BridgeTensor 与 *_impl,张量落在哪个后端由 Device 决定。

flowchart TB
  t["Tensor D K"] --> prim["primitive BridgeTensor"]
  t --> pk["PhantomData K"]
  prim --> blob["Opaque 定长存储"]
  blob --> var["BridgeTensorVariant"]
  var --> fl["Float"]
  var --> it["Int"]
  var --> bo["Bool"]
  var --> qf["QFloat"]
  fl --> dt["DispatchTensor"]
  pk --> basic["K: Basic"]
  basic --> num["Numeric"]
  num --> ord["Ordered"]
  num --> fm["FloatMath"]

本页的实验 crate 都从模板 base 复制,依赖 burn =0.22.1(开了 flex、ndarray、metal、autodiff、fusion 等 feature,去掉了模板里会让计时失真的 autotune-checks,见怎么读这一卷),平台 M2 Max、macOS 27、rustc 1.99.0。

一个字段的结构体

Tensor 的定义只有几行:

#[derive(new, Clone, Debug)]
pub struct Tensor<const D: usize, K = Float>
where
    K: Basic,
{
    pub(crate) primitive: BridgeTensor,
    _kind: PhantomData<K>,
}

两个类型参数和两个运行期的值分工如下:

信息放在哪例子
秩(维数)类型参数 const D: usizeTensor<2> 和 Tensor<3> 是两个类型,reshape 要写出目标秩
种类类型参数 K,默认 FloatTensor<2, Int> 没有 exp,Tensor<2, Bool> 没有 matmul
后端运行期,primitive 里的 DispatchTensor同一个 Tensor<2> 可以在 Flex 上,也可以在 Metal 上
dtype运行期,tensor.dtype()Float 张量可以是 F32、F16、BF16,取决于设备设置或创建时指定的值

秩放在类型上,是为了让 [usize; D] 形状数组、dims() 的返回值、reshape::<D2> 这类 API 在编译期就对得上;后端不放在类型上,是 0.22 的主旨(#4717,2026-05),代价和收益在设计取舍里比较。种类介于两者之间:它决定哪些方法存在,所以要在类型上;但它是一个封闭的小集合,不需要像后端那样可扩展。

primitive 的类型 BridgeTensor 不是 DispatchTensor,而是一块「不说自己装着什么」的定长存储(下一页讲它为什么这样做)。这块存储的大小在编译期由里面那个枚举决定,对齐固定为 64 字节。实测:

println!("Tensor<2>: size {} align {}", size_of::<Tensor<2>>(), align_of::<Tensor<2>>());
println!("Tensor<4, Int>: size {} align {}", size_of::<Tensor<4, Int>>(), align_of::<Tensor<4, Int>>());
println!("Device: size {} align {}", size_of::<Device>(), align_of::<Device>());
== sizes
Tensor<2>: size 384 align 64
Tensor<4, Int>: size 384 align 64
Device: size 128 align 64

秩和种类不影响大小,因为它们都只在类型上。384 这个数取决于开了哪些后端 feature:DispatchTensor 里每个开启的后端占一个枚举变体,最大的那个决定整体大小。这块存储内联在 Tensor 里,不在堆上,所以移动一个 Tensor 是复制 384 字节,不是复制一个指针。

种类:Float、Int、Bool

三个种类是三个空结构体,定义在 bridge/kind.rs。它们共同实现 TensorKind:

/// A type-level representation of the kind of a tensor.
/// Metadata access is lazy.
///
/// # Notes
/// This trait is intentionally sealed to keep the set of tensor kinds closed.
///
/// Although exposed publicly, tensor kinds are not meant to be extensible:
/// the backend dispatch system, `DType`, and all tensor ops assume a fixed,
/// closed set of tensor kinds (e.g. Float, Int, Bool), each mapping directly to a
/// corresponding backend implementation.
pub trait TensorKind: sealed::Sealed + Clone + Send + Sync + core::fmt::Debug {
    /// The tensor kind identifier.
    const KIND: Kind;

    /// The name of the tensor kind.
    fn name() -> &'static str {
        Self::KIND.as_str()
    }
}

sealed::Sealed 在一个私有模块里,外部 crate 实现不了它,种类集合因此是封闭的。const KIND 把类型上的种类换成一个运行期的值 Kind(Float / Int / Bool 三个变体的枚举)。这一点在下一页有用:泛型方法可以把 K::KIND 当普通参数传给一个不泛型的函数,由那个函数在运行期 match。

有一个「种类」不在类型上:量化浮点。BridgeTensor 内部的枚举 BridgeTensorVariant 有四个变体 Bool、Int、Float、QFloat,但用户只能写 Tensor<D, Float>。量化张量在类型上仍是 Float,各个 op 在运行期看变体:Float 的 matmul 对 (Float, QFloat) 这样的组合走 q_matmul,对 Int 或 Bool 变体则 panic!("Should be Float primitive kind")。后一种情况用户代码走不到,因为类型已经排除了。

公开 trait 包着内部 trait

决定「一个方法对哪种张量可用」的是 tensor/kind.rs 里的六个公开 trait:

/// The base trait for any tensor kind.
pub trait Basic: crate::ops::BasicOps {}
impl<K: crate::ops::BasicOps> Basic for K {}

/// Kinds that support numeric operations.
pub trait Numeric: Basic + crate::ops::Numeric {}
impl<K: Basic + crate::ops::Numeric> Numeric for K {}

/// Kinds that support ordered operations.
pub trait Ordered: Numeric + crate::ops::Ordered {}
impl<K: Numeric + crate::ops::Ordered> Ordered for K {}

/// Kinds that support float math operations.
pub trait FloatMath: Numeric + crate::ops::FloatMathOps {}
impl<K: Numeric + crate::ops::FloatMathOps> FloatMath for K {}
// ...

每个公开 trait 都是一个空壳,加一条 blanket impl:凡是实现了对应内部 trait 的种类,自动实现公开 trait。内部 trait(BasicOps、Numeric、Ordered、FloatMathOps、TransactionOp、BasicAutodiffOps)都是 pub(crate),方法签名里只有 BridgeTensor、Shape、Device 这些类型。文件头一行注释 #![allow(private_bounds)] 说明了这个布局的用意:公开 trait 的父 trait 是私有的,外部能拿它们做约束,却没法实现,也看不到里面的方法。

Tensor 的方法按约束分在不同文件里:

impl 块的约束文件典型方法实现者
K: Basicapi/base.rs、api/take.rsreshape、slice、to_device、into_dataFloat、Int、Bool
K: Numericapi/numeric.rs、api/pad.rsadd、matmul、sum_dim、randomFloat、Int
K: Orderedapi/orderable.rssort、argmax、clampFloat、Int
K: FloatMathapi/float.rs 末尾atan2 等Float
具体类型 Tensor<D> / Tensor<D, Int> / Tensor<D, Bool>api/float.rs、api/int.rs、api/bool.rsexp、erf、int;arange;bool_not各自一种
K: Autodiffapi/autodiff.rsis_autodiff、without_autodiff三种都有

用错种类时,rustc 的报错会把内部 trait 的完整路径说出来(tensor-kind-err crate,cargo build --bin kinderr):

use burn::tensor::{Bool, Device, Tensor};

fn main() {
  let device = Device::flex();
  let a = Tensor::<2, Bool>::empty([2, 2], &device);
  let _ = a.clone().matmul(a);
  let _s: Tensor<2, String>;
}
error[E0599]: the method `matmul` exists for struct `burn::Tensor<2, burn::tensor::Bool>`, but its trait bounds were not satisfied
  --> src/bin/kinderr.rs:6:21
   |
 6 |   let _ = a.clone().matmul(a);
   |                     ^^^^^^ method cannot be called on `burn::Tensor<2, burn::tensor::Bool>` due to unsatisfied trait bounds
   |
  ::: ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/burn-tensor-0.22.1/src/bridge/kind.rs:16:1
   |
16 | pub struct Bool;
   | --------------- doesn't satisfy `_: Numeric` or `burn::tensor::Bool: burn::tensor::kind::Numeric`
   |
   = note: the following trait bounds were not satisfied:
           `burn::tensor::Bool: burn_tensor::bridge::ops::numeric::Numeric`
           which is required by `burn::tensor::Bool: burn::tensor::kind::Numeric`

error[E0277]: the trait bound `std::string::String: burn_tensor::bridge::ops::base::BasicOps` is not satisfied
  --> src/bin/kinderr.rs:7:11
   |
 7 |   let _s: Tensor<2, String>;
   |           ^^^^^^^^^^^^^^^^^ the trait `burn_tensor::bridge::ops::base::BasicOps` is not implemented for `std::string::String`
...
   = note: required for `std::string::String` to implement `burn::tensor::kind::Basic`
note: required by a bound in `burn::Tensor`
...

报错里同时出现 burn::tensor::kind::Numeric(能 use 的公开 trait)和 burn_tensor::bridge::ops::numeric::Numeric(pub(crate) 的内部 trait),两个都叫 Numeric。读报错时认第一个就行:它告诉你 Bool 不是数值种类。第二条错误说明种类参数不是任意类型都能填,Tensor 结构体本身就带着 K: Basic 约束。

📝NOTE
冷知识:内部 trait 原来带着一个永远是 Dispatch 的后端参数

#4717 去掉 Tensor 的后端参数之后,内部 trait 一时还是 BasicOps<B: Backend> 的形状,公开 trait 写成 pub trait Basic: backend::BasicOps<Dispatch> {},Autodiff 那条 blanket impl 还要求 BasicAutodiffOps<Dispatch, InnerKind = K>。第二天的 #4957「Refactor tensor kind traits」把这层泛型拿掉,提交说明的头两条是「Remove generics from tensor kind traits and Elem associated type」和「Restrict ops traits to pub(crate)」:唯一的后端既然固定是 Dispatch,类型参数和 Elem 关联类型就只剩下噪声。改完之后内部 trait 变成现在的 pub(crate) trait BasicOps: TensorKind,公开 trait 只剩约束的用途。这个 PR 首次出现在 v0.22.0-pre.1。

Kind 这个名字也是后来定的:#5018 把 TensorKind::id() 换成 const KIND,并把 TensorKindId 改名为 Kind;#5022 给 TensorKind 加上了 sealed::Sealed。

TensorData、DType、Shape

张量和后端无关的三样东西都从 burn-std 出来,burn-tensor 原样再导出。

TensorData 是主机内存里的张量内容,into_data 的返回值、from_data 的参数:

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "TensorDataDe")]
pub struct TensorData {
    /// The values of the tensor (as bytes).
    pub(in crate::data) bytes: Bytes,

    /// The shape of the tensor.
    #[serde(with = "shape_inner")]
    pub(in crate::data) shape: Shape,

    /// The data type of the tensor.
    pub(in crate::data) dtype: DType,
}

三个字段都只在 burn_std::data 模块内可见(#5838,「make TensorData fields private and validate construction」),构造函数和反序列化都检查「字节数 = 元素数 × dtype 大小」。反序列化先进 TensorDataDe 再 try_from,就是为了让 serde 走同一个检查(serde 的 try_from 属性见 serde 卷)。这里的 Bytes 是 cubecl 的类型,经 burn-std 再导出,和 bytes 卷讲的 bytes::Bytes 不是同一个东西。

DType 是 15 个变体的枚举:F64、F32、Flex32、F16、BF16、四种有符号整数、四种无符号整数、Bool(BoolStore)、QFloat(QuantScheme)。布尔值怎么存是后端的事,所以 Bool 带一个 BoolStore(Native / U8 / U32)。同样的 zeros 在两个设备上:

let f = Tensor::<2>::zeros([2, 3], &device);       // device = Device::flex()
let i = Tensor::<2, Int>::zeros([2, 3], &device);
let b = Tensor::<2, Bool>::empty([2, 3], &device);
// ... 同样三个张量建在 Device::metal(DeviceKind::DefaultDevice) 上
== kind / dtype / shape
float dtype F32 shape Shape { dims: [2, 3] }
int   dtype I32
bool  dtype Bool(Native)
metal float F32 int I32 bool Bool(U8)

种类相同、设备不同,dtype 就可能不同:Flex 用原生 bool,Metal 用 u8。每个设备的默认 dtype 来自它的设置,弃用的 ndarray 后端上 Int 默认是 I64,详见 Device 的「设置与 configure」。

Shape 来自更下一层:burn-std 的 tensor/shape.rs 写的是 pub use cubecl_zspace::{MetadataError, Shape, ...}。cubecl 0.11.3 里 Shape 只有一个字段 dims: SmallVec<[usize; INLINE_DIMS]>,INLINE_DIMS 是 5:五维以内的形状不分配堆内存。

📝NOTE
没有蠢问题

问: Tensor::<2>::from([[1.0, 2.0], [3.0, 4.0]]) 不用传设备,张量建在哪?

答: 建在 Device::default() 上。From<T> 的实现是 Tensor::from_data(value.into(), &Default::default()),Deserialize 的实现同样用 Device::default()。默认设备由开了哪些 feature 决定,开了 metal 时它是 Cube(Wgpu(... backend: Auto)),和 Device::metal(DeviceKind::DefaultDevice) 不相等,两边的张量放在一起运算会 panic,见 Device 和检查与报错。需要确定设备时用 from_data(data, &device)。

按值消耗 self,clone 只加引用计数

Tensor 的绝大多数方法签名是 fn op(self, ...) -> Self,add、matmul、reshape、exp 都一样。于是同一个张量要用两次,必须先 clone:

let a = Tensor::<2>::ones([2, 2], &device);
let b = a.matmul(a.clone());
error[E0382]: borrow of moved value: `a`
 --> src/bin/moveerr.rs:6:20
  |
5 |   let a = Tensor::<2>::ones([2, 2], &device);
  |       - move occurs because `a` has type `burn::Tensor<2>`, which does not implement the `Copy` trait
6 |   let b = a.matmul(a.clone());
  |           -        ^ value borrowed here after move
  |           |
  |           value moved here
  |
help: consider cloning the value if the performance cost is acceptable
  |
6 |   let b = a.clone().matmul(a.clone());
  |            ++++++++
...

a.matmul(a.clone()) 不行,a.clone().matmul(a) 可以:方法调用先对接收者求值,a 作为 self 已经被移走,参数里的 a.clone() 再借用它就晚了。

clone 不复制数据。Tensor 是 #[derive(Clone)],转到 BridgeTensor::clone,再到 DispatchTensor 和后端张量的 clone。Flex 的张量 FlexTensor 把字节放在 Arc<Bytes> 里,注释直接写着「Clone increments refcount」;cubecl 和 fusion 的张量同样是带引用计数的句柄。

按值传递的好处在另一头:后端拿到 self 时可以问「这块缓冲还有没有别人在用」。Tensor::can_mut 把这个问题暴露出来,Flex 上它就是 is_unique,即 Arc::strong_count(&self.data) == 1。Flex 的逐元素 f32 二元运算 binary_op_f32 第一条路就是用它:

    // In-place SIMD fast path: lhs unique contiguous at offset 0, rhs
    // contiguous (no broadcast).
    if let Some(simd_op) = simd_hint
        && lhs.is_unique()
        && let (Some((0, l_end)), Some((r_start, r_end))) = (
            lhs.layout().contiguous_offsets(),
            rhs.layout().contiguous_offsets(),
        )
    {
        let r_slice: &[f32] = &rhs.storage()[r_start..r_end];
        let lhs_storage: &mut [f32] = lhs.storage_mut();
        let l_slice = &mut lhs_storage[..l_end];

        match simd_op {
            BinaryOp::Add => simd::add_inplace_f32(l_slice, r_slice),
            // ...
        }
        return lhs;
    }

左操作数唯一、连续、从偏移 0 开始时,结果直接写回左操作数的缓冲,不分配新内存;下面还有一条对称的路,右操作数唯一时写回右边。如果 API 是 fn add(&self, ...),调用方手里永远还有一个引用,这两条路都走不到。

实验:clone 和原地写

实验 crate tensor-kind,--release。程序装了一个只计数的全局分配器,记录每段代码向分配器要了多少字节:

for (name, device) in [("flex", Device::flex()), ("metal", metal.clone())] {
  let a = Tensor::<2>::ones([4, 4], &device);
  let before = a.can_mut();
  let a2 = a.clone();
  let shared = a.can_mut();
  drop(a2);
  let after = a.can_mut();
  println!("{name}: unique {before}, after clone {shared}, after drop of clone {after}");
}

let n = 4096;
let x = Tensor::<2>::random([n, n], Distribution::Default, &device); // Flex
let y = x.clone(); // 计时、计分配字节

let rhs = Tensor::<2>::ones([n, n], &device);
for round in 0..3 {
  let lhs = x.clone() + 0.0; // 新建一个唯一持有的缓冲
  let out = lhs + rhs.clone(); // 计时、计分配字节
  drop(out);
  let lhs = x.clone() + 0.0;
  let keep = lhs.clone(); // 让 lhs 不再唯一
  let out = lhs + rhs.clone(); // 计时、计分配字节
  drop((out, keep));
}
== clone shares the buffer (can_mut)
flex: unique true, after clone false, after drop of clone true
metal: unique true, after clone false, after drop of clone true
== clone vs copy cost on flex, 4096x4096 f32 (64 MiB)
clone: 584ns, allocated 16 bytes
== by-value add on flex: unique lhs vs shared lhs
round 0 unique lhs: 2.864292ms, allocated 0.091 MiB
round 0 shared lhs: 6.228667ms, allocated 64.000 MiB
round 1 unique lhs: 1.250541ms, allocated 0.000 MiB
round 1 shared lhs: 2.676833ms, allocated 64.000 MiB
round 2 unique lhs: 1.253792ms, allocated 0.000 MiB
round 2 shared lhs: 2.333834ms, allocated 64.000 MiB
  • 两个设备上 can_mut 都随 clone 的生灭翻转:clone 存在时原缓冲不能原地写,clone 释放后又能。Metal 设备上的张量走 fusion,can_mut 由 FusionTensor 的引用计数回答。
  • 64 MiB 的张量 clone 一次用了 584ns,只向分配器要了 16 字节,数据没有被复制。
  • 左操作数唯一时,lhs + rhs 不分配 64 MiB 的结果缓冲(第 0 轮那 0.091 MiB 是首次运行时的零碎分配);左操作数被 clone 过,每轮都要新分配 64 MiB。第 1、2 轮原地写约 1.25ms,另开缓冲 2.3 到 2.7ms;第 0 轮包含首次运行的开销,不算。耗时只作参考(这台机器上同时有别的构建在跑),分配量是确定的。

要从 &mut Tensor 里拿出一个可以按值消耗的张量,用 extract(换进一个空的零形状张量)或 inplace(取出、变换、放回)。源码注释说 inplace 「is not an optimization」:它只是让按值的 op 能用在可变引用上,例如更新模型里的权重。

❗IMPORTANT
动脑筋:把上面实验里「shared lhs」那一段改成先 drop(keep) 再做加法,lhs + rhs.clone() 会向分配器要多少字节?

平台同上,--release,形状 4096×4096、f32。提示:rhs.clone() 让右操作数也不唯一。

动脑筋答案

程序加一个命令行参数 dropkeep:给了它,就在 let keep = lhs.clone(); 之后立刻 drop(keep),再执行加法。tensor-kind dropkeep 的三轮输出:

round 0 unique lhs: 1.444416ms, allocated 0.091 MiB
round 0 shared lhs: 1.35475ms, allocated 0.000 MiB
round 1 unique lhs: 1.212167ms, allocated 0.000 MiB
round 1 shared lhs: 1.27275ms, allocated 0.000 MiB
round 2 unique lhs: 1.326834ms, allocated 0.000 MiB
round 2 shared lhs: 1.203291ms, allocated 0.000 MiB

答案是 0。clone 释放之后 Arc 的强引用计数回到 1,lhs.is_unique() 又成立,加法走第一条原地路径,结果写回 lhs 的缓冲。rhs.clone() 让右操作数不唯一,只影响第二条「写回右边」的路,第一条路不看它。决定能不能原地写的是调用那一刻的引用计数,不是这个张量过去有没有被 clone 过。

小结

  • Tensor<D, K>:秩和种类在类型上,后端和 dtype 在运行期;唯一的字段 BridgeTensor 是内联的定长存储,本卷的 feature 组合下 Tensor 占 384 字节、按 64 对齐。
  • Float / Int / Bool 是封闭的种类;量化浮点只是 Float 在运行期的一个变体。
  • Basic、Numeric、Ordered、FloatMath 等公开 trait 是空壳,真正的方法在 pub(crate) 的内部 trait 上;rustc 报错时两层的名字会一起出现。
  • TensorData、DType 来自 burn-std,Shape 来自 cubecl 的 cubecl-zspace;同一种类在不同设备上 dtype 可能不同。
  • 方法按值消耗 self,clone 只加引用计数;按值传递让后端能判断缓冲是否唯一,Flex 上唯一的左操作数会被原地写。

💬 关联讨论

发起讨论
加载中...