cubecl_server/stream/execute_scope.rs
1//! One scope around a unit of device work.
2//!
3//! Without the scope, every backend keeps the same bookkeeping by hand:
4//! decide whether an input can be trusted, gather what the work writes, claim
5//! it on every failure path, release it on the success path, and hope no
6//! early return forgot. The scope makes forgetting loud instead of silent.
7//!
8//! A scope is opened one of two ways and the choice is made once, in the
9//! constructor, which is why the two can never interleave:
10//!
11//! - a launch whose inputs all read cleanly, or work that reads nothing it
12//! must trust, **enters**: the write set is claimed by a provisional
13//! failure, and leaving either releases it or swaps the real error in;
14//! - a launch whose input carries a failure **skips**: the write set is
15//! pointed at that same failure instead, so a read downstream names the
16//! original cause rather than a new one, and the body never runs.
17//!
18//! Entering on a skip would be wrong rather than merely wasteful — the
19//! provisional would be minted, overwritten by the propagated failure, and
20//! never pruned, because the exit that prunes it is on the path that does not
21//! run. Nothing has to remember that, because a scope is one or the other
22//! before it exists.
23//!
24//! The body is a closure and the scope is not a guard value, because a guard
25//! enforces nothing here: `#[must_use]` says nothing about a bound value on a
26//! path that returns early, and a `Drop` implementation cannot reach the
27//! failure store and must not assert during an unwind. A path that never
28//! reaches the exit — a panic mid-launch above all — leaves the write set
29//! claimed, which is exactly what a read of one of its buffers has to fail on.
30
31use crate::id::KernelId;
32use crate::memory_management::FailureId;
33use crate::server::{BufferBinding, ServerError};
34use crate::stream::{FailureStore, StreamCapture};
35use alloc::vec::Vec;
36use cubecl_environment::stream::StreamId;
37
38/// A server whose device work runs inside an [`ExecuteScope`].
39///
40/// A server supplies the three things a scope cannot know — where its
41/// multi-stream driver lives, what a failure means to a measurement in
42/// flight, and whether the stream is recording a graph — and gets the scope
43/// for it, which is the only way its launch and host-copy paths should touch
44/// the failure bookkeeping.
45pub trait WriteScoped: Sized {
46 /// The multi-stream driver the server keeps.
47 type Streams: FailureStore;
48
49 /// The driver, split-borrowed from the server so the scope can claim on
50 /// the way in and settle on the way out while `body` holds the rest.
51 fn write_streams(&mut self) -> &mut Self::Streams;
52
53 /// Told about every failure a scope settles, so a measurement in flight
54 /// on `stream` is invalidated wherever the failure happened.
55 ///
56 /// A failed candidate that benchmarked at close to zero would otherwise
57 /// win a tune. The stream is named because some backends keep a
58 /// measurement per stream and some keep one per device; the scope knows
59 /// which stream its work was for, and the server knows which of those it
60 /// is. Defaults to doing nothing, for a server that measures nothing.
61 #[allow(unused_variables)]
62 fn on_failure(&mut self, stream: StreamId, error: &ServerError) {}
63
64 /// The graph capture of the pooled stream `stream` folds onto, for a
65 /// server that records captures. Defaults to `None`, for one that does
66 /// not.
67 ///
68 /// The scope is the window's only informant. Work that fails or is
69 /// skipped inside a recording window dooms it through this accessor —
70 /// the recording is missing an operation and must not seal, and the
71 /// replay contract has the caller write fresh inputs before each replay,
72 /// clearing the very claim that would explain the hole. Work that exits
73 /// clean hands the window its write set, so "recorded" and "in the
74 /// graph" are the same event and cannot disagree. A server that returns
75 /// its capture state gets all of that without wiring any of it.
76 ///
77 /// The stream is the one the work was issued on, which the server cannot
78 /// work out for itself: it runs on its own thread, so the caller's
79 /// current stream is not this one.
80 #[allow(unused_variables)]
81 fn capturing(&mut self, stream: StreamId) -> Option<&mut StreamCapture> {
82 None
83 }
84
85 /// An empty write set for the caller to fill with what the work it is
86 /// about to run writes.
87 ///
88 /// Filled by the caller rather than inside the scope, which is what lets
89 /// the body take the arguments the set was read from by value. A set left
90 /// empty claims nothing, which is what a dry run wants.
91 fn write_set(&mut self) -> Vec<BufferBinding> {
92 self.write_streams().write_set()
93 }
94}
95
96/// What a scope's work did.
97#[derive(Debug)]
98pub enum ScopedOutcome<R> {
99 /// It ran, and its write set has a writer again.
100 Executed(R),
101 /// It did not run, because an input it needed carried a failure. Its
102 /// write set carries that same failure now, so a read of one of those
103 /// buffers names the original cause.
104 Skipped,
105 /// It ran and failed. Its write set carries the error.
106 Failed(ServerError),
107}
108
109impl<R> ScopedOutcome<R> {
110 /// The result, when the work ran and succeeded.
111 ///
112 /// # Errors
113 ///
114 /// The error it failed with. A skip answers with an error saying so — the
115 /// failure its inputs carried is not the caller's to receive here; the
116 /// claim on the write set is the report, and a read of one of those
117 /// buffers names the root cause.
118 pub fn into_result(self) -> Result<R, ServerError> {
119 match self {
120 ScopedOutcome::Executed(result) => Ok(result),
121 ScopedOutcome::Failed(error) => Err(error),
122 ScopedOutcome::Skipped => Err(ServerError::Skipped),
123 }
124 }
125}
126
127/// Whether the scope claimed its write set for work about to run, or pointed
128/// it at the failure that stopped the work happening at all.
129enum Opened {
130 /// Entered: the write set carries a provisional failure until the exit
131 /// replaces or releases it. `None` when the set was empty, which claims
132 /// and mints nothing.
133 Entered {
134 provisional: Option<FailureId>,
135 written: Vec<BufferBinding>,
136 },
137 /// Skipped: the write set already carries the failure its inputs did, and
138 /// nothing else is owed.
139 Skipped,
140}
141
142/// One scope around a unit of device work.
143///
144/// Built by [`over`](Self::over) for work that reads nothing it must trust, or
145/// [`launching`](Self::launching) for a kernel, which is the only kind that
146/// can be skipped.
147///
148/// Which of the two it is is settled by the constructor and cannot change
149/// afterwards. Entering on a skip would be wrong rather than merely wasteful:
150/// the provisional failure would be minted, overwritten by the propagated one,
151/// and never pruned, because the exit that prunes it is on the path that does
152/// not run. Nothing has to remember that, because a scope is one or the other
153/// before it exists.
154pub struct ExecuteScope<'a, S: WriteScoped> {
155 server: &'a mut S,
156 /// The stream this work is for, which is what a failure has to name.
157 stream: StreamId,
158 opened: Opened,
159}
160
161impl<'a, S: WriteScoped> ExecuteScope<'a, S> {
162 /// A scope over work that writes `written` and reads nothing it has to
163 /// trust — a host copy, a graph replay, a launch that never compiled.
164 ///
165 /// Such work cannot be skipped, so this always enters.
166 pub fn over(server: &'a mut S, stream: StreamId, written: Vec<BufferBinding>) -> Self {
167 let provisional = server.write_streams().enter_write(&written);
168 Self {
169 server,
170 stream,
171 opened: Opened::Entered {
172 provisional,
173 written,
174 },
175 }
176 }
177
178 /// A scope over a launch of `kernel` on `stream`, reading `reads` and
179 /// writing `written`.
180 ///
181 /// Skips, rather than claiming, when an input carries a failure. A launch
182 /// whose input cannot be trusted does not run: a buffer holding garbage
183 /// can be read as a dynamic cube count or as gather indices, scattering
184 /// into memory that carried no failure at all. Its outputs take the
185 /// failure that stopped it, exactly as a failed launch's would, so a read
186 /// downstream fails on the root cause.
187 pub fn launching<'b>(
188 server: &'a mut S,
189 kernel: KernelId,
190 stream: StreamId,
191 reads: impl Iterator<Item = &'b BufferBinding>,
192 written: Vec<BufferBinding>,
193 ) -> Self {
194 let Some(found) = server.write_streams().read_failure(reads) else {
195 return Self::over(server, stream, written);
196 };
197 server.on_failure(stream, &found.error);
198 // A skip inside a recording window dooms it: the recording is missing
199 // this launch and must not seal. A no-op outside one.
200 if let Some(capture) = server.capturing(stream) {
201 capture.fail(found.error.clone());
202 }
203 server.write_streams().propagate(&found, kernel, written);
204 Self {
205 server,
206 stream,
207 opened: Opened::Skipped,
208 }
209 }
210
211 /// Whether this scope skipped, so its body will not run.
212 pub fn skipped(&self) -> bool {
213 matches!(self.opened, Opened::Skipped)
214 }
215
216 /// Run `body` and settle the write set.
217 ///
218 /// A skipped scope never runs it. Otherwise the claim is released if the
219 /// body succeeded and replaced with the real error if it did not, and
220 /// either way a measurement in flight hears about a failure, and a
221 /// recording window hears how the work ended. `body` may return early
222 /// anywhere.
223 pub fn execute<R>(
224 self,
225 body: impl FnOnce(&mut S) -> Result<R, ServerError>,
226 ) -> ScopedOutcome<R> {
227 let Opened::Entered {
228 provisional,
229 written,
230 } = self.opened
231 else {
232 return ScopedOutcome::Skipped;
233 };
234
235 let result = body(self.server);
236 // The recording window hears the exit before the claim settles, from
237 // the one place that always knows how the work ended. A clean exit
238 // hands it the write set — what the graph will write is what a scope
239 // inside it wrote, recorded here so the two cannot disagree. A failed
240 // exit dooms it: the recording is missing this work and must not
241 // seal. Both are no-ops outside a window.
242 if let Some(capture) = self.server.capturing(self.stream) {
243 match result.as_ref() {
244 Ok(_) => capture.record(written.iter().cloned()),
245 Err(error) => capture.fail(error.clone()),
246 }
247 }
248 self.server
249 .write_streams()
250 .exit_write(provisional, written, result.as_ref().err());
251 match result {
252 Ok(result) => ScopedOutcome::Executed(result),
253 Err(error) => {
254 self.server.on_failure(self.stream, &error);
255 ScopedOutcome::Failed(error)
256 }
257 }
258 }
259}
260
261/// Claim `written` for `error` without running anything: work that never
262/// started leaves its destinations exactly as they were, so a read of one of
263/// them has to fail on the error that stopped it.
264pub fn failed_writing<S: WriteScoped>(
265 server: &mut S,
266 stream: StreamId,
267 written: Vec<BufferBinding>,
268 error: ServerError,
269) {
270 let _ = ExecuteScope::over(server, stream, written).execute(|_| Err::<(), _>(error));
271}