Skip to main content

vyre_foundation/execution_plan/fusion/
fuse.rs

1//! Core `fuse_programs` family + multi-program implementation.
2
3use rustc_hash::{FxHashMap, FxHashSet};
4
5use crate::execution_plan::SchedulingPolicy;
6use crate::ir::{BufferAccess, BufferDecl, Ident, Node, Program};
7
8use super::alpha_rename::{multiply_declared_names, push_alpha_renamed_arm_entry_node, ArmRenamer};
9use super::collectors::collect_buffer_targets;
10use super::divergence::{
11    has_divergent_invocation_gated_store, has_launch_geometry_dependent_write,
12};
13use super::helpers::{fallback_composition_key, upgrade_buffer_access};
14use super::{
15    FusionError, FusionOverDispatchError, FusionSelfAliasingError, FusionWorkgroupGeometryError,
16};
17
18/// Combine `programs` into one fused [`Program`]. Returns the input verbatim
19/// for 0 or 1 program; multi-program runs go through the full hazard tracker.
20///
21/// # Errors
22///
23/// Returns [`FusionError`] when the batch contains conflicting buffer aliases,
24/// non-composable self-fusion, or over-dispatches the shared launch geometry.
25pub fn fuse_programs(programs: &[Program]) -> Result<Program, FusionError> {
26    match programs.len() {
27        0 => Ok(Program::empty()),
28        1 => Ok(programs[0].clone()),
29        _ => fuse_programs_multi(programs),
30    }
31}
32
33/// Fuse `programs` when the caller already owns a `Vec`.
34///
35/// For a single program this returns that value directly (no deep clone).
36/// Multi-arm batches delegate to the same implementation as [`fuse_programs`].
37///
38/// # Errors
39///
40/// Returns [`FusionError`] under the same conditions as [`fuse_programs`].
41#[inline]
42#[must_use]
43pub fn fuse_programs_vec(mut programs: Vec<Program>) -> Result<Program, FusionError> {
44    match programs.len() {
45        0 => Ok(Program::empty()),
46        1 => {
47            let Some(program) = programs.pop() else {
48                return Ok(Program::empty());
49            };
50            Ok(program)
51        }
52        _ => fuse_programs_multi(programs.as_slice()),
53    }
54}
55
56/// How a fused arm's local names and scope relate to the other arms.
57#[derive(Clone, Copy, PartialEq, Eq)]
58pub(crate) enum ArmNamespace {
59    /// Arms are **independent** programs (inter-rule batch fusion, the
60    /// megakernel builder). Each arm allocates temps from its own counter,
61    /// so two arms can reuse the same temp name for different values. The
62    /// fuser alpha-renames every arm-local name with the arm index and wraps
63    /// each arm body in its own `Block` scope, so the reused names cannot
64    /// collide in the combined program.
65    Isolated,
66    /// Arms are **sub-programs of one rule** that share a single global temp
67    /// namespace (one monotonic `temp_counter` per `LowerCtx`; recursion is
68    /// handled by the fixpoint operator, not by re-instantiating bodies, so
69    /// no name is ever reused for two values). Renaming such names is not
70    /// only unnecessary, it is actively wrong: a value produced in one arm
71    /// (`let __cmp_N = load(__quant_flag_…)`) and consumed in another arm
72    /// (`Var(__cmp_N)`) must keep ONE consistent name and live in ONE shared
73    /// scope, or the consumer references an undeclared variable. Shared arms
74    /// are therefore spliced flat, no per-arm rename, no per-arm `Block`
75    /// preserving decl→use linkage across the merge boundary.
76    Shared,
77}
78
79/// Merge sub-programs that share one rule's global temp namespace into a
80/// single program, preserving decl→use linkage across the merge boundary.
81///
82/// Same hazard analysis, buffer union, binding renumbering, and barrier
83/// insertion as [`fuse_programs`]; the only difference is that arm-local
84/// names and scopes are **shared**, not isolated (see [`ArmNamespace`]).
85/// This is the correct primitive for shared-scope composition where the merged
86/// arms must reference each other's local names, alpha-renaming would desync a
87/// flag/readback from its in-program consumer.
88///
89/// # Errors
90///
91/// Returns [`FusionError`] under the same conditions as [`fuse_programs`].
92pub fn merge_programs_shared(programs: &[Program]) -> Result<Program, FusionError> {
93    match programs.len() {
94        0 => Ok(Program::empty()),
95        1 => Ok(programs[0].clone()),
96        _ => fuse_programs_multi_with(programs, ArmNamespace::Shared),
97    }
98}
99
100fn fuse_programs_multi(programs: &[Program]) -> Result<Program, FusionError> {
101    fuse_programs_multi_with(programs, ArmNamespace::Isolated)
102}
103
104fn fuse_programs_multi_with(
105    programs: &[Program],
106    namespace: ArmNamespace,
107) -> Result<Program, FusionError> {
108    reject_non_composable_self_fusion(programs)?;
109
110    // ------------------------------------------------------------------
111    // Single pass over programs: collect entries, atomics, buffers,
112    // hazards, and workgroup size in one go.
113    // ------------------------------------------------------------------
114    let mut merged_buffers: Vec<BufferDecl> = Vec::new();
115    let mut name_to_index: FxHashMap<Ident, usize> = FxHashMap::default();
116    let mut next_binding = 0_u32;
117
118    let mut read_arms_per_buffer: FxHashMap<Ident, Vec<usize>> = FxHashMap::default();
119    // Track write-arm history per buffer so a later READER can force
120    // a barrier after the earlier writer. Without this, the fused
121    // kernel runs writer + reader in the same launch with no
122    // synchronization, and the reader sees stale data from threads
123    // that haven't completed the writer's body yet  -  the exact
124    // "stack_overflow_gets misses node 39" mode.
125    let mut write_arms_per_buffer: FxHashMap<Ident, Vec<usize>> = FxHashMap::default();
126    let mut barrier_after_arm: FxHashSet<usize> = FxHashSet::default();
127    // Arms whose writes are derived from launch geometry need a grid-level
128    // fence before later arms read them. A workgroup barrier waits only for
129    // the current block, so it cannot order "block 0 writes offsets, block 1
130    // reads offsets" shapes inside a fused launch.
131    let mut grid_sync_writer_arms: FxHashSet<usize> = FxHashSet::default();
132
133    let mut fused_workgroup = [1u32, 1, 1];
134    let mut max_arm_threads: u64 = 1;
135
136    let mut arm_entries: Vec<Vec<Node>> = Vec::with_capacity(programs.len());
137
138    // Shared-namespace merge prefixes ONLY names declared in ≥2 arms (genuine
139    // collisions, e.g. a primitive's internal `acc`). A name declared in
140    // exactly one arm, including a value produced in one arm and consumed in
141    // another (`let __cmp_N = …` / `Var(__cmp_N)`), is globally unique and
142    // stays unrenamed so the decl→use link survives. Isolated fusion renames
143    // every name (the set is unused for that mode).
144    let multiply_declared: FxHashSet<Ident> = match namespace {
145        ArmNamespace::Isolated => FxHashSet::default(),
146        ArmNamespace::Shared => {
147            let entries: Vec<&[Node]> = programs.iter().map(Program::entry).collect();
148            multiply_declared_names(&entries)
149        }
150    };
151
152    for (arm_idx, prog) in programs.iter().enumerate() {
153        // Walk entry nodes once: clone into segment and collect both
154        // atomic targets (writes) and Load targets (reads). Buffers
155        // referenced inside the body but NOT declared in the arm's
156        // own `buffers()` table  -  produced by an earlier arm  -  only
157        // surface here. Without this, RAW hazards across arms that
158        // read shared scalars (e.g. broadcast reading the scalar
159        // written by a single-thread `bitset_any`) get no barrier
160        // and silently produce stale reads on threads that haven't
161        // observed the writer's flush.
162        let entry = prog.entry();
163        let mut segment = Vec::with_capacity(entry.len());
164        let mut atomic_targets: FxHashSet<Ident> = FxHashSet::default();
165        let mut load_targets: FxHashSet<Ident> = FxHashSet::default();
166        let mut store_targets: FxHashSet<Ident> = FxHashSet::default();
167        let mut divergent_store_seen = false;
168        for node in entry {
169            match namespace {
170                ArmNamespace::Isolated => {
171                    push_alpha_renamed_arm_entry_node(&mut segment, node, arm_idx);
172                }
173                ArmNamespace::Shared => {
174                    ArmRenamer::shared(arm_idx, &multiply_declared)
175                        .push_entry_node(&mut segment, node);
176                }
177            }
178            collect_buffer_targets(
179                node,
180                &mut load_targets,
181                &mut store_targets,
182                &mut atomic_targets,
183            );
184            if has_divergent_invocation_gated_store(node, false) {
185                divergent_store_seen = true;
186            }
187        }
188        if divergent_store_seen || has_launch_geometry_dependent_write(prog.entry()) {
189            grid_sync_writer_arms.insert(arm_idx);
190        }
191        arm_entries.push(segment);
192
193        let mut arm_reads: FxHashSet<Ident> = FxHashSet::default();
194        let mut arm_explicit_writes: FxHashSet<Ident> = FxHashSet::default();
195        classify_and_merge_arm_buffers(
196            prog,
197            &mut arm_reads,
198            &mut arm_explicit_writes,
199            &mut merged_buffers,
200            &mut name_to_index,
201            &mut next_binding,
202        );
203
204        // Body-level reads from buffers declared by EARLIER arms.
205        // The arm's own buffers().iter() loop already populated
206        // `arm_reads` for declared ReadOnly inputs; this adds any
207        // additional reads inferred from `Expr::Load` references.
208        for target in &load_targets {
209            arm_reads.insert(target.clone());
210        }
211        // Body-level stores to buffers declared by earlier arms.
212        for target in &store_targets {
213            arm_explicit_writes.insert(target.clone());
214        }
215
216        // Atomic writes count only for buffers not already read or explicitly written.
217        let mut arm_writes = arm_explicit_writes.clone();
218        for target in &atomic_targets {
219            if !arm_reads.contains(target) && !arm_explicit_writes.contains(target) {
220                arm_writes.insert(target.clone());
221            }
222        }
223
224        // F-IR-22: WAR hazard  -  for each buffer this arm writes, if
225        // any previous arm read it, mark a barrier after every such
226        // earlier read arm so the new write can't clobber the read.
227        for write_buf in &arm_writes {
228            if let Some(read_arms) = read_arms_per_buffer.get(write_buf) {
229                for &read_arm in read_arms {
230                    barrier_after_arm.insert(read_arm);
231                }
232            }
233        }
234
235        // RAW hazard  -  for each buffer this arm reads, if any
236        // previous arm wrote it, the writer's results must be
237        // visible before this read. Insert a barrier after every
238        // such earlier writer arm. Required because the fused
239        // kernel runs as one backend launch; without a barrier,
240        // threads in this arm may execute the load before the
241        // writer arm's threads have completed their store, yielding
242        // stale data and silently dropping rule findings (recall=0
243        // mode previously observed on `stack_overflow_gets` for
244        // node ids past the warp boundary).
245        for read_buf in &arm_reads {
246            if let Some(write_arms) = write_arms_per_buffer.get(read_buf) {
247                for &write_arm in write_arms {
248                    barrier_after_arm.insert(write_arm);
249                }
250            }
251        }
252
253        // Update read tracking for later arms.
254        for read_buf in &arm_reads {
255            read_arms_per_buffer
256                .entry(read_buf.clone())
257                .or_default()
258                .push(arm_idx);
259        }
260        // Update write tracking for later RAW detection.
261        for write_buf in &arm_writes {
262            write_arms_per_buffer
263                .entry(write_buf.clone())
264                .or_default()
265                .push(arm_idx);
266        }
267
268        // Workgroup size tracking.
269        let wg = prog.workgroup_size();
270        fused_workgroup[0] = fused_workgroup[0].max(wg[0]);
271        fused_workgroup[1] = fused_workgroup[1].max(wg[1]);
272        fused_workgroup[2] = fused_workgroup[2].max(wg[2]);
273        let arm_threads = u64::from(wg[0]) * u64::from(wg[1]) * u64::from(wg[2]);
274        max_arm_threads = max_arm_threads.max(arm_threads);
275    }
276
277    reject_workgroup_geometry_change(programs, fused_workgroup)?;
278
279    let combined_entry = flatten_arm_entries(
280        arm_entries,
281        &barrier_after_arm,
282        &grid_sync_writer_arms,
283        programs.len(),
284        namespace,
285    );
286    reject_overdispatch(fused_workgroup, max_arm_threads)?;
287
288    // `Program::wrapped` builds a fresh program, which resets the metadata
289    // flags. `non_composable_with_self` describes the fused body just as much
290    // as it described the arm it came from: the fused program now CONTAINS
291    // that body, so fusing it again with another copy of the same arm carries
292    // the identical hazard. Carrying the OR forward is what lets
293    // `reject_non_composable_self_fusion` see it on a second round.
294    //
295    // `entry_op_id` is deliberately left cleared. It names one certified
296    // operation, and a program built from several arms is not that operation
297    // even when every arm happens to share an id.
298    let non_composable = programs.iter().any(Program::is_non_composable_with_self);
299    Ok(
300        Program::wrapped(merged_buffers, fused_workgroup, combined_entry)
301            .with_non_composable_with_self(non_composable),
302    )
303}
304
305fn classify_and_merge_arm_buffers(
306    prog: &Program,
307    arm_reads: &mut FxHashSet<Ident>,
308    arm_explicit_writes: &mut FxHashSet<Ident>,
309    merged_buffers: &mut Vec<BufferDecl>,
310    name_to_index: &mut FxHashMap<Ident, usize>,
311    next_binding: &mut u32,
312) {
313    for buf in prog.buffers() {
314        let name = Ident::from(buf.name());
315        match buf.access() {
316            BufferAccess::ReadOnly | BufferAccess::Uniform => {
317                arm_reads.insert(name.clone());
318            }
319            BufferAccess::ReadWrite => {
320                arm_explicit_writes.insert(name.clone());
321            }
322            _ => {}
323        }
324        if let Some(&idx) = name_to_index.get(&name) {
325            let existing = &mut merged_buffers[idx];
326            let access = buf.access();
327            upgrade_buffer_access(existing, &access);
328            if buf.count > existing.count {
329                existing.count = buf.count;
330            }
331            if buf.is_output() {
332                existing.is_output = true;
333                existing.pipeline_live_out = true;
334            }
335        } else {
336            let mut merged = buf.clone();
337            if merged.access() != BufferAccess::Workgroup {
338                merged.binding = *next_binding;
339                *next_binding += 1;
340            }
341            name_to_index.insert(Ident::from(merged.name()), merged_buffers.len());
342            merged_buffers.push(merged);
343        }
344    }
345}
346
347fn reject_non_composable_self_fusion(programs: &[Program]) -> Result<(), FusionError> {
348    let mut seen_op_ids: FxHashMap<String, bool> = FxHashMap::default();
349    for prog in programs {
350        let key = prog
351            .entry_op_id()
352            .map_or_else(|| fallback_composition_key(prog), ToString::to_string);
353        let is_non_comp = prog.is_non_composable_with_self();
354        match seen_op_ids.get_mut(&key) {
355            Some(has_non_comp) if *has_non_comp || is_non_comp => {
356                return Err(FusionError::SelfAliasing(FusionSelfAliasingError {
357                    op_id: key,
358                    fix: "rename the second parser's workgroup buffer or split into two separate dispatches",
359                }));
360            }
361            Some(_) => {}
362            None => {
363                seen_op_ids.insert(key, is_non_comp);
364            }
365        }
366    }
367    Ok(())
368}
369
370/// Refuse to widen the workgroup of an arm that reasons about its own.
371///
372/// The fused geometry is the axis-wise maximum over the arms. For an arm whose
373/// invocations are independent that is only a launch-size change. For an arm
374/// that synchronizes its workgroup or keeps state in workgroup memory it is a
375/// semantic change: the arm guards its body for its own width, so under a wider
376/// workgroup the extra invocations skip the guarded body and never reach the
377/// barrier the working invocations wait on. A workgroup barrier that is not
378/// reached by every invocation in the workgroup is undefined, and in practice
379/// the result is intermittently wrong rather than reliably wrong.
380///
381/// Failing closed is the only correct answer here. Fusion cannot rewrite the
382/// arm for the wider geometry, and quietly emitting the racy kernel gives the
383/// caller a program that passes most of the time.
384fn reject_workgroup_geometry_change(
385    programs: &[Program],
386    fused_workgroup: [u32; 3],
387) -> Result<(), FusionError> {
388    for (arm, prog) in programs.iter().enumerate() {
389        let arm_workgroup = prog.workgroup_size();
390        if arm_workgroup == fused_workgroup {
391            continue;
392        }
393        let uses_workgroup_memory = prog
394            .buffers()
395            .iter()
396            .any(|buf| buf.access() == BufferAccess::Workgroup);
397        let synchronizes = has_barrier(prog.entry());
398        let reason = match (uses_workgroup_memory, synchronizes) {
399            (true, true) => "keeps state in workgroup memory and synchronizes its workgroup",
400            (true, false) => "keeps state in workgroup memory sized for its own workgroup",
401            (false, true) => "synchronizes its workgroup with a barrier",
402            (false, false) => continue,
403        };
404        return Err(FusionError::WorkgroupGeometry(
405            FusionWorkgroupGeometryError {
406                arm,
407                arm_workgroup,
408                fused_workgroup,
409                reason,
410                fix: "dispatch this arm separately, or rebuild it for the wider workgroup before fusing",
411            },
412        ));
413    }
414    Ok(())
415}
416
417/// Is there a barrier anywhere in this node sequence?
418fn has_barrier(nodes: &[Node]) -> bool {
419    nodes.iter().any(|node| match node {
420        Node::Barrier { .. } => true,
421        Node::Region { body, .. } => has_barrier(body),
422        Node::Block(body) | Node::Loop { body, .. } => has_barrier(body),
423        Node::If {
424            then, otherwise, ..
425        } => has_barrier(then) || has_barrier(otherwise),
426        _ => false,
427    })
428}
429
430fn flatten_arm_entries(
431    arm_entries: Vec<Vec<Node>>,
432    barrier_after_arm: &FxHashSet<usize>,
433    grid_sync_writer_arms: &FxHashSet<usize>,
434    program_count: usize,
435    namespace: ArmNamespace,
436) -> Vec<Node> {
437    let total_nodes: usize = arm_entries.iter().map(Vec::len).sum();
438    let mut combined_entry = Vec::with_capacity(total_nodes + program_count);
439    for (arm_idx, segment) in arm_entries.into_iter().enumerate() {
440        match namespace {
441            // Isolated arms each get their own `Block` scope so reused
442            // arm-local names cannot collide across arms.
443            ArmNamespace::Isolated => combined_entry.push(Node::Block(segment)),
444            // Shared arms splice flat into the one rule-wide scope, so a
445            // `let` in an earlier arm stays visible to a later arm's use.
446            ArmNamespace::Shared => combined_entry.extend(segment),
447        }
448        if barrier_after_arm.contains(&arm_idx) {
449            // Workgroup `SeqCst` (`bar.sync 0`) is sufficient only when the
450            // prior write is uniform across the launch. Launch-geometry
451            // dependent writes must become a top-level `GridSync`, where the
452            // runtime split pass can lower the fused program into globally
453            // ordered dispatch segments.
454            let ordering = if grid_sync_writer_arms.contains(&arm_idx) {
455                crate::memory_model::MemoryOrdering::GridSync
456            } else {
457                crate::memory_model::MemoryOrdering::SeqCst
458            };
459            combined_entry.push(Node::barrier_with_ordering(ordering));
460        }
461    }
462    combined_entry
463}
464
465fn reject_overdispatch(fused_workgroup: [u32; 3], max_arm_threads: u64) -> Result<(), FusionError> {
466    let fused_threads = u64::from(fused_workgroup[0])
467        * u64::from(fused_workgroup[1])
468        * u64::from(fused_workgroup[2]);
469    let policy = SchedulingPolicy::standard();
470    if policy.allow_fused_threads(fused_threads, max_arm_threads) {
471        return Ok(());
472    }
473    Err(FusionError::OverDispatch(FusionOverDispatchError {
474        max_arm_threads,
475        fused_threads,
476        fix: "split the batch or use per-arm dispatch; axis-wise max exceeds the shared over-dispatch policy",
477    }))
478}