第 11 章 内核启动与执行 - Alpha Book
跳转至

第 11 章 内核启动与执行

本章核心源码:crates/cubecl-runtime/src/client.rs、crates/cubecl-runtime/src/dry_run.rs、crates/cubecl-cpu/src/compute/server.rs、crates/cubecl-server/src/stream/execute_scope.rs、crates/cubecl-server/src/stream/scheduler.rs、crates/cubecl-runtime/src/server/base.rs

本章导读:本章是全书的第 11 章,承接第 2 章的四条并行轴与第 5 章的自动调优搜索,把「一次 client.launch() 从被调用到真正 dispatch 出去」这条路一次走完。设备句柄与三种传输的形状见第 10 章,编译管线的算法见第 7、8、9 章,搜索空间与打分逻辑见第 5 章,同步原语对算法作者的契约见第 16 章,benchmark 设施的具体用法见第 18 章——本章只讲**任务是怎么被交出去的,交出去之后谁替谁记账**。

为什么一次 launch 需要两次不可逆的移交

把 launch 想成一次普通函数调用是最省事的模型:launch(kernel, count, bindings) 把三个值交给某个函数,函数推进硬件队列,回来。只讲到这里写出来的会是一份 API 顺序文档——解释不了源码里最费力的那部分代码为什么存在。CubeCL 的实际形状是:设备在**另一个线程**上。crates/cubecl-common/src/device/handle/channel.rs:51-53 写得很直接——注册服务时若这个 device_id 上还没有 runner 线程就现开一个;之所以要开,是因为驱动不保证线程安全而运行时的记账需要单点:CpuServer 的字段就是证据(crates/cubecl-cpu/src/compute/server.rs:41),编译缓存是一个裸 HashMap、流是一个 SchedulerMultiStream、失败账本藏在调度器里,全部无锁可达。

代价是**移交不可逆**,本章的张力全部来自这一句。crates/cubecl-runtime/src/dry_run.rs:18 是最直接的证据:dry run 的判定「once, on the thread that issues the launch」,因为「by the time a launch reaches a server thread, the context that produced it is gone」。会丢的是三样线程局部或作用域局部的东西:是不是 dry run、是不是被 autotune 的测量 guard 包着、是哪一条流。

流那一样值得单看。crates/cubecl-common/src/device/handle/channel.rs:68 的 submit_blocking 先在**调用线程**上取 StreamId::current(),再在设备线程上用 current.executes(...) 装回去。之所以要取回来再装,是因为 Client 上根本没有「这次 launch 属于哪条流」这个字段——crates/cubecl-runtime/src/client.rs:1266 那行 launch 的全部内容是 unsafe { self.launch_inner(kernel, count, bindings, self.stream_id()) },而 stream_id()(同文件 :243)在没被 set_stream 钉住时返回 StreamId::current()。流的身份来自调用线程,而不是来自参数;直接后果是两个线程发起的 launch 默认落在两条流上。把不可逆性反过来用——凡是「只有发起线程知道」的事都必须在过去之前做完——就是本章的组织线索。

设计:两跳之间各发生什么

两跳:为什么移交不可逆

sequenceDiagram autonumber participant C as 调用线程 participant K as Client participant D as "设备线程 (Server)" participant S as Stream C->>K: launch(kernel, count, bindings) K->>K: stream_id() = StreamId::current() Note over C,K: 流的身份来自调用线程,不来自参数 rect rgb(236, 242, 255) Note over K: 闸一 静态 grid 有零维就返回 Note over K: 闸二 每个 binding 过 expect_local Note over K: 闸三 launch_mode() 在发起线程上取 Note over K: 闸四 选 submit 还是 submit_blocking end K-->>C: 立即返回(launch 的签名里没有返回通道) K->>D: 移交:参数按值带走,上下文不再跟随 Note over K,D: 不可逆:dry run 状态、测量豁免、流身份都已固化 rect rgb(255, 244, 235) Note over D: 算对齐 → 缓存键 (KernelId, alignment) Note over D: compile_only:编译并填缓存,不碰任何缓冲 Note over D: dry run 在这里早退 Note over D: ExecuteScope::launching:认领写入集 Note over D: prepare_bindings → prepare_task end D->>S: register(task, args_streams) Note over S: align_streams:参数里出现别的流就排干它 Note over S: push 进队列;长度到阈值就地执行

