Skip to main content

ruda/runtime/tune/
tuner.rs

1use alloc::format;
2use alloc::sync::Arc;
3use alloc::vec::Vec;
4use ruda_core::profile::ProfileDuration;
5
6use core::time::Duration;
7
8use alloc::string::{String, ToString};
9use ruda_core::benchmark::{BenchmarkComputations, BenchmarkDurations};
10
11use crate::runtime::config::{Logger, autotune::AutotuneLogLevel};
12use crate::runtime::server::LaunchError;
13use crate::runtime::tune::{AutotuneResult, TuneCache, tune_benchmark};
14use crate::runtime::{client::ComputeClient, backend::Runtime};
15
16use super::{AutotuneKey, AutotuneOutput, TunableSet, TuneCacheResult, TuneInputs};
17
18#[derive(Debug)]
19/// Runs autotune benchmarks for a single device and caches the results.
20///
21/// On wasm, [`tune`](Self::tune) spawns its work on the browser event loop; elsewhere
22/// it blocks inline. Either way the benchmarking itself is synchronous; only the
23/// per-sample profile resolution is awaited.
24pub struct Tuner<K: AutotuneKey> {
25    cache: Arc<spin::Mutex<TuneCache<K>>>,
26    logger: Arc<spin::Mutex<Logger>>,
27}
28
29/// The measured outcome for a given autotune invocation.
30#[cfg_attr(std_io, derive(serde::Serialize, serde::Deserialize))]
31#[derive(new, Debug, Clone, PartialEq, Eq)]
32pub struct AutotuneOutcome {
33    name: String,
34    index: usize,
35    computation: BenchmarkComputations,
36}
37
38impl core::fmt::Display for AutotuneOutcome {
39    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
40        write!(
41            f,
42            "Autotune[{}] name {} => {:?}",
43            self.index, self.name, self.computation
44        )
45    }
46}
47
48/// Error from running autotune.
49#[derive(Debug, Clone)]
50#[cfg_attr(std_io, derive(serde::Serialize, serde::Deserialize))]
51pub enum AutotuneError {
52    /// An unknown error happened.
53    Unknown {
54        /// The name of the tunable.
55        name: String,
56        /// The unknown error,
57        err: String,
58    },
59    /// All samples are invalid.
60    InvalidSamples {
61        /// The name of the tunable.
62        name: String,
63    },
64    /// No autotune was flagged as valid for the problem.
65    ///
66    /// # Warning
67    ///
68    /// This is an unrecoverable error and will cause a panic.
69    NoValidKernelFound {
70        /// The formatted context on why no valid kernel was found.
71        context: String,
72    },
73    /// The autotune is skipped manually.
74    Skip {
75        /// The name of the skipped kernel.
76        name: String,
77    },
78
79    /// An error happened when launching a kernel.
80    Launch(LaunchError),
81}
82
83impl From<LaunchError> for AutotuneError {
84    fn from(value: LaunchError) -> Self {
85        Self::Launch(value)
86    }
87}
88
89/// A successfully-queued benchmark: the profile futures for each sample, plus its metadata.
90struct PendingBench {
91    index: usize,
92    name: String,
93    profiles: Vec<ProfileDuration>,
94}
95
96/// A queued tuning job: all data needed to resolve samples and commit the result.
97/// Holds no references so it's trivially `Send + 'static` for the wasm spawn path.
98struct TuneRequest<K: AutotuneKey> {
99    key: K,
100    results: Vec<AutotuneResult>,
101    #[cfg(std_io)]
102    checksum: String,
103    context_logs: Option<String>,
104    pending: Vec<PendingBench>,
105}
106
107#[allow(clippy::new_without_default)]
108impl<K: AutotuneKey> Tuner<K> {
109    /// Create a tuner. Its cache is seeded from the persistent on-disk cache when
110    /// `std_io` is enabled.
111    pub fn new(name: &str, device_id: &str) -> Self {
112        Self {
113            cache: Arc::new(spin::Mutex::new(TuneCache::new(name, device_id))),
114            logger: Arc::new(spin::Mutex::new(Logger::new())),
115        }
116    }
117
118    /// Fetch the fastest autotune operation index for an autotune key.
119    pub fn fastest(&self, key: &K) -> TuneCacheResult {
120        self.cache.lock().fastest(key)
121    }
122
123    /// Check the cache, validate checksums if needed, and kick off a tuning job if the
124    /// key is a miss. Returns the resolved cache state.
125    pub fn check_tune<'a, R: Runtime, F: TuneInputs, Out: AutotuneOutput>(
126        &self,
127        key: &K,
128        inputs: &F::At<'a>,
129        tunables: &TunableSet<K, F, Out>,
130        #[cfg_attr(not(std_io), allow(unused))] checksum: impl FnOnce() -> String + Send + Sync,
131        client: &ComputeClient<R>,
132    ) -> TuneCacheResult
133    where
134        <F as TuneInputs>::At<'a>: Clone + Send,
135    {
136        // Failed input kernels must not be consumed as failed tuning candidates.
137        // Submit outside the cache lock to preserve the device/cache lock order.
138        client.flush().expect("Cannot autotune with pre-existing stream errors");
139        {
140            let mut cache = self.cache.lock();
141            let cur = cache.fastest(key);
142
143            #[cfg(std_io)]
144            let cur = if matches!(cur, TuneCacheResult::Unchecked) {
145                let mut log = self.logger.lock();
146                let checksum = checksum();
147                if let AutotuneLogLevel::Full = log.log_level_autotune() {
148                    log.log_autotune(&format!("validate checksum key={key}, checksum={checksum}"));
149                }
150                cache.validate_checksum(key, &checksum)
151            } else {
152                cur
153            };
154
155            match cur {
156                TuneCacheResult::Hit { .. } | TuneCacheResult::Pending => return cur,
157                TuneCacheResult::Miss | TuneCacheResult::Unchecked => {
158                    cache.mark_pending(key.clone())
159                }
160            }
161            // Scope the guard: the rest of this function re-locks `self.cache` (fast
162            // path insert, `process_request`), and `spin::Mutex` is non-reentrant.
163        }
164
165        log::info!("Tuning {key}");
166
167        let autotunables = tunables.autotunables().collect::<Vec<_>>();
168        let mut results: Vec<AutotuneResult> = autotunables
169            .iter()
170            .map(|a| {
171                AutotuneResult::error(AutotuneError::Skip {
172                    name: a.name.to_string(),
173                })
174            })
175            .collect();
176
177        #[cfg(std_io)]
178        let checksum = tunables.compute_checksum();
179
180        // Fast path: single tunable, no benchmarking needed.
181        if results.len() == 1 {
182            self.cache.lock().cache_insert(key.clone(), 0);
183            return TuneCacheResult::Hit { fastest_index: 0 };
184        }
185
186        let test_inputs = tunables.generate_inputs(key, inputs);
187        client.flush().expect("Autotune input generation failed");
188        let mut plan = tunables.plan(key);
189        let mut context_logs = match self.logger.lock().log_level_autotune() {
190            AutotuneLogLevel::Full => Some(String::new()),
191            _ => None,
192        };
193
194        // Walk the plan batch by batch, launching each benchmark synchronously. A
195        // successful launch queues a `PendingBench` for the async resolver below;
196        // launch errors go straight into `results`. Retry the next batch if a whole
197        // batch failed to queue anything.
198        let mut pending = Vec::<PendingBench>::new();
199        loop {
200            let tunable_indices = plan.next(context_logs.as_mut());
201
202            if tunable_indices.is_empty() {
203                panic!(
204                    "Can't execute the autotune plan for key: {key:?}\n - plan: {plan:?}\n - results: {results:?}"
205                );
206            }
207
208            for index in tunable_indices {
209                let op = autotunables[index];
210
211                match tune_benchmark(op, test_inputs.clone(), client.clone()) {
212                    Ok(profiles) => pending.push(PendingBench {
213                        index,
214                        name: op.name.clone(),
215                        profiles,
216                    }),
217                    Err(err) => {
218                        results[index] = AutotuneResult::error(err);
219                    }
220                }
221            }
222
223            if !pending.is_empty() {
224                break;
225            }
226        }
227
228        let request = TuneRequest {
229            key: key.clone(),
230            results,
231            #[cfg(std_io)]
232            checksum,
233            context_logs,
234            pending,
235        };
236
237        // Resolve samples and commit the result. On wasm this runs on the browser
238        // event loop; elsewhere it blocks inline.
239        #[cfg(target_family = "wasm")]
240        {
241            let cache = self.cache.clone();
242            let logger = self.logger.clone();
243            wasm_bindgen_futures::spawn_local(async move {
244                process_request(request, &cache, &logger).await;
245            });
246
247            return TuneCacheResult::Pending;
248        }
249
250        #[cfg(not(target_family = "wasm"))]
251        ruda_core::future::block_on(process_request(request, &self.cache, &self.logger))
252    }
253}
254
255/// Await every profile sample, pick the fastest tunable, commit to the cache.
256async fn process_request<K: AutotuneKey>(
257    request: TuneRequest<K>,
258    cache: &spin::Mutex<TuneCache<K>>,
259    logger: &spin::Mutex<Logger>,
260) -> TuneCacheResult {
261    let TuneRequest {
262        key,
263        mut results,
264        #[cfg(std_io)]
265        checksum,
266        context_logs,
267        pending,
268    } = request;
269
270    for bench in pending {
271        let PendingBench {
272            index,
273            name,
274            profiles,
275        } = bench;
276
277        if profiles.is_empty() {
278            results[index] = AutotuneResult::error(AutotuneError::Unknown {
279                name: name.to_string(),
280                err: "No profiling available".to_string(),
281            });
282            continue;
283        }
284
285        let timing_method = profiles.first().unwrap().timing_method();
286        let mut durations = Vec::with_capacity(profiles.len());
287        for profile in profiles {
288            durations.push(profile.resolve().await.duration());
289        }
290
291        results[index] = AutotuneResult::success(AutotuneOutcome::new(
292            name.to_string(),
293            index,
294            BenchmarkComputations::new(&BenchmarkDurations::from_durations(
295                timing_method,
296                durations,
297            )),
298        ));
299    }
300
301    results.sort_by(|a, b| {
302        let a = a
303            .outcome
304            .as_ref()
305            .map(|r| r.computation.score())
306            .unwrap_or(u64::MAX);
307        let b = b
308            .outcome
309            .as_ref()
310            .map(|r| r.computation.score())
311            .unwrap_or(u64::MAX);
312        a.cmp(&b)
313    });
314
315    let fastest_index = results
316        .first()
317        .expect("At least one kernel needed.")
318        .outcome
319        .as_ref()
320        .expect("At least one kernel has to succeed.")
321        .index;
322
323    {
324        log_result(&mut logger.lock(), &key, &results, context_logs.as_deref());
325        cache.lock().cache_insert(key.clone(), fastest_index);
326        #[cfg(std_io)]
327        cache
328            .lock()
329            .persistent_cache_insert(key, checksum, fastest_index, results);
330    }
331
332    TuneCacheResult::Hit { fastest_index }
333}
334
335/// Emit the autotune result through the logger at the currently configured level.
336fn log_result<K: AutotuneKey>(
337    logger: &mut Logger,
338    key: &K,
339    results: &[AutotuneResult],
340    context_logs: Option<&str>,
341) {
342    match logger.log_level_autotune() {
343        AutotuneLogLevel::Minimal => {
344            let top_times = results
345                .iter()
346                .map(|r| {
347                    let time = r
348                        .outcome
349                        .as_ref()
350                        .map(|r| r.computation.median)
351                        .unwrap_or(Duration::MAX);
352
353                    let index = r.outcome.as_ref().map(|r| r.index).unwrap_or_default();
354                    (index, time)
355                })
356                .take(3)
357                .collect::<Vec<_>>();
358
359            let result = results
360                .first()
361                .expect("At least one kernel needed.")
362                .outcome
363                .as_ref()
364                .expect("At least one kernel has to succeed.");
365
366            let context = context_logs.unwrap_or("");
367            logger.log_autotune(&format!(
368                "Fastest result {}-{key}. \n Top 3 times: {top_times:?}, context: {context}",
369                result.name,
370            ));
371        }
372        AutotuneLogLevel::Full => {
373            let result = results
374                .first()
375                .expect("At least one kernel needed.")
376                .outcome
377                .as_ref()
378                .expect("At least one kernel has to succeed.");
379
380            let context = context_logs.unwrap_or("");
381            logger.log_autotune(&format!(
382                "Fastest result {}-{key}. Context: {context}",
383                result.name,
384            ));
385
386            for result in results.iter() {
387                match &result.outcome {
388                    Ok(val) => {
389                        logger.log_autotune(&format!("{val}"));
390                    }
391                    Err(err) => logger.log_autotune(&format!("{err:?}")),
392                }
393            }
394        }
395        AutotuneLogLevel::Disabled => {}
396    }
397}
398
399#[cfg(feature = "runtime-autotune-checks")]
400pub(crate) fn check_autotune_outputs<O: AutotuneOutput>(
401    mut checks_outputs: Vec<Result<O, AutotuneError>>,
402) {
403    let reference = checks_outputs.remove(checks_outputs.len() - 1);
404
405    if let Ok(reference) = reference {
406        for other in checks_outputs.into_iter().flatten() {
407            reference.check_equivalence(other);
408        }
409    }
410}