1use std::collections::BTreeMap;
4use std::fmt::Write as _;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use vyre_foundation::ir::Program;
9
10pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18#[non_exhaustive]
19pub enum Mode {
20 On,
22 NaturalGradient,
24 OffUseDefault,
26}
27
28impl Mode {
29 #[must_use]
35 pub const fn production_default() -> Self {
36 Mode::NaturalGradient
37 }
38
39 #[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
59pub trait BackendTimer {
61 type Error;
63
64 fn measure_candidate_ns(
71 &mut self,
72 program: &Program,
73 workgroup_size: [u32; 3],
74 ) -> Result<u64, Self::Error>;
75}
76
77#[derive(Debug, Default, Clone, PartialEq, Eq)]
79pub struct TunerCache {
80 pub entries: BTreeMap<String, [u32; 3]>,
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub struct StaticProgramShape {
87 pub workgroup_size: [u32; 3],
89 pub workgroup_count: Option<[u32; 3]>,
91 pub output_bytes: u64,
93}
94
95impl StaticProgramShape {
96 #[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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
109pub struct TunerProgramKey(String);
110
111impl TunerProgramKey {
112 #[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 #[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 #[must_use]
159 pub fn get(&self, program_fp: &str) -> Option<[u32; 3]> {
160 self.entries.get(program_fp).copied()
161 }
162
163 #[must_use]
165 pub fn get_key(&self, key: &TunerProgramKey) -> Option<[u32; 3]> {
166 self.get(key.as_str())
167 }
168
169 pub fn set(&mut self, program_fp: impl Into<String>, size: [u32; 3]) {
171 self.entries.insert(program_fp.into(), size);
172 }
173
174 pub fn set_key(&mut self, key: TunerProgramKey, size: [u32; 3]) {
179 self.entries.insert(key.0, size);
180 }
181
182 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272pub struct TuningMeasurement {
273 pub workgroup_size: [u32; 3],
275 pub elapsed_ns: u64,
277}
278
279pub const Q16_ONE: u32 = 1 << 16;
281
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292pub struct NaturalGradientPolicy {
293 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#[derive(Debug, Clone, PartialEq, Eq)]
307pub struct NaturalGradientTuningStep {
308 pub selected_workgroup_size: [u32; 3],
310 pub best_measured_workgroup_size: [u32; 3],
312 pub best_measured_elapsed_ns: u64,
314 pub policy_weights_q16: Vec<u32>,
316 pub natural_gradient_q16: Vec<u32>,
318}
319
320#[derive(Debug, Clone, PartialEq, Eq)]
322#[non_exhaustive]
323pub enum NaturalGradientTuningError {
324 EmptyMeasurements,
326 FisherMatrixShape {
328 measurements: usize,
330 cells: usize,
332 },
333 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 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#[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
442pub 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
500pub struct Tuner {
502 mode: Mode,
503 cache: TunerCache,
504 cache_path: PathBuf,
505}
506
507impl Tuner {
508 #[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 #[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 #[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 #[must_use]
546 pub const fn default_workgroup_size() -> [u32; 3] {
547 crate::pipeline::DEFAULT_1D_WORKGROUP_SIZE
548 }
549
550 #[must_use]
552 pub const fn mode(&self) -> Mode {
553 self.mode
554 }
555
556 #[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 #[must_use]
566 pub fn resolve_key(&self, key: &TunerProgramKey) -> [u32; 3] {
567 self.resolve(key.as_str())
568 }
569
570 pub fn record_decision(&mut self, program_fp: impl Into<String>, size: [u32; 3]) {
572 self.cache.set(program_fp, size);
573 }
574
575 pub fn record_key_decision(&mut self, key: TunerProgramKey, size: [u32; 3]) {
577 self.cache.set_key(key, size);
578 }
579
580 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 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 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 pub fn persist(&self) -> Result<(), String> {
666 self.cache.save(&self.cache_path)
667 }
668}
669
670#[derive(Debug, Clone)]
672pub struct TunerFeedback {
673 pub per_opcode_counts: Vec<(u32, u32)>,
675 pub wall_time_us: u64,
677 pub idle_us: u64,
679 pub observed_workgroup_size_x: u32,
681 pub observed_throughput_per_us: f64,
683}
684
685#[derive(Debug, Clone)]
687pub struct DefaultPolicy {
688 pub adapter_max_workgroup_size_x: u32,
690 pub minimum_workgroup_size_x: u32,
692 pub saturation_threshold_per_us: f64,
694 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 #[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}