这张图只承担一件事:哪些判断必须赶在第一跳之前(蓝框四道闸),以及**第二跳之前设备线程已经把能准备的都准备好了**(橙框四段)。那条分界线就是不可逆性。

跳上的第一站:发起线程的四道闸

launch_inner(crates/cubecl-runtime/src/client.rs:1080)是本章最需要逐行读的函数:它把四件必须在移交前决定的事分成四段,各有各的失效形态。

Rust
# crates/cubecl-runtime/src/client.rs:1088-1108
        if let CubeCount::Static(x, y, z) = &count
            && (*x == 0 || *y == 0 || *z == 0)
        {
            return;
        }
        if let CubeCount::Dynamic(binding) = &count {
            self.expect_local(binding);
        }
        for resource in &bindings.resources {
            self.expect_local(match resource {
                KernelResource::Buffer(binding) => binding,
                KernelResource::TensorMap(map) => &map.binding,
            });
        }

        crate::launched::note(|| kernel.id());

        // Decided here, on the issuing thread, because that is the only place
        // that still knows whether this launch is an autotune measurement — by
        // the time it reaches the server thread, that context is gone.
        let launch_mode = crate::dry_run::launch_mode();

闸一,零维短路。 紧邻的那行注释(crates/cubecl-runtime/src/client.rs:1087)理由是「某些驱动拒绝零维」。这是纯正确性的一闸:不短路,错误会在设备线程上变成一次 LaunchError,而 Server::launch(crates/cubecl-runtime/src/server/base.rs:633)没有返回通道可报。

闸二,外设备句柄就地 panic。 expect_local(crates/cubecl-runtime/src/client.rs:281)检查 binding 的 service 是否等于本 client 的 service_id()。为什么不能推到设备线程再查?crates/cubecl-runtime/src/server/handle.rs:16 说得很直白:内存坐标在别的设备上没有意义,「reading them there is not an error but garbage」——它不是能被检出的错误,而是静默的内存越界。

闸三,LaunchMode,服务于 autotune-as-search 的一闸。

闸四,提交路径的分叉。 普通路径走 self.device.submit(crates/cubecl-runtime/src/client.rs:1130,异步),profiling 路径走 submit_blocking(同文件 :1167,等设备线程接下任务并把 profile 带回来)。判据在同文件 :1120 那段注释:log 自己的计时等级与 observer 想要的计时是**同一份测量的两个读者**,让前者决定后者,就意味着调用方无法在不被迫把它记到别处的前提下给 launch 计时。同一条思路解释了上面 notify_launch 为什么必须在 submit 之前——crates/cubecl-runtime/src/client.rs:1112 说那是「the last point at which the caller's own context still exists」。

LaunchMode:两个态、一份深度、一层豁免

LaunchMode 只有两个变体,判据是嵌套深度而不是布尔开关:

Rust
# crates/cubecl-runtime/src/dry_run.rs:26-51
pub enum LaunchMode {
    /// Compile if needed, then run it. The normal case.
    Execute,
    /// Compile if needed, cache the artifact, and drop the launch.
    ///
    /// A server honoring this must still do everything a first launch does
    /// short of dispatching — expand, compile, validate, populate its caches —
    /// or the pass buys nothing.
    Skip,
}

impl LaunchMode {
    /// Whether the launch should be dropped rather than run.
    pub fn is_skipped(self) -> bool {
        matches!(self, LaunchMode::Skip)
    }
}

/// What to do with a launch issued on this thread, right now.
pub fn launch_mode() -> LaunchMode {
    if !dry_run() || real_run::depth() > 0 {
        return LaunchMode::Execute;
    }

    LaunchMode::Skip
}

三处设计各问一次为什么。为什么判据在设备线程上取不到:dry run 是进程级计数器(crates/cubecl-runtime/src/dry_run.rs:56)叠上线程局部的 RealRun 深度(同文件 :149),设备线程有自己的这两样。契约因此被写成参数传递而不是状态查询——Server::launch 的文档(crates/cubecl-runtime/src/server/base.rs:626)明说服务端「receive the verdict as a LaunchMode argument rather than deriving it」。为什么是深度不是布尔:crates/cubecl-runtime/src/dry_run.rs:53 给出了 swap-and-restore 的失效形态——一个线程的 guard 结束会取消另一个线程还在进行的 dry run,等后者 drop 时进程就停在 dry run 里出不来。为什么 autotune 的测量 launch 是例外:RealRun 的文档(同文件 :110)说得更直接,autotune 握着一个 guard,因为「its launches are the measurement a dry run exists to provoke, not the workload it exists to skip」;同一条文档还指出要紧的是**发起 launch 的那个线程**,而不一定是请求它们的线程。

