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>) {
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
501pub struct Tuner {
503 mode: Mode,
504 cache: TunerCache,
505 cache_path: PathBuf,
506}
507
508impl Tuner {
509 #[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 #[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 #[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 #[must_use]
547 pub const fn default_workgroup_size() -> [u32; 3] {
548 crate::pipeline::DEFAULT_1D_WORKGROUP_SIZE
549 }
550
551 #[must_use]
553 pub const fn mode(&self) -> Mode {
554 self.mode
555 }
556
557 #[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 #[must_use]
567 pub fn resolve_key(&self, key: &TunerProgramKey) -> [u32; 3] {
568 self.resolve(key.as_str())
569 }
570
571 pub fn record_decision(&mut self, program_fp: impl Into<String>, size: [u32; 3]) {
573 self.cache.set(program_fp, size);
574 }
575
576 pub fn record_key_decision(&mut self, key: TunerProgramKey, size: [u32; 3]) {
578 self.cache.set_key(key, size);
579 }
580
581 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 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 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 pub fn persist(&self) -> Result<(), String> {
667 self.cache.save(&self.cache_path)
668 }
669}
670
671#[derive(Debug, Clone)]
673pub struct TunerFeedback {
674 pub per_opcode_counts: Vec<(u32, u32)>,
676 pub wall_time_us: u64,
678 pub idle_us: u64,
680 pub observed_workgroup_size_x: u32,
682 pub observed_throughput_per_us: f64,
684}
685
686#[derive(Debug, Clone)]
688pub struct DefaultPolicy {
689 pub adapter_max_workgroup_size_x: u32,
691 pub minimum_workgroup_size_x: u32,
693 pub saturation_threshold_per_us: f64,
695 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 #[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}