#[cube] 内核
主线位置:算子主线的倒数第二段。cubek 的 matmul、
burn-cubecl的逐元素内核、融合生成的内核,最后都是一个#[cube]函数。本页讲这个函数被宏展开成了什么、一次launch在调用线程上做了什么、IR 什么时候在哪里构造。之后句柄、内存池和读回见运行时与 Client;IR 怎么编成 MSL、管线怎么缓存见 wgpu 与 Metal。
flowchart TB src["cube launch 标注的 fn"] --> orig["原函数 只做类型检查"] src --> modn["同名 mod"] modn --> exp["expand 构造 IR"] modn --> desc["内核描述体"] modn --> lau["launch 函数"] lau --> reg["登记参数"] reg --> cl["Client launch"] cl --> dev["设备线程 查 KernelId"] dev -->|没编译过| def["define 调 expand"] def --> msl["编成 MSL"]
本页的 cubecl 版本是 0.11.3(burn = "=0.22.1" 解析到的版本)。0.11 的前端和半年前差别很大:内核参数从 &Array<Line<F>> 变成了 &[Vector<F, N>],IR 换成了 pliron,ComputeClient<R> 变成了不带运行时参数的 Client。v0.11.0-pre.1(2026-07-28)之前发布的 cubecl 里内核参数还是 &Array<..> 写法,下面每个 API 都按 tag 核对过。
实验程序
实验 crate cube-kernels 只开 burn 的 metal 和 cubecl 两个 feature。cubecl feature 把 cubecl 转出成 burn::cubecl。#[cube] 宏生成的代码写的是相对路径 cubecl::prelude::...(core_path 只是一个 cubecl 标识符),所以不必单独依赖 cubecl,在 crate 根上 use burn::cubecl; 就能用:
use burn::cubecl;
use cubecl::prelude::*;
/// y = a * x + y, one vector of N floats per unit.
#[cube(launch)]
fn saxpy<F: Float, N: Size>(a: f32, x: &[Vector<F, N>], y: &mut [Vector<F, N>]) {
if ABSOLUTE_POS < x.len() {
y[ABSOLUTE_POS] = Vector::new(F::cast_from(a)) * x[ABSOLUTE_POS] + y[ABSOLUTE_POS];
}
}
fn run_saxpy(client: &cubecl::client::Client) {
let x: Vec<f32> = (0..16).map(|i| i as f32).collect();
let y: Vec<f32> = vec![1.0; 16];
let (x_handle, y_handle) = (upload(client, &x), upload(client, &y));
let vector_size = 4;
saxpy::launch::<f32>(
client,
CubeCount::Static(1, 1, 1),
CubeDim::new_1d(x.len() as u32 / vector_size as u32),
vector_size,
2.0f32,
// SAFETY: both handles hold exactly `len` f32 values.
unsafe { BufferArg::from_raw_parts(x_handle, x.len()) },
unsafe { BufferArg::from_raw_parts(y_handle.clone(), y.len()) },
);
let bytes = client.read_one(y_handle).unwrap();
println!("saxpy y = {:?}", f32::from_bytes(&bytes));
}
fn main() {
let device = cubecl::Device::default();
let client = device.client();
println!("device = {device:?}, runtime = {:?}", client.name());
// ...
}
upload 是 client.create_from_slice(f32::as_bytes(data))。16 个 f32 按每 4 个一组,4 个单元(unit)各算一组。debug 构建,M2 Max、macOS 27、rustc 1.99.0:
device = Wgpu(WgpuDevice { kind: DefaultDevice, backend: Auto }), runtime = "wgpu<msl>"
saxpy y = [1.0, 3.0, 5.0, 7.0, 9.0, 11.0, 13.0, 15.0, 17.0, 19.0, 21.0, 23.0, 25.0, 27.0, 29.0, 31.0]
cubecl::Device::default() 在只开了 wgpu 的构建里是 Wgpu(.. backend: Auto),在 macOS 上落到 MSL 编译器,和 burn 的 Device::metal(..) 走同一条路(见一次 matmul)。几处写法和旧教程不同:标量参数要写具体类型(a: F 会报 the trait bound F: LaunchArg is not satisfied,impl_scalar_launch! 只给具体的数值类型实现了 LaunchArg);BufferArg::from_raw_parts 是 unsafe fn,因为长度由调用者担保;Vector 的宽度不再挂在缓冲参数上,而是 launch 的第四个参数 vector_size。
宏展开成了什么
#[cube] 处理函数时输出两样东西:去掉辅助属性的原函数,和一个从函数生成的 Launch 结构(quote! { #[allow(dead_code, clippy::too_many_arguments)] #item #kernel })。cargo expand 这个 crate 得到 846 行,saxpy 一个函数(原函数加生成的模块)约 350 行。按项列出来(省略的部分标 ...):
#[allow(dead_code, clippy::too_many_arguments)]
/// y = a * x + y, one vector of N floats per unit.
fn saxpy<F: Float, N: Size>(a: f32, x: &[Vector<F, N>], y: &mut [Vector<F, N>]) {
if ABSOLUTE_POS < x.len() {
y[ABSOLUTE_POS] = Vector::new(F::cast_from(a)) * x[ABSOLUTE_POS]
+ y[ABSOLUTE_POS];
}
}
mod saxpy {
use super::*;
pub struct _F;
/// Type to be used as a generic for launch kernel argument.
pub type F = cubecl::prelude::DynamicScalar<_F>;
pub struct _N;
/// Type to be used as a generic for launch kernel argument.
pub type N = cubecl::prelude::DynamicSize<_N>;
pub fn expand<F: Float, N: Size>(
scope: &cubecl::prelude::Scope,
_n: usize,
a: <f32 as cubecl::prelude::CubeType>::ExpandType,
x: &<[Vector<F, N>] as cubecl::prelude::CubeType>::ExpandType,
y: &mut <[Vector<F, N>] as cubecl::prelude::CubeType>::ExpandType,
) -> () { ... }
///saxpy Kernel
pub struct Saxpy<F: Float> {
settings: cubecl::prelude::KernelSettings,
device_properties: cubecl::__private::Arc<cubecl::prelude::DeviceProperties>,
target_properties: cubecl::__private::Arc<cubecl::prelude::TargetProperties>,
a: <f32 as cubecl::prelude::LaunchArg>::CompilationArg,
x: <[Vector<F, N>] as cubecl::prelude::LaunchArg>::CompilationArg,
y: <[Vector<F, N>] as cubecl::prelude::LaunchArg>::CompilationArg,
_n: usize,
__ty: ::core::marker::PhantomData<(F)>,
}
struct SaxpyInfo<F: Float> { ... } // 同样几个字段,派生 Hash / Eq / Debug
impl<F: Float> Saxpy<F> { pub fn new(...) -> Self { ... } }
impl<F: Float> cubecl::prelude::KernelMetadata for Saxpy<F> {
fn id(&self) -> cubecl::prelude::KernelId { ... }
// ...
}
impl<F: Float> cubecl::prelude::CubeKernel for Saxpy<F> {
fn define(&self) -> cubecl::prelude::KernelDefinition { ... }
}
pub fn launch<'kernel, F: Float>(
__client: &cubecl::prelude::Client,
__cube_count: cubecl::prelude::CubeCount,
__cube_dim: cubecl::prelude::CubeDim,
_n: usize,
a: cubecl::RuntimeArg<f32>,
x: cubecl::RuntimeArg<[Vector<F, N>]>,
y: cubecl::RuntimeArg<[Vector<F, N>]>,
) { ... }
}
| 生成的项 | 做什么 | 什么时候运行 |
|---|---|---|
原函数 fn saxpy | 让 rustc 按普通 Rust 检查函数体的类型 | 从不调用 |
saxpy::expand | 把函数体改写成一串「往 Scope 里登记 IR 操作」的调用 | 设备线程上,这个内核第一次编译时 |
Saxpy<F> 和 SaxpyInfo<F> | 一次启动的「编译参数」:设置、设备属性、每个参数的 CompilationArg、向量宽度 _n | launch 时构造,交给 Client |
KernelMetadata::id | 把类型、设置和 SaxpyInfo 合成 KernelId,当缓存键 | 设备线程查缓存时 |
CubeKernel::define | 建 KernelBuilder,为每个参数造 IR 值,调 expand,build() | 缓存没命中时 |
saxpy::launch | 登记运行期参数,构造 Saxpy,提交 | 调用线程上,每次调用 |
两个 mod saxpy 里的类型别名是理解泛型的关键。launch::<f32> 的 F 是真类型,用来算元素大小和注册元素类型;define 调 expand 时传的却是 expand::<DynamicScalar<_F>, DynamicSize<_N>>。元素类型和向量宽度先登记进 scope 的类型表(register_type::<DynamicScalar<_F>>(F::elem_type(..))、register_size::<DynamicSize<_N>>(self._n.into())),expand 里再查出来。所以构造 IR 的代码对每个内核只单态化一份,f32、f16 用的是同一段 expand,区别只在类型表里的值。burn 的 examples/custom-cubecl-kernel 更进一步,用 #[define(F)] _dtype: ElemType 让元素类型在启动时由一个运行期值决定,后端按张量的 dtype 传进去。
问: 原函数照常编译,函数体里的 ABSOLUTE_POS 在 CPU 上是什么值?saxpy 会不会被当成 CPU 函数调用?
答: 不会调用,它只为类型检查而存在(所以带 #[allow(dead_code)])。ABSOLUTE_POS 在 constant_usize! 里定义成 pub const ABSOLUTE_POS: usize = 2;,值是随手写的,只为让 ABSOLUTE_POS < x.len() 这种表达式类型对得上。同一个宏还生成一个同名的模块 ABSOLUTE_POS,里面的 expand 才是真身:登记一个 ReadBuiltinOp,读 Builtin::AbsolutePos。expand 函数里写的是 ABSOLUTE_POS::expand(scope),use 常量时模块一起进了作用域,文件头的注释管这叫「a trick」。
expand:函数体变成构造 IR 的调用
saxpy::expand 里每个运算符都换成了方法调用,参数是上一步返回的 IR 值。去掉层层 IntoExpand::into_expand 包装,结构是:
cubecl::frontend::branch::if_expand(
scope,
ABSOLUTE_POS::expand(scope).__expand_lt_method(scope, &x.__expand_len_method(scope)),
|scope| {
let _value = Vector::__expand_new(scope, F::__expand_cast_from(scope, a.into()))
.__expand_mul_method(scope, __expand_deref(scope, x.__expand_index_method(scope, ABSOLUTE_POS::expand(scope))))
.__expand_add_method(scope, __expand_deref(scope, y.__expand_index_method(scope, ABSOLUTE_POS::expand(scope))));
cubecl::prelude::__expand_assign(
scope,
y.__expand_index_mut_method(scope, ABSOLUTE_POS::expand(scope)),
_value,
)
},
);
(为了能读,这段是从展开结果里删掉包装后整理的,不是原样输出。)
这些 __expand_* 方法最后都落到同一个模式:在 pliron 的 Context 里建一个操作,插到 Scope 当前的插入点。二元运算是 binary_expand,取下标是 index_expand:
pub(crate) fn binary_expand<F, O>(
scope: &Scope,
lhs: ExpandValue,
rhs: ExpandValue,
func: F,
) -> ExpandValue
where
F: Fn(&mut Context, Value, Value) -> O,
O: Op + OneResultInterface,
{
let [lhs, rhs] =
normalize_same_vectorization(scope, [lhs.read_value(scope), rhs.read_value(scope)]);
let op = func(scope.ctx_mut(), lhs, rhs);
scope.register_with_result(&op).into()
}
pub(crate) fn index_expand(scope: &Scope, list: Value, index: Value, checked: bool) -> Value {
let op = IndexOp::maybe_checked(scope.ctx_mut(), list, index, checked);
scope.register_with_result(&op)
}
Scope 只有三个字段:pliron 上下文的句柄、插入点、处理 return / break 的展开状态;register 就是 self.inserter().append_op(ctx, op)。normalize_same_vectorization 是 Vector 和标量能直接相乘的原因:一边是标量、另一边是宽度 4 的向量时,先把标量广播成向量;两边宽度都大于 1 又不相等就 panic。运行期的 if 变成 if_expand:条件在展开时已经是常量的,直接调用闭包或者跳过;否则建一个 IfOp,给 then、else 两个块各开一个子 Scope,闭包往 then 块里继续登记。
内核的参数从哪来:define 对每个参数调 LaunchArg::expand。切片参数走 impl LaunchArg for [C],由 Scope::global 给入口函数加一个类型为「指向运行期数组的指针」的参数,再配上从元数据里读出的长度,拼成一个切片值。x.len() 读的就是那个长度。
前端类型
| 写法 | 是什么 | 在 MSL 里变成 |
|---|---|---|
&[T]、&mut [T] | 全局缓冲,运行期参数是 BufferArg(句柄 + 长度) | device T const* / device T*,长度进 info_st.static_meta |
&Tensor<T> | 带形状和 strides 的全局缓冲,参数是 TensorArg | 同上,另有形状、strides 元数据 |
Vector<F, N> | N 个 F 的向量,N: Size 由 launch 的 vector_size 定 | float_4 这样的结构体 |
f32、u32 等标量参数 | 按值传入 | info_st.scalars_f32[..] |
#[comptime] relu: bool | 编译期常量,进 KernelId | 不出现,分支已经选好 |
comptime!(expr) | 在展开时(CPU 上)算出的值 | 一个字面量 |
Array<E> | 内核里的定长局部数组,Array::new(#[comptime] length) | 局部数组 |
ABSOLUTE_POS 等 | 内建的位置和尺寸 | 由 threadgroup_position_in_grid 等算出 |
最后一行之前的 Array 值得说一句:旧教程里的 &Array<Line<F>> 是全局缓冲,0.11 里 Array<E> 只剩内核里的定长数组这一个用途,全局缓冲改用切片。Vector<P, N> 也不是旧的 Line<P> 改个名:宽度从运行期的 line_size 字段变成了类型参数 N。
cubecl 仓库的 examples/gelu 一直是「最小内核」的范例,它的签名记录了前端的两次重写。#1221(「refactor: Line size generic」,首发 v0.10.0-pre.3)把 fn gelu_array<F: Float>(input: &Array<Line<F>>, ..) 改成 fn gelu_array<F: Float, N: Size>(input: &Array<Vector<F, N>>, ..),启动参数从 ArrayArg::from_raw_parts::<f32>(input_handle, input.len(), line_size) 变成在 launch 上单独传 vector_size:宽度成了类型的一部分,进 KernelId,同一个宽度编一次。#1322(「mega-refactor: Totally change the frontend to enable references, among other things」,首发 v0.11.0-pre.1)再把参数换成 input: &[Vector<F, N>],ArrayArg 换成 BufferArg,同一个 diff 里 x * (Vector::erf(tmp) + 1.0) / 2.0 也改成了显式的 Vector::one() 和 Vector::new(F::new(2.0f32))。两个提交只有标题、没有正文。所以 0.22.1 的 burn 拉进来的 cubecl,和 2026 年初以前的 cubecl 教程,内核签名写法互不相通。
launch:在调用线程上只做登记
展开出的 saxpy::launch(生成它的代码)原样是:
pub fn launch<'kernel, F: Float>(
__client: &cubecl::prelude::Client,
__cube_count: cubecl::prelude::CubeCount,
__cube_dim: cubecl::prelude::CubeDim,
_n: usize,
a: cubecl::RuntimeArg<f32>,
x: cubecl::RuntimeArg<[Vector<F, N>]>,
y: cubecl::RuntimeArg<[Vector<F, N>]>,
) {
let mut __settings = cubecl::prelude::KernelSettings::new(
__cube_dim.into(),
cubecl::prelude::ExecutionMode::Checked,
cubecl::prelude::AddressType::U32,
);
let mut launcher = cubecl::prelude::KernelLauncher::new(__settings.clone());
launcher
.with_scope(|scope| {
scope.device_properties(__client.properties());
scope
.register_type::<
cubecl::prelude::DynamicScalar<_F>,
>(F::elem_type(&scope));
scope.register_size::<cubecl::prelude::DynamicSize<_N>>(_n.into());
});
launcher.declare_io(cubecl::prelude::BufferIOAttr::ReadWrite);
let comp_arg_0 = <f32 as cubecl::prelude::LaunchArg>::register(a, &mut launcher);
launcher.declare_io(cubecl::prelude::BufferIOAttr::ReadOnly);
let comp_arg_1 = <[Vector<
F,
N,
>] as cubecl::prelude::LaunchArg>::register(x, &mut launcher);
launcher.declare_io(cubecl::prelude::BufferIOAttr::ReadWrite);
let comp_arg_2 = <[Vector<
F,
N,
>] as cubecl::prelude::LaunchArg>::register(y, &mut launcher);
let __kernel = Saxpy::<
F,
>::new(
__settings,
__client.properties_shared(),
__client.target_properties_shared(),
comp_arg_0,
comp_arg_1,
comp_arg_2,
_n,
);
launcher.launch(__cube_count, __kernel, __client)
}
每个参数的 register 把运行期的部分(句柄的绑定、标量的字节、缓冲长度)记进 KernelLauncher,返回编译期的部分(CompilationArg:缓冲是否原地复用某个输入、标量没有)。宏从参数的可变性推出读写属性:x 是 &[..],标成 ReadOnly;y 是 &mut [..],标成 ReadWrite。Saxpy::new 只收编译参数,不碰数据。KernelLauncher::launch 把绑定打包、把 Saxpy 装进 Box<dyn CubeKernel>,交给 client.launch。这个调用把任务丢给设备线程就返回(见运行时与 Client),整个 launch 没有构造一条 IR。
IR 在设备线程上构造。wgpu 运行时的 WgpuServer::pipeline 开头:
fn pipeline(
&mut self,
kernel: Box<dyn CubeKernel>,
bindings: &KernelArguments,
) -> Result<PipelineEntry, LaunchError> {
let kernel_id = kernel.id();
let mode = kernel_id.mode;
if let Some(pipeline) = self.pipelines.get(&kernel_id) {
return Ok(pipeline.clone());
}
// ...
let definition = cubecl_core::define_kernel(&*kernel)?;
// ...
let mut compiler = C::init(self.backend, &self.compilation_options);
let mut compiled = compiler.compile_kernel(self, kernel, definition)?;
先用 kernel.id() 查已经建好的管线,命中就直接用;没命中才 define_kernel → Saxpy::define → expand,再交给编译器。define_kernel 用 catch_unwind 包住 define,expand 里的 panic(比如两个宽度不同的向量相加)变成 CompilationError::ExpansionPanicked,挂在这次启动要写的缓冲上,读那个缓冲时才报出来。编译和管线缓存的其余部分见 wgpu 与 Metal。
看生成的 MSL
cubecl 的调试日志会把每个编译出的内核源码打出来:环境变量 CUBECL_DEBUG_LOG=stdout(由 override_from_env 读取)把编译、性能、autotune 三种日志都打到标准输出。跑 saxpy,98 行输出,内核部分(typedef 几行省略,标 ...;日志在 MSL 源码前后各有一行 Markdown 代码围栏,这里删掉了):
[START_KERNEL_COMPILATION]
name: cube_kernels::saxpy::Saxpy<f32>
id: KernelId {
type: cube_kernels::saxpy::Saxpy<f32>,
address_type: U32,
cube_dim: Dim3 {
x: 4,
y: 1,
z: 1,
},
mode: Checked,
info: SaxpyInfo {
n: 4,
a: (),
x: BufferCompilationArg {
inplace: None,
},
y: BufferCompilationArg {
inplace: None,
},
},
}
source:
#include <metal_stdlib>
using namespace metal;
...
struct alignas(16) float_4 { float i_0; float i_1; float i_2; float i_3; };
struct info_st {
float scalars_f32[2];uint32_t static_meta[2];
};
[[max_total_threads_per_threadgroup(64)]] [[kernel]] void saxpy_f_f32_n_4_daf77f4d(device float_4 const* const v2 [[buffer(0)]], device float_4 * const v6 [[buffer(1)]], constant info_st const* const v33, uint3 const threadgroups_per_grid_v166 [[threadgroups_per_grid]], uint32_t const thread_index_in_threadgroup_v167 [[thread_index_in_threadgroup]], uint3 const threadgroup_position_in_grid_v168 [[threadgroup_position_in_grid]]) {
constant uint32_t const* const dynamic_meta_v35 = reinterpret_cast<constant uint32_t const*>(v33 + 1);
constant info_st& info_v34 = *v33;
float const v36 = info_v34.scalars_f32[0];
uint32_t const v37 = info_v34.static_meta[0];
uint32_t const v169 = threadgroup_position_in_grid_v168[2];
...
uint32_t const v162 = 4;
uint32_t const v85 = v82 * v162;
uint32_t const v87 = uint32_t(thread_index_in_threadgroup_v167);
uint32_t const v88 = v85 + v87;
bool const v12 = v88 < v37;
if(v12) {
device float_4 const* const v18 = &v2[v88];
float_4 const v19 = *v18;
float const v41 = v19.i_0;
float const v42 = v36 * v41;
...
float const v63 = v26.i_3;
float const v64 = v51 + v63;
float_4 const v65 = float_4{v55, v58, v61, v64};
*v25 = v65;
}
return;
}
[END_KERNEL_COMPILATION]
| 12.334µs | Saxpy | f32_daf77f4d
对着 Rust 源码读:
KernelId就是缓存键,info: SaxpyInfo { n: 4, .. }里的n是向量宽度。a: ()说明标量没有编译期部分:换一个a不会重新编译。- 入口名
saxpy_f_f32_n_4_daf77f4d由Saxpy::new拼出:函数名、_f_f32(F的类型名)、_n_4(宽度),再加一个哈希。 ABSOLUTE_POS没有对应的 Metal 内建,由threadgroup_position_in_grid、threadgroups_per_grid和thread_index_in_threadgroup算出来(v88)。乘的4是cube_dim.x,它是编译期常量(KernelId里有cube_dim)。x.len()是info_st.static_meta[0](v37);标量a是info_st.scalars_f32[0](v36)。启动时的元数据和标量都放进同一个常量缓冲info_st。Vector<f32, 4>编成了float_4结构体,乘加逐个分量写出(v19.i_0到i_3),没有用 Metal 原生的float4。- 我们写的
if ABSOLUTE_POS < x.len()就是if(v12)。
最后一行 | 12.334µs | Saxpy | 是性能日志:CUBECL_DEBUG_LOG 同时把性能日志调到 Medium,Client::launch_inner 于是走「先测时间再提交」的分支,每次启动都要等设备做完(submit_blocking),不再是丢给设备线程就返回。开着它测出的时间不能当正常运行的时间。
同一个函数,几个内核
KernelId 由内核类型、地址类型、cube_dim、执行模式和 Info 组成(struct KernelId)。第二个实验内核把这几样各变一下:
/// out = x + y with no bounds guard of our own; `relu` is decided when the kernel is compiled.
#[cube(launch, launch_unchecked)]
fn add<F: Float>(x: &[F], y: &[F], out: &mut [F], #[comptime] relu: bool) {
let v = x[ABSOLUTE_POS] + y[ABSOLUTE_POS];
if relu {
out[ABSOLUTE_POS] = F::max(v, F::new(0.0f32));
} else {
out[ABSOLUTE_POS] = v;
}
}
relu 带 #[comptime],展开后 expand 的签名里它就是一个普通的 relu: bool,函数体里那个 if 也原样留成 Rust 的 if,没有变成 if_expand:
if relu {
{
let _value = cubecl::prelude::IntoExpand::into_expand(
{
F::__expand_max(
scope,
(v).into(),
({ F::__expand_new(scope, (0.0f32).into()) }).into(),
)
},
scope,
);
// ...
所以 relu = true 时 IR 里只有 max 那一支,false 时只有赋值那一支;relu 的值同时进了 AddInfo,两个值是两个 KernelId。
add,启动四次,编译几次?run_add(client, checked, relu) 用 6 个元素、8 个单元调一次 add,checked 选 launch 还是 launch_unchecked。程序依次调用 (true, false)、(false, false)、(true, true)、(true, false),开 CUBECL_DEBUG_LOG=stdout,数 [START_KERNEL_COMPILATION] 出现几次。答案在本页末尾。
launch 与 launch_unchecked
#[cube(launch, launch_unchecked)] 生成两个函数,区别只在交给 KernelSettings 的执行模式:launch 是 ExecutionMode::Checked,launch_unchecked 是 unsafe fn、ExecutionMode::Unchecked。按设计,Checked 模式下编译器管线里的 CheckedIo pass 把每个带 checked 标记的下标改写成 checked_index:
pub fn checked_index(index: usize, buffer_len: usize) -> usize {
index.min(buffer_len - 1)
}
切片的 x[i] 确实带这个标记(IndexExpand for SliceExpand 调 read_offset::expand(.., true)),MSL 编译器也确实挂了这个 pass(cubecl-cpp 的 add_pass(CheckedIoPass::new(..)))。但上面 add 的 MSL 里,launch 和 launch_unchecked 编出来的两份除了入口名的哈希和内建参数的顺序,逐行一样,都没有 min:
device float const* const v17 = &v1[v60];
float const v18 = *v17;
device float const* const v23 = &v5[v60];
float const v24 = *v23;
float const v25 = v18 + v24;
device float * const v30 = &v9[v60];
*v30 = v25;
再跑一个越界读的程序:x、y 各 6 个元素,out 8 个,启动 8 个单元,单元 6、7 读 x[6]、x[7]。如果下标被夹到 len - 1,它们应当读到 x[5] + y[5] = 66:
oob checked=true out = [11.0, 22.0, 33.0, 44.0, 55.0, 66.0, 0.0, 0.0]
oob checked=false out = [11.0, 22.0, 33.0, 44.0, 55.0, 66.0, 0.0, 0.0]
两种模式都读到了 0(分配的对齐填充),没有夹取。原因在 pass 的匹配条件。CheckedIo 只改写 base 本身是 RuntimeArrayType 的 IndexOp:
impl MatchRewrite for CheckedIo {
fn r#match(&mut self, ctx: &Context, op: Ptr<Operation>) -> bool {
Operation::get_op::<IndexOp>(op, ctx)
.is_some_and(|it| it.checked(ctx) && is_runtime_array(ctx, it.base(ctx)))
}
// ...
}
fn is_runtime_array(ctx: &Context, value: Value) -> bool {
let ty = value.get_type(ctx).deref(ctx);
ty.downcast_ref::<RuntimeArrayType>().is_some()
}
而全局缓冲在 Scope::global 里的类型是 PointerType::get(ctx, ty_arr.into(), AddressSpace::Global(..)),即「指向」运行期数组的指针,IndexOp 的 base 也要求是指针(indexed_ptr_ty 里 .expect("Should be pointer"))。外层是 PointerType,downcast_ref::<RuntimeArrayType>() 落空,这个 pass 对全局缓冲不会触发。这是本卷在 0.11.3 + MSL 上实测并读源码得出的结论,CUDA / SPIR-V 编译器挂的是同一个 pass,只读了源码、没有实测。实际影响是:在这个版本里不能指望 launch 把越界下标夹回来,内核要自己写 if ABSOLUTE_POS < x.len() 这样的守卫。
#1402(「refactor: Make everything Pliron」,首发 v0.11.0-pre.3)把 cubecl 自己的 IR 整个换成了 pliron(一个用 Rust 写的、MLIR 风格的 IR 框架),提交只有标题。改之前,index_expand 自己建输出值、登记一条 Instruction:scope.register(Instruction::new(Memory::Index(IndexOperands { list, index, unroll_factor: 1, checked }), output)),输出类型写成 Type::pointer(ty, class);越界检查是一个 CheckedIoVisitor,在 Memory::Index(op) if op.checked 且 op.list.has_buffer_length() 时展开成带长度的版本。改之后,同一个函数变成上面两行的 IndexOp::maybe_checked + register_with_result,检查变成 pliron 的 MatchRewrite,条件换成了按 base 的类型判断,也就是上一节对全局缓冲不触发的那个条件。
动脑筋答案
3 次。 日志里 [START_KERNEL_COMPILATION] 出现 3 次,第四次调用没有编译、直接复用了第一次的管线(入口名的哈希也一样):
3
name: cube_kernels::add::Add<f32>
mode: Checked,
relu: false,
[[max_total_threads_per_threadgroup(64)]] [[kernel]] void add_f_f32_7c592e50
| 12.25µs | Add | f32_7c592e50
add checked=true relu=false out = [1.5, -1.5, 3.5, -3.5, 5.5, -5.5]
name: cube_kernels::add::Add<f32>
mode: Unchecked,
relu: false,
[[max_total_threads_per_threadgroup(64)]] [[kernel]] void add_f_f32_b1476a3d
| 12.667µs | Add | f32_b1476a3d
add checked=false relu=false out = [1.5, -1.5, 3.5, -3.5, 5.5, -5.5]
name: cube_kernels::add::Add<f32>
mode: Checked,
relu: true,
[[max_total_threads_per_threadgroup(64)]] [[kernel]] void add_f_f32_a1caed20
| 13.166µs | Add | f32_a1caed20
add checked=true relu=true out = [1.5, 0.0, 3.5, 0.0, 5.5, 0.0]
add checked=true relu=false out = [1.5, -1.5, 3.5, -3.5, 5.5, -5.5]
| 12.167µs | Add | f32_7c592e50
(第一行是 grep -c 的计数,其余是 grep 出的入口名、模式、relu 和结果行。)
- 第二次只换了执行模式:
mode是KernelId的字段,Unchecked是另一个键,所以重新编译,尽管上一节看到它编出的 MSL 和Checked一样。 - 第三次只换了
relu:#[comptime]参数进了AddInfo,也是另一个键,编出的 MSL 多了max(v25, v26)。 - 第四次和第一次的
KernelId完全相同,WgpuServer::pipeline在self.pipelines里命中,连define都没调。输入数据不同不影响:数据只在运行期参数里,不在键里。
小结
#[cube(launch)]保留原函数只做类型检查,另生成同名模块:expand(构造 IR)、内核描述体Saxpy<F>(只带编译参数)、KernelMetadata::id、CubeKernel::define、launch。expand把每个运算换成__expand_*调用,最后都是建一个 pliron 操作、Scope::register插进去;泛型F、N换成DynamicScalar/DynamicSize,真实类型查scope的类型表。- 0.11 的前端:全局缓冲是
&[T]/&Tensor<T>,向量是Vector<F, N>,Array只剩内核里的定长数组;#[comptime]参数在展开时就是普通 Rust 值,并进入KernelId。 launch在调用线程上只登记参数、构造描述体、提交;IR 在设备线程上、按KernelId第一次编译时才构造,之后复用管线。CUBECL_DEBUG_LOG=stdout能看到每个内核的 MSL,但它同时让每次启动变成阻塞的,不能用来计时。- 0.11.3 的 MSL 路径上,
launch和launch_unchecked编出相同的代码,越界不会被夹取;内核要自己写下标守卫。