这一层就是 autotune-as-search 论点在运行时的落点:Skip 要求服务端「做完一次首次 launch 所欠下的一切,只是不下发」,于是**整个候选闭包表**(第 5 章的搜索空间)都能在不付工作本身代价的前提下编译并缓存一遍。搜索之所以便宜,压舱的不是搜索算法,而是这条「编译但丢弃」的路。

跳上的第二站:设备线程第一步是编译

CPU 后端是主样本,因为它是唯一能看到完整顺序的实现(CUDA 版只作同形状的对照)。CpuServer::launch(crates/cubecl-cpu/src/compute/server.rs:372)四段:算对齐、编译或复用、dry run 早退、开 scope。第一段算的是缓存键的另一半:

Rust
# crates/cubecl-cpu/src/compute/server.rs:401-416
        let alignment =
            bindings
                .resources
                .iter()
                .fold(
                    BytesStorage::ALIGNMENT as u32,
                    |align, resource| match resource {
                        KernelResource::Buffer(binding) => {
                            let bits = binding.offset_start.unwrap_or(0).trailing_zeros();
                            1u32 << bits.min(align.trailing_zeros())
                        }
                        _ => align,
                    },
                );
        let kernel_id = kernel.id();
        let cache_key = (kernel_id.clone(), alignment);

折叠取的是最强约束:所有 view 的 offset_start 里最低的那个二进制位决定对齐(crates/cubecl-cpu/src/compute/server.rs:398),因为存储基址与池内偏移都按 64 字节对齐,而 view 的起点偏移可以削弱它。紧邻的下一条注释是一条独立约束(crates/cubecl-cpu/src/compute/server.rs:400)——只读描述符、不解析任何资源,否则一次预热会为了算一个对齐值把整张工作负载的内存都映射出来。

第二段 compile_only(同文件 :156)是「首次 launch 所欠下的一切」的最小实现:查表、未命中就 kernel.define() + CompiledKernel::compile、填缓存。它还拒绝一种产物(同文件 :174),compiled.repr.is_none() 时报「it runs compiled IR, not source text」——GPU 后端要驱动能加载的 PTX,CPU 后端要一个可执行的 JIT 引擎。

编译为什么必须排在认领之前。 crates/cubecl-cpu/src/compute/server.rs:381 那段注释论证密度最高:编译排在最前(memoize 之后只是一次查表),因为 write scope 要按编译后的内核所说的那样布置写入集。而这个答案来自编译器的可见性分析(crates/cubecl-runtime/src/compiler.rs:76 的 buffer_io)。一个编译失败的内核没有 IR,也就没有可见性答案,写入集只能退回调用方声明的 IO:源码把后果写成了 bindings.buffers_written(None)(同文件 :423);CUDA 版是第二份证据(crates/cubecl-cuda/src/compute/server.rs:629)。同一条规则因此出现两次:一次决定回退到谁的答案,一次决定回退的方向。

第三段是 dry run 早退(同文件 :427),在编译之后、任何碰缓冲的东西之前。第四段才开 scope:

