Skip to main content

vyre_driver/
launch.rs

1//! Backend-neutral dispatch launch preparation.
2
3use 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/// Fully prepared launch metadata shared by concrete drivers.
30#[derive(Clone, Debug, Eq, PartialEq)]
31pub struct LaunchPlan {
32    /// Logical element count passed to the lowered kernel.
33    pub element_count: u32,
34    /// Effective workgroup/block shape after dispatch overrides.
35    pub workgroup: [u32; 3],
36    /// Effective grid shape after dispatch overrides or inference.
37    pub grid: [u32; 3],
38    /// Per-buffer element-count metadata uploaded as the shared params buffer.
39    pub param_words: Vec<u32>,
40    /// Maximum preferred alignment across all launch bindings.
41    ///
42    /// Concrete drivers use this to pick upload staging and device-buffer
43    /// allocation paths without re-inspecting Program buffer declarations.
44    pub max_binding_alignment: usize,
45}
46
47impl LaunchPlan {
48    /// Empty launch plan with reusable parameter-word storage.
49    #[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    /// Prepare dispatch geometry and parameter words from a validated binding plan.
61    ///
62    /// # Errors
63    ///
64    /// Returns when caller overrides produce zero dimensions, overflow the
65    /// logical launch element count, or exceed backend-reported launch limits.
66    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    /// Prepare dispatch geometry and parameter words, reusing this plan's buffers.
78    ///
79    /// # Errors
80    ///
81    /// Returns when caller overrides produce zero dimensions, overflow the
82    /// logical launch element count, or exceed backend-reported launch limits.
83    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                // Non-1D workgroups need an explicit grid_override  -
109                // there's no single right way to map an unknown
110                // element_count across N×M (or N×M×K) thread tiles,
111                // and silently picking one produces silently-wrong
112                // results. Force the caller to make the choice.
113                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/// Resolve the backend-visible workgroup shape for a dispatch.
194///
195/// Explicit caller overrides remain authoritative. When no override is
196/// supplied and `VYRE_AUTOTUNER` resolves to natural-gradient mode, eligible
197/// 1D storage-only kernels receive a deterministic natural-gradient cold-start
198/// workgroup before grid inference.
199#[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/// Resolve the backend-visible workgroup shape with an explicit tuner mode.
210///
211/// This is public so backends whose shader/pipeline compilation must include
212/// the selected workgroup size can derive the same shape before lowering.
213#[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/// Record a measured launch result for the natural-gradient launch resolver.
233///
234/// Backends should call this only after a real dispatch timing is available.
235/// The function returns `true` when the measurement was accepted into the
236/// bounded feedback cache. Explicit caller overrides, explicit grid launches,
237/// non-natural tuner modes, non-1D kernels, workgroup-local scratch kernels,
238/// zero timings, and out-of-limit candidates are ignored so measured feedback
239/// never changes kernel semantics.
240#[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/// Compute the shared VSA program fingerprint used by backend caches.
646#[must_use]
647pub fn program_vsa_fingerprint(program: &Program) -> Vec<u32> {
648    program_vsa_fingerprint_words(program).to_vec()
649}
650
651/// Compute the shared VSA program fingerprint without heap allocation.
652#[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    // Reproducing test for: launch-cache-mutex-poison-silent-fallback
959    // Before fix: .lock().ok() silently returned None on mutex poison, causing a silent
960    // fallback from feedback-informed to cold-start workgroup selection.
961    // After fix: .unwrap_or_else(|p| p.into_inner()) recovers the guard and preserves
962    // accumulated timing data even after a thread panics while holding the lock.
963    #[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        // Write a known workgroup selection into the cache.
980        natural_launch_cache_set(
981            key,
982            NaturalLaunchEntry {
983                selected: [128, 1, 1],
984                measurements: BTreeMap::new(),
985            },
986        );
987
988        // Poison the mutex by panicking inside a std::thread::scope closure while holding a
989        // lock acquired via get_or_init. We do this by manually poisoning via std::panic.
990        // Since NATURAL_LAUNCH_CACHE is a process-global OnceLock we simulate the recovery
991        // path by verifying .unwrap_or_else(|p| p.into_inner()) in cache_get directly.
992        // The key observable: cache_get must return the previously-written selection
993        // (not None) even when poison recovery is required.
994        //
995        // We cannot poison the global mutex in a test without affecting parallel tests;
996        // instead we verify the recovery API is correct: unwrap_or_else on a non-poisoned
997        // mutex must return the same result as .unwrap(), proving the path is correct.
998        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        // Also verify the source does not use .lock().ok() (the silencing pattern).
1006        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    // Reproducing test for: launch-cache-measurements-unwrap-or-default-silent-feedback-loss
1018    // Before fix: natural_launch_cache_measurements returned None on mutex poison and
1019    // record_launch_measurement_for_mode_with_store would .unwrap_or_default() that None,
1020    // overwriting all prior measurement history with a single-sample empty map.
1021    // After fix (driven by the mutex fix): None from cache_measurements means genuinely
1022    // no prior entry, not a poison-induced data loss. The measurement path correctly starts
1023    // from an empty map only when no prior measurements exist.
1024    #[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        // First measurement accepted — starts from empty, correct.
1045        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        // Read back the selection — must be [256, 1, 1] (only candidate with real timing).
1060        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        // Second measurement with a *faster* timing for a different candidate.
1067        // The history from the first must be preserved — not replaced by an empty map.
1068        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}