Skip to main content

cubecl_runtime/
dry_run.rs

1//! Running a workload for the compilation and tuning it provokes, without
2//! running the workload itself.
3//!
4//! Under a [`DryRun`] every launch is still expanded, compiled, validated and
5//! cached, and is then dropped instead of reaching the device. A warm-up pass
6//! then pays for compilation and tuning without also paying for the work that
7//! provoked them, which is what makes producing a shippable environment
8//! affordable.
9//!
10//! The launches autotune issues are the exception: they *are* the measurement,
11//! so [`RealRun`] opts them back into executing.
12//!
13//! **Buffers are left as they were**, so anything read back during a dry run is
14//! meaningless. It only suits a pass driven by the *shapes* it produces, which
15//! is what keys the caches, and never one that branches on a computed value.
16//!
17//! The decision is made here, once, on the thread that issues the launch.
18//! Servers receive the verdict as a [`LaunchMode`] argument rather than
19//! deriving it: by the time a launch reaches a server thread, the context that
20//! produced it is gone.
21
22use cubecl_environment::sync::{AtomicUsize, Ordering};
23
24/// What a server should do with a launch.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum LaunchMode {
27    /// Compile if needed, then run it. The normal case.
28    Execute,
29    /// Compile if needed, cache the artifact, and drop the launch.
30    ///
31    /// A server honoring this must still do everything a first launch does
32    /// short of dispatching — expand, compile, validate, populate its caches —
33    /// or the pass buys nothing.
34    Skip,
35}
36
37impl LaunchMode {
38    /// Whether the launch should be dropped rather than run.
39    pub fn is_skipped(self) -> bool {
40        matches!(self, LaunchMode::Skip)
41    }
42}
43
44/// What to do with a launch issued on this thread, right now.
45pub fn launch_mode() -> LaunchMode {
46    if !dry_run() || real_run::depth() > 0 {
47        return LaunchMode::Execute;
48    }
49
50    LaunchMode::Skip
51}
52
53/// How many dry runs are open in this process.
54///
55/// A depth rather than a flag so overlapping guards compose: a swap-and-restore
56/// would let one thread's guard end a dry run another thread is still inside,
57/// and leave the process dry-running forever once that one dropped in turn.
58static DRY_RUN: AtomicUsize = AtomicUsize::new(0);
59
60/// Whether launches are currently compiled and dropped rather than run.
61pub fn dry_run() -> bool {
62    DRY_RUN.load(Ordering::Relaxed) > 0
63}
64
65/// Makes every launch a dry run for as long as it lives, on every thread and
66/// every device.
67///
68/// Overlapping guards compose, so a pass that opens one while another is still
69/// open leaves the mode on until the last of them drops.
70///
71/// The flag is read on the thread issuing a launch, with relaxed ordering, so a
72/// launch another thread had already begun issuing may still execute. What is
73/// guaranteed is the launches issued by the thread that opened the guard, and
74/// every launch issued after other threads observe it.
75///
76/// This is the only way in: there is deliberately no configuration file or
77/// environment variable for it. A dry run left on by accident turns the rest of
78/// the process into launches that quietly do nothing and read back
79/// uninitialized memory, so its lifetime belongs to a scope in the code that
80/// wants it, not to an ambient default nothing in the process can see.
81///
82/// ```no_run
83/// # fn warm_up() {}
84/// let _dry_run = cubecl_runtime::dry_run::DryRun::new();
85/// warm_up();
86/// ```
87#[derive(Debug)]
88pub struct DryRun {
89    _private: (),
90}
91
92impl DryRun {
93    /// Opens a dry run until the guard drops.
94    #[allow(clippy::new_without_default, reason = "a guard is not a value")]
95    pub fn new() -> Self {
96        DRY_RUN.fetch_add(1, Ordering::Relaxed);
97        Self { _private: () }
98    }
99}
100
101impl Drop for DryRun {
102    fn drop(&mut self) {
103        DRY_RUN.fetch_sub(1, Ordering::Relaxed);
104    }
105}
106
107/// Makes the launches issued on this thread execute for real even inside a
108/// [`DryRun`], for as long as it lives.
109///
110/// Autotune holds one: its launches are the measurement a dry run exists to
111/// provoke, not the workload it exists to skip. Held across warm-up and samples
112/// alike, since a candidate that was never warmed is a candidate measured on
113/// its first, slowest run.
114///
115/// Thread-local, and the thread that matters is the one issuing the launches,
116/// which is not always the one that asked for them: a task handed to
117/// [`ComputeClient::exclusive`](crate::client::ComputeClient::exclusive) runs on
118/// the device thread. The guard has to live inside that task, alongside the
119/// launches it covers, not around the call that submits it.
120#[derive(Debug)]
121pub struct RealRun {
122    _private: (),
123}
124
125impl RealRun {
126    /// Opts this thread back into executing until the guard drops.
127    #[allow(clippy::new_without_default, reason = "a guard is not a value")]
128    pub fn new() -> Self {
129        real_run::enter();
130        Self { _private: () }
131    }
132}
133
134impl Drop for RealRun {
135    fn drop(&mut self) {
136        real_run::exit();
137    }
138}
139
140#[cfg(feature = "std")]
141mod real_run {
142    use core::cell::Cell;
143
144    std::thread_local! {
145        /// How many [`RealRun`](super::RealRun) guards are open on this thread.
146        /// A depth rather than a flag: a tunable may itself dispatch through
147        /// another tuner, and the inner one finishing must not un-mark the
148        /// outer.
149        static DEPTH: Cell<usize> = const { Cell::new(0) };
150    }
151
152    pub(super) fn depth() -> usize {
153        DEPTH.with(|depth| depth.get())
154    }
155
156    pub(super) fn enter() {
157        DEPTH.with(|depth| depth.set(depth.get() + 1));
158    }
159
160    pub(super) fn exit() {
161        DEPTH.with(|depth| depth.set(depth.get().saturating_sub(1)));
162    }
163}
164
165#[cfg(not(feature = "std"))]
166mod real_run {
167    // No threads to be local to; this keeps the call sites uniform.
168    pub(super) fn depth() -> usize {
169        0
170    }
171    pub(super) fn enter() {}
172    pub(super) fn exit() {}
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    // `serial_test`'s macro expands to `vec!`, which a `no_std` crate has to
179    // bring in itself.
180    use alloc::vec;
181
182    /// The guard nests: an inner measurement ending must not cancel the outer
183    /// one, or a tunable that dispatches through another tuner would have the
184    /// rest of its own measurement dropped.
185    #[test]
186    fn real_run_nests() {
187        assert_eq!(real_run::depth(), 0);
188        let outer = RealRun::new();
189        {
190            let _inner = RealRun::new();
191            assert_eq!(real_run::depth(), 2);
192        }
193        assert_eq!(real_run::depth(), 1, "the outer guard is still open");
194        drop(outer);
195        assert_eq!(real_run::depth(), 0);
196    }
197
198    /// Nothing is skipped outside a dry run, whatever the depth.
199    #[test]
200    #[serial_test::serial]
201    fn launches_execute_by_default() {
202        assert_eq!(launch_mode(), LaunchMode::Execute);
203        let _real_run = RealRun::new();
204        assert_eq!(launch_mode(), LaunchMode::Execute);
205    }
206
207    /// The whole contract in one place: in a dry run every launch is dropped
208    /// *except* the ones a measurement issues, which are the tuning the mode
209    /// exists to keep.
210    #[test]
211    #[serial_test::serial]
212    fn a_dry_run_spares_the_measurements() {
213        let _dry_run = DryRun::new();
214
215        assert_eq!(launch_mode(), LaunchMode::Skip);
216        {
217            let _real_run = RealRun::new();
218            assert_eq!(launch_mode(), LaunchMode::Execute, "a measurement runs");
219        }
220        assert_eq!(launch_mode(), LaunchMode::Skip);
221    }
222
223    /// Overlapping guards compose, so neither an inner guard ending nor an
224    /// outer one can leave the process in the wrong mode. This is what a
225    /// swap-and-restore got wrong across threads.
226    #[test]
227    #[serial_test::serial]
228    fn dry_runs_nest() {
229        assert!(!dry_run());
230        {
231            let _outer = DryRun::new();
232            {
233                let _inner = DryRun::new();
234                assert!(dry_run());
235            }
236            assert!(dry_run(), "the outer guard is still in force");
237        }
238        assert!(!dry_run(), "and the process is back to executing");
239    }
240}