Rust
# crates/cubecl-cpu/src/compute/server.rs:441-458
        let mut written = self.write_set();
        written.extend(bindings.buffers_written(io.as_deref()).cloned());
        // A dynamic count travels outside `resources`, so `buffers_read`
        // never names it — yet the dispatch reads it as its grid dimensions,
        // which is exactly the garbage-as-cube-count read the skip exists to
        // prevent.
        let count_read = match &count {
            CubeCount::Dynamic(binding) => Some(binding),
            CubeCount::Static(..) => None,
        };
        ExecuteScope::launching(
            self,
            kernel_id.clone(),
            stream_id,
            bindings.buffers_read(io.as_deref()).chain(count_read),
            written,
        )
        .execute(|server| {

count_read 那条注释是四条轴的第一次现身:CubeCount::Dynamic 携带的 buffer 不在 resources 里,所以 buffers_read 不会点到它,而 dispatch 会把它的内容当成网格尺寸读——一条带着失败的内容被读成 cube count,正是那次 skip 存在的理由。scope 能按值取走 bindings 是因为 write_set(crates/cubecl-server/src/stream/execute_scope.rs:91)由调用方提前填好;空集合什么都不认领,那正是 dry run 想要的。

CubeCount::Dynamic:第四条轴在运行时的落点

第三条轴(CubeDim)在编译期就烧死了,第 2 章说过;第四条轴的网格尺寸却在运行期才定下来,两个后端的代价完全不同。CPU 后端在 prepare_task(crates/cubecl-cpu/src/compute/server.rs:125)里解:

Rust
# crates/cubecl-cpu/src/compute/server.rs:132-149
        let cube_count = match count {
            CubeCount::Static(x, y, z) => [x, y, z],
            CubeCount::Dynamic(binding) => {
                let stream = self.scheduler.stream(&binding.stream);
                let resource = stream
                    .memory_management
                    .get_resource(binding.memory, binding.offset_start, binding.offset_end)
                    .unwrap();

                stream.submit();

                let bytes = resource.read();
                let x = u32::from_ne_bytes(bytes[0..4].try_into().unwrap());
                let y = u32::from_ne_bytes(bytes[4..8].try_into().unwrap());
                let z = u32::from_ne_bytes(bytes[8..12].try_into().unwrap());
                [x, y, z]
            }
        };

中间那行 stream.submit() 是全部要害。它不是「提交任务」,而是自旋到该流的任务全部跑完(crates/cubecl-cpu/src/compute/stream.rs:182 的注释解释了为什么先自旋一千次再让出:客户端没被 pin 住,纯自旋会把工作线程所在的逻辑 CPU 占到下一个 timer tick)。也就是说 CPU 后端为了读 12 个字节,会把这条流排干一次。CUDA 后端(crates/cubecl-cuda/src/compute/server.rs:794)的做法是 future::block_on 一次 12 字节读回,读同一份数据却付出另一种阻塞:CPU 那次排干发生在 scope 内部,会让设备线程停在上游工作上;CUDA 那次读回发生在 scope 之前(同文件 :181 解释原因——进 scope 会替换掉输出当前带的任何认领,而解出零的 count 不下发任何东西),失败则走 failed_writing。

这就是同一抽象在两种硬件上代价差异最干净的标本:GPU 有独立的硬件队列可以异步读回,读回因而能排在 dispatch 之前;CPU 只有一条共享线程池,读回只能靠排干。差异不在抽象里,在 CubeCL 必须尊重的硬件语义里。

流与依赖:靠绑定集合自动推断

第二次移交是 register(crates/cubecl-server/src/stream/scheduler.rs:282),三件事:

Rust
# crates/cubecl-server/src/stream/scheduler.rs:282-294
    pub fn register(&mut self, stream_id: StreamId, task: B::Task, args_streams: &[StreamId]) {
        // Align streams to ensure dependencies are handled correctly.
        self.align_streams(stream_id, args_streams);

        // Get the stream for the given stream ID and add the task to its queue.
        let current = self.pool.get_mut(&stream_id);
        current.tasks.push(task);

        // If the task queue exceeds the maximum, execute the stream.
        if current.tasks.len() >= self.max_tasks {
            self.execute_streams(vec![stream_id]);
        }
    }

align_streams(同文件 :297)的判据只有一条:参数里出现的 stream 集合。只要某个绑定的 stream 不等于本次 launch 的流,就把那条流执行干净(同文件 :310 的日志文案 Binding on {} is shared on {} 就是触发条件)。它之所以足以承担全部依赖推断,是因为两个 kernel 之间存在数据依赖当且仅当它们共享一个 buffer,而「本次 launch 会碰到哪些 buffer」在这一步已知:CpuServer::launch 正是抽出 b.stream 填进 streams_pool 来喂这个参数。

于是 CubeCL 不要求用户手写事件依赖:CUDA 的事件与 barrier 语义、HIP 的弱序内存模型、WebGPU 的队列语义都不出现在用户代码里。代价是同一条规则的两面——它只会过度同步,不会同步不足:任何共享 buffer 的 launch 都会把对方那条流排干,哪怕两者在数据上真正独立。第二个后果来自队列阈值:register 在长度到 max_tasks 时就地执行这条流,于是「什么时候执行」不再只由 sync 决定——别的线程一次 launch 只要参数里带了本流的 buffer 就能把本流排干。流同步的边界因此不由流自己划定,而由谁碰了它的 buffer 划定。

失败不是返回值,是 buffer 上的一个状态

Client::launch(crates/cubecl-runtime/src/client.rs:1266)返回 (),Server::launch(crates/cubecl-runtime/src/server/base.rs:633)也返回 ()。两条路径上都没有失败通道,而设备侧显然会失败。失败必须被放到别处,CubeCL 选的位置是:这次 launch 没写成的那些输出缓冲。写入集的定义是一条三级回退链:

Rust
# crates/cubecl-runtime/src/server/base.rs:1390-1403
    pub fn buffers_written<'a>(
        &'a self,
        io: Option<&'a [BufferIOAttr]>,
    ) -> impl Iterator<Item = &'a BufferBinding> {
        self.buffers()
            .enumerate()
            .filter_map(move |(index, binding)| {
                let written = self
                    .io_attr(io, index)
                    .map(|io| io.is_writable())
                    .unwrap_or(true);
                written.then_some(binding)
            })
    }

