Skip to main content

vyre_driver/program_walks/
dispatch_params.rs

1//! Dispatch ABI parameter derivation from binding plans.
2
3use vyre_foundation::ir::Program;
4
5use crate::binding::{Binding, BindingRole};
6
7/// Derive the dispatch element count from a binding plan.
8#[must_use]
9pub fn dispatch_element_count(bindings: &[Binding]) -> u32 {
10    dispatch_element_count_inner(bindings, false)
11}
12
13/// Derive the dispatch element count from a binding plan and Program body.
14///
15/// Atomics and subgroup collectives execute over the logical input span even
16/// when they compact into a smaller output. Using the compact output length
17/// would suppress participating lanes before the atomic or collective runs.
18#[must_use]
19pub fn dispatch_element_count_for_program(program: &Program, bindings: &[Binding]) -> u32 {
20    let capabilities = vyre_foundation::program_caps::scan(program);
21    dispatch_element_count_inner(
22        bindings,
23        program_contains_atomic(program) || capabilities.subgroup_ops,
24    )
25}
26
27fn dispatch_element_count_inner(bindings: &[Binding], force_full_span: bool) -> u32 {
28    // Single pass over bindings: collect every fact the dispatch
29    // policy needs (any-shared / max non-shared / max output) in one
30    // scan. Previously up to three independent .iter() passes
31    // traversed the same slice  -  for launch shapes that carry 60+
32    // bindings each pass is real work.
33    let mut any_shared = false;
34    let mut max_non_shared: u32 = 0;
35    let mut max_output: u32 = 0;
36    for binding in bindings {
37        if binding.role == BindingRole::Shared {
38            any_shared = true;
39            continue;
40        }
41        if binding.element_count > max_non_shared {
42            max_non_shared = binding.element_count;
43        }
44        if matches!(binding.role, BindingRole::Output | BindingRole::InputOutput)
45            && binding.element_count > max_output
46        {
47            max_output = binding.element_count;
48        }
49    }
50    if any_shared || force_full_span {
51        return max_non_shared.max(1);
52    }
53    if max_output > 0 {
54        return max_output;
55    }
56    max_non_shared.max(1)
57}
58
59fn program_contains_atomic(program: &Program) -> bool {
60    // ProgramStats::atomic_op_count is incremented exactly once per
61    // Expr::Atomic during the cached single-pass stats walk. Reading
62    // the cached count replaces the recursive node + expr scan this
63    // function previously performed.
64    program.stats().atomic_op_count > 0
65}
66
67/// Build per-buffer element-count parameter words for a dispatch with fallible
68/// host-staging allocation.
69pub fn try_dispatch_param_words(
70    bindings: &[Binding],
71    element_count: u32,
72) -> Result<Vec<u32>, String> {
73    let mut words = Vec::new();
74    try_dispatch_param_words_into(bindings, element_count, &mut words)?;
75    Ok(words)
76}
77
78/// Build per-buffer element-count parameter words into caller-owned storage.
79///
80/// # Errors
81///
82/// Returns an error when any binding slot overflows `usize`, or when
83/// the host staging allocation fails. The caller-owned `words` buffer is
84/// left in an unspecified but valid state (may be partially written); it
85/// is the caller's responsibility to handle the error before using the buffer.
86pub fn dispatch_param_words_into(
87    bindings: &[Binding],
88    element_count: u32,
89    words: &mut Vec<u32>,
90) -> Result<(), String> {
91    try_dispatch_param_words_into(bindings, element_count, words)
92}
93
94/// Build per-buffer element-count parameter words into caller-owned storage
95/// with explicit allocation and ABI-width errors.
96pub fn try_dispatch_param_words_into(
97    bindings: &[Binding],
98    element_count: u32,
99    words: &mut Vec<u32>,
100) -> Result<(), String> {
101    let word_len = dispatch_param_word_len_for_bindings(bindings)?;
102    reserve_dispatch_param_words(words, word_len)?;
103    words.clear();
104    words.resize(word_len, 0);
105    words[0] = element_count;
106    for binding in bindings {
107        if binding.role == BindingRole::Shared {
108            continue;
109        }
110        let slot = dispatch_param_word_slot(binding)?;
111        words[slot] = if binding.element_count == 0 {
112            element_count
113        } else {
114            binding.element_count
115        };
116    }
117    Ok(())
118}
119
120fn dispatch_param_word_len_for_bindings(bindings: &[Binding]) -> Result<usize, String> {
121    let mut word_len = dispatch_param_word_len_checked(bindings.len())?;
122    for binding in bindings {
123        if binding.role == BindingRole::Shared {
124            continue;
125        }
126        let required = dispatch_param_word_slot(binding)?
127            .checked_add(1)
128            .ok_or_else(|| {
129                format!(
130                    "dispatch binding slot {} overflows ABI parameter word count. Fix: split the Program before launch-parameter planning.",
131                    binding.binding
132                )
133            })?;
134        if required > word_len {
135            word_len = required;
136        }
137    }
138    Ok(word_len)
139}
140
141fn dispatch_param_word_slot(binding: &Binding) -> Result<usize, String> {
142    let slot = usize::try_from(binding.binding).map_err(|error| {
143        format!(
144            "dispatch binding slot {} does not fit host usize ({error}). Fix: split the Program before launch-parameter planning.",
145            binding.binding
146        )
147    })?;
148    slot.checked_add(1).ok_or_else(|| {
149        format!(
150            "dispatch binding slot {} overflows ABI parameter slot. Fix: split the Program before launch-parameter planning.",
151            binding.binding
152        )
153    })
154}
155
156fn dispatch_param_word_len_checked(binding_count: usize) -> Result<usize, String> {
157    binding_count.checked_add(1).ok_or_else(|| {
158        format!(
159            "dispatch binding count {binding_count} overflows ABI parameter word count. Fix: split the Program before launch-parameter planning."
160        )
161    })
162}
163
164fn reserve_dispatch_param_words(words: &mut Vec<u32>, word_len: usize) -> Result<(), String> {
165    crate::allocation::try_reserve_vec_to_capacity(words, word_len).map_err(|error| {
166        format!(
167            "dispatch parameter staging could not reserve {word_len} u32 word(s): {error}. Fix: split the Program before launch-parameter planning."
168        )
169    })
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use crate::binding::BindingRole;
176    use std::sync::Arc;
177
178    fn binding(buffer_index: usize, element_count: u32) -> Binding {
179        Binding {
180            name: Arc::from("test"),
181            binding: u32::try_from(buffer_index).expect("Fix: test binding index fits u32"),
182            buffer_index,
183            role: BindingRole::Input,
184            element_size: 4,
185            preferred_alignment: 4,
186            element_count,
187            static_byte_len: None,
188            input_index: Some(0),
189            output_index: None,
190        }
191    }
192
193    /// Subgroup collectives require every logical input lane to participate.
194    /// A compact bitmap output cannot define the launch span because the
195    /// ballot needs all 32 lanes before lane zero writes one output word.
196    #[test]
197    fn subgroup_collective_dispatch_uses_full_input_span() {
198        let mut output = binding(0, 16);
199        output.role = BindingRole::Output;
200        output.output_index = Some(0);
201        output.input_index = None;
202        let input = binding(1, 512);
203        let program = vyre_foundation::ir::Program::wrapped(
204            Vec::new(),
205            [256, 1, 1],
206            vec![vyre_foundation::ir::Node::let_bind(
207                "mask",
208                vyre_foundation::ir::Expr::subgroup_ballot(vyre_foundation::ir::Expr::bool(true)),
209            )],
210        );
211
212        assert_eq!(
213            dispatch_element_count_for_program(&program, &[output, input]),
214            512
215        );
216    }
217
218    #[test]
219    fn dispatch_params_support_sparse_binding_indices_without_repeated_growth() {
220        let bindings = [binding(4, 9), binding(1, 0)];
221        let words = try_dispatch_param_words(&bindings, 7)
222            .expect("Fix: sparse binding parameter words should stage");
223
224        assert_eq!(words, vec![7, 0, 7, 0, 0, 9]);
225    }
226
227    #[test]
228    fn dispatch_params_are_indexed_by_binding_slot_not_program_buffer_index() {
229        let mut sparse = binding(0, 4);
230        sparse.binding = 9;
231        let mut dynamic = binding(7, 0);
232        dynamic.binding = 2;
233        let words = try_dispatch_param_words(&[sparse, dynamic], 11)
234            .expect("Fix: sparse binding-slot parameter words should stage");
235
236        assert_eq!(words.len(), 11);
237        assert_eq!(words[0], 11);
238        assert_eq!(words[3], 11);
239        assert_eq!(words[10], 4);
240        assert_eq!(
241            words[8], 0,
242            "Fix: CUDA/PTX parameter words are indexed by binding slot, not buffer_index."
243        );
244    }
245
246    #[test]
247    fn generated_dispatch_params_cover_sparse_binding_slot_matrix() {
248        let mut checked = 0usize;
249        for seed in 0..4096u32 {
250            let binding_count = (seed as usize % 8) + 1;
251            let mut bindings = Vec::with_capacity(binding_count);
252            for index in 0..binding_count {
253                let mut item = binding(
254                    index,
255                    if index % 3 == 0 {
256                        0
257                    } else {
258                        seed + index as u32
259                    },
260                );
261                item.binding = ((seed.wrapping_mul(17) + (index as u32 * 97)) % 1024) + 1;
262                item.buffer_index = binding_count - 1 - index;
263                bindings.push(item);
264            }
265            let element_count = seed.wrapping_mul(31) | 1;
266            let words = try_dispatch_param_words(&bindings, element_count)
267                .expect("Fix: generated sparse binding-slot param words should stage.");
268            assert_eq!(words[0], element_count, "seed {seed}");
269            for item in &bindings {
270                let slot = usize::try_from(item.binding).expect("Fix: test binding fits usize") + 1;
271                let expected = if item.element_count == 0 {
272                    element_count
273                } else {
274                    item.element_count
275                };
276                assert_eq!(
277                    words[slot], expected,
278                    "Fix: generated dispatch-param seed {seed} binding slot {} must map to words[slot+1], regardless of buffer_index {}.",
279                    item.binding, item.buffer_index
280                );
281                checked += 1;
282            }
283        }
284        assert!(
285            checked >= 18_000,
286            "Fix: generated dispatch-param ABI coverage should exercise thousands of sparse binding layouts, got {checked}."
287        );
288    }
289
290    // Reproducing test for: dispatch-param-words-silent-empty-return
291    // Before fix: `dispatch_param_words` (infallible) existed and returned Vec::new() on error,
292    // silently producing a zero-length param buffer. After fix: function is removed; callers
293    // must use `try_dispatch_param_words` which returns a Result.
294    // Reproducing test for: dispatch-param-words-into-silent-clear
295    // Before fix: `dispatch_param_words_into` returned () and silently cleared `words` on error.
296    // After fix: it returns `Result<(), String>` so callers can observe and propagate the error.
297    #[test]
298    fn dispatch_param_words_into_propagates_error_instead_of_silently_clearing() {
299        // Valid path: must succeed and populate correctly.
300        let bindings = [binding(0, 7), binding(2, 3)];
301        let mut words = Vec::new();
302        dispatch_param_words_into(&bindings, 7, &mut words)
303            .expect("Fix: dispatch_param_words_into must succeed for valid bindings");
304        assert_eq!(
305            words,
306            vec![7, 7, 0, 3],
307            "Fix: dispatch_param_words_into must produce [element_count, b0, b1_zero, b2] for valid bindings"
308        );
309
310        // Error path: a binding slot whose `slot + 1` overflows `usize` must return
311        // Err (and surface a `Fix:` hint), not silently clear the buffer. `binding`
312        // is a `u32`, so `usize::try_from` always succeeds and `slot.checked_add(1)`
313        // only overflows when `usize` is 32-bit (binding == u32::MAX => MAX + 1). On a
314        // 64-bit host u32::MAX + 1 fits `usize`, and the only other error source
315        // (a ~17 GB host-staging allocation) is non-deterministic, so the overflow
316        // assertion is gated to 32-bit where it is exact. On all hosts the success
317        // path above proves the function returns `Ok(...)` with the populated buffer
318        // (not the old silent `()`), and `no_infallible_dispatch_param_words_function_exists_in_public_surface`
319        // proves the silent-clear fallback is absent from the source.
320        #[cfg(target_pointer_width = "32")]
321        {
322            let mut overflow_binding = binding(0, 5);
323            overflow_binding.binding = u32::MAX;
324            let mut words2 = vec![0xdead_beef_u32; 4];
325            let err = dispatch_param_words_into(&[overflow_binding], 5, &mut words2);
326            assert!(
327                err.is_err(),
328                "Fix: dispatch_param_words_into must return Err on ABI slot overflow, not silently clear the buffer"
329            );
330            let msg = err.unwrap_err();
331            assert!(
332                msg.contains("Fix:"),
333                "Fix: dispatch_param_words_into error message must include a Fix: hint, got: {msg}"
334            );
335        }
336    }
337}