1#[cfg(std_io)]
2use alloc::format;
3use alloc::sync::Arc;
4use alloc::vec::Vec;
5use cubecl_common::profile::ProfileDuration;
6use derive_more::Display;
7
8use core::time::Duration;
9
10use cubecl_environment::sync::Mutex;
11
12use alloc::string::{String, ToString};
13use cubecl_common::benchmark::{BenchmarkComputations, BenchmarkDurations};
14
15use crate::config::Logger;
16#[cfg(std_io)]
17use crate::config::autotune::AutotuneLogLevel;
18use crate::server::LaunchError;
19use crate::tune::{AutotuneLoggerExt, AutotuneResult, TimeBound, TuneCache, tune_benchmark};
20use crate::{client::ComputeClient, runtime::Runtime};
21use cubecl_environment::config::RuntimeConfig;
22
23use super::{
24 AutotuneKey, AutotuneOutput, TunableSet, TuneCacheResult, TuneFn, TuneInputs, TunePlan,
25};
26
27#[derive(Debug)]
28pub struct Tuner<K: AutotuneKey> {
34 cache: Arc<Mutex<TuneCache<K>>>,
35 logger: Arc<Mutex<Logger>>,
36}
37
38#[cfg_attr(autotune_persistence, derive(serde::Serialize, serde::Deserialize))]
40#[derive(new, Debug, Clone, PartialEq, Eq)]
41pub struct AutotuneOutcome {
42 pub name: String,
44 pub index: usize,
46 pub computation: BenchmarkComputations,
48}
49
50impl core::fmt::Display for AutotuneOutcome {
51 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
52 write!(
53 f,
54 "Autotune[{}] name {} => {:?}",
55 self.index, self.name, self.computation
56 )
57 }
58}
59
60#[derive(Clone, Display)]
62#[cfg_attr(autotune_persistence, derive(serde::Serialize, serde::Deserialize))]
63pub enum AutotuneError {
64 #[display("{name}: An unknown error happened.\n{err}")]
66 Unknown {
67 name: String,
69 err: String,
71 },
72 #[display("{name}: All samples are invalid.")]
74 InvalidSamples {
75 name: String,
77 },
78 #[display("No autotune was flagged as valid for the problem.\n{context}")]
84 NoValidKernelFound {
85 context: String,
87 },
88 #[display("{name}: The autotune is skipped manually.")]
90 Skip {
91 name: String,
93 },
94
95 Launch(LaunchError),
97}
98
99impl core::fmt::Debug for AutotuneError {
100 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
101 write!(f, "{self}")
102 }
103}
104
105impl From<LaunchError> for AutotuneError {
106 fn from(value: LaunchError) -> Self {
107 Self::Launch(value)
108 }
109}
110
111struct PendingBench {
113 index: usize,
114 name: String,
115 profiles: Vec<ProfileDuration>,
116 launch: Option<Duration>,
119}
120
121struct TuneJob<'t, 'i, K: AutotuneKey, F: TuneInputs, Out> {
124 key: K,
125 autotunables: Vec<&'t TuneFn<F, Out>>,
126 test_inputs: <F as TuneInputs>::At<'i>,
127 plan: TunePlan,
128 results: Vec<AutotuneResult>,
129 #[cfg(any(not(target_family = "wasm"), autotune_persistence))]
130 limit: Option<Duration>,
131 #[cfg(autotune_persistence)]
132 bounds: Option<crate::tune::Bounds>,
133 #[cfg(not(target_family = "wasm"))]
134 short_circuit: bool,
135 #[cfg(autotune_persistence)]
136 checksum: String,
137 log_context: Option<crate::tune::AutotuneLogContext>,
138}
139
140impl<K: AutotuneKey, F: TuneInputs, Out> TuneJob<'_, '_, K, F, Out> {
141 fn into_request(self, pending: Vec<PendingBench>, decided: Option<usize>) -> TuneRequest<K> {
142 TuneRequest {
143 key: self.key,
144 results: self.results,
145 #[cfg(autotune_persistence)]
146 checksum: self.checksum,
147 log_context: self.log_context,
148 pending,
149 decided,
150 #[cfg(autotune_persistence)]
151 limit: self.limit,
152 #[cfg(autotune_persistence)]
153 bounds: self.bounds,
154 }
155 }
156}
157
158struct TuneRequest<K: AutotuneKey> {
161 key: K,
162 results: Vec<AutotuneResult>,
163 #[cfg(autotune_persistence)]
164 checksum: String,
165 log_context: Option<crate::tune::AutotuneLogContext>,
166 pending: Vec<PendingBench>,
167 decided: Option<usize>,
170 #[cfg(autotune_persistence)]
171 limit: Option<Duration>,
172 #[cfg(autotune_persistence)]
173 bounds: Option<crate::tune::Bounds>,
174}
175
176#[allow(clippy::new_without_default)]
177impl<K: AutotuneKey> Tuner<K> {
178 pub fn new(name: &str, device_id: &str) -> Self {
182 Self {
183 cache: Arc::new(Mutex::new(TuneCache::new(name, device_id))),
184 logger: Arc::new(Mutex::new(Logger::new())),
185 }
186 }
187
188 pub fn fastest(&self, key: &K) -> TuneCacheResult {
197 #[cfg_attr(not(autotune_persistence), allow(unused_mut))]
198 let mut cache = self.cache.lock();
199 #[cfg(autotune_persistence)]
200 cache.reset_if_environment_switched();
201
202 cache.fastest(key)
203 }
204
205 pub fn logger(&self) -> Arc<Mutex<Logger>> {
207 self.logger.clone()
208 }
209
210 pub fn check_tune<'a, R: Runtime, F: TuneInputs, Out: AutotuneOutput>(
213 &self,
214 key: &K,
215 inputs: &F::At<'a>,
216 tunables: &TunableSet<K, F, Out>,
217 #[cfg_attr(not(autotune_persistence), allow(unused))] checksum: impl FnOnce() -> String
218 + Send
219 + Sync,
220 client: &ComputeClient<R>,
221 mut log_context: Option<crate::tune::AutotuneLogContext>,
222 ) -> TuneCacheResult
223 where
224 <F as TuneInputs>::At<'a>: Clone + Send,
225 {
226 {
227 let mut cache = self.cache.lock();
228 #[cfg(autotune_persistence)]
229 cache.reset_if_environment_switched();
230 let cur = cache.fastest(key);
231
232 #[cfg(autotune_persistence)]
236 let cur = if matches!(cur, TuneCacheResult::Miss) {
237 cache.sync_persistent();
238 cache.fastest(key)
239 } else {
240 cur
241 };
242
243 #[cfg(autotune_persistence)]
244 let cur = if matches!(cur, TuneCacheResult::Unchecked) {
245 let mut log = self.logger.lock();
246 let checksum = checksum();
247 if let AutotuneLogLevel::Full = log.log_level_autotune() {
248 log.log_autotune(&format!("validate checksum key={key}, checksum={checksum}"));
249 }
250 cache.validate_checksum(key, &checksum)
251 } else {
252 cur
253 };
254
255 match cur {
256 TuneCacheResult::Hit { .. } | TuneCacheResult::Pending => return cur,
257 TuneCacheResult::Miss | TuneCacheResult::Unchecked => {
258 cache.mark_pending(key.clone())
259 }
260 }
261 }
264
265 log::info!("Tuning {key}");
266
267 let autotunables = tunables.autotunables().collect::<Vec<_>>();
268 let results: Vec<AutotuneResult> = autotunables
269 .iter()
270 .map(|a| {
271 AutotuneResult::error(AutotuneError::Skip {
272 name: a.name.to_string(),
273 })
274 })
275 .collect();
276
277 #[cfg(autotune_persistence)]
278 let checksum = tunables.compute_checksum();
279
280 if results.len() == 1 {
282 self.cache.lock().cache_insert(key.clone(), 0);
283 return TuneCacheResult::Hit { fastest_index: 0 };
284 }
285
286 let test_inputs = tunables.generate_inputs(key, inputs);
287 let plan = tunables.plan(key);
288 let bounds = tunables.bounds(key, inputs);
289 let limit = bounds.as_ref().and_then(|bounds| bounds.time_limit());
290
291 log_context.set_bounds(bounds.clone());
292 log_context.set_limit(limit);
293
294 #[cfg(not(target_family = "wasm"))]
297 let short_circuit = limit.is_some()
298 && tunables.is_short_circuit_enabled()
299 && !crate::config::CubeClRuntimeConfig::get()
300 .autotune
301 .disable_short_circuit;
302
303 let job = TuneJob {
304 key: key.clone(),
305 autotunables,
306 test_inputs,
307 plan,
308 results,
309 #[cfg(any(not(target_family = "wasm"), autotune_persistence))]
310 limit,
311 #[cfg(autotune_persistence)]
312 bounds,
313 #[cfg(not(target_family = "wasm"))]
314 short_circuit,
315 #[cfg(autotune_persistence)]
316 checksum,
317 log_context,
318 };
319
320 #[cfg(not(target_family = "wasm"))]
321 if crate::config::CubeClRuntimeConfig::get()
322 .autotune
323 .bench
324 .adaptive
325 {
326 return self.tune_adaptive(job, client);
327 }
328
329 self.tune_fixed_samples(job, client)
330 }
331
332 #[cfg(not(target_family = "wasm"))]
335 fn tune_adaptive<'i, R: Runtime, F: TuneInputs, Out: AutotuneOutput>(
336 &self,
337 mut job: TuneJob<'_, 'i, K, F, Out>,
338 client: &ComputeClient<R>,
339 ) -> TuneCacheResult
340 where
341 <F as TuneInputs>::At<'i>: Clone + Send,
342 {
343 let schedule = crate::tune::schedule::Schedule {
344 config: crate::config::CubeClRuntimeConfig::get()
345 .autotune
346 .bench
347 .clone(),
348 limit: job.limit,
349 short_circuit: job.short_circuit,
350 track_steps: job.log_context.is_some(),
351 };
352
353 let outcome = schedule.run_plan(
354 &job.key,
355 &mut job.plan,
356 &job.autotunables,
357 &job.test_inputs,
358 client,
359 &mut job.results,
360 );
361
362 for (name, duration) in outcome.steps {
363 job.log_context.push_tuning_step(name, duration);
364 }
365 if let Some(name) = outcome.short_circuit {
366 job.log_context.push_short_circuit(name);
367 }
368
369 let request = job.into_request(Vec::new(), outcome.decided);
370
371 cubecl_environment::future::block_on(process_request(request, &self.cache, &self.logger))
372 }
373
374 fn tune_fixed_samples<'i, R: Runtime, F: TuneInputs, Out: AutotuneOutput>(
377 &self,
378 mut job: TuneJob<'_, 'i, K, F, Out>,
379 client: &ComputeClient<R>,
380 ) -> TuneCacheResult
381 where
382 <F as TuneInputs>::At<'i>: Clone + Send,
383 {
384 #[cfg(not(target_family = "wasm"))]
388 let mut batch_success = false;
389 #[cfg(target_family = "wasm")]
390 let batch_success = false;
391
392 let mut pending = Vec::<PendingBench>::new();
397 loop {
398 let tunable_indices = job.plan.next();
399
400 if tunable_indices.is_empty() {
401 let key = &job.key;
402 panic!(
403 "Can't execute the autotune plan for key: {key:?}\n - plan: {:?}\n - results: {:?}",
404 job.plan, job.results
405 );
406 }
407
408 for index in tunable_indices {
409 let op = job.autotunables[index];
410
411 let start_time = job
412 .log_context
413 .is_some()
414 .then(cubecl_common::profile::Instant::now);
415
416 match tune_benchmark(op, job.test_inputs.clone(), client.clone()) {
417 Ok(profiles) => {
418 let bench = PendingBench {
419 index,
420 name: op.name.clone(),
421 profiles,
422 launch: start_time.map(|start| start.elapsed()),
423 };
424
425 #[cfg(not(target_family = "wasm"))]
426 if job.short_circuit {
427 let result = cubecl_environment::future::block_on(resolve_bench(bench));
428
429 let close_enough = result
431 .outcome
432 .as_ref()
433 .is_ok_and(|out| out.computation.median <= job.limit.unwrap());
434
435 batch_success |= result.outcome.is_ok();
436 job.results[index] = result;
437
438 if let Some(start) = start_time {
439 job.log_context
440 .push_tuning_step(op.name.to_string(), start.elapsed());
441 }
442
443 if close_enough {
444 job.log_context.push_short_circuit(op.name.to_string());
445 break;
446 }
447
448 continue;
449 }
450
451 pending.push(bench);
454 }
455 Err(err) => {
456 job.results[index] = AutotuneResult::error(err);
457 if let Some(start) = start_time {
458 job.log_context
459 .push_tuning_step(op.name.to_string(), start.elapsed());
460 }
461 }
462 }
463 }
464
465 #[cfg(not(target_family = "wasm"))]
466 if !pending.is_empty() || batch_success {
467 break;
468 }
469 #[cfg(target_family = "wasm")]
470 if !pending.is_empty() {
471 break;
472 }
473 }
474
475 let request = job.into_request(pending, None);
478
479 #[cfg(target_family = "wasm")]
482 {
483 let cache = self.cache.clone();
484 let logger = self.logger.clone();
485 wasm_bindgen_futures::spawn_local(async move {
486 process_request(request, &cache, &logger).await;
487 });
488
489 return TuneCacheResult::Pending;
490 }
491
492 #[cfg(not(target_family = "wasm"))]
493 cubecl_environment::future::block_on(process_request(request, &self.cache, &self.logger))
494 }
495}
496
497async fn resolve_bench(bench: PendingBench) -> AutotuneResult {
503 let PendingBench {
504 index,
505 name,
506 profiles,
507 launch: _,
508 } = bench;
509
510 let Some(first) = profiles.first() else {
511 return AutotuneResult::error(AutotuneError::Unknown {
512 name: name.to_string(),
513 err: "No profiling available".to_string(),
514 });
515 };
516 let timing_method = first.timing_method();
517
518 let durations: Vec<Duration> =
519 futures_util::future::join_all(profiles.into_iter().map(ProfileDuration::resolve))
520 .await
521 .into_iter()
522 .map(|ticks| ticks.duration())
523 .collect();
524
525 AutotuneResult::success(AutotuneOutcome::new(
526 name,
527 index,
528 BenchmarkComputations::new(&BenchmarkDurations::from_durations(
529 timing_method,
530 durations,
531 )),
532 ))
533}
534
535async fn process_request<K: AutotuneKey>(
537 request: TuneRequest<K>,
538 cache: &Mutex<TuneCache<K>>,
539 logger: &Mutex<Logger>,
540) -> TuneCacheResult {
541 let TuneRequest {
542 key,
543 mut results,
544 #[cfg(autotune_persistence)]
545 checksum,
546 mut log_context,
547 pending,
548 decided,
549 #[cfg(autotune_persistence)]
550 limit,
551 #[cfg(autotune_persistence)]
552 bounds,
553 } = request;
554
555 let resolved = futures_util::future::join_all(pending.into_iter().map(|bench| {
560 let index = bench.index;
561 let name = bench.name.clone();
562 let launch = bench.launch;
563
564 async move {
565 let started = cubecl_common::profile::Instant::now();
566 let result = resolve_bench(bench).await;
567 let step = launch.map(|launch| (name, launch + started.elapsed()));
568
569 (index, step, result)
570 }
571 }))
572 .await;
573
574 for (index, step, result) in resolved {
575 if let Some((name, duration)) = step {
576 log_context.push_tuning_step(name, duration);
577 }
578
579 results[index] = result;
580 }
581
582 results.sort_by(|a, b| {
583 let a = a
584 .outcome
585 .as_ref()
586 .map(|r| r.computation.score())
587 .unwrap_or(u64::MAX);
588 let b = b
589 .outcome
590 .as_ref()
591 .map(|r| r.computation.score())
592 .unwrap_or(u64::MAX);
593 a.cmp(&b)
594 });
595
596 let fastest_index = match decided {
600 Some(index) => index,
601 None => {
602 results
603 .first()
604 .expect("At least one kernel needed.")
605 .outcome
606 .as_ref()
607 .expect("At least one kernel has to succeed.")
608 .index
609 }
610 };
611
612 {
613 log_context.log_result(&mut logger.lock(), &key, &results);
614 cache.lock().cache_insert(key.clone(), fastest_index);
615 #[cfg(autotune_persistence)]
616 cache.lock().persistent_cache_insert(
617 key,
618 checksum,
619 crate::tune::PersistentCacheValue {
620 fastest_index,
621 results,
622 bounds,
623 limit,
624 },
625 );
626 }
627
628 TuneCacheResult::Hit { fastest_index }
629}
630
631#[cfg(feature = "autotune-checks")]
632pub(crate) fn check_autotune_outputs<O: AutotuneOutput>(
633 mut checks_outputs: Vec<(String, Result<O, AutotuneError>)>,
634) -> Vec<crate::tune::log::CheckResult> {
635 if checks_outputs.is_empty() {
636 return Vec::new();
637 }
638
639 let reference_idx = checks_outputs
640 .iter()
641 .position(|(_, res)| res.is_ok())
642 .unwrap_or(checks_outputs.len() - 1);
643 let reference = checks_outputs.remove(reference_idx);
644 let reference_result = reference.1;
645 #[cfg(std_io)]
646 let reference_name = reference.0;
647
648 let is_recording = is_recording_enabled();
649
650 #[cfg(std_io)]
651 {
652 let reference_passed = reference_result.is_ok();
653 let mut check_results = execute_checks(checks_outputs, reference_result, is_recording);
654 check_results.push(crate::tune::log::CheckResult {
655 name: reference_name,
656 passed: reference_passed,
657 });
658
659 check_results
660 }
661
662 #[cfg(not(std_io))]
663 {
664 execute_checks(checks_outputs, reference_result, is_recording)
665 }
666}
667
668#[cfg(feature = "autotune-checks")]
672fn is_recording_enabled() -> bool {
673 crate::config::CubeClRuntimeConfig::get()
674 .autotune
675 .recording_enabled()
676}
677
678#[cfg(feature = "autotune-checks")]
679fn execute_checks<O: AutotuneOutput>(
680 checks_outputs: Vec<(String, Result<O, AutotuneError>)>,
681 reference_result: Result<O, AutotuneError>,
682 is_recording: bool,
683) -> Vec<crate::tune::log::CheckResult> {
684 let mut check_results = Vec::new();
685
686 let Ok(reference) = reference_result else {
687 for (name, _) in checks_outputs.into_iter() {
688 check_results.push(crate::tune::log::CheckResult {
689 name,
690 passed: false,
691 });
692 }
693 return check_results;
694 };
695
696 for (name, other_result) in checks_outputs.into_iter() {
697 if let Ok(other) = other_result {
698 let passed = check_equivalence(&reference, other, is_recording);
699 check_results.push(crate::tune::log::CheckResult { name, passed });
700 } else {
701 check_results.push(crate::tune::log::CheckResult {
702 name,
703 passed: false,
704 });
705 }
706 }
707
708 check_results
709}
710
711#[cfg(feature = "autotune-checks")]
712fn check_equivalence<O: AutotuneOutput>(reference: &O, other: O, is_recording: bool) -> bool {
713 if is_recording {
716 #[cfg(std_io)]
717 {
718 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
719 reference.check_equivalence(other);
720 }))
721 .is_ok()
722 }
723 #[cfg(not(std_io))]
724 {
725 reference.check_equivalence(other);
726 true
727 }
728 } else {
729 reference.check_equivalence(other);
730 true
731 }
732}