最后三行是全章最该抄下来的一段代码——其上的文档注释(crates/cubecl-runtime/src/server/base.rs:1377)把这个方向写清楚了,而它不是随手选的。两个错误模式的代价不对称:漏报一个它写的缓冲,会把上一次的旧字节原样交回调用方,静默;误报一个它只读的缓冲,会让一次本来合法的读大声失败。前者是数值错误,后者是噪声,所以回退链在第三级 over-name——找不到答案就当它写了。读集合走同一条链但反向(同文件 :1414),并明确说纯输出不参与读检查:「a pure output is not read, so a relaunch into a tainted buffer is exactly how the buffer gets repaired」。失败因此不是需要被清理的状态,而是**可以被下一次写入覆盖的状态**。

stateDiagram-v2 direction LR [*] --> Clean Clean --> Claimed: launching 进入<br/>输入全干净 Clean --> Clean: dry run 不认领<br/>它本来也不会写 Clean --> Failed: 从没开始执行<br/>failed_writing Claimed --> Clean: body 成功<br/>释放临时认领 Claimed --> Failed: body 失败或 panic<br/>换成真错误 Failed --> Failed: 下一次 launching 遇到它<br/>跳过,写入集指向同一失败 note right of Clean 读它 = 拷贝字节 end note note right of Failed 读它 = 报出这个失败 end note

这张图是本章的结论图:失败不是 launch 的返回值,而是 buffer 上的一个状态。 三条迁移条件各解释一条规则——为什么 dry run 一个都不认领(它不写)、为什么进入时先挂一个临时失败而不在退出时才记(body 可能提前返回甚至 panic)、为什么失败可以被修复。认领的记账收在一处:ExecuteScope 的文件头注释(crates/cubecl-server/src/stream/execute_scope.rs:1)把动机写成一句话,「没有这个 scope,每个后端都手抄一遍同样的记账,然后祈祷没有一条 early return 忘了」。scope 只有两种开法,在构造器里一次性选定:

Rust
# crates/cubecl-server/src/stream/execute_scope.rs:187-209
    pub fn launching<'b>(
        server: &'a mut S,
        kernel: KernelId,
        stream: StreamId,
        reads: impl Iterator<Item = &'b BufferBinding>,
        written: Vec<BufferBinding>,
    ) -> Self {
        let Some(found) = server.write_streams().read_failure(reads) else {
            return Self::over(server, stream, written);
        };
        server.on_failure(stream, &found.error);
        // A skip inside a recording window dooms it: the recording is missing
        // this launch and must not seal. A no-op outside one.
        if let Some(capture) = server.capturing(stream) {
            capture.fail(found.error.clone());
        }
        server.write_streams().propagate(&found, kernel, written);
        Self {
            server,
            stream,
            opened: Opened::Skipped,
        }
    }

