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