burn-ir
主线位置:算子主线第 5 步用到的数据格式。GPU 设备上
Fusion::float_matmul不调内核,而是把这次调用写成一条OperationIr送进流(见一次 matmul)。本页讲这条数据长什么样、里面的张量怎么描述、执行时怎么按 id 找到真正的显存句柄;数据进了流之后怎么排队,见 Fusion 装饰器。
flowchart TB callop["Fusion float_mul"] --> opir["OperationIr 一条数据"] opir --> tir["TensorIr id 形状 状态"] opir --> fusion["Fusion 流和融合搜索"] opir --> router["BackendRouter"] router --> remote["Remote 发到服务器"] router --> capture["Capture 记成 GraphIr"] fusion --> hc["HandleContainer 按 id 取句柄"] remote --> interp["TensorInterpreter 重放"] interp --> hc
burn-ir 只有 8 个源文件,8 311 行,其中 operation.rs 一个文件 5 292 行,几乎全是枚举、结构体和对它们的 match。crate 文档开头一句话说清了它为什么存在:
Describing work as data rather than calls lets it be inspected, optimized and moved before it runs.
「调用」一发出就结束了;写成数据之后,可以先看(融合搜索要看前后几个 op 能不能合并)、先改(把张量 id 换成相对编号再查缓存)、先搬(序列化发到另一台机器)。下面先看数据本身,再看谁在用。
OperationIr:两层枚举
顶层 OperationIr 有 14 个变体,按「张量种类 + op 类别」分,第二层才是具体的 op:
pub enum OperationIr {
/// Basic operation on a float tensor.
BaseFloat(BaseOperationIr),
/// Basic operation on an int tensor.
BaseInt(BaseOperationIr),
/// Basic operation on a bool tensor.
BaseBool(BaseOperationIr),
/// Numeric operation on a float tensor.
NumericFloat(DType, NumericOperationIr),
/// Numeric operation on an int tensor.
NumericInt(DType, NumericOperationIr),
/// Operation specific to a bool tensor.
Bool(BoolOperationIr),
/// Operation specific to an int tensor.
Int(IntOperationIr),
/// Operation specific to a float tensor.
Float(DType, FloatOperationIr),
/// Module operation.
Module(ModuleOperationIr),
/// Initialize operation.
Init(InitOperationIr),
/// A custom operation.
Custom(CustomOpIr),
/// A tensor is dropped.
Drop(TensorIr),
/// Operation specific to a distributed tensor.
Distributed(DistributedOperationIr),
/// Activation function operation (relu, gelu, sigmoid, softmax, …).
Activation(ActivationOperationIr),
}
| 第二层枚举 | 变体数 | 装的是什么 |
|---|---|---|
NumericOperationIr | 56 | 浮点和整数共有的算术、比较、归约:Add、MulScalar、SumDim、ArgMax…… |
ModuleOperationIr | 53 | 卷积、池化、插值、Linear、Embedding、Attention 及其反向 |
FloatOperationIr | 35 | 只有浮点才有的:Exp、Log、三角函数、Matmul、Random、量化 |
BaseOperationIr | 30 | 三种张量都有的:Reshape、Slice、Gather、Cat、Cast、Equal…… |
ActivationOperationIr | 14 | Relu、Gelu、Softmax 等激活函数及其反向 |
IntOperationIr / BoolOperationIr / DistributedOperationIr | 13 / 6 / 2 | 位运算、整数 Matmul;布尔的与或非;AllReduce |
两层的好处是共用:Add 只在 NumericOperationIr 里定义一次,浮点张量的加法是 NumericFloat(F32, Add(..)),整数的是 NumericInt(I32, Add(..));Reshape 也只有一份,靠外层的 BaseFloat / BaseInt / BaseBool 区分。dtype 是运行期的值,跟在外层变体里,和 0.22 的 Tensor<D, K> 去掉后端参数是同一个方向(见张量与种类)。
变体里装的是一百多个 *OpIr 结构体(文件里 109 个 pub struct),每个都只是「输入、输出、参数」。最常见的 BinaryOpIr:
pub struct BinaryOpIr {
pub lhs: TensorIr,
pub rhs: TensorIr,
pub out: TensorIr,
}
所有这些类型都派生 Clone, Debug, Hash, PartialEq, Serialize, Deserialize。Hash 和 PartialEq 给融合搜索查缓存用(见融合搜索),Serialize 给 Remote 走网络、Capture 导出用。OperationIr 自己的方法只有几个,全是对 14 个变体的 match 转发(impl OperationIr):inputs()、outputs()、nodes()(两者相连)、visit_mut(就地改写其中的张量、标量、切片范围),和一个 mark_read_only。后者在 0.22.1 的工作区里没有调用者。
谁来填输出?builder.rs 用宏 impl_ir_create! 给每个 *OpIr 生成 create 构造函数,输入是已有的 TensorIr,输出的形状和 dtype 在这里推出来,id 由调用方的闭包给。matmul 的那一份(builder.rs):
impl_ir_create!(
MatmulOpIr {
lhs: TensorIr,
rhs: TensorIr
},
shape = calculate_matmul_output(&lhs.shape, &rhs.shape).unwrap(),
dtype = output_dtype_mixed([&lhs.dtype, &rhs.dtype]).unwrap(),
// Additional constructor for mixed dtypes
create_mixed(out_dtype: DType)
);
所以 Fusion 登记一个 op 时,输出张量的形状已经算好了,不用等内核跑完;后面的 op 可以接着用这个形状继续登记。
实验:一条 OperationIr 打出来
实验 crate fusion-lab 依赖 burn(默认 feature,加 metal、tracing、capture)、burn-ir、burn-fusion。burn-fusion 有一个公开的观察接口 FusionObserver:装上之后,融合服务每登记一个 op 就回调一次 registered(&OperationIr)。下面的观察者遇到第一个 NumericFloat 时,用 {:#?} 和 serde_json 各打一遍:
struct Once(AtomicBool);
impl FusionObserver for Once {
fn registered(&self, op: &OperationIr) {
if matches!(op, OperationIr::NumericFloat(..)) && !self.0.swap(true, Ordering::SeqCst) {
println!("{op:#?}");
println!("json: {}", serde_json::to_string(op).unwrap());
}
}
fn block_starts(&self, _block: BlockOperations<'_>) {}
fn block_ran(&self) {}
}
let _obs = FusionObservation::new(Arc::new(Once(AtomicBool::new(false))));
let a = small(&device, 1.0); // 2x2 Tensor::from_floats on Device::metal(..)
let b = small(&device, 2.0);
let d = a * b;
let _ = d.into_data();
debug 构建,M2 Max、macOS 27、rustc 1.99.0(rhs、out 两段的 shape 和 lhs 的一样,省略,标 ...):
NumericFloat(
F32,
Mul(
BinaryOpIr {
lhs: TensorIr {
id: TensorId {
value: 1,
},
shape: Shape {
dims: [
2,
2,
],
},
status: ReadWrite,
dtype: F32,
},
rhs: TensorIr {
id: TensorId {
value: 2,
},
...
status: ReadWrite,
dtype: F32,
},
out: TensorIr {
id: TensorId {
value: 3,
},
...
status: NotInit,
dtype: F32,
},
},
),
)
json: {"NumericFloat":["F32",{"Mul":{"lhs":{"id":{"value":1},"shape":{"dims":[2,2]},"status":"ReadWrite","dtype":"F32"},"rhs":{"id":{"value":2},"shape":{"dims":[2,2]},"status":"ReadWrite","dtype":"F32"},"out":{"id":{"value":3},"shape":{"dims":[2,2]},"status":"NotInit","dtype":"F32"}}}]}
一次 a * b 就是这么多:两个输入、一个输出,各带 id、形状、状态、dtype。没有显存地址,没有后端类型。
TensorIr 和三种状态
TensorIr 的文档注释说它是「a snapshot of a tensor when it was used」:
pub struct TensorIr {
/// The [tensor id](TensorId).
pub id: TensorId,
/// The shape of the tensor.
pub shape: Shape,
/// The [status](TensorStatus) of the tensor when it was used.
pub status: TensorStatus,
/// The [type](DType) of the tensor.
pub dtype: DType,
}
同一个张量每被用一次,就留下一份快照,status 每次可以不同。TensorStatus 三个值:
| 状态 | 含义 | 什么时候出现 |
|---|---|---|
NotInit | 还没有句柄 | op 的输出:登记时数据还不存在 |
ReadOnly | 能读不能写 | 这次用完之后,别处还持有这个张量 |
ReadWrite | 可以就地改写 | 这是最后一次使用,用完就可以释放,或者让输出直接复用它的显存 |
上面的实验里 a、b 被 move 进 a * b,没有别的副本,所以两个输入都是 ReadWrite。如果写成 a.clone() * b.clone(),a、b 还活着,状态就是 ReadOnly。状态不是用户写的,是 Fusion 在把张量变成 TensorIr 的那一刻按引用计数算的,规则见 Fusion 装饰器。
TensorId 只是一个 u64,在 burn-fusion 里由一个全局的 AtomicU64 发号。有了这三个状态,IR 本身就带着内存管理需要的信息:执行器看到 ReadWrite 就知道这是最后一次读,读完可以释放。
(a * b + c).exp() 一共登记了几条 OperationIr?Metal 设备,a、b、c 是三个 from_floats 建的 2×2 张量,全部 move 进表达式,结果 d 最后 into_data()。从三个 from_floats 开始数到读回为止,Fusion 收到几条 OperationIr?其中有没有 Drop?答案在本页末尾。
HandleContainer:按 id 找句柄
IR 里只有 id,执行时需要真正的显存句柄。HandleContainer<H> 就是这张表:
pub struct HandleContainer<H> {
handles: HashMap<TensorId, Handle<H>>,
counter: u64,
// ...
errored: usize,
}
H 由后端决定,cubecl 后端是 CubeFusionHandle(显存句柄加 strides、dtype 等)。取句柄的 get_handle 把状态用上了:
let (id, handle) = self
.take(id)
.unwrap_or_else(|| panic!("Should have handle for tensor {id:?}"));
match handle {
Handle::Existing(handle) => match status {
TensorStatus::ReadOnly => {
self.put(id, Handle::Existing(handle.clone()));
handle
}
TensorStatus::ReadWrite => handle,
TensorStatus::NotInit => panic!(
"Cannot get uninitialized tensor {id:?}. Tensor exist but with wrong status"
),
},
先把条目拿出来;ReadOnly 再放回去一份克隆,ReadWrite 就不放回了。表里没有了这个条目,句柄的最后一个引用随着 op 执行完被丢掉,显存回到 cubecl 的内存池。表里找不到 id 时 panic 的就是这一行:检查与报错里跨设备 cat 在 DSU-100-0 线程上报的 Should have handle for tensor TensorId { value: 1 },就是另一个设备的张量 id 不在这个设备的表里。
条目除了 NotInit 和 Existing(H),还有第三种 Handle::Errored(TensorError):写它的那段工作失败了,失败原因挂在这个张量上,等有人读它时再报。这套机制在 Fusion 装饰器里有实验。fork 复制整张表给 autotune 试跑用,GPU 缓冲是引用计数的,复制的只是映射。
句柄和后端张量类型之间的换算是 BackendIr:四对 float_tensor(handle) / float_tensor_handle(tensor) 之类的函数。实现它的有 CubeBackend、Flex、NdArray、Fusion<B> 和 BackendRouter<R>。CubeBackend 有两份实现,按 burn-cubecl 的 fusion feature 二选一:开着时 Handle = CubeFusionHandle(burn-cubecl/src/fusion.rs),关着时直接是 CubeTensor。
谁在用 IR
| 使用者 | 用 IR 做什么 | 怎么核实的 |
|---|---|---|
burn-fusion | 每个 op 登记成 OperationIr 进流,融合搜索看 IR 决定怎么合并,执行时用 HandleContainer 取句柄 | 本页和后三页的实验 |
burn-router | BackendRouter<R> 的每个 op 都调 RouterClient::register_op(OperationIr);接收端的 TensorInterpreter<B: BackendIr> 在真后端上重放 | 读源码 |
burn-remote | RemoteClient 实现 RouterClient,把 IR 发到服务器;服务器每个会话一个 TensorInterpreter | 读源码,没有起服务器 |
burn-capture | CaptureClient 也实现 RouterClient,只记录不执行,产出 GraphIr(op 序列加输入输出 id),给 ONNX 导出之类的工具 | 下面的实验 |
Router、Remote、Capture 不是类型层的装饰器:BackendRouter<R: RouterChannel> 的参数是一个通道,Remote 和 Capture 只是两种 RouterClient。Fusion 和它们共享的只是这份数据格式,执行路径完全不同:Fusion 在本机攒 op、生成融合内核;Router 一族把 op 交给别处。Fusion<B> 也实现了 BackendIr(句柄类型就是 FusionTensor),注释写着「to enable router backend usage」:服务器端的解释器可以跑在一个带融合的后端上。
实验:同一个 relu,两个使用者看到的不一样
ActivationOperationIr 的文档注释说,激活函数单独成一个枚举,是为了让后端「match a single arm to dispatch the fused implementation, instead of receiving the function decomposed into 5–20 primitive ops」。但 Fusion 对 ActivationOps 的实现是空的,全部走默认实现。同一个 relu(a * b + 1.0) 分别交给 Metal(经 Fusion)和 Device::capture():
fn compute(device: &Device) -> Tensor<2> {
let a = Tensor::<2>::from_floats([[1.0, -2.0], [3.0, -4.0]], device);
let b = Tensor::<2>::from_floats([[5.0, 6.0], [7.0, 8.0]], device);
relu(a * b + 1.0)
}
// Metal: a FusionObserver prints every registered op.
let out = compute(&metal);
// Capture: the scope records instead of executing.
let cap = Device::capture();
let graph = cap
.capture_scope(|scope| {
let _out = compute(&cap);
scope.complete([], [])
})
.unwrap();
for op in &graph.graph.operations { /* print the variant */ }
--- Metal (Fusion)
fusion registered: Init
fusion registered: Init
fusion registered: NumericFloat(F32, Mul)
fusion registered: NumericFloat(F32, AddScalar)
fusion registered: NumericFloat(F32, LowerEqualElem)
fusion registered: BaseFloat(MaskFill)
result [6.0, 0.0, 22.0, 0.0]
--- Device::capture()
captured: Init
captured: Init
captured: NumericFloat(F32, Mul)
captured: NumericFloat(F32, AddScalar)
captured: Activation(Relu)
inputs [] outputs [], 2 constant(s)
GraphIr as JSON: 924 bytes
- Fusion 收到的 relu 已经被默认实现拆成「
x <= 0比较 +mask_fill」两条通用 op(默认实现见 Backend trait)。逐元素融合器认识这两条,和前面的Mul、AddScalar编进了同一个内核(BURN_FUSION_LOG=full里是一个fused ElementWise (score=730, 4 ops));它不认识Activation变体(融合器对它的处理见生成融合内核)。 - Router 一族实现了
ActivationOps,Capture 记下的是一条Activation(Relu),GraphIr里还是一个 relu。 from_floats在两边都是一条Init:数据已经在设备上(或在 Capture 的常量表里),Init只是告诉 IR 的使用者「这个 id 从这里开始存在」。
现在的 burn-ir 是 #2796(「Refactor/burn ir」,首次发布于 v0.17.0)从 burn-tensor/src/repr/ 搬出来的。提交信息列了几步:「Move IR to its own crate」「Rename description to (intermediate) repr」「Change to Ir suffix」。diff 里能看到改名前后:OperationDescription → OperationIr,TensorDescription → TensorIr,FloatOperationDescription → FloatOperationIr。当时数值类 op 还带元素类型参数(NumericOperationIr<E>);到 0.22.1,dtype 变成外层变体里的运行期值 NumericFloat(DType, ..)。
动脑筋答案
六条,没有 Drop。fusion-lab ir 模式用观察者打印每条登记(t<id>:<状态>;debug 构建;块执行和内核提交的行省略,标 ...,完整输出在 Fusion 装饰器):
[registered] Init in=[] out=[t1:NotInit]
...
[registered] Init in=[] out=[t2:NotInit]
...
[registered] Init in=[] out=[t3:NotInit]
...
[registered] NumericFloat(F32, Mul) in=[t1:ReadWrite, t2:ReadWrite] out=[t4:NotInit]
[registered] NumericFloat(F32, Add) in=[t4:ReadWrite, t3:ReadWrite] out=[t5:NotInit]
[registered] Float(F32, Exp) in=[t5:ReadWrite] out=[t6:NotInit]
- 三条
Init来自三个from_floats,三条计算各一条。 - 每个张量都只用了一次,输入全是
ReadWrite:t1、t2、t3被 move 进去,中间结果t4、t5只被下一个 op 读一次。最后一次读本身就宣告了释放,不需要单独的Drop:不融合时HandleContainer::get_handle按ReadWrite取走句柄;这三条被融合成一个内核时,t4、t5根本不落到显存(见生成融合内核)。 d(t6)被into_data消费,读的时候也是ReadWrite。- 换成
let d = (a.clone() * b.clone() + c.clone()).exp(); drop(d);(drop模式),三个输入变成ReadOnly,并且多出一条Drop in=[t6:ReadWrite]:d没被任何 op 消费就离开了作用域,只能靠一条Drop告诉服务端释放它。什么时候登记Drop、什么时候不登记,是 Fusion 装饰器里引用计数那一节的内容。
小结
burn-ir把每个后端 op 写成数据:OperationIr两层枚举,外层按张量种类和类别分 14 种、带运行期 dtype,内层是具体 op,载荷是一百多个「输入 + 输出 + 参数」的*OpIr结构体,全部可哈希、可序列化。- 输出的形状和 dtype 在构造 IR 时就由
create推出,后面的 op 不用等内核就能接着登记。 TensorIr是张量某一次被使用时的快照,NotInit/ReadOnly/ReadWrite告诉执行器这次能不能改写、是不是最后一次读;HandleContainer::get_handle对ReadWrite取走条目,显存随之释放。- 用这份格式的有四方:Fusion 在本机攒 op 融合;
BackendRouter把 op 交给RouterClient,Remote 发往服务器、Capture 记成GraphIr。 - 同一个 relu,Fusion 收到的是拆开的比较加
mask_fill,Capture 记下的是一条Activation(Relu)。