进入(输入全干净):写入集先被一个**临时失败**认领,离开时要么释放、要么把真错误换上去。跳过(输入带失败):写入集直接指向**那个**失败,于是下游读到的是根因而不是新错误,body 一行都不跑;文件头注释解释了为什么「进入之后才发现该跳过」是错的而不只是浪费——临时认领会被传播失败覆盖,而清理它的那次退出恰好在不会运行的路径上。另一个入口是 failed_writing(crates/cubecl-server/src/stream/execute_scope.rs:264):「work that never started leaves its destinations exactly as they were」,所以**从没开始执行的工作也要认领**写入集;编译失败、count 解析失败、零网格三条早退都走它。

这套语义有一个直接推论,也是第 18 章那个案例能成立的原因:sync() 报不出 launch 的失败。 crates/cubecl-runtime/src/client.rs:1374 那段文档划清了界限——device fault 归 sync,launch failure 不归,它活在它没写成的那些缓冲上。于是 sync_buffers(同文件 :1394)是两段式:先 barrier,再回答;check(同文件 :1285)是「不做读的读检查」。benchmark 一侧同构:crates/cubecl-common/src/benchmark.rs:249 的 fn sync(&self) 返回 ()。它能拿到的时长只来自「工作已提交到流上」,是否真的产出了数据只能由读回来检验——计时与可信度因此被拆成两件事,这是把失败挂在 buffer 上换来的东西,不是它的代价。

录制与同步:同一套记账的另两种用法

graph capture 是三步:graph_prepare(crates/cubecl-runtime/src/client.rs:1308)为窗口做准备,start_capture(同文件 :1330)开窗,跑一遍录制,stop_capture 拿回一个 Graph。

start_capture 的文档(同文件 :1318)列了一份禁令清单:录制窗口里**读、同步、profiling、往 handle 写**,一律拒绝。四条各有各的理由——读与同步会打断驱动的录制状态机,profiling 需要在设备上打时间戳而录制要的是「只有内核操作」,往 handle 写之所以被拒,是因为**录好的图无法携带一次 host 拷贝**,新鲜输入只能在两次 replay 之间写。还有两条更细的规则:拒绝是晚到的(由 stop_capture 失败报告,而不是交回一个悄悄跳过写入的 graph),窗口里不分配任何内存(池子满足不了的请求会让录制失败,这正是 warmup run 存在的理由)。

replay 的契约写在 Graph::replay 的 Safety 段(同文件 :115):存活(录制时读写的每个 buffer 仍被分配)、无并发使用、刷新必须同流。三条都是因为 replay 用录制时捕获的裸设备指针重跑内核,没有任何东西验证这些 buffer 是否还在。硬件图(CUDA / HIP)把整段录制成一次 dispatch,软件图(wgpu)重编码每一条已解析的 dispatch——这是 autotune 之外的第二种摊薄方式。

sync 的两种实现是这套差异的最后一角。GPU 侧(crates/cubecl-server/src/command/base.rs:535)是「在流上插一个 fence,然后等这个 fence」,因为 launch 早就是异步下发的;CPU 侧(crates/cubecl-cpu/src/compute/server.rs:498)是先把队列推给线程池,再在 stream.flush(crates/cubecl-cpu/src/compute/stream.rs:205)里自旋一个原子计数器——同一个 sync 方法下的两种等待原语。还有一处对应差异值得点名,因为它把第 9 章的 sync_cube 拉回本章:CPU 目标的 cube barrier 需要每个 unit 都真的在跑,于是 ensure_workers(crates/cubecl-cpu/src/compute/threadpool/scheduler/dispatcher.rs:49)把线程池扩到 cube_dim.num_elems(),判据是编译期算出的 needs_parallelism(crates/cubecl-llvm/src/shared/base.rs:263)。GPU 上 barrier 是一条指令、零条宿主线程——同一条抽象,在一种硬件上是「开 N 条 OS 线程」,在另一种上是「没有对应物」。

源码走读:跟一次 launch 走完两跳

以 CPU 后端为主样本、CUDA 后端在同一位置作对照,把上面的段落压成一条线。四道闸之后按 profile level 分叉,profiling 路径把参数塞进一个 Arc<Mutex<Option<…>>>(crates/cubecl-runtime/src/client.rs:1152:profile 可以被拒绝——录制窗口会当场拒绝一个——而拒绝必须把 launch 退还回去)。Handle 在移交之前已变成 BufferBinding(Client::bindings,同文件 :1417),因为它是借用的而回答它的闭包跑在别处。

