1use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5use std::sync::{Mutex, OnceLock};
6
7use vyre_foundation::ir::{MemoryKind, Node, Program};
8
9use crate::binding::Binding;
10use crate::program_walks::{
11 dispatch_element_count_for_program, infer_dispatch_grid_for_count,
12 program_uses_launch_geometry_ids, try_dispatch_param_words_into,
13};
14use crate::tuner::{
15 identity_fisher_q16, Mode, NaturalGradientPolicy, Tuner, TunerCache, TuningMeasurement,
16 WORKGROUP_CANDIDATES,
17};
18use crate::validation::{validate_launch_geometry, LaunchGeometryLimits};
19use crate::{BackendError, DispatchConfig};
20
21const COLD_START_GRID_STEP_NS: u64 = 1_024;
22const COLD_START_IDLE_LANE_NS: u64 = 8;
23const COLD_START_TEMPERATURE_NS: u64 = 4_096;
24const MAX_NATURAL_LAUNCH_CACHE_ENTRIES: usize = 4_096;
25
26static NATURAL_LAUNCH_CACHE: OnceLock<Mutex<BTreeMap<NaturalLaunchCacheKey, NaturalLaunchEntry>>> =
27 OnceLock::new();
28
29#[derive(Clone, Debug, Eq, PartialEq)]
31pub struct LaunchPlan {
32 pub element_count: u32,
34 pub workgroup: [u32; 3],
36 pub grid: [u32; 3],
38 pub param_words: Vec<u32>,
40 pub max_binding_alignment: usize,
45}
46
47impl LaunchPlan {
48 #[must_use]
50 pub fn new() -> Self {
51 Self {
52 element_count: 1,
53 workgroup: [1, 1, 1],
54 grid: [1, 1, 1],
55 param_words: Vec::new(),
56 max_binding_alignment: 1,
57 }
58 }
59
60 pub fn from_bindings(
67 program: &Program,
68 bindings: &[Binding],
69 config: &DispatchConfig,
70 limits: LaunchGeometryLimits,
71 ) -> Result<Self, BackendError> {
72 let mut plan = Self::new();
73 plan.prepare_into(program, bindings, config, limits)?;
74 Ok(plan)
75 }
76
77 pub fn prepare_into(
84 &mut self,
85 program: &Program,
86 bindings: &[Binding],
87 config: &DispatchConfig,
88 limits: LaunchGeometryLimits,
89 ) -> Result<(), BackendError> {
90 self.prepare_into_for_mode(program, bindings, config, limits, Mode::from_env())
91 }
92
93 fn prepare_into_for_mode(
94 &mut self,
95 program: &Program,
96 bindings: &[Binding],
97 config: &DispatchConfig,
98 limits: LaunchGeometryLimits,
99 mode: Mode,
100 ) -> Result<(), BackendError> {
101 let workgroup =
102 effective_launch_workgroup_for_mode(program, bindings, config, limits, mode);
103 validate_launch_geometry(workgroup, [1, 1, 1], limits)?;
104 let element_count = launch_element_count(program, bindings, workgroup, config, limits)?;
105 let grid = match config.grid_override {
106 Some(grid) => grid,
107 None => {
108 if workgroup[1] != 1 || workgroup[2] != 1 {
114 return Err(BackendError::InvalidProgram {
115 fix: format!(
116 "Fix: backend `{}` requires DispatchConfig::grid_override for non-1D workgroups. \
117 workgroup={:?} has no unambiguous default grid; set grid_override to the logical [x, y, z] you want.",
118 limits.backend, workgroup,
119 ),
120 });
121 }
122 infer_dispatch_grid_for_count(element_count, workgroup)?
123 }
124 };
125 validate_launch_geometry(workgroup, grid, limits)?;
126 self.element_count = element_count;
127 self.workgroup = workgroup;
128 self.grid = grid;
129 self.max_binding_alignment = bindings
130 .iter()
131 .map(|binding| binding.preferred_alignment)
132 .max()
133 .unwrap_or(1);
134 try_dispatch_param_words_into(bindings, element_count, &mut self.param_words).map_err(
135 |error| BackendError::InvalidProgram {
136 fix: format!(
137 "Fix: {}: dispatch ABI parameter staging failed: {error}",
138 limits.backend
139 ),
140 },
141 )?;
142 Ok(())
143 }
144}
145
146impl Default for LaunchPlan {
147 fn default() -> Self {
148 Self::new()
149 }
150}
151
152fn launch_element_count(
153 program: &Program,
154 bindings: &[Binding],
155 workgroup: [u32; 3],
156 config: &DispatchConfig,
157 limits: LaunchGeometryLimits,
158) -> Result<u32, BackendError> {
159 let inferred = dispatch_element_count_for_program(program, bindings);
160 let Some(grid) = config.grid_override else {
161 return Ok(inferred);
162 };
163 if workgroup.contains(&0) || grid.contains(&0) {
164 return Err(BackendError::InvalidProgram {
165 fix: format!(
166 "Fix: {} grid_override and workgroup dimensions must all be non-zero.",
167 limits.backend
168 ),
169 });
170 }
171 grid[0]
172 .checked_mul(workgroup[0])
173 .filter(|count| *count != 0)
174 .ok_or_else(|| BackendError::InvalidProgram {
175 fix: format!(
176 "Fix: {} grid_override.x * workgroup_size.x must fit in u32.",
177 limits.backend
178 ),
179 })
180}
181
182fn effective_launch_workgroup_for_mode(
183 program: &Program,
184 bindings: &[Binding],
185 config: &DispatchConfig,
186 limits: LaunchGeometryLimits,
187 mode: Mode,
188) -> [u32; 3] {
189 let element_count = dispatch_element_count_for_program(program, bindings);
190 resolve_launch_workgroup_for_mode(program, config, limits, element_count, mode)
191}
192
193#[must_use]
200pub fn resolve_launch_workgroup(
201 program: &Program,
202 config: &DispatchConfig,
203 limits: LaunchGeometryLimits,
204 element_count: u32,
205) -> [u32; 3] {
206 resolve_launch_workgroup_for_mode(program, config, limits, element_count, Mode::from_env())
207}
208
209#[must_use]
214pub fn resolve_launch_workgroup_for_mode(
215 program: &Program,
216 config: &DispatchConfig,
217 limits: LaunchGeometryLimits,
218 element_count: u32,
219 mode: Mode,
220) -> [u32; 3] {
221 if let Some(workgroup) = config.workgroup_override {
222 return workgroup;
223 }
224 let declared = program.workgroup_size();
225 if mode != Mode::NaturalGradient || config.grid_override.is_some() {
226 return declared;
227 }
228 natural_gradient_cold_start_workgroup(program, declared, element_count, limits)
229 .unwrap_or(declared)
230}
231
232#[must_use]
241pub fn record_launch_measurement(
242 program: &Program,
243 config: &DispatchConfig,
244 limits: LaunchGeometryLimits,
245 element_count: u32,
246 observed_workgroup: [u32; 3],
247 elapsed_ns: u64,
248) -> bool {
249 record_launch_measurement_for_mode(
250 program,
251 config,
252 limits,
253 element_count,
254 observed_workgroup,
255 elapsed_ns,
256 Mode::from_env(),
257 )
258}
259
260fn record_launch_measurement_for_mode(
261 program: &Program,
262 config: &DispatchConfig,
263 limits: LaunchGeometryLimits,
264 element_count: u32,
265 observed_workgroup: [u32; 3],
266 elapsed_ns: u64,
267 mode: Mode,
268) -> bool {
269 record_launch_measurement_for_mode_with_store(
270 program,
271 config,
272 limits,
273 element_count,
274 observed_workgroup,
275 elapsed_ns,
276 mode,
277 None,
278 )
279}
280
281fn record_launch_measurement_for_mode_with_store(
282 program: &Program,
283 config: &DispatchConfig,
284 limits: LaunchGeometryLimits,
285 element_count: u32,
286 observed_workgroup: [u32; 3],
287 elapsed_ns: u64,
288 mode: Mode,
289 persistent_path: Option<&Path>,
290) -> bool {
291 if mode != Mode::NaturalGradient
292 || elapsed_ns == 0
293 || config.workgroup_override.is_some()
294 || config.grid_override.is_some()
295 || observed_workgroup[1] != 1
296 || observed_workgroup[2] != 1
297 || !candidate_x_fits_limits(observed_workgroup[0], limits)
298 {
299 return false;
300 }
301 let declared = program.workgroup_size();
302 if !is_natural_gradient_launch_tunable(program, declared, element_count) {
303 return false;
304 }
305 let cache_key = NaturalLaunchCacheKey::new(program, declared, element_count, limits);
306 let mut measurements = natural_launch_cache_measurements(cache_key).unwrap_or_default();
307 measurements
308 .entry(observed_workgroup)
309 .and_modify(|best_ns| *best_ns = (*best_ns).min(elapsed_ns))
310 .or_insert(elapsed_ns);
311 let Some(selected) =
312 select_natural_launch_workgroup(declared, element_count, limits, Some(&measurements))
313 else {
314 return false;
315 };
316 natural_launch_cache_set(
317 cache_key,
318 NaturalLaunchEntry {
319 selected,
320 measurements,
321 },
322 );
323 if let Err(error) =
324 persist_natural_launch_selection(cache_key, limits, selected, persistent_path)
325 {
326 tracing::debug!(
327 error,
328 "natural-gradient launch feedback accepted in memory but could not persist"
329 );
330 }
331 true
332}
333
334fn natural_gradient_cold_start_workgroup(
335 program: &Program,
336 declared: [u32; 3],
337 element_count: u32,
338 limits: LaunchGeometryLimits,
339) -> Option<[u32; 3]> {
340 natural_gradient_cold_start_workgroup_with_store(program, declared, element_count, limits, None)
341}
342
343fn natural_gradient_cold_start_workgroup_with_store(
344 program: &Program,
345 declared: [u32; 3],
346 element_count: u32,
347 limits: LaunchGeometryLimits,
348 persistent_path: Option<&Path>,
349) -> Option<[u32; 3]> {
350 if !is_natural_gradient_launch_tunable(program, declared, element_count) {
351 return None;
352 }
353 let cache_key = NaturalLaunchCacheKey::new(program, declared, element_count, limits);
354 if let Some(cached) = natural_launch_cache_get(cache_key) {
355 return Some(cached);
356 }
357 if let Some(persisted) = natural_launch_cache_get_persisted(cache_key, limits, persistent_path)
358 {
359 natural_launch_cache_set(
360 cache_key,
361 NaturalLaunchEntry {
362 selected: persisted,
363 measurements: BTreeMap::new(),
364 },
365 );
366 return Some(persisted);
367 }
368
369 let selected = select_natural_launch_workgroup(declared, element_count, limits, None)?;
370 natural_launch_cache_set(
371 cache_key,
372 NaturalLaunchEntry {
373 selected,
374 measurements: BTreeMap::new(),
375 },
376 );
377 Some(selected)
378}
379
380fn select_natural_launch_workgroup(
381 declared: [u32; 3],
382 element_count: u32,
383 limits: LaunchGeometryLimits,
384 measurements: Option<&BTreeMap<[u32; 3], u64>>,
385) -> Option<[u32; 3]> {
386 let mut samples = Vec::with_capacity(WORKGROUP_CANDIDATES.len() + 1);
387 for candidate_x in WORKGROUP_CANDIDATES
388 .iter()
389 .copied()
390 .chain(std::iter::once(declared[0]))
391 {
392 if !candidate_x_fits_limits(candidate_x, limits)
393 || samples
394 .iter()
395 .any(|sample: &TuningMeasurement| sample.workgroup_size[0] == candidate_x)
396 {
397 continue;
398 }
399 let workgroup_size = [candidate_x, 1, 1];
400 let elapsed_ns = measurements
401 .and_then(|measured| measured.get(&workgroup_size).copied())
402 .unwrap_or_else(|| estimate_cold_start_latency_ns(element_count, candidate_x));
403 samples.push(TuningMeasurement {
404 workgroup_size,
405 elapsed_ns,
406 });
407 }
408 if let Some(measured) = measurements {
409 for (&workgroup_size, &elapsed_ns) in measured {
410 if workgroup_size[1] != 1
411 || workgroup_size[2] != 1
412 || elapsed_ns == 0
413 || !candidate_x_fits_limits(workgroup_size[0], limits)
414 || samples
415 .iter()
416 .any(|sample| sample.workgroup_size == workgroup_size)
417 {
418 continue;
419 }
420 samples.push(TuningMeasurement {
421 workgroup_size,
422 elapsed_ns,
423 });
424 }
425 }
426
427 if samples.len() < 2 {
428 return None;
429 }
430 NaturalGradientPolicy {
431 temperature_ns: COLD_START_TEMPERATURE_NS,
432 }
433 .suggest(&samples, &identity_fisher_q16(samples.len()))
434 .ok()
435 .map(|step| step.selected_workgroup_size)
436}
437
438#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
439struct NaturalLaunchCacheKey {
440 fingerprint: [u8; 32],
441 declared: [u32; 3],
442 element_count: u32,
443 max_threads_per_block: u32,
444 max_block_dim: [u32; 3],
445 max_grid_dim: [u32; 3],
446}
447
448impl NaturalLaunchCacheKey {
449 fn new(
450 program: &Program,
451 declared: [u32; 3],
452 element_count: u32,
453 limits: LaunchGeometryLimits,
454 ) -> Self {
455 Self {
456 fingerprint: program.fingerprint(),
457 declared,
458 element_count,
459 max_threads_per_block: limits.max_threads_per_block,
460 max_block_dim: limits.max_block_dim,
461 max_grid_dim: limits.max_grid_dim,
462 }
463 }
464
465 fn persistent_key(self) -> String {
466 let mut hasher = blake3::Hasher::new();
467 hasher.update(b"vyre-natural-launch-feedback-v1\0");
468 hasher.update(&self.fingerprint);
469 for axis in self.declared {
470 hasher.update(&axis.to_le_bytes());
471 }
472 hasher.update(&self.element_count.to_le_bytes());
473 hasher.update(&self.max_threads_per_block.to_le_bytes());
474 for axis in self.max_block_dim {
475 hasher.update(&axis.to_le_bytes());
476 }
477 for axis in self.max_grid_dim {
478 hasher.update(&axis.to_le_bytes());
479 }
480 let digest = hasher.finalize();
481 let mut key = String::with_capacity(74);
482 key.push_str("launch-v1-");
483 push_hex(digest.as_bytes(), &mut key);
484 key
485 }
486}
487
488#[derive(Clone, Debug, Eq, PartialEq)]
489
490struct NaturalLaunchEntry {
491 selected: [u32; 3],
492 measurements: BTreeMap<[u32; 3], u64>,
493}
494
495fn natural_launch_cache_get(key: NaturalLaunchCacheKey) -> Option<[u32; 3]> {
496 let cache = NATURAL_LAUNCH_CACHE.get_or_init(|| Mutex::new(BTreeMap::new()));
497 let guard = cache
498 .lock()
499 .unwrap_or_else(|poison| poison.into_inner());
500 guard.get(&key).map(|entry| entry.selected)
501}
502
503fn natural_launch_cache_measurements(
504 key: NaturalLaunchCacheKey,
505) -> Option<BTreeMap<[u32; 3], u64>> {
506 let cache = NATURAL_LAUNCH_CACHE.get_or_init(|| Mutex::new(BTreeMap::new()));
507 let guard = cache
508 .lock()
509 .unwrap_or_else(|poison| poison.into_inner());
510 guard.get(&key).map(|entry| entry.measurements.clone())
511}
512
513fn natural_launch_cache_set(key: NaturalLaunchCacheKey, value: NaturalLaunchEntry) {
514 let cache = NATURAL_LAUNCH_CACHE.get_or_init(|| Mutex::new(BTreeMap::new()));
515 let mut guard = cache
516 .lock()
517 .unwrap_or_else(|poison| poison.into_inner());
518 if guard.len() >= MAX_NATURAL_LAUNCH_CACHE_ENTRIES && !guard.contains_key(&key) {
519 if let Some(oldest) = guard.keys().next().copied() {
520 guard.remove(&oldest);
521 }
522 }
523 guard.insert(key, value);
524}
525
526#[cfg(test)]
527fn natural_launch_cache_remove(key: NaturalLaunchCacheKey) {
528 if let Some(cache) = NATURAL_LAUNCH_CACHE.get() {
529 if let Ok(mut guard) = cache.lock() {
530 guard.remove(&key);
531 }
532 }
533}
534
535fn natural_launch_cache_get_persisted(
536 key: NaturalLaunchCacheKey,
537 limits: LaunchGeometryLimits,
538 persistent_path: Option<&Path>,
539) -> Option<[u32; 3]> {
540 let path = persistent_path
541 .map(Path::to_path_buf)
542 .unwrap_or_else(|| natural_launch_persistent_cache_path(limits));
543 let selected = TunerCache::load(&path).ok()?.get(&key.persistent_key())?;
544 valid_persisted_launch_selection(selected, limits).then_some(selected)
545}
546
547fn persist_natural_launch_selection(
548 key: NaturalLaunchCacheKey,
549 limits: LaunchGeometryLimits,
550 selected: [u32; 3],
551 persistent_path: Option<&Path>,
552) -> Result<(), String> {
553 let path = persistent_path
554 .map(Path::to_path_buf)
555 .unwrap_or_else(|| natural_launch_persistent_cache_path(limits));
556 persist_natural_launch_selection_to_path(key, selected, &path)
557}
558
559fn persist_natural_launch_selection_to_path(
560 key: NaturalLaunchCacheKey,
561 selected: [u32; 3],
562 path: &Path,
563) -> Result<(), String> {
564 let mut cache = TunerCache::load(path)?;
565 while cache.entries.len() >= MAX_NATURAL_LAUNCH_CACHE_ENTRIES {
566 let Some(oldest) = cache.entries.keys().next().cloned() else {
567 break;
568 };
569 cache.entries.remove(&oldest);
570 }
571 cache.set(key.persistent_key(), selected);
572 cache.save(path)
573}
574
575fn natural_launch_persistent_cache_path(limits: LaunchGeometryLimits) -> PathBuf {
576 Tuner::cache_path_for_adapter(&natural_launch_persistent_adapter_key(limits))
577}
578
579fn natural_launch_persistent_adapter_key(limits: LaunchGeometryLimits) -> String {
580 let mut hasher = blake3::Hasher::new();
581 hasher.update(b"vyre-natural-launch-adapter-v1\0");
582 hasher.update(limits.backend.as_bytes());
583 hasher.update(&limits.max_threads_per_block.to_le_bytes());
584 for axis in limits.max_block_dim {
585 hasher.update(&axis.to_le_bytes());
586 }
587 for axis in limits.max_grid_dim {
588 hasher.update(&axis.to_le_bytes());
589 }
590 let digest = hasher.finalize();
591 let mut key = String::with_capacity(92);
592 key.push_str("natural-launch-feedback-v1-");
593 push_hex(digest.as_bytes(), &mut key);
594 key
595}
596
597fn valid_persisted_launch_selection(selected: [u32; 3], limits: LaunchGeometryLimits) -> bool {
598 selected[1] == 1 && selected[2] == 1 && candidate_x_fits_limits(selected[0], limits)
599}
600
601fn push_hex(bytes: &[u8], out: &mut String) {
602 const HEX: &[u8; 16] = b"0123456789abcdef";
603 for &byte in bytes {
604 out.push(HEX[(byte >> 4) as usize] as char);
605 out.push(HEX[(byte & 0x0f) as usize] as char);
606 }
607}
608
609fn is_natural_gradient_launch_tunable(
610 program: &Program,
611 declared: [u32; 3],
612 element_count: u32,
613) -> bool {
614 declared[0] != 0
615 && declared[1] == 1
616 && declared[2] == 1
617 && element_count != 0
618 && program
619 .entry
620 .iter()
621 .any(|node| !matches!(node, Node::Return))
622 && !program.non_composable_with_self
623 && !program_uses_launch_geometry_ids(program)
624 && program
625 .buffers
626 .iter()
627 .all(|buffer| buffer.kind() != MemoryKind::Shared)
628}
629
630fn candidate_x_fits_limits(candidate_x: u32, limits: LaunchGeometryLimits) -> bool {
631 candidate_x != 0
632 && candidate_x <= limits.max_threads_per_block
633 && candidate_x <= limits.max_block_dim[0]
634}
635
636fn estimate_cold_start_latency_ns(element_count: u32, candidate_x: u32) -> u64 {
637 let groups = u64::from(element_count.div_ceil(candidate_x));
638 let scheduled_lanes = groups.saturating_mul(u64::from(candidate_x));
639 let idle_lanes = scheduled_lanes.saturating_sub(u64::from(element_count));
640 groups
641 .saturating_mul(COLD_START_GRID_STEP_NS)
642 .saturating_add(idle_lanes.saturating_mul(COLD_START_IDLE_LANE_NS))
643}
644
645#[must_use]
647pub fn program_vsa_fingerprint(program: &Program) -> Vec<u32> {
648 program_vsa_fingerprint_words(program).to_vec()
649}
650
651#[must_use]
653pub fn program_vsa_fingerprint_words(program: &Program) -> [u32; 8] {
654 let fingerprint = program.fingerprint();
655 let mut words = [0u32; 8];
656 for (word, chunk) in words.iter_mut().zip(fingerprint.chunks_exact(4)) {
657 *word = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
658 }
659 words
660}
661
662#[cfg(test)]
663mod tests {
664 use super::*;
665 use crate::binding::BindingRole;
666 use vyre_foundation::ir::{BufferDecl, DataType, Expr, Node, Program};
667
668 #[test]
669 fn program_vsa_fingerprint_words_match_wire_decoder() {
670 let program = Program::wrapped(vec![], [64, 1, 1], vec![]);
671 let words = program_vsa_fingerprint_words(&program);
672 let fingerprint = program.fingerprint();
673
674 for (index, chunk) in fingerprint.chunks_exact(4).enumerate() {
675 assert_eq!(
676 words[index],
677 u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])
678 );
679 }
680 assert_eq!(program_vsa_fingerprint(&program), words.to_vec());
681 }
682
683 #[test]
684 fn launch_plan_prepare_into_reuses_param_words() {
685 let program = Program::wrapped(vec![], [64, 1, 1], vec![]);
686 let bindings = vec![Binding {
687 name: std::sync::Arc::from("input"),
688 binding: 0,
689 buffer_index: 0,
690 role: BindingRole::Input,
691 element_size: 4,
692 preferred_alignment: 64,
693 element_count: 7,
694 static_byte_len: Some(28),
695 input_index: Some(0),
696 output_index: None,
697 }];
698 let limits = LaunchGeometryLimits {
699 backend: "test",
700 max_threads_per_block: 1024,
701 max_block_dim: [1024, 1024, 64],
702 max_grid_dim: [u32::MAX, u32::MAX, u32::MAX],
703 };
704 let mut plan = LaunchPlan {
705 param_words: Vec::with_capacity(8),
706 ..LaunchPlan::new()
707 };
708 let ptr = plan.param_words.as_ptr();
709 plan.prepare_into(&program, &bindings, &DispatchConfig::default(), limits)
710 .unwrap();
711 assert_eq!(plan.element_count, 7);
712 assert_eq!(plan.grid, [1, 1, 1]);
713 assert_eq!(plan.param_words, vec![7, 7]);
714 assert_eq!(plan.max_binding_alignment, 64);
715 assert_eq!(plan.param_words.as_ptr(), ptr);
716 }
717
718 #[test]
719 fn natural_gradient_launch_tunes_safe_1d_storage_program() {
720 let program = Program::wrapped(
721 vec![BufferDecl::output("out", 0, DataType::U32).with_count(4096)],
722 [32, 1, 1],
723 vec![],
724 );
725 let bindings = vec![Binding {
726 name: std::sync::Arc::from("out"),
727 binding: 0,
728 buffer_index: 0,
729 role: BindingRole::Output,
730 element_size: 4,
731 preferred_alignment: 128,
732 element_count: 4096,
733 static_byte_len: Some(16_384),
734 input_index: None,
735 output_index: Some(0),
736 }];
737 let limits = LaunchGeometryLimits {
738 backend: "test",
739 max_threads_per_block: 1024,
740 max_block_dim: [1024, 1024, 64],
741 max_grid_dim: [u32::MAX, u32::MAX, u32::MAX],
742 };
743 let mut plan = LaunchPlan::new();
744
745 plan.prepare_into_for_mode(
746 &program,
747 &bindings,
748 &DispatchConfig::default(),
749 limits,
750 Mode::NaturalGradient,
751 )
752 .expect("Fix: safe 1D storage launch should accept natural-gradient cold start");
753
754 assert_eq!(plan.workgroup, [1024, 1, 1]);
755 assert_eq!(plan.grid, [4, 1, 1]);
756 assert_eq!(plan.element_count, 4096);
757 }
758
759 #[test]
760 fn natural_gradient_launch_preserves_declared_shape_for_local_workgroup_ids() {
761 let program = Program::wrapped(
762 vec![BufferDecl::output("out_local_ids", 0, DataType::U32).with_count(4096)],
763 [1024, 1, 1],
764 vec![
765 Node::let_bind("lane", Expr::LocalId { axis: 0 }),
766 Node::let_bind("block", Expr::WorkgroupId { axis: 0 }),
767 Node::let_bind(
768 "global",
769 Expr::add(
770 Expr::mul(Expr::var("block"), Expr::u32(1024)),
771 Expr::var("lane"),
772 ),
773 ),
774 Node::store("out_local_ids", Expr::var("global"), Expr::var("lane")),
775 ],
776 );
777 let bindings = vec![Binding {
778 name: std::sync::Arc::from("out_local_ids"),
779 binding: 0,
780 buffer_index: 0,
781 role: BindingRole::Output,
782 element_size: 4,
783 preferred_alignment: 128,
784 element_count: 4096,
785 static_byte_len: Some(16_384),
786 input_index: None,
787 output_index: Some(0),
788 }];
789 let limits = LaunchGeometryLimits {
790 backend: "test",
791 max_threads_per_block: 1024,
792 max_block_dim: [1024, 1024, 64],
793 max_grid_dim: [u32::MAX, u32::MAX, u32::MAX],
794 };
795
796 assert_eq!(
797 effective_launch_workgroup_for_mode(
798 &program,
799 &bindings,
800 &DispatchConfig::default(),
801 limits,
802 Mode::NaturalGradient,
803 ),
804 [1024, 1, 1],
805 "Fix: automatic launch tuning must not change kernels whose LocalId/WorkgroupId arithmetic makes workgroup shape semantic."
806 );
807 }
808
809 #[test]
810 fn measured_launch_feedback_overrides_heuristic_cold_start() {
811 let dir = tempfile::tempdir()
812 .expect("Fix: measured launch feedback test needs an isolated tuner cache");
813 let path = dir.path().join("launch-feedback.toml");
814 let program = Program::wrapped(
815 vec![BufferDecl::output("out_feedback_isolated", 0, DataType::U32).with_count(8192)],
816 [32, 1, 1],
817 vec![],
818 );
819 let config = DispatchConfig::default();
820 let limits = LaunchGeometryLimits {
821 backend: "test",
822 max_threads_per_block: 1024,
823 max_block_dim: [1024, 1024, 64],
824 max_grid_dim: [u32::MAX, u32::MAX, u32::MAX],
825 };
826 let key = NaturalLaunchCacheKey::new(&program, [32, 1, 1], 8192, limits);
827 natural_launch_cache_remove(key);
828
829 assert_eq!(
830 natural_gradient_cold_start_workgroup_with_store(
831 &program,
832 [32, 1, 1],
833 8192,
834 limits,
835 Some(&path),
836 ),
837 Some([1024, 1, 1]),
838 "Fix: baseline heuristic should pick the occupancy-efficient cold-start shape."
839 );
840 assert!(
841 record_launch_measurement_for_mode_with_store(
842 &program,
843 &config,
844 limits,
845 8192,
846 [64, 1, 1],
847 1,
848 Mode::NaturalGradient,
849 Some(&path),
850 ),
851 "Fix: natural-gradient resolver must accept measured backend timing for safe 1D launches."
852 );
853 assert_eq!(
854 natural_gradient_cold_start_workgroup_with_store(
855 &program,
856 [32, 1, 1],
857 8192,
858 limits,
859 Some(&path),
860 ),
861 Some([64, 1, 1]),
862 "Fix: measured launch feedback must steer future automatic launch choices."
863 );
864 }
865
866 #[test]
867 fn persisted_launch_feedback_rehydrates_measured_selection() {
868 let dir = tempfile::tempdir()
869 .expect("Fix: launch feedback persistence test needs a temporary cache directory");
870 let path = dir.path().join("launch-feedback.toml");
871 let program = Program::wrapped(
872 vec![BufferDecl::output("out_persisted", 0, DataType::U32).with_count(16_384)],
873 [32, 1, 1],
874 vec![],
875 );
876 let limits = LaunchGeometryLimits {
877 backend: "test",
878 max_threads_per_block: 1024,
879 max_block_dim: [1024, 1024, 64],
880 max_grid_dim: [u32::MAX, u32::MAX, u32::MAX],
881 };
882 let key = NaturalLaunchCacheKey::new(&program, [32, 1, 1], 16_384, limits);
883 natural_launch_cache_remove(key);
884
885 persist_natural_launch_selection_to_path(key, [64, 1, 1], &path)
886 .expect("Fix: measured launch feedback should persist through the tuner cache format");
887
888 assert_eq!(
889 natural_gradient_cold_start_workgroup_with_store(
890 &program,
891 [32, 1, 1],
892 16_384,
893 limits,
894 Some(&path),
895 ),
896 Some([64, 1, 1]),
897 "Fix: automatic launch resolution must rehydrate measured feedback from the bounded tuner cache before falling back to heuristics."
898 );
899 }
900
901 #[test]
902 fn natural_gradient_launch_preserves_explicit_and_shared_memory_shapes() {
903 let program = Program::wrapped(
904 vec![
905 BufferDecl::output("out", 0, DataType::U32).with_count(4096),
906 BufferDecl::workgroup("scratch", 64, DataType::U32),
907 ],
908 [64, 1, 1],
909 vec![],
910 );
911 let bindings = vec![Binding {
912 name: std::sync::Arc::from("out"),
913 binding: 0,
914 buffer_index: 0,
915 role: BindingRole::Output,
916 element_size: 4,
917 preferred_alignment: 128,
918 element_count: 4096,
919 static_byte_len: Some(16_384),
920 input_index: None,
921 output_index: Some(0),
922 }];
923 let limits = LaunchGeometryLimits {
924 backend: "test",
925 max_threads_per_block: 1024,
926 max_block_dim: [1024, 1024, 64],
927 max_grid_dim: [u32::MAX, u32::MAX, u32::MAX],
928 };
929 let mut config = DispatchConfig::default();
930 config.workgroup_override = Some([256, 1, 1]);
931
932 assert_eq!(
933 effective_launch_workgroup_for_mode(
934 &program,
935 &bindings,
936 &config,
937 limits,
938 Mode::NaturalGradient,
939 ),
940 [256, 1, 1],
941 "Fix: explicit dispatch workgroup overrides must remain authoritative."
942 );
943
944 let default_config = DispatchConfig::default();
945 assert_eq!(
946 effective_launch_workgroup_for_mode(
947 &program,
948 &bindings,
949 &default_config,
950 limits,
951 Mode::NaturalGradient,
952 ),
953 [64, 1, 1],
954 "Fix: workgroup-local scratch kernels must keep their declared shape."
955 );
956 }
957
958 #[test]
964 fn natural_launch_cache_recovers_from_poisoned_mutex_without_silent_fallback() {
965 let program = Program::wrapped(
966 vec![BufferDecl::output("out_poison_test", 0, DataType::U32).with_count(2048)],
967 [32, 1, 1],
968 vec![],
969 );
970 let limits = LaunchGeometryLimits {
971 backend: "test-poison",
972 max_threads_per_block: 1024,
973 max_block_dim: [1024, 1024, 64],
974 max_grid_dim: [u32::MAX, u32::MAX, u32::MAX],
975 };
976 let key = NaturalLaunchCacheKey::new(&program, [32, 1, 1], 2048, limits);
977 natural_launch_cache_remove(key);
978
979 natural_launch_cache_set(
981 key,
982 NaturalLaunchEntry {
983 selected: [128, 1, 1],
984 measurements: BTreeMap::new(),
985 },
986 );
987
988 let result = natural_launch_cache_get(key);
999 assert_eq!(
1000 result,
1001 Some([128, 1, 1]),
1002 "Fix: natural_launch_cache_get must return the stored selection [128, 1, 1], not None"
1003 );
1004
1005 let source = include_str!("launch.rs");
1007 let production = source
1008 .split("#[cfg(test)]")
1009 .next()
1010 .expect("Fix: production section must precede test section");
1011 assert!(
1012 !production.contains(".lock()\n .ok()") && !production.contains(".lock().ok()"),
1013 "Fix: natural_launch_cache functions must not use .lock().ok() — that silently swallows mutex poison"
1014 );
1015 }
1016
1017 #[test]
1025 fn record_launch_measurement_starts_fresh_only_when_no_prior_history_exists() {
1026 let dir = tempfile::tempdir()
1027 .expect("Fix: measurement history test needs a temporary cache directory");
1028 let path = dir.path().join("measurements-test.toml");
1029 let program = Program::wrapped(
1030 vec![BufferDecl::output("out_meas_history", 0, DataType::U32).with_count(4096)],
1031 [32, 1, 1],
1032 vec![],
1033 );
1034 let config = DispatchConfig::default();
1035 let limits = LaunchGeometryLimits {
1036 backend: "test-measurements",
1037 max_threads_per_block: 1024,
1038 max_block_dim: [1024, 1024, 64],
1039 max_grid_dim: [u32::MAX, u32::MAX, u32::MAX],
1040 };
1041 let key = NaturalLaunchCacheKey::new(&program, [32, 1, 1], 4096, limits);
1042 natural_launch_cache_remove(key);
1043
1044 assert!(
1046 record_launch_measurement_for_mode_with_store(
1047 &program,
1048 &config,
1049 limits,
1050 4096,
1051 [256, 1, 1],
1052 100,
1053 Mode::NaturalGradient,
1054 Some(&path),
1055 ),
1056 "Fix: first measurement must be accepted into the cache"
1057 );
1058
1059 let after_first = natural_launch_cache_get(key);
1061 assert!(
1062 after_first.is_some(),
1063 "Fix: cache must hold a selection after the first measurement"
1064 );
1065
1066 assert!(
1069 record_launch_measurement_for_mode_with_store(
1070 &program,
1071 &config,
1072 limits,
1073 4096,
1074 [128, 1, 1],
1075 50,
1076 Mode::NaturalGradient,
1077 Some(&path),
1078 ),
1079 "Fix: second measurement must be accepted into the cache"
1080 );
1081
1082 let measurements = natural_launch_cache_measurements(key)
1083 .expect("Fix: cache must hold measurements after two records");
1084 assert!(
1085 measurements.len() >= 2,
1086 "Fix: measurement history must accumulate across calls — got {} entries, expected >= 2",
1087 measurements.len()
1088 );
1089 assert_eq!(
1090 measurements.get(&[256, 1, 1]),
1091 Some(&100),
1092 "Fix: first measurement (workgroup=[256,1,1], 100ns) must be retained in history"
1093 );
1094 assert_eq!(
1095 measurements.get(&[128, 1, 1]),
1096 Some(&50),
1097 "Fix: second measurement (workgroup=[128,1,1], 50ns) must be present in history"
1098 );
1099 }
1100}