Rust in Everything
基于 burn 0.22.1

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),
}
第二层枚举变体数装的是什么
NumericOperationIr56浮点和整数共有的算术、比较、归约:Add、MulScalar、SumDim、ArgMax……
ModuleOperationIr53卷积、池化、插值、Linear、Embedding、Attention 及其反向
FloatOperationIr35只有浮点才有的:Exp、Log、三角函数、Matmul、Random、量化
BaseOperationIr30三种张量都有的:Reshape、Slice、Gather、Cat、Cast、Equal……
ActivationOperationIr14Relu、Gelu、Softmax 等激活函数及其反向
IntOperationIr / BoolOperationIr / DistributedOperationIr13 / 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 就知道这是最后一次读,读完可以释放。

❗IMPORTANT
动脑筋:(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-routerBackendRouter<R> 的每个 op 都调 RouterClient::register_op(OperationIr);接收端的 TensorInterpreter<B: BackendIr> 在真后端上重放读源码
burn-remoteRemoteClient 实现 RouterClient,把 IR 发到服务器;服务器每个会话一个 TensorInterpreter读源码,没有起服务器
burn-captureCaptureClient 也实现 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 从这里开始存在」。
📝NOTE
冷知识:IR 在 0.17 之前叫 Description,住在 burn-tensor 里

现在的 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)。

💬 关联讨论

发起讨论
加载中...