Skip to main content

vyre_driver/
tuner.rs

1//! Backend-neutral autotuner framework and cache metadata.
2
3use std::collections::BTreeMap;
4use std::fmt::Write as _;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use vyre_foundation::ir::Program;
9
10/// Canonical 1D workgroup-size probes shared by live dispatch tuning and
11/// backend timer sweeps.
12pub const WORKGROUP_CANDIDATES: &[u32] = &[32, 64, 128, 256, 512, 1024];
13const AUTOTUNER_ENV: &str = "VYRE_AUTOTUNER";
14const MAX_TUNER_CACHE_BYTES: u64 = 4 * 1024 * 1024;
15
16/// Tuner runtime mode.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18#[non_exhaustive]
19pub enum Mode {
20    /// Sweep candidate sizes on first dispatch.
21    On,
22    /// Sweep candidate sizes and use Fisher-preconditioned policy updates.
23    NaturalGradient,
24    /// Use cached decisions when present, otherwise the default workgroup.
25    OffUseDefault,
26}
27
28impl Mode {
29    /// Production default when `VYRE_AUTOTUNER` is unset.
30    ///
31    /// Explicit `VYRE_AUTOTUNER=off` or `default` still gives the stable
32    /// cached/default path for deterministic bisects, but the release path
33    /// exercises the Fisher-preconditioned autotuner by default.
34    #[must_use]
35    pub const fn production_default() -> Self {
36        Mode::NaturalGradient
37    }
38
39    /// Resolve mode from `VYRE_AUTOTUNER`.
40    #[must_use]
41    pub fn from_env() -> Self {
42        match std::env::var(AUTOTUNER_ENV).ok() {
43            Some(value) => Self::from_env_value(Some(value.as_str())),
44            None => Self::production_default(),
45        }
46    }
47
48    fn from_env_value(value: Option<&str>) -> Self {
49        match value {
50            Some("on") => Mode::On,
51            Some("natural" | "ng") => Mode::NaturalGradient,
52            Some("off" | "default") => Mode::OffUseDefault,
53            Some(_) => Self::production_default(),
54            None => Self::production_default(),
55        }
56    }
57}
58
59/// Backend timing hook used by the generic best-of-N framework.
60pub trait BackendTimer {
61    /// Error type returned by a concrete timing implementation.
62    type Error;
63
64    /// Measure one workgroup-size candidate and return elapsed nanoseconds.
65    ///
66    /// # Errors
67    ///
68    /// Returns the concrete backend timing error when the dispatch or timer
69    /// instrumentation fails.
70    fn measure_candidate_ns(
71        &mut self,
72        program: &Program,
73        workgroup_size: [u32; 3],
74    ) -> Result<u64, Self::Error>;
75}
76
77/// Per-adapter tuner decisions keyed by program fingerprint.
78#[derive(Debug, Default, Clone, PartialEq, Eq)]
79pub struct TunerCache {
80    /// `program_fingerprint -> best_workgroup_size`.
81    pub entries: BTreeMap<String, [u32; 3]>,
82}
83
84/// Static program shape used to disambiguate autotuner decisions.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub struct StaticProgramShape {
87    /// Declared or overridden workgroup shape.
88    pub workgroup_size: [u32; 3],
89    /// Static workgroup-count override when known.
90    pub workgroup_count: Option<[u32; 3]>,
91    /// Static visible output byte count used by the dispatch.
92    pub output_bytes: u64,
93}
94
95impl StaticProgramShape {
96    /// Build a shape record from a program and caller-known launch facts.
97    #[must_use]
98    pub fn new(program: &Program, workgroup_count: Option<[u32; 3]>, output_bytes: u64) -> Self {
99        Self {
100            workgroup_size: program.workgroup_size(),
101            workgroup_count,
102            output_bytes,
103        }
104    }
105}
106
107/// Stable key for per-adapter workgroup autotuning decisions.
108#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
109pub struct TunerProgramKey(String);
110
111impl TunerProgramKey {
112    /// Build a key from the canonical program fingerprint plus static shape.
113    #[must_use]
114    pub fn from_program(program: &Program, shape: StaticProgramShape) -> Self {
115        let mut hasher = blake3::Hasher::new();
116        hasher.update(b"vyre-driver-workgroup-tuner-v1\0program\0");
117        hasher.update(&program.fingerprint());
118        hasher.update(b"\0workgroup-size\0");
119        for axis in shape.workgroup_size {
120            hasher.update(&axis.to_le_bytes());
121        }
122        hasher.update(b"\0workgroup-count\0");
123        match shape.workgroup_count {
124            Some(count) => {
125                hasher.update(&[1]);
126                for axis in count {
127                    hasher.update(&axis.to_le_bytes());
128                }
129            }
130            None => {
131                hasher.update(&[0]);
132            }
133        }
134        hasher.update(b"\0output-bytes\0");
135        hasher.update(&shape.output_bytes.to_le_bytes());
136        let digest = hasher.finalize();
137        let mut key = String::with_capacity(67);
138        key.push_str("v1-");
139        crate::pipeline::hashing::push_lower_hex(digest.as_bytes(), &mut key);
140        Self(key)
141    }
142
143    /// String form used in the TOML cache.
144    #[must_use]
145    pub fn as_str(&self) -> &str {
146        &self.0
147    }
148}
149
150impl AsRef<str> for TunerProgramKey {
151    fn as_ref(&self) -> &str {
152        self.as_str()
153    }
154}
155
156impl TunerCache {
157    /// Return the best workgroup size for the given key, if cached.
158    #[must_use]
159    pub fn get(&self, program_fp: &str) -> Option<[u32; 3]> {
160        self.entries.get(program_fp).copied()
161    }
162
163    /// Return the cached decision for a typed tuner key.
164    #[must_use]
165    pub fn get_key(&self, key: &TunerProgramKey) -> Option<[u32; 3]> {
166        self.get(key.as_str())
167    }
168
169    /// Record a decision.
170    pub fn set(&mut self, program_fp: impl Into<String>, size: [u32; 3]) {
171        self.entries.insert(program_fp.into(), size);
172    }
173
174    /// Record a decision under a typed key.
175    ///
176    /// HOT PATH (autotuner cache write): takes ownership of `key` so the fingerprint `String`
177    /// moves into the map  -  `set(key.as_str(), …)` would allocate a second copy of the same bytes.
178    pub fn set_key(&mut self, key: TunerProgramKey, size: [u32; 3]) {
179        self.entries.insert(key.0, size);
180    }
181
182    /// Load from a TOML file. Missing file returns an empty cache.
183    ///
184    /// # Errors
185    ///
186    /// Returns when the file exists but contains invalid TOML.
187    pub fn load(path: &Path) -> Result<Self, String> {
188        let Ok(contents) = read_tuner_cache_bounded(path) else {
189            return Ok(Self::default());
190        };
191        let parsed: toml::Value = toml::from_str(&contents).map_err(|error| {
192            format!(
193                "Fix: tuner cache `{}` is not valid TOML: {error}",
194                path.display()
195            )
196        })?;
197        let mut entries = BTreeMap::new();
198        if let Some(table) = parsed.as_table() {
199            for (key, value) in table {
200                if let Some(array) = value.as_array() {
201                    if array.len() == 3 {
202                        let mut triple = [0u32; 3];
203                        for (index, value) in array.iter().enumerate() {
204                            if let Some(number) = value.as_integer() {
205                                if let Ok(converted) = u32::try_from(number) {
206                                    triple[index] = converted;
207                                }
208                            }
209                        }
210                        entries.insert(key.clone(), triple);
211                    }
212                }
213            }
214        }
215        Ok(Self { entries })
216    }
217
218    /// Persist to disk. Creates parent directories as needed.
219    ///
220    /// # Errors
221    ///
222    /// Returns when the parent directory cannot be created or the file cannot
223    /// be written.
224    pub fn save(&self, path: &Path) -> Result<(), String> {
225        if let Some(parent) = path.parent() {
226            fs::create_dir_all(parent).map_err(|error| {
227                format!(
228                    "Fix: could not create tuner cache directory {}: {error}",
229                    parent.display()
230                )
231            })?;
232        }
233        let mut out = String::with_capacity(tuner_cache_string_capacity(self.entries.len()));
234        for (key, size) in &self.entries {
235            let _ = writeln!(out, "\"{}\" = [{}, {}, {}]", key, size[0], size[1], size[2]);
236        }
237        fs::write(path, &out).map_err(|error| {
238            format!(
239                "Fix: could not write tuner cache {}: {error}",
240                path.display()
241            )
242        })
243    }
244}
245
246fn read_tuner_cache_bounded(path: &Path) -> std::io::Result<String> {
247    use std::io::Read as _;
248
249    let mut file = fs::File::open(path)?;
250    let metadata = file.metadata()?;
251    if metadata.len() > MAX_TUNER_CACHE_BYTES {
252        return Err(std::io::Error::new(
253            std::io::ErrorKind::InvalidData,
254            format!("tuner cache exceeds {MAX_TUNER_CACHE_BYTES} byte limit"),
255        ));
256    }
257    let mut text = String::with_capacity(metadata.len() as usize);
258    file.by_ref()
259        .take(MAX_TUNER_CACHE_BYTES + 1)
260        .read_to_string(&mut text)?;
261    if text.len() as u64 > MAX_TUNER_CACHE_BYTES {
262        return Err(std::io::Error::new(
263            std::io::ErrorKind::InvalidData,
264            "tuner cache exceeded bounded read limit",
265        ));
266    }
267    Ok(text)
268}
269
270/// Best-of-N measurement result.
271#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272pub struct TuningMeasurement {
273    /// Winning workgroup size.
274    pub workgroup_size: [u32; 3],
275    /// Measured elapsed nanoseconds for the winner.
276    pub elapsed_ns: u64,
277}
278
279/// 16.16 fixed-point value representing 1.0.
280pub const Q16_ONE: u32 = 1 << 16;
281
282/// Natural-gradient policy for choosing the next autotune probe from
283/// measured latency samples.
284///
285/// The policy treats the candidate set as a discrete distribution over
286/// launch configurations. Latency samples become a softmax over
287/// `-elapsed_ns / temperature_ns`; the supplied inverse-Fisher square-root
288/// matrix preconditions that probability/gradient vector before the driver
289/// picks the next candidate. CUDA/self-substrate can produce the same
290/// fixed-point matrix through the primitive-backed natural-gradient path.
291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292pub struct NaturalGradientPolicy {
293    /// Softmax temperature in nanoseconds. Larger values explore more.
294    pub temperature_ns: u64,
295}
296
297impl Default for NaturalGradientPolicy {
298    fn default() -> Self {
299        Self {
300            temperature_ns: 10_000,
301        }
302    }
303}
304
305/// Result of a natural-gradient autotune policy update.
306#[derive(Debug, Clone, PartialEq, Eq)]
307pub struct NaturalGradientTuningStep {
308    /// Candidate selected after Fisher preconditioning.
309    pub selected_workgroup_size: [u32; 3],
310    /// Fastest candidate observed in the raw measurement window.
311    pub best_measured_workgroup_size: [u32; 3],
312    /// Fastest elapsed time observed in the raw measurement window.
313    pub best_measured_elapsed_ns: u64,
314    /// Softmax policy weights in 16.16 fixed-point form.
315    pub policy_weights_q16: Vec<u32>,
316    /// Fisher-preconditioned gradient magnitudes in 16.16 fixed-point form.
317    pub natural_gradient_q16: Vec<u32>,
318}
319
320/// Errors returned by natural-gradient autotune policy construction.
321#[derive(Debug, Clone, PartialEq, Eq)]
322#[non_exhaustive]
323pub enum NaturalGradientTuningError {
324    /// No latency samples were provided.
325    EmptyMeasurements,
326    /// The inverse-Fisher square-root matrix was not `n * n`.
327    FisherMatrixShape {
328        /// Number of latency samples.
329        measurements: usize,
330        /// Number of fixed-point cells in the supplied matrix.
331        cells: usize,
332    },
333    /// The softmax temperature was zero.
334    ZeroTemperature,
335}
336
337impl std::fmt::Display for NaturalGradientTuningError {
338    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
339        match self {
340            Self::EmptyMeasurements => {
341                write!(
342                    f,
343                    "natural-gradient tuner received no measurements. Fix: measure at least one candidate before policy update."
344                )
345            }
346            Self::FisherMatrixShape {
347                measurements,
348                cells,
349            } => write!(
350                f,
351                "natural-gradient tuner expected an inverse-Fisher matrix with {} cells for {measurements} measurement(s), got {cells}. Fix: pass an n*n 16.16 matrix.",
352                measurements.saturating_mul(*measurements)
353            ),
354            Self::ZeroTemperature => {
355                write!(
356                    f,
357                    "natural-gradient tuner temperature is zero. Fix: use a positive temperature_ns."
358                )
359            }
360        }
361    }
362}
363
364impl std::error::Error for NaturalGradientTuningError {}
365
366impl NaturalGradientPolicy {
367    /// Suggest the next workgroup-size candidate from latency samples and an
368    /// inverse-Fisher square-root matrix.
369    ///
370    /// `fisher_inv_sqrt_q16` is row-major `n x n`, 16.16 fixed-point. Passing
371    /// an identity matrix makes the policy reduce to the softmax-gradient
372    /// candidate. Non-identity blocks let the runtime bias exploration by the
373    /// local latency manifold instead of blindly reusing the single fastest
374    /// point.
375    ///
376    /// # Errors
377    ///
378    /// Returns [`NaturalGradientTuningError`] when the measurement set is
379    /// empty, temperature is zero, or the Fisher matrix shape does not match.
380    pub fn suggest(
381        &self,
382        measurements: &[TuningMeasurement],
383        fisher_inv_sqrt_q16: &[u32],
384    ) -> Result<NaturalGradientTuningStep, NaturalGradientTuningError> {
385        if measurements.is_empty() {
386            return Err(NaturalGradientTuningError::EmptyMeasurements);
387        }
388        if self.temperature_ns == 0 {
389            return Err(NaturalGradientTuningError::ZeroTemperature);
390        }
391        let expected_cells = measurements.len().checked_mul(measurements.len()).ok_or(
392            NaturalGradientTuningError::FisherMatrixShape {
393                measurements: measurements.len(),
394                cells: fisher_inv_sqrt_q16.len(),
395            },
396        )?;
397        if fisher_inv_sqrt_q16.len() != expected_cells {
398            return Err(NaturalGradientTuningError::FisherMatrixShape {
399                measurements: measurements.len(),
400                cells: fisher_inv_sqrt_q16.len(),
401            });
402        }
403
404        let mut best_index = 0usize;
405        let mut best_elapsed = measurements[0].elapsed_ns;
406        for (index, measurement) in measurements.iter().enumerate().skip(1) {
407            if measurement.elapsed_ns < best_elapsed {
408                best_index = index;
409                best_elapsed = measurement.elapsed_ns;
410            }
411        }
412
413        let policy_weights_q16 =
414            latency_softmax_weights_q16(measurements, best_elapsed, self.temperature_ns);
415        let natural_gradient_q16 =
416            precondition_q16(fisher_inv_sqrt_q16, &policy_weights_q16, measurements.len());
417        let selected_index = natural_gradient_q16
418            .iter()
419            .enumerate()
420            .max_by_key(|(_, value)| *value)
421            .map(|(index, _)| index)
422            .unwrap_or(best_index);
423
424        Ok(NaturalGradientTuningStep {
425            selected_workgroup_size: measurements[selected_index].workgroup_size,
426            best_measured_workgroup_size: measurements[best_index].workgroup_size,
427            best_measured_elapsed_ns: best_elapsed,
428            policy_weights_q16,
429            natural_gradient_q16,
430        })
431    }
432}
433
434/// Build an identity inverse-Fisher square-root matrix in 16.16 fixed point.
435#[must_use]
436pub fn identity_fisher_q16(candidate_count: usize) -> Vec<u32> {
437    let mut out = Vec::new();
438    identity_fisher_q16_into(candidate_count, &mut out);
439    out
440}
441
442/// Write an identity inverse-Fisher square-root matrix into caller-owned
443/// storage.
444pub fn identity_fisher_q16_into(candidate_count: usize, out: &mut Vec<u32>) {
445    let Some(cells) = candidate_count.checked_mul(candidate_count) else {
446        out.clear();
447        return;
448    };
449    out.clear();
450    out.resize(cells, 0);
451    for index in 0..candidate_count {
452        out[index * candidate_count + index] = Q16_ONE;
453    }
454}
455
456fn latency_softmax_weights_q16(
457    measurements: &[TuningMeasurement],
458    best_elapsed: u64,
459    temperature_ns: u64,
460) -> Vec<u32> {
461    let temperature = temperature_ns as f64;
462    let mut weights = Vec::with_capacity(measurements.len());
463    let mut sum = 0.0f64;
464    for measurement in measurements {
465        let penalty = measurement.elapsed_ns.saturating_sub(best_elapsed) as f64;
466        let weight = (-penalty / temperature).exp();
467        weights.push(weight);
468        sum += weight;
469    }
470    let mut out = Vec::with_capacity(measurements.len());
471    let mut assigned = 0u32;
472    for (index, weight) in weights.iter().enumerate() {
473        if index + 1 == weights.len() {
474            out.push(Q16_ONE.saturating_sub(assigned));
475            break;
476        }
477        let q16 = ((*weight / sum) * f64::from(Q16_ONE)).round() as u32;
478        let remaining = Q16_ONE.saturating_sub(assigned);
479        let q16 = q16.min(remaining);
480        assigned = assigned.saturating_add(q16);
481        out.push(q16);
482    }
483    out
484}
485
486fn precondition_q16(matrix_q16: &[u32], gradient_q16: &[u32], n: usize) -> Vec<u32> {
487    let mut out = vec![0u32; n];
488    for row in 0..n {
489        let mut acc = 0u64;
490        for col in 0..n {
491            let matrix = u64::from(matrix_q16[row * n + col]);
492            let gradient = u64::from(gradient_q16[col]);
493            acc = acc.saturating_add((matrix.saturating_mul(gradient)) >> 16);
494        }
495        out[row] = acc.min(u64::from(u32::MAX)) as u32;
496    }
497    out
498}
499
500/// Workgroup-size autotuner.
501pub struct Tuner {
502    mode: Mode,
503    cache: TunerCache,
504    cache_path: PathBuf,
505}
506
507impl Tuner {
508    /// Build a new tuner for the adapter fingerprinted as `adapter_fp`.
509    #[must_use]
510    pub fn new(adapter_fp: &str, mode: Mode) -> Self {
511        let cache_path = Self::cache_path_for_adapter(adapter_fp);
512        let cache = TunerCache::load(&cache_path).unwrap_or_default();
513        Self {
514            mode,
515            cache,
516            cache_path,
517        }
518    }
519
520    /// Cache file path for a given adapter fingerprint.
521    #[must_use]
522    pub fn cache_path_for_adapter(adapter_fp: &str) -> PathBuf {
523        let mut home = dirs_cache_root();
524        home.push("vyre");
525        home.push("tuner");
526        home.push(format!("{adapter_fp}.toml"));
527        home
528    }
529
530    /// Candidate workgroup sizes bounded by `max_invocations`.
531    #[must_use]
532    pub fn candidates_for(&self, max_invocations: u32) -> Vec<u32> {
533        let mut candidates = Vec::new();
534        let _ = candidates.try_reserve_exact(WORKGROUP_CANDIDATES.len());
535        candidates.extend(
536            WORKGROUP_CANDIDATES
537                .iter()
538                .copied()
539                .filter(|candidate| *candidate <= max_invocations),
540        );
541        candidates
542    }
543
544    /// Default workgroup size used without cache data.
545    #[must_use]
546    pub const fn default_workgroup_size() -> [u32; 3] {
547        crate::pipeline::DEFAULT_1D_WORKGROUP_SIZE
548    }
549
550    /// Mode this tuner is running in.
551    #[must_use]
552    pub const fn mode(&self) -> Mode {
553        self.mode
554    }
555
556    /// Resolve the workgroup size for a program key.
557    #[must_use]
558    pub fn resolve(&self, program_fp: &str) -> [u32; 3] {
559        self.cache
560            .get(program_fp)
561            .unwrap_or_else(Self::default_workgroup_size)
562    }
563
564    /// Resolve the workgroup size for a typed program/static-shape key.
565    #[must_use]
566    pub fn resolve_key(&self, key: &TunerProgramKey) -> [u32; 3] {
567        self.resolve(key.as_str())
568    }
569
570    /// Record a sweep outcome in memory.
571    pub fn record_decision(&mut self, program_fp: impl Into<String>, size: [u32; 3]) {
572        self.cache.set(program_fp, size);
573    }
574
575    /// Record a sweep outcome for a typed key.
576    pub fn record_key_decision(&mut self, key: TunerProgramKey, size: [u32; 3]) {
577        self.cache.set_key(key, size);
578    }
579
580    /// Measure candidate sizes and choose the fastest one.
581    ///
582    /// # Errors
583    ///
584    /// Returns a backend timing error from [`BackendTimer`].
585    pub fn best_of<T: BackendTimer>(
586        &self,
587        program: &Program,
588        candidates: impl IntoIterator<Item = [u32; 3]>,
589        timer: &mut T,
590    ) -> Result<Option<TuningMeasurement>, T::Error> {
591        let mut best = None;
592        for workgroup_size in candidates {
593            let elapsed_ns = timer.measure_candidate_ns(program, workgroup_size)?;
594            let measurement = TuningMeasurement {
595                workgroup_size,
596                elapsed_ns,
597            };
598            if best
599                .map(|current: TuningMeasurement| elapsed_ns < current.elapsed_ns)
600                .unwrap_or(true)
601            {
602                best = Some(measurement);
603            }
604        }
605        Ok(best)
606    }
607
608    /// Measure candidates, then choose the next probe with a
609    /// Fisher-preconditioned natural-gradient policy.
610    ///
611    /// This is the concrete runtime handoff for `VYRE_AUTOTUNER=natural`.
612    /// It reuses the same backend timer as [`Self::best_of`], records every
613    /// measured candidate, and feeds those measurements into
614    /// [`NaturalGradientPolicy`]. The returned step includes both the raw
615    /// fastest measurement and the Fisher-directed next candidate.
616    ///
617    /// # Errors
618    ///
619    /// Returns backend timing errors from [`BackendTimer`] or policy errors
620    /// from [`NaturalGradientPolicy`].
621    pub fn best_of_natural_gradient<T: BackendTimer>(
622        &self,
623        program: &Program,
624        candidates: impl IntoIterator<Item = [u32; 3]>,
625        timer: &mut T,
626        fisher_inv_sqrt_q16: &[u32],
627        policy: NaturalGradientPolicy,
628    ) -> Result<Result<NaturalGradientTuningStep, NaturalGradientTuningError>, T::Error> {
629        let mut measurements = Vec::new();
630        for workgroup_size in candidates {
631            let elapsed_ns = timer.measure_candidate_ns(program, workgroup_size)?;
632            measurements.push(TuningMeasurement {
633                workgroup_size,
634                elapsed_ns,
635            });
636        }
637        Ok(policy.suggest(&measurements, fisher_inv_sqrt_q16))
638    }
639
640    /// Convert measured candidates into a Fisher-preconditioned next probe.
641    ///
642    /// This keeps the best-of-N timing hook compatible while giving CUDA and
643    /// other GPU backends a richer update rule than "pick the current fastest
644    /// sample forever." Backends can feed `fisher_inv_sqrt_q16` from the
645    /// primitive-backed natural-gradient self-substrate path.
646    ///
647    /// # Errors
648    ///
649    /// Returns [`NaturalGradientTuningError`] when the policy input is
650    /// malformed.
651    pub fn natural_gradient_step(
652        &self,
653        measurements: &[TuningMeasurement],
654        fisher_inv_sqrt_q16: &[u32],
655        policy: NaturalGradientPolicy,
656    ) -> Result<NaturalGradientTuningStep, NaturalGradientTuningError> {
657        policy.suggest(measurements, fisher_inv_sqrt_q16)
658    }
659
660    /// Write the cache to disk.
661    ///
662    /// # Errors
663    ///
664    /// Returns the structured error from [`TunerCache::save`].
665    pub fn persist(&self) -> Result<(), String> {
666        self.cache.save(&self.cache_path)
667    }
668}
669
670/// Snapshot of live behavior the tuner consumes for adaptive resizing.
671#[derive(Debug, Clone)]
672pub struct TunerFeedback {
673    /// `(opcode_id, execution_count)` pairs from backend metrics.
674    pub per_opcode_counts: Vec<(u32, u32)>,
675    /// Total wall-time in microseconds.
676    pub wall_time_us: u64,
677    /// Idle microseconds inside the window.
678    pub idle_us: u64,
679    /// Workgroup size x this feedback was gathered on.
680    pub observed_workgroup_size_x: u32,
681    /// Observed throughput per microsecond.
682    pub observed_throughput_per_us: f64,
683}
684
685/// Hysteresis-based default resize policy.
686#[derive(Debug, Clone)]
687pub struct DefaultPolicy {
688    /// Upper bound from the adapter capability probe.
689    pub adapter_max_workgroup_size_x: u32,
690    /// Floor below which we never shrink.
691    pub minimum_workgroup_size_x: u32,
692    /// Throughput below which we grow.
693    pub saturation_threshold_per_us: f64,
694    /// Idle time above which we shrink.
695    pub idle_shrink_us: u64,
696}
697
698impl Default for DefaultPolicy {
699    fn default() -> Self {
700        Self {
701            adapter_max_workgroup_size_x: 1024,
702            minimum_workgroup_size_x: 32,
703            saturation_threshold_per_us: 1.0,
704            idle_shrink_us: 100_000,
705        }
706    }
707}
708
709impl DefaultPolicy {
710    /// Suggest a new workgroup size for the next feedback window.
711    #[must_use]
712    pub fn suggest_resize(&self, feedback: &TunerFeedback) -> Option<u32> {
713        let current = feedback.observed_workgroup_size_x.max(1);
714        if feedback.idle_us > self.idle_shrink_us {
715            let shrunk = current / 2;
716            if shrunk >= self.minimum_workgroup_size_x && shrunk != current {
717                return Some(shrunk);
718            }
719            return None;
720        }
721        if feedback.observed_throughput_per_us < self.saturation_threshold_per_us {
722            let grown = current.checked_mul(2)?;
723            if grown <= self.adapter_max_workgroup_size_x && grown != current {
724                return Some(grown);
725            }
726        }
727        None
728    }
729}
730
731fn tuner_cache_string_capacity(entries: usize) -> usize {
732    entries.saturating_mul(96)
733}
734
735fn dirs_cache_root() -> PathBuf {
736    if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME") {
737        PathBuf::from(xdg)
738    } else if let Some(home) = std::env::var_os("HOME") {
739        let mut path = PathBuf::from(home);
740        path.push(".cache");
741        path
742    } else {
743        PathBuf::from(".")
744    }
745}
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750
751    fn measurements() -> Vec<TuningMeasurement> {
752        vec![
753            TuningMeasurement {
754                workgroup_size: [64, 1, 1],
755                elapsed_ns: 12_000,
756            },
757            TuningMeasurement {
758                workgroup_size: [128, 1, 1],
759                elapsed_ns: 8_000,
760            },
761            TuningMeasurement {
762                workgroup_size: [256, 1, 1],
763                elapsed_ns: 10_000,
764            },
765        ]
766    }
767
768    struct StaticTimer {
769        fail_on: Option<u32>,
770        measured: Vec<[u32; 3]>,
771    }
772
773    impl StaticTimer {
774        fn new() -> Self {
775            Self {
776                fail_on: None,
777                measured: Vec::new(),
778            }
779        }
780
781        fn failing(fail_on: u32) -> Self {
782            Self {
783                fail_on: Some(fail_on),
784                measured: Vec::new(),
785            }
786        }
787    }
788
789    impl BackendTimer for StaticTimer {
790        type Error = &'static str;
791
792        fn measure_candidate_ns(
793            &mut self,
794            _program: &Program,
795            workgroup_size: [u32; 3],
796        ) -> Result<u64, Self::Error> {
797            self.measured.push(workgroup_size);
798            if self.fail_on == Some(workgroup_size[0]) {
799                return Err("timer failed");
800            }
801            Ok(match workgroup_size[0] {
802                64 => 12_000,
803                128 => 8_000,
804                256 => 10_000,
805                _ => 50_000,
806            })
807        }
808    }
809
810    fn empty_program() -> Program {
811        Program::wrapped(Vec::new(), [64, 1, 1], Vec::new())
812    }
813
814    #[test]
815    fn unset_autotuner_mode_defaults_to_natural_gradient_release_path() {
816        assert_eq!(Mode::production_default(), Mode::NaturalGradient);
817        assert_eq!(Mode::from_env_value(None), Mode::NaturalGradient);
818    }
819
820    #[test]
821    fn explicit_env_modes_preserve_escape_hatches() {
822        assert_eq!(Mode::from_env_value(Some("natural")), Mode::NaturalGradient);
823        assert_eq!(Mode::from_env_value(Some("ng")), Mode::NaturalGradient);
824        assert_eq!(Mode::from_env_value(Some("on")), Mode::On);
825        assert_eq!(Mode::from_env_value(Some("off")), Mode::OffUseDefault);
826        assert_eq!(Mode::from_env_value(Some("default")), Mode::OffUseDefault);
827    }
828
829    #[test]
830    fn identity_fisher_preserves_fastest_candidate_policy_gradient() {
831        let policy = NaturalGradientPolicy {
832            temperature_ns: 4_000,
833        };
834        let samples = measurements();
835        let step = policy
836            .suggest(&samples, &identity_fisher_q16(samples.len()))
837            .expect("Fix: identity Fisher natural-gradient update should be valid");
838
839        assert_eq!(step.best_measured_workgroup_size, [128, 1, 1]);
840        assert_eq!(step.selected_workgroup_size, [128, 1, 1]);
841        assert_eq!(step.best_measured_elapsed_ns, 8_000);
842    }
843
844    #[test]
845    fn anisotropic_fisher_can_redirect_next_probe_without_changing_measurement_winner() {
846        let policy = NaturalGradientPolicy {
847            temperature_ns: 4_000,
848        };
849        let samples = measurements();
850        let mut fisher = identity_fisher_q16(samples.len());
851        fisher[0] = Q16_ONE * 8;
852
853        let step = policy
854            .suggest(&samples, &fisher)
855            .expect("Fix: diagonal Fisher natural-gradient update should be valid");
856
857        assert_eq!(step.best_measured_workgroup_size, [128, 1, 1]);
858        assert_eq!(
859            step.selected_workgroup_size,
860            [64, 1, 1],
861            "Fix: Fisher geometry must be able to steer exploration away from the raw fastest sample."
862        );
863        assert!(
864            step.natural_gradient_q16[0] > step.natural_gradient_q16[1],
865            "Fix: preconditioned gradient should reflect the anisotropic Fisher block."
866        );
867    }
868
869    #[test]
870    fn softmax_weights_conserve_q16_probability_mass_across_hostile_latencies() {
871        let policy = NaturalGradientPolicy { temperature_ns: 1 };
872        for base in [0_u64, 1, 10, 1_000, u64::MAX - 2] {
873            let samples = vec![
874                TuningMeasurement {
875                    workgroup_size: [32, 1, 1],
876                    elapsed_ns: base,
877                },
878                TuningMeasurement {
879                    workgroup_size: [64, 1, 1],
880                    elapsed_ns: base.saturating_add(1),
881                },
882                TuningMeasurement {
883                    workgroup_size: [128, 1, 1],
884                    elapsed_ns: base.saturating_add(2),
885                },
886            ];
887            let step = policy
888                .suggest(&samples, &identity_fisher_q16(samples.len()))
889                .expect("Fix: hostile latency range should still produce a normalized policy");
890            let total: u32 = step.policy_weights_q16.iter().sum();
891            assert_eq!(
892                total, Q16_ONE,
893                "Fix: fixed-point policy weights must conserve probability mass for base={base}."
894            );
895        }
896    }
897
898    #[test]
899    fn rejects_empty_measurements_zero_temperature_and_bad_fisher_shape() {
900        let policy = NaturalGradientPolicy::default();
901        assert_eq!(
902            policy.suggest(&[], &[]),
903            Err(NaturalGradientTuningError::EmptyMeasurements)
904        );
905
906        let samples = measurements();
907        let zero_temp = NaturalGradientPolicy { temperature_ns: 0 };
908        assert_eq!(
909            zero_temp.suggest(&samples, &identity_fisher_q16(samples.len())),
910            Err(NaturalGradientTuningError::ZeroTemperature)
911        );
912        assert_eq!(
913            policy.suggest(&samples, &[Q16_ONE]),
914            Err(NaturalGradientTuningError::FisherMatrixShape {
915                measurements: samples.len(),
916                cells: 1,
917            })
918        );
919    }
920
921    #[test]
922    fn tuner_exposes_natural_gradient_step_surface() {
923        let tuner = Tuner::new("natural-gradient-test-adapter", Mode::OffUseDefault);
924        let samples = measurements();
925        let step = tuner
926            .natural_gradient_step(
927                &samples,
928                &identity_fisher_q16(samples.len()),
929                NaturalGradientPolicy::default(),
930            )
931            .expect("Fix: tuner natural-gradient policy surface should accept identity Fisher");
932
933        assert_eq!(step.selected_workgroup_size, [128, 1, 1]);
934    }
935
936    #[test]
937    fn measured_natural_gradient_sweep_uses_backend_timer_and_fisher_policy() {
938        let tuner = Tuner::new(
939            "measured-natural-gradient-test-adapter",
940            Mode::NaturalGradient,
941        );
942        let mut timer = StaticTimer::new();
943        let mut fisher = identity_fisher_q16(3);
944        fisher[0] = Q16_ONE * 8;
945
946        let step = tuner
947            .best_of_natural_gradient(
948                &empty_program(),
949                [[64, 1, 1], [128, 1, 1], [256, 1, 1]],
950                &mut timer,
951                &fisher,
952                NaturalGradientPolicy {
953                    temperature_ns: 4_000,
954                },
955            )
956            .expect("Fix: backend timer should succeed")
957            .expect("Fix: natural-gradient policy should accept measured candidates");
958
959        assert_eq!(
960            timer.measured,
961            vec![[64, 1, 1], [128, 1, 1], [256, 1, 1]],
962            "Fix: natural-gradient sweep must measure every supplied candidate."
963        );
964        assert_eq!(step.best_measured_workgroup_size, [128, 1, 1]);
965        assert_eq!(
966            step.selected_workgroup_size,
967            [64, 1, 1],
968            "Fix: measured natural-gradient sweep must use Fisher policy, not raw fastest-only selection."
969        );
970    }
971
972    #[test]
973    fn measured_natural_gradient_sweep_propagates_timer_failures() {
974        let tuner = Tuner::new(
975            "measured-natural-gradient-error-test-adapter",
976            Mode::NaturalGradient,
977        );
978        let mut timer = StaticTimer::failing(128);
979        let err = tuner
980            .best_of_natural_gradient(
981                &empty_program(),
982                [[64, 1, 1], [128, 1, 1], [256, 1, 1]],
983                &mut timer,
984                &identity_fisher_q16(3),
985                NaturalGradientPolicy::default(),
986            )
987            .expect_err("Fix: backend timer failures must propagate before policy update");
988
989        assert_eq!(err, "timer failed");
990        assert_eq!(
991            timer.measured,
992            vec![[64, 1, 1], [128, 1, 1]],
993            "Fix: failed measurements must stop the sweep instead of producing a fake policy result."
994        );
995    }
996}