一次 matmul
主线位置:算子主线从头到尾。
Tensor::matmul→Numeric::matmul→Dispatch→(Autodiff)→ Flex 的 gemm;或者 →(Autodiff)→Fusion登记 IR,读数据时才 drain →CubeBackend→ autotune → cubek 内核 → cubecl 提交。每一步的源码和细讲的页见怎么读这一卷的算子主线表,本页用实验把它们一层层露出来。
sequenceDiagram participant M as main 线程 participant D as Dispatch participant A as Autodiff participant F as Fusion participant U as DSU 设备线程 participant W as DSD 设备线程 M->>D: a.matmul(b) D->>A: float_matmul A->>F: float_matmul 并记反向步骤 F-->>M: register 后立刻返回 M->>F: c.into_data() F->>U: read_tensor_float U->>U: drain 流 并执行 matmul U->>W: Client launch 内核 W->>W: 编译 MSL 并提交 U-->>M: 读回的数据
实验程序
实验 crate intro-lean 只开 burn 的 flex、metal、tracing 三个 feature(默认 feature 开着,所以 cubecl 后端自带 fusion 和 autotune,见怎么读这一卷),另外直接依赖 burn-backend 和 burn-dispatch,只为调用 Backend::name。没有开 autotune-checks:那个 feature 让 autotune 把每个候选都跑一遍再核对结果,会改变下面的内核数和计时。四个设备、一个 2×2 的 matmul:
use burn::tensor::{Device, DeviceKind, Tensor};
use burn_backend::Backend;
use burn_dispatch::Dispatch;
fn devices() -> Vec<(&'static str, Device)> {
vec![
("flex", Device::flex()),
("flex.autodiff()", Device::flex().autodiff()),
("metal", Device::metal(DeviceKind::DefaultDevice)),
("metal.autodiff()", Device::metal(DeviceKind::DefaultDevice).autodiff()),
]
}
fn one_matmul(device: &Device) -> Vec<f32> {
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);
let c = a.matmul(b);
c.into_data().try_to_vec::<f32>().unwrap()
}
fn names() {
for (label, device) in devices() {
let name = Dispatch::name(device.as_dispatch());
println!("{label:18} {device:?}");
println!("{:18} name = {name}", "");
println!("{:18} result = {:?}", "", one_matmul(&device));
}
}
除非另外说明,下面的输出都是 debug 构建;计时一节是 release。
每一层叫什么
Device 是一个门面,as_dispatch 取出里面的 DispatchDevice。每个后端的 Backend::name 都把内层的名字包一层:Dispatch 写 dispatch<{inner}>,Autodiff 写 autodiff<..>,Fusion 写 fusion<..>,CubeBackend 写 cubecl<{client.name()}>,Flex 直接返回 "flex"。所以名字就是这次调用要穿过的层:
flex Device<Flex(Cpu)>
name = dispatch<flex>
result = [19.0, 22.0, 43.0, 50.0]
flex.autodiff() Device<Autodiff { device: Flex(Cpu), checkpointing: Disabled }>
name = dispatch<autodiff<flex>>
result = [19.0, 22.0, 43.0, 50.0]
metal Device<Cube(Wgpu(WgpuDevice { kind: DefaultDevice, backend: Metal }))>
name = dispatch<fusion<cubecl<wgpu<msl>>>>
result = [19.0, 22.0, 43.0, 50.0]
metal.autodiff() Device<Autodiff { device: Cube(Wgpu(WgpuDevice { kind: DefaultDevice, backend: Metal })), checkpointing: Disabled }>
name = dispatch<autodiff<fusion<cubecl<wgpu<msl>>>>>
result = [19.0, 22.0, 43.0, 50.0]
几点从输出里直接读得出来:
.autodiff()不是换了一个设备,而是把原设备包进DispatchDevice::Autodiff { device, checkpointing };后端名多出最外面的autodiff<..>(Dispatch 之内)。- Metal 设备的值是
Cube(Wgpu(..backend: Metal)):七种 cubecl 运行时共用Cube这一个变体,「是 Metal」只是值里的一个字段。 fusion套在cubecl外面,因为burn-cubecl开了fusionfeature 时Cube = Fusion<CubeBackend>。Flex 是 eager 后端,没有这一层。wgpu<msl>:Metal 走的是 wgpu 运行时加 MSL 编译器(cubecl 的metal = ["wgpu-msl"]),不是原生的cubecl-metal,细节见 wgpu 与 Metal。
名字说明了层级,但没说明「什么时候」「在哪个线程上」经过它们。下面用 lldb 和 tracing 看这两点。
Flex:调用当场算完
在 Flex 的 gemm 调用处(burn_flex::ops::matmul::gemm_call)下断点,跑 flex.autodiff() 那一组,bt 的前 11 帧(参数和过长的类型名截断,标 ...):
* thread #1, name = 'main', queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
* frame #0: intro-lean`burn_flex::ops::matmul::gemm_call::<f32>(m=2, n=2, k=2, ...)
frame #1: intro-lean`burn_flex::ops::matmul::matmul_2d_strided::<f32>
frame #2: intro-lean`burn_flex::ops::matmul::matmul_gemm::<f32>
frame #3: intro-lean`burn_flex::ops::matmul::matmul
frame #4: intro-lean`<burn_flex::backend::Flex as ...FloatTensorOps<burn_flex::backend::Flex>>::float_matmul
frame #5: intro-lean`<burn_autodiff::backend::Autodiff<burn_flex::backend::Flex> as ...>::float_matmul
frame #6: intro-lean`<burn_dispatch::backend::Dispatch as ...FloatTensorOps<burn_dispatch::backend::Dispatch>>::float_matmul
frame #7: intro-lean`<burn_tensor::bridge::kind::Float as burn_tensor::bridge::ops::numeric::Numeric>::matmul
frame #8: intro-lean`<burn_tensor::tensor::api::base::Tensor<2>>::matmul
frame #9: intro-lean`intro_lean::trace
frame #10: intro-lean`intro_lean::main
从下往上正好是算子主线的前四步:泛型的 Tensor::matmul → 不泛型的 Numeric::matmul → Dispatch::float_matmul(match 是宏展开在这一帧里的,不单独占一帧)→ Autodiff::float_matmul → Flex::float_matmul → matmul::matmul 按 dtype 选 matmul_gemm::<f32>,最后 gemm_call 调 gemm::gemm。全程在 main 线程上,a.matmul(b) 返回时结果已经在内存里。.autodiff() 只多了第 5 帧:Autodiff 登记反向步骤之后把原语交给内层(见一个算子的反向)。
Metal:matmul 只登记一条 IR
同样的程序跑 metal.autodiff(),断点下在 GlobalFusionClient::register 的 MatmulOps 实例上:
* thread #1, name = 'main', queue = 'com.apple.main-thread', stop reason = breakpoint 1.58
...
frame #2: intro-lean`<burn_fusion::client::GlobalFusionClient<burn_cubecl::fusion::FusionCubeRuntime>>::register::<...MatmulOps...>
frame #3: intro-lean`<burn_fusion::backend::Fusion<burn_cubecl::backend::CubeBackend> as ...>::float_matmul
frame #4: intro-lean`<burn_autodiff::backend::Autodiff<burn_fusion::backend::Fusion<burn_cubecl::backend::CubeBackend>> as ...>::float_matmul
frame #5: intro-lean`<burn_dispatch::backend::Dispatch as ...FloatTensorOps<burn_dispatch::backend::Dispatch>>::float_matmul
frame #6: intro-lean`<burn_tensor::bridge::kind::Float as burn_tensor::bridge::ops::numeric::Numeric>::matmul
frame #7: intro-lean`<burn_tensor::tensor::api::base::Tensor<2>>::matmul
frame #8: intro-lean`intro_lean::trace
frame #9: intro-lean`intro_lean::main
前几层一样,Dispatch 的 match 这次走到了 Cube 分支,内层是 Fusion<CubeBackend>。Fusion::float_matmul 不碰 GPU:
fn float_matmul(lhs: FloatTensor<Self>, rhs: FloatTensor<Self>) -> FloatTensor<Self> {
binary_float_ops!(MatmulOps, B::float_matmul);
let streams = StreamId::current();
let client = lhs.client.clone();
let desc = MatmulOpIr::create(lhs.into_ir(), rhs.into_ir(), || {
client.create_empty_handle()
});
client
.register(
streams,
OperationIr::Float(desc.out.dtype, FloatOperationIr::Matmul(desc.clone())),
MatmulOps::<B>::new(desc.into()),
)
.output()
}
它做两件事:把这次调用描述成 OperationIr::Float(.., FloatOperationIr::Matmul(..))(给融合搜索看的数据),再附上一个 MatmulOps(真要执行时调 B::float_matmul 的闭包),一起交给 register 送进当前线程的流,返回一个还没有数据的 FusionTensor。IR 长什么样、流怎么排队,见 burn-ir 和 Fusion 装饰器。
a.matmul(b) 这一行要多久?两个 1024×1024 的 f32 矩阵,已经在 Metal 设备上,前面已经跑过同一个形状的 matmul(内核编译过、autotune 选过)。程序先计时 let c = a.clone().matmul(b.clone()); 这一行,再计时到 device.sync() 返回。release 构建。matmul 这一行大约是微秒级、毫秒级,还是和 sync 之后差不多?答案在本页末尾。
内核什么时候提交:into_data
开 tracing feature 之后,burn 和 cubecl 在几个地方编进了 span:各层的 float_from_data / float_into_data、cubecl 的 launch_inner(字段里带内核名)、内核编译(integrate)、内存池。matmul 本身没有 span。这些 span 是 trace 级别,要配 subscriber 才看得到(tracing 卷讲 Layer)。实验里写了一个只实现 on_new_span 的 Layer,每建一个 span 就打印线程名、target 和祖先链:
impl<S> Layer<S> for SpanPrinter
where
S: tracing::Subscriber + for<'a> LookupSpan<'a>,
{
fn on_new_span(&self, attrs: &Attributes<'_>, _id: &Id, ctx: Context<'_, S>) {
let meta = attrs.metadata();
// cubecl's persistent cache is a turso (SQLite) database; its spans are noise here.
if meta.target().starts_with("turso") {
return;
}
// ... 从 ctx.lookup_current() 收集祖先 span 的名字,从 attrs 里取 kernel.name 字段
let thread = std::thread::current().name().unwrap_or("?").to_string();
println!("[span] [{thread}] {:<28} {}{}", meta.target(), path.join(" > "), kernel);
}
}
先用一个张量把设备初始化掉,再装上 subscriber,跑一次 from_data ×2、matmul、into_data(metal.autodiff(),autotune 缓存已有这个形状;内存池的 span 省略,标 ...):
--- from_data x2
[span] [main] burn_autodiff::ops::tensor float_from_data
[span] [main] burn_fusion::ops::tensor float_from_data > float_from_data
[span] [main] burn_cubecl::ops::tensor float_from_data > float_from_data > float_from_data
[span] [main] burn_autodiff::ops::tensor float_from_data
[span] [main] burn_fusion::ops::tensor float_from_data > float_from_data
[span] [main] burn_cubecl::ops::tensor float_from_data > float_from_data > float_from_data
--- matmul
--- into_data
[span] [main] cubecl_environment::future::base block_on
[span] [main] burn_autodiff::ops::tensor block_on > float_into_data
[span] [main] burn_fusion::ops::tensor block_on > float_into_data > float_into_data
[span] [DSU-100-16384] cubecl_environment::persistence::store new
[span] [DSU-100-16384] cubecl_environment::future::base new > block_on
...
[span] [DSU-100-16384] cubecl_runtime::client launch_inner kernel=cubek_matmul::multi_level::components::batch::partitioned_matmul::matmul::matmul_entry::MatmulEntry<...>
[span] [main] burn_cubecl::ops::tensor block_on > float_into_data > float_into_data > float_into_data
[span] [main] burn_cubecl::kernel::contiguous block_on > float_into_data > float_into_data > float_into_data > into_contiguous_aligned
[span] [DSD-4-16384] cubecl_server::memory_management::memory_manage reserve
...
[span] [DSD-4-16384] cubecl_core::codegen::integrator integrate
...
--- result [19.0, 22.0, 43.0, 50.0]
--- matmul 和 --- into_data 之间一个 span 也没有:matmul 那一行只登记了 IR。from_data 的 span 嵌套了三层(autodiff > fusion > cubecl),和后端名一致。读数据时:
- 同步的
into_data外面包了一层block_on(futures_lite的,burn 不依赖 tokio),里面是各层的async fn float_into_data。 Fusion的float_into_data把读请求交给另一个线程DSU-100-16384,matmul 内核在那个线程上launch_inner。在它之前的persistence::store new和一串block_on是打开 cubecl 的持久缓存、查这个形状的 autotune 结果(缓存见下文)。- 内核编译(
integrate)和显存分配又在第三个线程DSD-4-16384上。
这两个线程是 cubecl 起的设备服务线程,DeviceClient::new 起线程时把名字定成 DS{U|D}-<设备类型>-<设备号>:U 是 DeviceServiceStage::Upstream(文档注释:「Produces work ahead of kernel dispatch (e.g. autodiff graph, fusion)」),D 是 Downstream(「Consumes upstream work and streams kernels to the device」)。每个阶段每个设备一个线程,让融合这类上游工作和内核下发重叠。
想知道 launch_inner 是从哪条路调过来的,在 on_new_span 里遇到 launch_inner 时 Backtrace::force_capture(),只留 burn / cubecl / cubek 的帧。93 帧里挑出算子主线相关的(帧号是过滤后的序号,中间的闭包、catch_unwind、线程局部存储帧省略):
bt 1 <cubecl_runtime::client::Client>::launch_inner
bt 2 <cubecl_runtime::client::Client>::launch
bt 3 <cubecl_core::compute::launcher::KernelLauncher>::launch::<cubek_matmul::...::MatmulEntry<...>>
...
bt 13 cubek_matmul::launch::launch_ref
bt 14 burn_cubecl::kernel::matmul::base::launch_matmul::<cubek_matmul::multi_level::strategy::Strategy>
bt 15 burn_cubecl::kernel::matmul::tune::base::matmul_autotune::{closure#3}::{closure#9}
...
bt 19 <cubecl_runtime::tune::local::LocalTuner<cubek_matmul::tune_key::MatmulAutotuneKey, cubecl_core::id::CubeTuneId>>::execute::<...>
bt 20 burn_cubecl::kernel::matmul::tune::base::matmul_autotune
bt 21 burn_cubecl::kernel::matmul::base::matmul
bt 22 <burn_cubecl::backend::CubeBackend as burn_backend::backend::ops::tensor::FloatTensorOps<burn_cubecl::backend::CubeBackend>>::float_matmul
bt 23 <<burn_fusion::backend::Fusion<_> as ...>::float_matmul::MatmulOps<burn_cubecl::backend::CubeBackend> as burn_fusion::stream::exec...
...
bt 25 <burn_fusion::op::UnfusedOpInArena<burn_cubecl::fusion::FusionCubeRuntime>>::execute
...
bt 38 <burn_fusion::stream::execution::ordering::OrderedExecution<burn_cubecl::fusion::FusionCubeRuntime>>::execute_operations
...
bt 47 <burn_fusion::stream::queue::base::OperationQueue<burn_cubecl::fusion::FusionCubeRuntime>>::execute_block_optimization
...
bt 51 <burn_fusion::stream::execution::processor::Processor<burn_cubecl_fusion::optim::base::CubeOptimization>>::process::<...>
bt 52 <burn_fusion::stream::multi::MultiStream<burn_cubecl::fusion::FusionCubeRuntime>>::drain::{closure#0}
...
bt 55 <burn_fusion::server::FusionServer<burn_cubecl::fusion::FusionCubeRuntime>>::drain_stream
bt 56 <burn_fusion::server::FusionServer<burn_cubecl::fusion::FusionCubeRuntime>>::prepare_read
bt 57 <burn_fusion::server::FusionServer<burn_cubecl::fusion::FusionCubeRuntime>>::read_float::<burn_cubecl::backend::CubeBackend>
bt 58 <burn_fusion::server::FusionServer<burn_cubecl::fusion::FusionCubeRuntime>>::float_data::<burn_cubecl::backend::CubeBackend>
bt 59 <burn_fusion::client::GlobalFusionClient<burn_cubecl::fusion::FusionCubeRuntime>>::read_tensor_float::<burn_cubecl::backend::CubeBackend>::{closure#0}
...
bt 84 <cubecl_common::device::handle::channel::custom_channel::Server>::start
bt 85 <cubecl_common::device::handle::channel::custom_channel::DeviceClient>::new::<...>::{closure#0}
...
从下往上读,就是算子主线的后半段:
| 帧 | 发生了什么 | 讲它的页 |
|---|---|---|
| 84–85 | 设备服务线程的主循环,收到 main 线程发来的读请求 | 运行时与 Client |
| 55–59 | FusionServer::drain_stream:读之前先把这个流里攒下的操作全部执行 | Fusion 装饰器 |
| 38–52 | 流被切成块、问各个优化器能不能融合;matmul 单独一块,最后以 UnfusedOp 执行 | 融合搜索 |
| 22–23 | 登记时附上的 MatmulOps 被执行,调 CubeBackend::float_matmul | burn-cubecl 后端 |
| 15–21 | matmul_autotune 经 LocalTuner 查这个形状的调优结果,执行选中的那个候选 | autotune |
| 1–14 | cubek 的 launch_ref 按策略选出内核族,KernelLauncher::launch → Client::launch | #[cube] 内核 |
这次的 matmul 没有被融合:一串里只有一个 matmul、后面没有逐元素操作可以接上。matmul 后面接逐元素 op 时 burn-cubecl-fusion 会把它们编进同一个内核,见生成融合内核。
第一次和之后
空缓存的第一次:两万多个内核
把 autotune 缓存删掉,再跑一次上面的 2×2 实验,按内核名数 launch_inner(debug 构建):
run 1: launches=18125 integrate=6
1 burn_cubecl::kernel::unary_numeric::unary_numeric::UnaryNumeric<burn_cubecl::tensor::base::CubeTensor::copy::C
51 cubecl_std::throughput::runners::compute_cmma::compute_cmma_throughput::ComputeCmmaThroughput
18046 cubecl_std::throughput::runners::launch_overhead::launch_overhead::LaunchOverhead
7 cubecl_std::throughput::runners::memory_direct::memory_direct_throughput::MemoryDirectThroughput
1 cubecl_std::throughput::runners::memory_probe::prime_buffer::PrimeBuffer
4 cubek_matmul::multi_level::components::batch::partitioned_matmul::matmul::matmul_entry::MatmulEntry<cubek_matm
run 2: launches=1 integrate=1
1 cubek_matmul::multi_level::components::batch::partitioned_matmul::matmul::matmul_entry::MatmulEntry<cubek_matm
同一个 2×2 矩阵乘,第一次(空缓存,debug 构建)提交了 18 125 个内核,第二次(另一个进程)只有 1 个;release 构建里第一次的数目是几万到二十几万,随测速采样而变,见 autotune。多出来的几乎都是 cubecl_std::throughput 下的测速内核:matmul 的 autotune 用 roofline_bounds 给候选设一个「离峰值多近就够了」的上界,峰值算力、显存带宽和内核启动开销要先在这台设备上实测一遍;4 个 MatmulEntry 里有 3 个是 autotune 在比较候选,1 个是真正的计算。
问: 一个 2×2 的矩阵乘,为什么要先跑一万八千次 LaunchOverhead?这是每次启动都要付的代价吗?
答: 不是每次。测速结果和 autotune 结果都写进 cubecl 的持久缓存,第二个进程直接读出来,上面的 run 2 只剩 1 个内核。这个缓存默认就开着:cubecl-runtime 的默认 feature 里有 persistence,不需要打开 burn/persistence。它是一个 turso(Rust 写的 SQLite)数据库,CacheConfig 的默认值是 Target:从当前目录往上找最外层的 Cargo.toml,放在它旁边的 target/environment/default.db(还有 -wal、-tshm 两个文件);找不到 Cargo.toml 时退回系统的用户缓存目录。它不看 CARGO_TARGET_DIR:本卷的实验把编译产物放在别处,缓存却出现在每个实验 crate 自己的 target/environment/ 下。换一个目录运行同一个二进制,就会再测一遍。
计时:1024×1024
release 构建,time metal 1024 模式:Tensor::<2>::random 建两个 1024×1024 的 f32 矩阵、sync,然后同一个 matmul 跑 6 次,每次先记 matmul 返回的时间,再 device.sync()、记总时间。第一组是空缓存的新进程,第二组是紧接着的另一个进程:
## process 1 (empty cache)
metal 1024x1024 f32: 2x random + sync (incl. device init): 249.754667ms
run 0: matmul returned after 44.333µs, after sync 4.22719725s
run 1: matmul returned after 11.875µs, after sync 2.625292ms
run 2: matmul returned after 9.334µs, after sync 1.887292ms
run 3: matmul returned after 7.375µs, after sync 1.94525ms
run 4: matmul returned after 7.375µs, after sync 2.119583ms
run 5: matmul returned after 7.542µs, after sync 1.888292ms
## process 2
metal 1024x1024 f32: 2x random + sync (incl. device init): 101.383ms
run 0: matmul returned after 21.291µs, after sync 110.741208ms
run 1: matmul returned after 6.708µs, after sync 1.794375ms
run 2: matmul returned after 2.958µs, after sync 1.898083ms
...
## flex
flex 1024x1024 f32: 2x random + sync (incl. device init): 13.251ms
run 0: matmul returned after 6.278084ms, after sync 6.278209ms
run 1: matmul returned after 5.207875ms, after sync 5.208167ms
run 2: matmul returned after 5.12625ms, after sync 5.126459ms
...
| 第一次 matmul(到 sync) | 之后每次 | 第一次里有什么 | |
|---|---|---|---|
| Metal,空缓存的新进程 | 4.23 s | 约 1.9 ms | 设备测速 + autotune 比较候选 + 编译 MSL |
| Metal,缓存已有 | 111 ms | 约 1.8–1.9 ms | autotune 结果从缓存读,内核仍要编译(编译产物默认不进缓存,见下) |
| Flex | 6.3 ms | 约 5.1–5.8 ms | 没有编译和调优 |
数字只对这台机器(M2 Max、macOS 27、rustc 1.99.0)有效。1024³ 的矩阵乘约 2.1 GFLOP,稳定后 Metal 约 1.9 ms、Flex 约 5 ms。内核编译不进持久缓存,所以缓存已有时第一次仍要 111 ms。burn 的 crate 文档说 persistence 之外「Compiled-kernel caching also requires compilation.cache = true」,但在 wgpu<msl> 上打开它也不缓存编译产物(只有 SPIR-V 会存),实测见 wgpu 与 Metal。这些阶段各自的机制见 autotune 和 wgpu 与 Metal。
autotune 选了谁,可以让 cubecl 自己说:环境变量 CUBECL_DEBUG_LOG=stdout(override_from_env 读它)把编译、性能和 autotune 三种日志都打到标准输出。空缓存跑一次 time metal 1024,近 3.8 万行输出里有每个内核的 MSL 源码(以 #include <metal_stdlib> 开头),和这几行(长行截断,标 ...):
Fastest result matmul_simple_cyclic_cmma_multi_rows-MatmulAutotuneKey - Definition: MatmulProblemDefinition { m: 1024, n: 1024, k: 1024, ...
Short circuiting autotune. matmul_simple_cyclic_cmma_multi_rows is close enough to peak throughput.
Autotune[14] name matmul_simple_cyclic_cmma_multi_rows => BenchmarkComputations { mean: 1.332687ms, median: 1.333292ms, variance: 0ns, min: 1.332083ms, max: 1.333292ms }
Autotune[12] name matmul_simple_cyclic_cmma => BenchmarkComputations { mean: 1.56325ms, median: 1.56325ms, variance: 0ns, min: 1.56325ms, max: 1.56325ms }
matmul_naive: The autotune is skipped manually.
...
这一次只实测了两个候选,较快的那个已经「close enough to peak throughput」(同一段日志的 Calculated bounds 一行里阈值是 threshold: 0.8),autotune 提前收工;日志里另有 31 行「The autotune is skipped manually」。提前收工是开日志带来的:CUBECL_DEBUG_LOG 让每次启动都阻塞,测出来的启动开销从约 3.6 µs 变成约 1.6 ms,上界跟着放宽;不开日志时同一个形状会实测 10 个候选里的 6 个,不会提前收工。所以开着这个变量时的计时和 autotune 选择都不能当真,细节见 autotune。
动脑筋答案
Metal 上 matmul 那一行是微秒级:缓存热了之后 3–12 µs,第一次 21–44 µs;时间都花在 sync 里(稳定后约 1.9 ms)。Flex 正好相反,matmul 返回时已经算完,sync 前后只差零点几微秒:
## Metal(缓存已有)
run 1: matmul returned after 6.708µs, after sync 1.794375ms
run 2: matmul returned after 2.958µs, after sync 1.898083ms
## Flex
run 1: matmul returned after 5.207875ms, after sync 5.208167ms
- Metal 的几微秒是走完
Tensor::matmul→Dispatch→Fusion::float_matmul的时间:建一个MatmulOpIr、register进流,没有碰 GPU。 sync这一步才让融合流 drain、在设备线程上提交内核并等 GPU 做完。所以在 GPU 上计时要在结束处sync(或读数据),否则测到的只是登记的时间。- Flex 的
sync几乎不花时间:eager 后端没有排队的东西。
小结
Backend::name一层套一层,就是一次调用要穿过的层:Flex 上dispatch<flex>,Metal 上dispatch<fusion<cubecl<wgpu<msl>>>>,.autodiff()在dispatch里面再包一层autodiff。- 前半段(
Tensor::matmul→Numeric::matmul→Dispatch→Autodiff)两种设备一样,都在调用线程上。 - Flex 在同一个调用里由
gemm算完;Metal 上Fusion只把调用记成OperationIr送进流,几微秒就返回。 - 内核在读数据(或
sync)时提交:FusionServer::drain_stream在 cubecl 的上游设备线程上执行流,CubeBackend→matmul_autotune→ cubek →Client::launch;编译和下发在下游线程。 - 空缓存的第一次要先测设备(上万个测速内核)、比较候选、编译 MSL,1024² 的 matmul 首次 4.2 s;autotune 结果默认持久化在最外层
Cargo.toml旁的target/environment/,编译产物默认不缓存。