Skip to main content

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}