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.
444
445pub fn identity_fisher_q16_into(candidate_count: usize, out: &mut Vec<u32>) {
446    let Some(cells) = candidate_count.checked_mul(candidate_count) else {
447        out.clear();
448        return;
449    };
450    out.clear();
451    out.resize(cells, 0);
452    for index in 0..candidate_count {
453        out[index * candidate_count + index] = Q16_ONE;
454    }
455}
456
457fn latency_softmax_weights_q16(
458    measurements: &[TuningMeasurement],
459    best_elapsed: u64,
460    temperature_ns: u64,
461) -> Vec<u32> {
462    let temperature = temperature_ns as f64;
463    let mut weights = Vec::with_capacity(measurements.len());
464    let mut sum = 0.0f64;
465    for measurement in measurements {
466        let penalty = measurement.elapsed_ns.saturating_sub(best_elapsed) as f64;
467        let weight = (-penalty / temperature).exp();
468        weights.push(weight);
469        sum += weight;
470    }
471    let mut out = Vec::with_capacity(measurements.len());
472    let mut assigned = 0u32;
473    for (index, weight) in weights.iter().enumerate() {
474        if index + 1 == weights.len() {
475            out.push(Q16_ONE.saturating_sub(assigned));
476            break;
477        }
478        let q16 = ((*weight / sum) * f64::from(Q16_ONE)).round() as u32;
479        let remaining = Q16_ONE.saturating_sub(assigned);
480        let q16 = q16.min(remaining);
481        assigned = assigned.saturating_add(q16);
482        out.push(q16);
483    }
484    out
485}
486
487fn precondition_q16(matrix_q16: &[u32], gradient_q16: &[u32], n: usize) -> Vec<u32> {
488    let mut out = vec![0u32; n];
489    for row in 0..n {
490        let mut acc = 0u64;
491        for col in 0..n {
492            let matrix = u64::from(matrix_q16[row * n + col]);
493            let gradient = u64::from(gradient_q16[col]);
494            acc = acc.saturating_add((matrix.saturating_mul(gradient)) >> 16);
495        }
496        out[row] = acc.min(u64::from(u32::MAX)) as u32;
497    }
498    out
499}
500
501/// Workgroup-size autotuner.
502pub struct Tuner {
503    mode: Mode,
504    cache: TunerCache,
505    cache_path: PathBuf,
506}
507
508impl Tuner {
509    /// Build a new tuner for the adapter fingerprinted as `adapter_fp`.
510    #[must_use]
511    pub fn new(adapter_fp: &str, mode: Mode) -> Self {
512        let cache_path = Self::cache_path_for_adapter(adapter_fp);
513        let cache = TunerCache::load(&cache_path).unwrap_or_default();
514        Self {
515            mode,
516            cache,
517            cache_path,
518        }
519    }
520
521    /// Cache file path for a given adapter fingerprint.
522    #[must_use]
523    pub fn cache_path_for_adapter(adapter_fp: &str) -> PathBuf {
524        let mut home = dirs_cache_root();
525        home.push("vyre");
526        home.push("tuner");
527        home.push(format!("{adapter_fp}.toml"));
528        home
529    }
530
531    /// Candidate workgroup sizes bounded by `max_invocations`.
532    #[must_use]
533    pub fn candidates_for(&self, max_invocations: u32) -> Vec<u32> {
534        let mut candidates = Vec::new();
535        let _ = candidates.try_reserve_exact(WORKGROUP_CANDIDATES.len());
536        candidates.extend(
537            WORKGROUP_CANDIDATES
538                .iter()
539                .copied()
540                .filter(|candidate| *candidate <= max_invocations),
541        );
542        candidates
543    }
544
545    /// Default workgroup size used without cache data.
546    #[must_use]
547    pub const fn default_workgroup_size() -> [u32; 3] {
548        crate::pipeline::DEFAULT_1D_WORKGROUP_SIZE
549    }
550
551    /// Mode this tuner is running in.
552    #[must_use]
553    pub const fn mode(&self) -> Mode {
554        self.mode
555    }
556
557    /// Resolve the workgroup size for a program key.
558    #[must_use]
559    pub fn resolve(&self, program_fp: &str) -> [u32; 3] {
560        self.cache
561            .get(program_fp)
562            .unwrap_or_else(Self::default_workgroup_size)
563    }
564
565    /// Resolve the workgroup size for a typed program/static-shape key.
566    #[must_use]
567    pub fn resolve_key(&self, key: &TunerProgramKey) -> [u32; 3] {
568        self.resolve(key.as_str())
569    }
570
571    /// Record a sweep outcome in memory.
572    pub fn record_decision(&mut self, program_fp: impl Into<String>, size: [u32; 3]) {
573        self.cache.set(program_fp, size);
574    }
575
576    /// Record a sweep outcome for a typed key.
577    pub fn record_key_decision(&mut self, key: TunerProgramKey, size: [u32; 3]) {
578        self.cache.set_key(key, size);
579    }
580
581    /// Measure candidate sizes and choose the fastest one.
582    ///
583    /// # Errors
584    ///
585    /// Returns a backend timing error from [`BackendTimer`].
586    pub fn best_of<T: BackendTimer>(
587        &self,
588        program: &Program,
589        candidates: impl IntoIterator<Item = [u32; 3]>,
590        timer: &mut T,
591    ) -> Result<Option<TuningMeasurement>, T::Error> {
592        let mut best = None;
593        for workgroup_size in candidates {
594            let elapsed_ns = timer.measure_candidate_ns(program, workgroup_size)?;
595            let measurement = TuningMeasurement {
596                workgroup_size,
597                elapsed_ns,
598            };
599            if best
600                .map(|current: TuningMeasurement| elapsed_ns < current.elapsed_ns)
601                .unwrap_or(true)
602            {
603                best = Some(measurement);
604            }
605        }
606        Ok(best)
607    }
608
609    /// Measure candidates, then choose the next probe with a
610    /// Fisher-preconditioned natural-gradient policy.
611    ///
612    /// This is the concrete runtime handoff for `VYRE_AUTOTUNER=natural`.
613    /// It reuses the same backend timer as [`Self::best_of`], records every
614    /// measured candidate, and feeds those measurements into
615    /// [`NaturalGradientPolicy`]. The returned step includes both the raw
616    /// fastest measurement and the Fisher-directed next candidate.
617    ///
618    /// # Errors
619    ///
620    /// Returns backend timing errors from [`BackendTimer`] or policy errors
621    /// from [`NaturalGradientPolicy`].
622    pub fn best_of_natural_gradient<T: BackendTimer>(
623        &self,
624        program: &Program,
625        candidates: impl IntoIterator<Item = [u32; 3]>,
626        timer: &mut T,
627        fisher_inv_sqrt_q16: &[u32],
628        policy: NaturalGradientPolicy,
629    ) -> Result<Result<NaturalGradientTuningStep, NaturalGradientTuningError>, T::Error> {
630        let mut measurements = Vec::new();
631        for workgroup_size in candidates {
632            let elapsed_ns = timer.measure_candidate_ns(program, workgroup_size)?;
633            measurements.push(TuningMeasurement {
634                workgroup_size,
635                elapsed_ns,
636            });
637        }
638        Ok(policy.suggest(&measurements, fisher_inv_sqrt_q16))
639    }
640
641    /// Convert measured candidates into a Fisher-preconditioned next probe.
642    ///
643    /// This keeps the best-of-N timing hook compatible while giving CUDA and
644    /// other GPU backends a richer update rule than "pick the current fastest
645    /// sample forever." Backends can feed `fisher_inv_sqrt_q16` from the
646    /// primitive-backed natural-gradient self-substrate path.
647    ///
648    /// # Errors
649    ///
650    /// Returns [`NaturalGradientTuningError`] when the policy input is
651    /// malformed.
652    pub fn natural_gradient_step(
653        &self,
654        measurements: &[TuningMeasurement],
655        fisher_inv_sqrt_q16: &[u32],
656        policy: NaturalGradientPolicy,
657    ) -> Result<NaturalGradientTuningStep, NaturalGradientTuningError> {
658        policy.suggest(measurements, fisher_inv_sqrt_q16)
659    }
660
661    /// Write the cache to disk.
662    ///
663    /// # Errors
664    ///
665    /// Returns the structured error from [`TunerCache::save`].
666    pub fn persist(&self) -> Result<(), String> {
667        self.cache.save(&self.cache_path)
668    }
669}
670
671/// Snapshot of live behavior the tuner consumes for adaptive resizing.
672#[derive(Debug, Clone)]
673pub struct TunerFeedback {
674    /// `(opcode_id, execution_count)` pairs from backend metrics.
675    pub per_opcode_counts: Vec<(u32, u32)>,
676    /// Total wall-time in microseconds.
677    pub wall_time_us: u64,
678    /// Idle microseconds inside the window.
679    pub idle_us: u64,
680    /// Workgroup size x this feedback was gathered on.
681    pub observed_workgroup_size_x: u32,
682    /// Observed throughput per microsecond.
683    pub observed_throughput_per_us: f64,
684}
685
686/// Hysteresis-based default resize policy.
687#[derive(Debug, Clone)]
688pub struct DefaultPolicy {
689    /// Upper bound from the adapter capability probe.
690    pub adapter_max_workgroup_size_x: u32,
691    /// Floor below which we never shrink.
692    pub minimum_workgroup_size_x: u32,
693    /// Throughput below which we grow.
694    pub saturation_threshold_per_us: f64,
695    /// Idle time above which we shrink.
696    pub idle_shrink_us: u64,
697}
698
699impl Default for DefaultPolicy {
700    fn default() -> Self {
701        Self {
702            adapter_max_workgroup_size_x: 1024,
703            minimum_workgroup_size_x: 32,
704            saturation_threshold_per_us: 1.0,
705            idle_shrink_us: 100_000,
706        }
707    }
708}
709
710impl DefaultPolicy {
711    /// Suggest a new workgroup size for the next feedback window.
712    #[must_use]
713    pub fn suggest_resize(&self, feedback: &TunerFeedback) -> Option<u32> {
714        let current = feedback.observed_workgroup_size_x.max(1);
715        if feedback.idle_us > self.idle_shrink_us {
716            let shrunk = current / 2;
717            if shrunk >= self.minimum_workgroup_size_x && shrunk != current {
718                return Some(shrunk);
719            }
720            return None;
721        }
722        if feedback.observed_throughput_per_us < self.saturation_threshold_per_us {
723            let grown = current.checked_mul(2)?;
724            if grown <= self.adapter_max_workgroup_size_x && grown != current {
725                return Some(grown);
726            }
727        }
728        None
729    }
730}
731
732fn tuner_cache_string_capacity(entries: usize) -> usize {
733    entries.saturating_mul(96)
734}
735
736fn dirs_cache_root() -> PathBuf {
737    if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME") {
738        PathBuf::from(xdg)
739    } else if let Some(home) = std::env::var_os("HOME") {
740        let mut path = PathBuf::from(home);
741        path.push(".cache");
742        path
743    } else {
744        PathBuf::from(".")
745    }
746}
747
748#[cfg(test)]
749mod tests {
750    use super::*;
751
752    fn measurements() -> Vec<TuningMeasurement> {
753        vec![
754            TuningMeasurement {
755                workgroup_size: [64, 1, 1],
756                elapsed_ns: 12_000,
757            },
758            TuningMeasurement {
759                workgroup_size: [128, 1, 1],
760                elapsed_ns: 8_000,
761            },
762            TuningMeasurement {
763                workgroup_size: [256, 1, 1],
764                elapsed_ns: 10_000,
765            },
766        ]
767    }
768
769    struct StaticTimer {
770        fail_on: Option<u32>,
771        measured: Vec<[u32; 3]>,
772    }
773
774    impl StaticTimer {
775        fn new() -> Self {
776            Self {
777                fail_on: None,
778                measured: Vec::new(),
779            }
780        }
781
782        fn failing(fail_on: u32) -> Self {
783            Self {
784                fail_on: Some(fail_on),
785                measured: Vec::new(),
786            }
787        }
788    }
789
790    impl BackendTimer for StaticTimer {
791        type Error = &'static str;
792
793        fn measure_candidate_ns(
794            &mut self,
795            _program: &Program,
796            workgroup_size: [u32; 3],
797        ) -> Result<u64, Self::Error> {
798            self.measured.push(workgroup_size);
799            if self.fail_on == Some(workgroup_size[0]) {
800                return Err("timer failed");
801            }
802            Ok(match workgroup_size[0] {
803                64 => 12_000,
804                128 => 8_000,
805                256 => 10_000,
806                _ => 50_000,
807            })
808        }
809    }
810
811    fn empty_program() -> Program {
812        Program::wrapped(Vec::new(), [64, 1, 1], Vec::new())
813    }
814
815    #[test]
816    fn unset_autotuner_mode_defaults_to_natural_gradient_release_path() {
817        assert_eq!(Mode::production_default(), Mode::NaturalGradient);
818        assert_eq!(Mode::from_env_value(None), Mode::NaturalGradient);
819    }
820
821    #[test]
822    fn explicit_env_modes_preserve_escape_hatches() {
823        assert_eq!(Mode::from_env_value(Some("natural")), Mode::NaturalGradient);
824        assert_eq!(Mode::from_env_value(Some("ng")), Mode::NaturalGradient);
825        assert_eq!(Mode::from_env_value(Some("on")), Mode::On);
826        assert_eq!(Mode::from_env_value(Some("off")), Mode::OffUseDefault);
827        assert_eq!(Mode::from_env_value(Some("default")), Mode::OffUseDefault);
828    }
829
830    #[test]
831    fn identity_fisher_preserves_fastest_candidate_policy_gradient() {
832        let policy = NaturalGradientPolicy {
833            temperature_ns: 4_000,
834        };
835        let samples = measurements();
836        let step = policy
837            .suggest(&samples, &identity_fisher_q16(samples.len()))
838            .expect("Fix: identity Fisher natural-gradient update should be valid");
839
840        assert_eq!(step.best_measured_workgroup_size, [128, 1, 1]);
841        assert_eq!(step.selected_workgroup_size, [128, 1, 1]);
842        assert_eq!(step.best_measured_elapsed_ns, 8_000);
843    }
844
845    #[test]
846    fn anisotropic_fisher_can_redirect_next_probe_without_changing_measurement_winner() {
847        let policy = NaturalGradientPolicy {
848            temperature_ns: 4_000,
849        };
850        let samples = measurements();
851        let mut fisher = identity_fisher_q16(samples.len());
852        fisher[0] = Q16_ONE * 8;
853
854        let step = policy
855            .suggest(&samples, &fisher)
856            .expect("Fix: diagonal Fisher natural-gradient update should be valid");
857
858        assert_eq!(step.best_measured_workgroup_size, [128, 1, 1]);
859        assert_eq!(
860            step.selected_workgroup_size,
861            [64, 1, 1],
862            "Fix: Fisher geometry must be able to steer exploration away from the raw fastest sample."
863        );
864        assert!(
865            step.natural_gradient_q16[0] > step.natural_gradient_q16[1],
866            "Fix: preconditioned gradient should reflect the anisotropic Fisher block."
867        );
868    }
869
870    #[test]
871    fn softmax_weights_conserve_q16_probability_mass_across_hostile_latencies() {
872        let policy = NaturalGradientPolicy { temperature_ns: 1 };
873        for base in [0_u64, 1, 10, 1_000, u64::MAX - 2] {
874            let samples = vec![
875                TuningMeasurement {
876                    workgroup_size: [32, 1, 1],
877                    elapsed_ns: base,
878                },
879                TuningMeasurement {
880                    workgroup_size: [64, 1, 1],
881                    elapsed_ns: base.saturating_add(1),
882                },
883                TuningMeasurement {
884                    workgroup_size: [128, 1, 1],
885                    elapsed_ns: base.saturating_add(2),
886                },
887            ];
888            let step = policy
889                .suggest(&samples, &identity_fisher_q16(samples.len()))
890                .expect("Fix: hostile latency range should still produce a normalized policy");
891            let total: u32 = step.policy_weights_q16.iter().sum();
892            assert_eq!(
893                total, Q16_ONE,
894                "Fix: fixed-point policy weights must conserve probability mass for base={base}."
895            );
896        }
897    }
898
899    #[test]
900    fn rejects_empty_measurements_zero_temperature_and_bad_fisher_shape() {
901        let policy = NaturalGradientPolicy::default();
902        assert_eq!(
903            policy.suggest(&[], &[]),
904            Err(NaturalGradientTuningError::EmptyMeasurements)
905        );
906
907        let samples = measurements();
908        let zero_temp = NaturalGradientPolicy { temperature_ns: 0 };
909        assert_eq!(
910            zero_temp.suggest(&samples, &identity_fisher_q16(samples.len())),
911            Err(NaturalGradientTuningError::ZeroTemperature)
912        );
913        assert_eq!(
914            policy.suggest(&samples, &[Q16_ONE]),
915            Err(NaturalGradientTuningError::FisherMatrixShape {
916                measurements: samples.len(),
917                cells: 1,
918            })
919        );
920    }
921
922    #[test]
923    fn tuner_exposes_natural_gradient_step_surface() {
924        let tuner = Tuner::new("natural-gradient-test-adapter", Mode::OffUseDefault);
925        let samples = measurements();
926        let step = tuner
927            .natural_gradient_step(
928                &samples,
929                &identity_fisher_q16(samples.len()),
930                NaturalGradientPolicy::default(),
931            )
932            .expect("Fix: tuner natural-gradient policy surface should accept identity Fisher");
933
934        assert_eq!(step.selected_workgroup_size, [128, 1, 1]);
935    }
936
937    #[test]
938    fn measured_natural_gradient_sweep_uses_backend_timer_and_fisher_policy() {
939        let tuner = Tuner::new(
940            "measured-natural-gradient-test-adapter",
941            Mode::NaturalGradient,
942        );
943        let mut timer = StaticTimer::new();
944        let mut fisher = identity_fisher_q16(3);
945        fisher[0] = Q16_ONE * 8;
946
947        let step = tuner
948            .best_of_natural_gradient(
949                &empty_program(),
950                [[64, 1, 1], [128, 1, 1], [256, 1, 1]],
951                &mut timer,
952                &fisher,
953                NaturalGradientPolicy {
954                    temperature_ns: 4_000,
955                },
956            )
957            .expect("Fix: backend timer should succeed")
958            .expect("Fix: natural-gradient policy should accept measured candidates");
959
960        assert_eq!(
961            timer.measured,
962            vec![[64, 1, 1], [128, 1, 1], [256, 1, 1]],
963            "Fix: natural-gradient sweep must measure every supplied candidate."
964        );
965        assert_eq!(step.best_measured_workgroup_size, [128, 1, 1]);
966        assert_eq!(
967            step.selected_workgroup_size,
968            [64, 1, 1],
969            "Fix: measured natural-gradient sweep must use Fisher policy, not raw fastest-only selection."
970        );
971    }
972
973    #[test]
974    fn measured_natural_gradient_sweep_propagates_timer_failures() {
975        let tuner = Tuner::new(
976            "measured-natural-gradient-error-test-adapter",
977            Mode::NaturalGradient,
978        );
979        let mut timer = StaticTimer::failing(128);
980        let err = tuner
981            .best_of_natural_gradient(
982                &empty_program(),
983                [[64, 1, 1], [128, 1, 1], [256, 1, 1]],
984                &mut timer,
985                &identity_fisher_q16(3),
986                NaturalGradientPolicy::default(),
987            )
988            .expect_err("Fix: backend timer failures must propagate before policy update");
989
990        assert_eq!(err, "timer failed");
991        assert_eq!(
992            timer.measured,
993            vec![[64, 1, 1], [128, 1, 1]],
994            "Fix: failed measurements must stop the sweep instead of producing a fake policy result."
995        );
996    }
997}