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}