Skip to main content

cubecl_runtime/config/
autotune.rs

1use super::logger::{LogLevel, LoggerConfig};
2
3/// Configuration for autotuning in `CubeCL`.
4#[derive(Default, Clone, Debug, serde::Serialize, serde::Deserialize)]
5pub struct AutotuneConfig {
6    /// Logger configuration for autotune logs, using autotune-specific log levels.
7    #[serde(default)]
8    pub logger: LoggerConfig<AutotuneLogLevel>,
9
10    /// Where to write one [`AutotuneDecision`](crate::tune::AutotuneDecision) per tuning
11    /// decision, as JSON, for a tool to read back.
12    ///
13    /// Independent of [`logger`](Self::logger), because the two answer different questions and both
14    /// can be wanted at once: the logger's level says how much to tell a human, `decisions` says
15    /// where to put the machine-readable account.
16    ///
17    /// Also read under `recorder`.
18    #[serde(default)]
19    #[serde(alias = "recorder")]
20    pub decisions: LoggerConfig<DecisionLevel>,
21
22    /// Autotune level, controlling the intensity of autotuning.
23    #[serde(default)]
24    pub level: AutotuneLevel,
25
26    /// Whether to disable the persistent cache of autotune results.
27    ///
28    /// The in-memory cache is unaffected: a key is still tuned only once per process.
29    #[serde(default)]
30    pub disable_cache: bool,
31
32    /// Whether to disable the short circuit logic during autotuning.
33    #[serde(default)]
34    pub disable_short_circuit: bool,
35
36    /// Sampling budget and elimination thresholds used while benchmarking candidates.
37    #[serde(default)]
38    pub bench: BenchConfig,
39}
40
41/// Controls how many samples autotune collects per candidate and when candidates are dropped.
42///
43/// Only [`max_samples`](Self::max_samples) and [`adaptive`](Self::adaptive) mean anything to the
44/// fixed-count pass; the rest describe elimination, which only the adaptive scheduler performs.
45/// Each field says so, because a knob that silently does nothing on the strategy actually running
46/// is worse than no knob at all — and `adaptive` is native-only, so on wasm that is every run.
47#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
48#[serde(default)]
49pub struct BenchConfig {
50    /// Samples every surviving candidate gets before any elimination happens.
51    ///
52    /// Adaptive only: the fixed-count pass eliminates nothing, so every candidate gets
53    /// [`max_samples`](Self::max_samples) regardless.
54    pub min_samples: usize,
55
56    /// Upper bound on samples collected for a single candidate.
57    ///
58    /// Read by both strategies: the ceiling for the adaptive scheduler, and the flat count for
59    /// the fixed pass, which has no elimination to spend a smaller budget on.
60    pub max_samples: usize,
61
62    /// Samples that must independently land under the time limit to short circuit.
63    ///
64    /// A short circuit decision is written to the persistent cache and reused on later runs, so
65    /// it is confirmed rather than taken from a single possibly-lucky sample.
66    ///
67    /// Adaptive only: the fixed pass has the whole sample set in hand before it tests the limit,
68    /// so it has nothing to confirm.
69    pub short_circuit_samples: usize,
70
71    /// How many times slower than the current best a candidate may be before elimination.
72    ///
73    /// Adaptive only. Values below `1.0` are read as `1.0`, which eliminates every candidate
74    /// slower than the leader that the survivor floor allows.
75    pub speed_factor: f64,
76
77    /// Whether to use the adaptive round robin benchmark instead of a fixed sample count.
78    ///
79    /// Ignored on wasm, which cannot resolve samples between rounds and so always takes the
80    /// fixed-count pass.
81    pub adaptive: bool,
82}
83
84impl Default for BenchConfig {
85    fn default() -> Self {
86        Self {
87            min_samples: 3,
88            max_samples: 10,
89            short_circuit_samples: 2,
90            speed_factor: 1.5,
91            adaptive: true,
92        }
93    }
94}
95
96/// Every knob is read through an accessor that clamps it into its usable range, so a config file
97/// can hold a nonsensical value without any single call site having to remember the repair.
98impl BenchConfig {
99    /// The sample budget, clamped so the range is always usable.
100    pub fn samples(&self) -> (usize, usize) {
101        let min = self.min_samples.max(1);
102        (min, self.max_samples.max(min))
103    }
104
105    /// How many samples must land under the limit, clamped so a short circuit always needs one.
106    pub fn short_circuit_samples(&self) -> usize {
107        self.short_circuit_samples.max(1)
108    }
109
110    /// The elimination threshold, clamped so it can never sit below the leader's own time.
111    pub fn speed_factor(&self) -> f64 {
112        self.speed_factor.max(1.0)
113    }
114}
115
116/// Log levels for autotune logging in `CubeCL`.
117#[derive(Default, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)]
118pub enum AutotuneLogLevel {
119    /// Autotune logging is disabled.
120    #[serde(rename = "disabled")]
121    Disabled,
122
123    /// Minimal autotune information is logged such as the fastest kernel selected and a few
124    /// statistics (default).
125    #[default]
126    #[serde(rename = "minimal")]
127    Minimal,
128
129    /// Full autotune details are logged.
130    #[serde(rename = "full")]
131    Full,
132}
133
134impl LogLevel for AutotuneLogLevel {}
135
136/// The verbosity [`AutotuneConfig::decisions`] lacks.
137///
138/// A decision is written in one fixed schema, which is the whole point: a tool reads it back and depends on its
139/// shape, so there is no "how much" to choose. Decisions are written when they have a sink
140/// (see [`AutotuneConfig::decisions_enabled`]); this type exists only so it can reuse
141/// [`LoggerConfig`]'s sinks.
142#[derive(Default, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)]
143pub struct DecisionLevel;
144
145impl LogLevel for DecisionLevel {}
146
147impl AutotuneConfig {
148    /// Whether tuning decisions have somewhere to be written.
149    pub fn decisions_enabled(&self) -> bool {
150        #[cfg(std_io)]
151        let has_file = self.decisions.file.is_some();
152        #[cfg(not(std_io))]
153        let has_file = false;
154
155        has_file || self.decisions.stdout || self.decisions.stderr
156    }
157}
158
159/// Autotune levels controlling the intensity of autotuning.
160#[derive(Default, Clone, Debug, serde::Serialize, serde::Deserialize)]
161pub enum AutotuneLevel {
162    /// Minimal autotuning effort.
163    #[serde(rename = "minimal")]
164    Minimal,
165
166    /// Balanced autotuning effort (default).
167    #[default]
168    #[serde(rename = "balanced")]
169    Balanced,
170
171    /// Increased autotuning effort.
172    #[serde(rename = "extensive")]
173    Extensive,
174
175    /// Maximum autotuning effort.
176    #[serde(rename = "full")]
177    Full,
178}
179
180#[cfg(all(test, feature = "std"))]
181mod tests {
182    use super::*;
183
184    /// A config naming the sink `recorder` still writes decisions there.
185    #[test]
186    fn decisions_are_read_under_recorder_too() {
187        let config: AutotuneConfig = toml::from_str("[recorder]\nstdout = true").unwrap();
188        assert!(config.decisions.stdout);
189        assert!(config.decisions_enabled());
190        let config: AutotuneConfig = toml::from_str("[decisions]\nstderr = true").unwrap();
191        assert!(config.decisions.stderr);
192    }
193}