设备线程上,compile_only 查表(命中时是一次 HashMap::contains_key,未命中是完整的展开与 JIT),失败则走 buffers_written(None) + failed_writing(crates/cubecl-cpu/src/compute/server.rs:382);dry run 在此早退,此时写入集一个都没认领、也没有任何流依赖被注册(同文件 :389)。开 scope 后的 body 只有四步,驱动若拒绝 enqueue 由 scope 的 exit 把错误换到写入集上,body 若 panic 则写入集**保持认领**(crates/cubecl-server/src/stream/execute_scope.rs:28)。发现有三个入口,代价递增:check、sync_buffers、read。

边界情况

录制窗口里什么都别分配。 graph_prepare 的文档(crates/cubecl-runtime/src/server/base.rs:651)要求必须在 warmup run **之前**调,因为窗口复用 warmup 留在池子里的东西;同一条文档还说「Prefer having kernels already autotuned before this call」——autotune 必须先跑完,录制才有东西可录。一次被当场拒绝的 start_capture 让已有捕获保持原样(crates/cubecl-runtime/src/client.rs:1337)。

跨线程的窗口与 guard。 ProfileWindow(同文件 :167)记得自己开在哪条流上,但一个既没结束也没放弃的窗口就留在 server 上。Client::exclusive(同文件 :575)更把调用者的 StreamId 一路传进设备线程(crates/cubecl-common/src/device/handle/channel.rs:89)——这正是 RealRun 警告过的形状,guard 要活在那个任务里。

失败模式

输入带失败。 launch 被跳过而不执行,写入集指向同一个失败,所以下游读到的是根因。理由(crates/cubecl-server/src/stream/execute_scope.rs:183)是:一个装着垃圾的缓冲可能被读成动态 cube count 或 gather 下标,散写进本来没带任何失败的内存。

device fault。 归 sync 独占——它没有任何缓冲能报告。

body 里 panic。 写入集保持认领。scope 的 body 是闭包而不是 guard 值,原因写在文件头:#[must_use] 对提前返回路径上的绑定值无话可说,而 Drop 够不到失败账本、也不该在 unwind 中断言。

可运行示例

想亲眼看见「失败挂在 buffer 上」而不是返回值上,最省事的办法是让一次编译失败发生在测量循环里。crates/cubecl-runtime/src/dry_run.rs:212 的 a_dry_run_spares_the_measurements 已经把「dry run 不跳过测量 launch」做成了断言。落到 launch 路径上按这个顺序观察:

  1. 手写一个 #[cube] 内核引用设备不支持的指令,使 compile_only(crates/cubecl-cpu/src/compute/server.rs:156)返回 Err。launch 立刻返回,ServerError 到不了调用方。
  2. 紧接着 client.check(&[output])。这次查表会失败;check 的文档(crates/cubecl-runtime/src/client.rs:1281)只保证 enqueue-time 失败可见,所以编译失败会被完整报出。
  3. 换成一个编译得过去的版本再 launch 一次,check 恢复干净——验证的是「往被污染的输出里重新 launch 正是修复它的方式」。
  4. 把 LaunchMode 切成 Skip 再走一遍:编译仍会发生、缓存仍会被填,但一个缓冲都不会被认领。

第 4 步最反直觉:跳过一次 launch 不等于什么都不做,它做全部编译工作、零次设备执行、零次认领。

被否决的替代方案

方案一:让 launch 返回 Result。 GPU 上 launch 是异步的,返回 Ok 只意味着 enqueue 被接受,crates/cubecl-server/src/command/base.rs:544 明说「The driver's refusal to enqueue the launch」才是这里能报的。即使能同步等,一次 launch 可能写多个缓冲,单个错误值说不出**哪些**缓冲可疑,而读路径无论如何都得逐个查。它也与 benchmark 的需求冲突:benchmark 要的正是 launch 返回之后工作还在流上跑。

方案二:把 LaunchMode 做成 Client 上的一个字段。 设备线程于是不需要知道任何上下文。否决的理由是它把「执行上下文」错当成「调用者对象」的性质:autotune 会在同一个 client 上交替发出测量 launch 与普通 launch,谁何时设这个字段就成了一份隐式协议。源码选择的是相反方向——模式是线程与作用域的性质,不是对象的。

方案三:把回退链反过来,找不到答案就当只读。 听起来更保守更安全。否决理由就是那个不对称性:这一改会把静默返回旧字节换成大声失败,等于**用正确性换可用性**。

