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: usize | Tensor<2> 和 Tensor<3> 是两个类型,reshape 要写出目标秩 |
| 种类 | 类型参数 K,默认 Float | Tensor<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: Basic | api/base.rs、api/take.rs | reshape、slice、to_device、into_data | Float、Int、Bool |
K: Numeric | api/numeric.rs、api/pad.rs | add、matmul、sum_dim、random | Float、Int |
K: Ordered | api/orderable.rs | sort、argmax、clamp | Float、Int |
K: FloatMath | api/float.rs 末尾 | atan2 等 | Float |
具体类型 Tensor<D> / Tensor<D, Int> / Tensor<D, Bool> | api/float.rs、api/int.rs、api/bool.rs | exp、erf、int;arange;bool_not | 各自一种 |
K: Autodiff | api/autodiff.rs | is_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 约束。
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:五维以内的形状不分配堆内存。
问: 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 能用在可变引用上,例如更新模型里的权重。
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 上唯一的左操作数会被原地写。