Fusion 装饰器
主线位置:算子主线第 5–6 步。
Dispatch的Cube分支落到Fusion<CubeBackend>,每个 op 被写成一条OperationIr送进流,调用立刻返回;到必须出结果的时候,流在设备线程上执行。本页讲 op 怎么从调用线程到达服务端、按什么分流、什么时候执行、FusionTensor怎么用引用计数决定 IR 里的状态。执行时怎么切块、怎么选融合方案见融合搜索。
sequenceDiagram participant M as 调用线程 participant C as GlobalFusionClient participant Q as 通道 32 个槽 participant S as DSU FusionServer M->>C: float_mul 等 op C->>Q: submit 登记任务 不等待 C-->>M: 输出 FusionTensor M->>C: into_data C->>Q: submit_blocking 填满缓冲 Q->>S: 一批任务 S->>S: 按 StreamId 入队 融合搜索 S->>S: drain 流 提交内核 S-->>M: 读回的数据
Fusion 是一个空结构体
Fusion<B> 只有一个 PhantomData<B>。它在类型表里把四种张量原语都换成同一个 FusionTensor<B::FusionRuntime>,设备类型照用 B 的:
impl<B: FusionBackend> BackendTypes for Fusion<B> {
type Device = B::Device;
type FloatTensorPrimitive = FusionTensor<B::FusionRuntime>;
type IntTensorPrimitive = FusionTensor<B::FusionRuntime>;
type BoolTensorPrimitive = FusionTensor<B::FusionRuntime>;
type QuantizedTensorPrimitive = FusionTensor<B::FusionRuntime>;
// ...
}
能被包的后端要实现 FusionBackend:它是 BackendIr 的子 trait(句柄类型和设备类型都由运行时规定),再指定一个 FusionRuntime。运行时说清三件事:句柄是什么(FusionHandle)、设备是什么、有哪些融合器(fn fusers(device))。0.22.1 里只有 CubeBackend 实现了它,FusionCubeRuntime 返回 burn-cubecl 注册表里的五个内置融合器(见生成融合内核)。开没开 fusion,从 Backend::name 就看得出来(fusion-lab 用默认 feature,fusion-nofuse 用 default-features = false, features = ["std", "metal", "autotune", "tracing"],两个 crate 共用同一份 main.rs):
dispatch<fusion<cubecl<wgpu<msl>>>>
dispatch<cubecl<wgpu<msl>>>
一个 op 怎么登记
每个 op 的实现都是同一个形状:建一个 *OpIr,附上一个「真要执行时怎么调 B」的 Operation,交给 register。float_matmul 的全文在一次 matmul里;二元逐元素 op 的 Operation 由宏 binary_float_ops! 生成,执行时按 IR 里的 id 和状态从 HandleContainer 取出 B 的张量、调 B 的 op、把结果按输出 id 登记回去:
impl<B: FusionBackend> Operation<B::FusionRuntime> for $name<B> {
fn execute(
&self,
handles: &mut HandleContainer<B::Handle>,
) -> Result<(), $crate::ExecutionError> {
let lhs = handles.get_float_tensor::<B>(&self.desc.lhs);
let rhs = handles.get_float_tensor::<B>(&self.desc.rhs);
let output = $ops(lhs, rhs);
handles.register_float_tensor::<B>(&self.desc.out.id, output);
Ok(())
}
}
这个 Operation 是不融合时的退路;能融合的话它根本不会被调用,执行的是融合器生成的内核。GlobalFusionClient::register 先按 IR 的输出建好返回给调用方的 FusionTensor,再把「登记」作为一个任务交给服务端:
// Create output tensors returned by this operation
let outputs = repr
.outputs()
.map(|output| {
FusionTensor::new(
output.id,
output.shape.clone(),
output.dtype,
self.clone(),
stream,
)
})
.collect();
// By doing this comparison, we reduce the number of bytes transferred in the device handle
// queue.
if size_of::<O>() < size_of::<UnfusedOp<R>>() {
// Here the [`O`] type is smaller than the [`UnfusedOp`] type, so it's better to
// transfer it directly.
self.server.submit(move |server| {
let operation = UnfusedOp::new(operation, stream);
server.register(stream, repr, operation);
});
} else {
// ...
}
outputs
输出张量的 id 来自 create_empty_handle 的全局 AtomicU64,形状在建 IR 时已经推好,所以调用方拿到的 FusionTensor 立刻就能参与下一个 op。size_of 那个比较只是为了让通道里传的闭包尽量小:UnfusedOp 会把 Operation 放进线程局部的一块 arena(每项最多 512 字节、256 项),放不下才 Arc 到堆上。
from_data 是例外:float_from_data 在调用线程上直接调 B::float_from_data 上传数据,再登记一条 OperationIr::Init,附带的 Operation 是 NoOp。数据不需要等。
服务端在 DSU 线程上,任务成批到达
GlobalFusionClient 手里只有一个 DeviceHandle<FusionServer<R>>。FusionServer 是 cubecl 的一种设备服务,声明自己在上游阶段(DeviceServiceStage::Upstream),所以每个设备一个服务实例,跑在 一次 matmul 里见过的 DSU-<类型>-<编号> 线程上;cubecl 自己的内核编译和下发在下游的 DSD 线程。设备服务线程和它们之间的通道属于 运行时与 ComputeClient,这里只用到一个性质:submit 写进通道就返回,不唤醒服务线程。cubecl 的通道(cubecl-common 的 custom_channel)一个缓冲区有 CHANNEL_MAX_TASK = 32 个槽,写满才交给服务线程;submit_blocking(读数据、sync 走这条)会调 DeviceClient::flush 用空任务把缓冲区补满。
实验:登记什么时候被服务端看到
fusion-lab ir 模式:装一个 FusionObserver(observer.rs,在服务线程上回调 registered / block_starts),再装一个只认 cubecl launch_inner span 的 tracing Layer(每个 span 是一次内核提交,字段里有内核名,做法同一次 matmul)。主线程每打一行 --- 分隔线前先睡 50 ms,给服务线程留出处理时间:
marker("from_floats x3");
let a = small(&device, 1.0);
let b = small(&device, 2.0);
let c = small(&device, 3.0);
marker("let d = (a * b + c).exp()");
let d = (a * b + c).exp();
marker("d.into_data()");
let v = d.into_data().to_vec::<f32>().unwrap();
marker(&format!("result {v:?}"));
debug 构建,autotune 缓存已有:
--- from_floats x3
--- let d = (a * b + c).exp()
--- d.into_data()
[registered] Init in=[] out=[t1:NotInit]
[block] 1 op(s): Init
[registered] Init in=[] out=[t2:NotInit]
[block] 1 op(s): Init
[registered] Init in=[] out=[t3:NotInit]
[block] 1 op(s): Init
[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]
[block] 3 op(s): NumericFloat(F32, Mul), NumericFloat(F32, Add), Float(F32, Exp)
[launch] [DSU-100-16384] burn_cubecl_fusion::optim::elemwise::optimization::elemwise_fuse::ElemwiseFuse
--- result [148.41316, 22026.467, 24154958.0, 195729570000.0]
两次 50 ms 的等待之后,服务端仍然一条登记都没看到:六个任务躺在通道缓冲区里,直到 into_data 的阻塞调用把缓冲区补满。服务端随后按顺序处理:三条 Init 各自成块(没有融合器收它,是空操作),三条计算合成一个块,提交一个融合内核 ElemwiseFuse,在 DSU 线程上。所以「Fusion 是惰性的」有两层:通道先攒满 32 个任务才送达;送达之后,流还要等融合器决定(下一节)。
流:每个线程一个 StreamId
register 的第一个参数是 StreamId::current()。StreamId 是 cubecl 的类型(cubecl-environment/src/stream/id.rs,burn_backend::StreamId 是它的再导出),current 按 StreamPolicy 取值:
| 策略 | 怎么分 | 说明 |
|---|---|---|
PerThread(默认) | 每个 OS 线程第一次用时从全局计数器领一个 | 注释:「the historical behavior and the default」 |
PerTask | tokio 任务 id 哈希,最高位置 1 | 要开 cubecl-environment 的 tokio feature,否则退回 PerThread 并打一条警告;工作窃取的执行器里一个任务换线程也不换流 |
Single | 全部是 0 | wasm 和 no_std 实际就是这样 |
策略可以在 cubecl.toml 的 [streaming] 段或 set_policy 里改;StreamId::executes 可以临时覆盖。
服务端的 MultiStream 为每个 StreamId 维护一条 Stream:一个 OperationQueue(攒着的 IR 和对应的 UnfusedOp)和一个 Processor(融合搜索的状态机)。执行计划的缓存 optimizations 在 MultiStream 上,同一设备的各条流共用。每条流的 IR 只引用本流的张量 id,这是整个设计的前提:一个线程攒的 op 只和同一个线程的 op 一起融合,不同线程的 op 互不打断。
实验:张量交给另一个线程
FusionTensor 是 Send + Clone 的,可以交给另一个线程。threads 模式:主线程登记 x = (a * 2.0).exp()(不读),新线程里 y = x.clone() + 1.0 再读 y:
let x = (a * 2.0).exp();
let h = std::thread::spawn(move || {
println!("spawned thread stream: {}", StreamId::current());
let y = x.clone() + 1.0;
settle();
println!("--- spawned: y.into_data()");
let v = y.into_data().to_vec::<f32>().unwrap();
// ...
});
main thread stream: StreamId(0)
--- main: let x = (a * 2.0).exp()
--- spawn: let y = x.clone() + 1.0 on another thread
spawned thread stream: StreamId(1)
[registered] Init in=[] out=[t1:NotInit]
[block] 1 op(s): Init
[registered] NumericFloat(F32, MulScalar) in=[t1:ReadWrite] out=[t2:NotInit]
[registered] Float(F32, Exp) in=[t2:ReadWrite] out=[t3:NotInit]
[block] 2 op(s): NumericFloat(F32, MulScalar), Float(F32, Exp)
[launch] [DSU-100-16384] burn_cubecl_fusion::optim::elemwise::optimization::elemwise_fuse::ElemwiseFuse
--- spawned: y.into_data()
[registered] NumericFloat(F32, AddScalar) in=[t4:ReadWrite] out=[t5:NotInit]
[block] 1 op(s): NumericFloat(F32, AddScalar)
[launch] [DSU-100-16384] burn_cubecl::kernel::binary::kernel_scalar_binop::KernelScalarBinop<burn_cubecl::kernel::binary::AddOp>
--- spawned: result [8.389055, 55.59815, 404.42868, 2981.9578]
(最后那个块只有一个 op,不值得融合,走的是 CubeBackend 自己的 KernelScalarBinop。)
新线程的 x.clone() 发现 x 的家在 StreamId(0),不能直接把 t3 写进 StreamId(1) 的队列,于是走 FusionTensor::shared_view:领一个新 id t4,阻塞地请服务端执行 tag_shared_view。t3 还没有句柄(产生它的 op 还在主线程的流里排队),服务端只好先把 StreamId(0) 整个 drain,执行出 t3,再把同一块显存以 t4 的名义登记一份。输出里「[block] 2 op(s) 在 y.into_data() 之前」就是这次被迫的执行。之后 y 的 op 只引用 t4,在 StreamId(1) 上照常排队。
什么时候必须执行
把源码里所有会 drain 一条流、或者让流自己执行的路径列出来:
| 触发 | 路径 | 执行的范围 |
|---|---|---|
读数据:into_data / to_data | FusionServer::read_float → prepare_read:要读的张量还没有句柄就 ReadPlan::Drain | 这个张量所在的流 |
Device::sync / flush,memory_pool_usage 等 | try_sync:先 drain_stream(StreamId::current()) 再调 B 的同名方法 | 调用线程的流 |
跨线程 clone / into_ir | tag_shared_view:源张量没有句柄就 drain 源流 | 张量的家所在的流 |
取内层原语(resolve_tensor_*) | 先 drain 张量所在的流 | 同上 |
| 融合器都关上了 | 登记时 Processor 以 ExecutionMode::Lazy 运行,探索完成就执行(见融合搜索) | 已经能确定方案的那一段 |
最后一行不需要任何读:攒下的 op 一旦到达服务端、融合器判断「再来什么也接不上了」,就地执行。long 模式登记 100 个逐元素 op(* 0.5、+ 0.25、sin、abs 轮流),不读,睡 50 ms 再读:
--- let y = chain(x, 100)
[block] 1 op(s), first Init
[block] 65 op(s), first NumericFloat(F32, MulScalar)
[launch] [DSU-100-16384] burn_cubecl_fusion::optim::elemwise::optimization::elemwise_fuse::ElemwiseFuse
--- y.into_data()
[block] 35 op(s), first NumericFloat(F32, AddScalar)
[launch] [DSU-100-16384] burn_cubecl_fusion::optim::elemwise::optimization::elemwise_fuse::ElemwiseFuse
--- done
一个融合内核最多收 65 个 op(原因见生成融合内核),第 66 个让融合器关闭,前 65 个在读之前就提交了。同样的程序换成 70 个 op,两个块都等到 into_data 才执行:第 66 个 op 落在第三个通道缓冲区里,那个缓冲区要等阻塞调用补满才送达。所以 op 什么时候执行,除了看融合器,还看攒了几个任务。
问: 既然 sync 会执行攒下的 op,GPU 计时前后各 sync 一次就够了吗?中间那段 op 是不是早就在跑?
答: 未必在跑。上面的实验里,op 少于一个通道缓冲区、融合器又还开着时,服务端一条都没看到;计时窗口里只有登记的开销,内核全落在结尾那次 sync 里。反过来,op 多到填满缓冲区、融合器又关上了,前半段会在 sync 之前就提交。所以只看「从开始到 sync 返回」的总时间才有意义,不要拿中间的时间戳推断某个 op 用了多久;想看设备上每段的时间,用Device 页讲的 profile。
FusionTensor 的引用计数
FusionTensor 不持有数据,只有 id 和一个共享的计数:
pub struct FusionTensor<R: FusionRuntime> {
/// Tensor id.
pub id: TensorId,
/// The shape of the tensor.
pub shape: Shape,
/// The fusion client.
pub client: Client<R>,
/// The datatype of the tensor.
pub dtype: DType,
/// The current stream id this tensor is on.
pub stream: StreamId,
pub(crate) count: Arc<AtomicU32>,
}
同一流上 clone 只把 count 加一,id 不变。IR 里的状态就从这个数来(status):count <= 1 是 ReadWrite,否则 ReadOnly。把张量交给一个 op 时调 into_ir:
pub fn into_ir(mut self) -> TensorIr {
let current = StreamId::current();
if self.stream != current {
self = self.shared_view(current);
}
let count = self.count.load(Ordering::Acquire);
let status = self.status(count);
let mut shape_out = Shape::from(Vec::<usize>::new());
core::mem::swap(&mut self.shape, &mut shape_out);
if let TensorStatus::ReadWrite = status {
// Avoids an unwanted drop on the same thread.
//
// Since `drop` is called after `into_ir`, we must not register a drop if the tensor
// was consumed with a `ReadWrite` status.
self.count.fetch_add(1, Ordering::Acquire);
}
TensorIr {
status,
shape: shape_out,
id: self.id,
dtype: self.dtype,
}
}
into_ir 拿走了 self,函数结束时 self 照样被 drop。ReadWrite 的情况下这个 op 就是最后一次使用,释放已经由「ReadWrite 读完即取走句柄」负责了,所以先把计数加一,让随后的 Drop 看到「还有别人」而什么也不做。只有没被任何 op 消费、计数归零的那一份,Drop 才登记一条 OperationIr::Drop:
let register = move || {
if StreamId::current() == stream {
client.register(stream, OperationIr::Drop(ir), DropOp { id });
} else {
client.foreign_drop(stream, ir);
}
};
// ...
if std::thread::panicking() {
deferred::defer(register);
} else {
register();
}
两个分支:在别的线程上 drop 走 foreign_drop,不碰那条流的队列;在 panic 展开途中 drop,先放进线程局部的待办(deferred::defer),下次这个线程再调客户端时补登记,因为登记可能触发执行、执行中再 panic 会让进程 abort。
计数还有一个用处:can_mut 按同样的规则回答「这块显存能不能被就地改写」。上面 ir 实验里 a 是 ReadWrite,生成的融合内核把结果直接写回了 a 的缓冲区(MSL 见生成融合内核)。
foreign_drop 来自 #5901(首次发布于 v0.22.0)。提交信息:「A tensor dropped on another thread before its producer ran made the fusion server drain the owning stream on the spot, cutting it wherever it happened to be. burn-train's event thread drops every step's outputs, so over the remote backend each step was cut differently: a new plan to explore and new graphs to register every step. The free now waits in the stream's deferred frees until the last queued op that references the tensor, its producer included, has run.」现在的代码里,还有排队的 op 引用这个张量时,释放进 deferred_frees,等下一个执行边界;没有就当场释放。流不再被截断,同样的一步每次切出同样的块,执行计划缓存才能命中(见融合搜索)。
没人读的结果
文档里有一句容易漏掉的话(burn-tensor/src/tensor/api/graph.rs 的注释):「A lazy backend (fusion) elides a computation whose result is dropped before it drains」。drop 模式验证它:
let d = (a.clone() * b.clone() + c.clone()).exp();
drop(d);
marker("device.sync()");
device.sync().unwrap();
marker(&format!("launches since start: {}", take_launches().0));
d,sync 之后提交了几个内核?Metal,2×2 的 a、b、c,debug 构建。d 登记了三个 op,然后被 drop,接着 device.sync()。从程序开始数,launch_inner 出现了几次?答案在本页末尾。
错误挂在张量上
服务端执行一个块失败(op 返回 Err 或 panic)时,不把错误交给「碰巧在 drain 这条流的人」,而是写进 HandleContainer:这个块本该写出的每个张量都变成 Handle::Errored(TensorError)。之后读到其中某个张量,read_float 先 take_error,把它变成 ExecutionError 返回;不相干的张量照常可读。err 模式:bad 是跨设备的 cat(检查与报错里那个),worse 接在 bad 后面,good 和它们同一条流但不相干:
let a = Tensor::<1>::from_floats([1.0, 2.0], &metal);
let b = Tensor::<1>::from_floats([3.0, 4.0], &other); // Device::default(), another Cube device
let bad = Tensor::cat(vec![a.clone(), b], 0);
let worse = bad.clone() + 1.0;
let good = a * 10.0;
println!("{:?}", good.try_to_data().map(|d| d.to_vec::<f32>().unwrap()));
// then bad.try_to_data(), worse.try_to_data()
--- register: bad = cat([a, b]); worse = bad + 1; good = a * 10
--- good.try_to_data()
thread 'DSU-100-16384' (3347454) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/burn-ir-0.22.1/src/handle.rs:333:32:
Should have handle for tensor TensorId { value: 3 }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Ok([10.0, 20.0])
--- bad.try_to_data()
Err: An error happened during execution
Caused by:
Should have handle for tensor TensorId { value: 3 }
--- worse.try_to_data()
Err: An error happened during execution
Caused by:
Should have handle for tensor TensorId { value: 3 }
读 good 时整条流被 drain,cat 所在的块在服务线程上 panic,panic 信息照常打到 stderr;但 good.try_to_data() 拿到的是 Ok。错误等到读 bad 和 worse 时才交出来。两者原因相同,是因为 cat 和 + 1 被融合进了同一个块(BURN_FUSION_LOG=full 显示 fused ElementWise (score=310, 2 ops),两个输出一起被认领);如果 worse 在另一个块里,它会被跳过,报错写成 skipped 1 operation(s) below a failure(TensorError 的 Display)。这就是「出错的 op 和报错的位置不在一行」的来由:op 在登记时不执行,错误只能在读的时候交到调用方手里。
开和不开:同一串逐元素 op
count 模式:一个 1024×1024 的随机张量,连续 8 个逐元素 op(* 0.5、+ 0.25、sin、abs 各两次),into_data 读回,数 launch_inner。两个 crate 各跑三次,debug 构建:
dispatch<cubecl<wgpu<msl>>>
run 0: 8 ops -> 8 launch(es)
2 burn_cubecl::kernel::binary::kernel_scalar_binop::KernelScalarBinop<burn_cubecl::kernel::binary::Mul...
2 burn_cubecl::kernel::binary::kernel_scalar_binop::KernelScalarBinop<burn_cubecl::kernel::binary::Add...
4 burn_cubecl::kernel::unary_float::unary_float::UnaryFloat<burn_cubecl::kernel::unary_float::unary_ba...
run 1: 8 ops -> 8 launch(es)
run 2: 8 ops -> 8 launch(es)
...
dispatch<fusion<cubecl<wgpu<msl>>>>
run 0: 8 ops -> 1 launch(es)
1 burn_cubecl_fusion::optim::elemwise::optimization::elemwise_fuse::ElemwiseFuse
run 1: 8 ops -> 1 launch(es)
run 2: 8 ops -> 1 launch(es)
...
内核数用两种办法对过:launch_inner span 计数,和观察者的 block_starts(一个融合块对应一个融合内核;不融合的块一个 op 一个内核)。两者一致。
计时用 bench chain8 模式,release 构建,4096×4096 f32。每轮在 5 组互不相干的输入上各算一遍、保留全部结果、最后 sync 一次,报每次的平均。结果必须留着:没人读就丢掉的结果不会被计算(上一节)。第一次那一行是进程里第一次算(含内核编译;这几个逐元素内核不经 autotune,fusion-nofuse 跑完所有实验,crate 目录下连 autotune 缓存 target/environment/ 都没有生成):
| 第一次 | 稳定后(每次,7 轮中位数) | 每轮 5 次提交的内核数 | |
|---|---|---|---|
| 开 fusion | 26.6 ms | 505 µs | 1 |
| 不开 fusion | 59.6 ms | 3.03 ms | 40 |
开 fusion 时 5 组互不相干的计算被合进了同一个内核(为什么会合并见融合搜索),所以每轮只有 1 次提交;不开时 5 × 8 = 40 次。稳定后的差距主要是显存流量:8 个 op 不融合时每个 op 都要读写一遍 64 MiB 的中间结果,融合后只读一次输入、写一次输出。开 fusion 的第 0 轮是 8.57 ms:那一轮要为「5 个输出」的新组合再编译一个内核(expr 模式下用 CUBECL_DEBUG_LOG=stdout 看到第 0 轮多编译了一个 ElemwiseFuse)。数字只对这台机器(M2 Max、macOS 27、rustc 1.99.0)有效。
动脑筋答案
零个。drop 模式的输出(debug 构建):
--- let d = (a * b + c).exp(); drop(d)
--- device.sync()
[registered] Init in=[] out=[t1:NotInit]
[block] 1 op(s): Init
[registered] Init in=[] out=[t2:NotInit]
[block] 1 op(s): Init
[registered] Init in=[] out=[t3:NotInit]
[block] 1 op(s): Init
[registered] NumericFloat(F32, Mul) in=[t1:ReadOnly, t2:ReadOnly] out=[t4:NotInit]
[registered] NumericFloat(F32, Add) in=[t4:ReadWrite, t3:ReadOnly] out=[t5:NotInit]
[registered] Float(F32, Exp) in=[t5:ReadWrite] out=[t6:NotInit]
[registered] Drop in=[t6:ReadWrite] out=[]
[block] 4 op(s): NumericFloat(F32, Mul), NumericFloat(F32, Add), Float(F32, Exp), Drop
--- launches since start: 0
- 因为
a、b、c是 clone 进去的,三个输入都是ReadOnly;d没被消费就离开作用域,计数归零,登记了一条Drop。 - 四条 op 进了同一个融合块,块确实「执行」了,但没有提交内核:最终输出
t6在块内就被释放,融合器没有任何需要写回显存的东西。sync只是让这个块走完。 - 换成 Flex 这样的即时后端,三个 op 在调用当场就算完了,
drop救不回来。
小结
Fusion<B>是空结构体,把所有张量原语换成FusionTensor(id + 形状 + 流 + 共享计数);register当场建好输出张量、把登记作为任务交给设备的DSU线程,调用立刻返回。- 任务先进 cubecl 设备通道的 32 槽缓冲区,阻塞调用(读、
sync)才把它补满送达;op 少时服务端在读之前一条都看不到。 - 服务端按
StreamId分流,默认一个线程一条流;张量交给别的线程时,源流没算出来就先 drain,再以新 id 共享同一块显存。 - 必须执行的时机:读数据、
sync/flush、跨线程共享、取内层原语,以及融合器自己关闭时(不需要读);没人读就 drop 的结果,块走完也不提交内核。 - 计数
<= 1是ReadWrite:最后一次使用,读完释放,也允许就地写;into_ir消费ReadWrite时先加一,让Drop不再登记。 - 执行失败的错误挂在本该写出的张量上,读它时才交出,不相干的张量照常可读。
- 同一串 8 个逐元素 op,不开 fusion 8 个内核,开了 1 个;4096² 上稳定后每次 3.03 ms 对 505 µs。