练习

  1. 删掉 crates/cubecl-runtime/src/client.rs:1087 的零维短路,在 CPU 后端上发一次 CubeCount::Static(0, 1, 1)。观察错误从哪条路径出来——它很可能**出不来**,因为 Server::launch 没有返回通道。这就是「闸一必须在移交之前」的可观察后果。
  2. 盯着 crates/cubecl-cpu/src/compute/server.rs:423 的 buffers_written(None) 想清楚:为什么编译失败时**不能**多认领只读缓冲?后果落在一轮 autotune 扫描的第几个候选上。
  3. 在 crates/cubecl-runtime/src/dry_run.rs:186 的 real_run_nests 上加一个 DryRun::new(),看断言还成不成立。想清楚为什么**两个** guard 的叠加是唯一正确的语义。
  4. 对照读 crates/cubecl-cpu/src/compute/server.rs:141 的 stream.submit() 与 crates/cubecl-cuda/src/compute/server.rs:807 的 future::block_on:一个把流排干,一个把 12 字节读回。想清楚为什么 CUDA 版必须赶在开 scope **之前**解 count。

设计启示

不可逆的边界决定职责的边界。 凡是只有发起线程知道的事就在发起线程决定;四道闸看起来是四个不相关的检查,其实是同一条规则的四次应用。判断一个新检查该放在哪一边,问的不是「它属于编译还是执行」,而是「过了这条边之后,我还有没有做它的机会」。

把状态放在能被下一次操作修复的地方。 失败挂在 buffer 上看起来像妥协,实际相反:认领自带自愈,并把「哪些缓冲可信」推迟到真正需要字节的那一刻,代价只是一次查表。需要显式清理的状态,等于要求每条控制流都记得清理,而这正是 ExecuteScope 文件头那句「祈祷没有一条 early return 忘了」要消灭的。同理,回退链的方向要选错得起的那个:找不到答案的地方,都该选那个把不确定性变成噪声而不是数据的分支。

抽象的成本由硬件语义决定。 CubeCount::Dynamic 在 CPU 上意味着排干一条流、在 GPU 上意味着一次阻塞读回;sync_cube 在 CPU 上意味着扩线程池、在 GPU 上是一条指令。衡量抽象好不好,要看的正是它把多少代价留在了两种硬件之间那道缝里。

小结

一次 launch 跨过两次不可逆的移交。第一跳不可逆,因为过去之后产生这次调用的上下文就没了,于是发起线程上排着四道闸;第二跳不可逆,因为「这次算不算一次工作」在 ExecuteScope 认领写入集的那一刻就已经回答了,于是设备线程的第一步是编译。CPU 后端是唯一能看到完整顺序的实现,CUDA 后端是第二份证据,差别落在同一处:动态 count 在 GPU 上可以在开 scope 之前读回,在 CPU 上只能排干整条流。

本章最重的一条:失败不是 launch 的返回值,而是 buffer 上的一个状态。 写入集的三级回退链在最后一级 over-name,因为漏报的代价是静默返回旧字节、误报的代价是一次大声的读失败;读集合反向走同一条链且排除纯输出,于是「往被污染的输出里重新 launch」成了修复方式。GPU 上没有一个统一的「这次 launch 失败了」的返回位置,于是 CubeCL 选了一个可以被下一次读发现的位置。

过渡

下一章换视角:前十一章讲的是「CubeCL 拿走了硬件的哪些自由度、又把哪些还给了用户」,从下一章开始问题变成**这套东西是用什么语言特性写出来的**——kernel 怎么用 trait 声明可替换算子、泛型怎么在展开期被具体化。运行时那一层的移交与对账依然成立,只是要挂在类型系统上,而不是驱动语义上。


主线回扣:本章支撑 four-axes 与 autotune-as-search —— 四条轴在运行时只剩第 4 条还活着,CubeCount::Dynamic 的 buffer 必须被手工接进读集合,且它在不同硬件上的解算代价相差一个数量级,说明「轴」不是编译期就一劳永逸的东西;而 LaunchMode 让一次 dry run 能把整个候选闭包表编译并缓存而不执行,搜索之所以能在冷启动时铺开,压舱的是这条运行时的移交契约而不是搜索算法本身。


版本演化说明

本章基于 cubecl 的 a6321cd4 提交。