Skip to main content

rlx_compile/
fusion_pipeline.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Centralized fusion pass pipelines per backend target.
6//!
7//! [`fusion_passes_for_supported`] selects passes from a backend's
8//! [`rlx_ir::OpKind`] claim set so fusion never emits fused ops the
9//! target cannot lower. [`fusion_passes`] keeps the legacy
10//! [`FusionTarget`] entry point and delegates to the same selector.
11
12use rlx_ir::OpKind;
13
14use crate::DeadCodeElimination;
15use crate::io_output_gate::SelectPeaksOnlyOutputs;
16use rlx_fusion::control_flow::LowerControlFlow;
17use rlx_fusion::fk_fusion::{
18    DecomposeFusionRegions, FuseBatchPreprocess, FuseRegionPrologue, MarkBatchSliceRegions,
19    MarkTransformRegions,
20};
21use rlx_fusion::fusion::{
22    FuseAdaLayerNorm, FuseAttentionBackwardAll, FuseAttentionBlock, FuseConvAffineAct,
23    FuseConvBiasAct, FuseGatedResidual, FuseMatMulBiasAct, FuseMatMulResidual, FuseResidualLN,
24    FuseResidualRmsNorm, FuseRmsNormReshape, FuseSharedInputMatMul, FuseSwiGLU,
25    FuseSwiGLUDualMatmul, FuseTransformerLayer, MarkElementwiseRegions, UnfuseElementwiseRegions,
26};
27use rlx_fusion::limits::{FusionLimits, with_fusion_limits};
28use rlx_fusion::lower_dot_general::LowerDotGeneral;
29use rlx_fusion::pass::{Pass, run_passes};
30use rlx_ir::Graph;
31
32use crate::fusion_target::with_fusion_target;
33
34/// Compile target that selects a fusion pipeline.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum FusionTarget {
37    Cpu,
38    Metal,
39    Mlx,
40    Wgpu,
41    Cuda,
42    Rocm,
43    Tpu,
44}
45
46/// Per-target fusion toggles (env-driven on Metal today).
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub struct FusionOptions {
49    /// Skip all pattern fusions (Metal: `RLX_METAL_NO_FUSION`).
50    pub skip_fusion: bool,
51    /// Break `ElementwiseRegion` back into primitives after marking.
52    pub unfuse_elementwise_regions: bool,
53    /// Keep fused `ElementwiseRegion` through lowering (env: `RLX_KEEP_ELEMENTWISE_REGIONS`).
54    pub keep_elementwise_regions: bool,
55    /// Decompose FKL-style transform / batch regions before backend lowering.
56    pub decompose_fusion_regions: bool,
57    /// Run FKL passes (`MarkTransformRegions`, prologue, batch). Env: `RLX_NO_FK_FUSION=1` disables.
58    pub fk_fusion: bool,
59    /// Fold `ResizeNearest2x` into `ElementwiseRegion` prologue. Env: `RLX_FUSE_REGION_PROLOGUE=0` disables.
60    pub fuse_region_prologue: bool,
61    /// Merge parallel region slices into `BatchElementwiseRegion`. Env: `RLX_FUSE_BATCH_PREPROCESS=0` disables.
62    pub fuse_batch_preprocess: bool,
63    /// Keep `TransformRegion` / `BatchElementwiseRegion` in MIR for native lowering. Env: `RLX_NATIVE_FK_REGIONS=1`.
64    pub native_fk_regions: bool,
65    /// Caps for fused elementwise chains (encoder / scratch limits).
66    pub fusion_limits: FusionLimits,
67    /// Skip Conv+Bias+Act fusion (`RLX_DISABLE_CONV_BIAS_ACT_FUSION`).
68    pub disable_conv_bias_act_fusion: bool,
69    /// Disable IO-gated peaks-only output selection (`RLX_NO_IO_PEAKS_OUTPUT`).
70    pub no_io_peaks_output: bool,
71}
72
73impl Default for FusionOptions {
74    fn default() -> Self {
75        Self {
76            skip_fusion: false,
77            unfuse_elementwise_regions: false,
78            keep_elementwise_regions: false,
79            decompose_fusion_regions: false,
80            fk_fusion: true,
81            fuse_region_prologue: true,
82            fuse_batch_preprocess: true,
83            native_fk_regions: false,
84            fusion_limits: FusionLimits::default(),
85            disable_conv_bias_act_fusion: false,
86            no_io_peaks_output: false,
87        }
88    }
89}
90
91impl FusionOptions {
92    /// Read Metal-specific env overrides.
93    pub fn from_metal_env() -> Self {
94        Self {
95            skip_fusion: rlx_ir::env::flag("RLX_METAL_NO_FUSION"),
96            unfuse_elementwise_regions: rlx_ir::env::flag("RLX_METAL_UNFUSE_REGIONS"),
97            keep_elementwise_regions: rlx_ir::env::flag("RLX_KEEP_ELEMENTWISE_REGIONS"),
98            decompose_fusion_regions: rlx_ir::env::flag("RLX_DECOMPOSE_FUSION_REGIONS"),
99            fk_fusion: !rlx_ir::env::flag("RLX_NO_FK_FUSION"),
100            fuse_region_prologue: if rlx_ir::env::is_unset("RLX_FUSE_REGION_PROLOGUE") {
101                true
102            } else {
103                rlx_ir::env::flag("RLX_FUSE_REGION_PROLOGUE")
104            },
105            fuse_batch_preprocess: if rlx_ir::env::is_unset("RLX_FUSE_BATCH_PREPROCESS") {
106                true
107            } else {
108                rlx_ir::env::flag("RLX_FUSE_BATCH_PREPROCESS")
109            },
110            native_fk_regions: rlx_ir::env::flag("RLX_NATIVE_FK_REGIONS"),
111            ..Self::default()
112        }
113    }
114
115    /// Merge session options with compile-time env overrides.
116    pub fn merge_env(mut self) -> Self {
117        if rlx_ir::env::flag("RLX_METAL_NO_FUSION") {
118            self.skip_fusion = true;
119        }
120        if rlx_ir::env::flag("RLX_METAL_UNFUSE_REGIONS") {
121            self.unfuse_elementwise_regions = true;
122        }
123        if rlx_ir::env::flag("RLX_KEEP_ELEMENTWISE_REGIONS") {
124            self.keep_elementwise_regions = true;
125        }
126        if rlx_ir::env::flag("RLX_DECOMPOSE_FUSION_REGIONS") {
127            self.decompose_fusion_regions = true;
128        }
129        if rlx_ir::env::flag("RLX_NO_FK_FUSION") {
130            self.fk_fusion = false;
131        }
132        if !rlx_ir::env::is_unset("RLX_FUSE_REGION_PROLOGUE") {
133            self.fuse_region_prologue = rlx_ir::env::flag("RLX_FUSE_REGION_PROLOGUE");
134        }
135        if !rlx_ir::env::is_unset("RLX_FUSE_BATCH_PREPROCESS") {
136            self.fuse_batch_preprocess = rlx_ir::env::flag("RLX_FUSE_BATCH_PREPROCESS");
137        }
138        if rlx_ir::env::flag("RLX_NATIVE_FK_REGIONS") {
139            self.native_fk_regions = true;
140        }
141        if rlx_ir::env_registry::flag("RLX_NO_NATIVE_FK_REGIONS") {
142            self.native_fk_regions = false;
143        }
144        if rlx_ir::env_registry::flag("RLX_DISABLE_CONV_BIAS_ACT_FUSION") {
145            self.disable_conv_bias_act_fusion = true;
146        }
147        if rlx_ir::env_registry::flag("RLX_NO_IO_PEAKS_OUTPUT") {
148            self.no_io_peaks_output = true;
149        }
150        self
151    }
152
153    /// GPU-class targets keep native FKL regions unless opted out.
154    pub fn apply_native_fk_defaults(mut self, target: FusionTarget) -> Self {
155        if rlx_ir::env::flag("RLX_NO_NATIVE_FK_REGIONS") {
156            self.native_fk_regions = false;
157            return self;
158        }
159        if self.native_fk_regions || rlx_ir::env::flag("RLX_NATIVE_FK_REGIONS") {
160            self.native_fk_regions = true;
161            return self;
162        }
163        if matches!(
164            target,
165            FusionTarget::Metal
166                | FusionTarget::Cuda
167                | FusionTarget::Rocm
168                | FusionTarget::Wgpu
169                | FusionTarget::Mlx
170                | FusionTarget::Tpu
171        ) {
172            self.native_fk_regions = true;
173        }
174        self
175    }
176
177    /// CPU executes element-wise chains as per-op thunks — mark then unfuse.
178    pub fn for_cpu() -> Self {
179        Self {
180            unfuse_elementwise_regions: true,
181            fusion_limits: FusionLimits::UNBOUNDED,
182            ..Self::default()
183        }
184    }
185
186    /// Metal keeps RMSNorm / matmul fusions but unfuses `ElementwiseRegion` by
187    /// default (fused MSL mis-lowers *long* chains on deep transformer graphs) —
188    /// UNLESS `RLX_KEEP_ELEMENTWISE_REGIONS` asks to keep them (short chains lower
189    /// fine and stay fused → fewer dispatches on the dispatch-bound backward).
190    pub fn for_metal() -> Self {
191        let mut opts = Self::from_metal_env();
192        if !opts.keep_elementwise_regions {
193            opts.unfuse_elementwise_regions = true;
194        }
195        opts
196    }
197
198    /// wgpu region kernel only supports trailing/scalar broadcast via
199    /// modulus — unfuse so LegalizeBroadcast Expand + Binary run separately.
200    pub fn for_wgpu() -> Self {
201        let keep = rlx_ir::env::flag("RLX_KEEP_ELEMENTWISE_REGIONS");
202        Self {
203            unfuse_elementwise_regions: !keep,
204            keep_elementwise_regions: keep,
205            ..Self::default()
206        }
207    }
208}
209
210/// The limits a compile should actually run with: the target's caps, bounded by
211/// what a region implementation can express whenever regions will survive.
212///
213/// Both this crate's `fuse` entry point and `rlx_runtime::stages::pipeline_for`
214/// need this, and they used to each carry their own copy of the target-default
215/// line. They drifted the moment a clamp was added to one of them — which is
216/// the same failure mode as every other duplicated platform list in this tree,
217/// so there is one function now.
218///
219/// **Why the clamp.** `UNBOUNDED` is only safe while regions are unfused again
220/// straight afterwards, which is why CPU asks for it. When a region instead
221/// survives to execution it has to be expressible, and CPU's region interpreter
222/// carries a fixed `[0f32; 32]` scratch that it indexes by chain step:
223///
224/// ```text
225/// panicked at rlx-cpu/src/thunk/ops/elementwise.rs:
226/// index out of bounds: the len is 32 but the index is 32
227/// ```
228///
229/// Reachable today via `RLX_KEEP_ELEMENTWISE_REGIONS=1`, which removes the
230/// unfuse for CPU, MLX, Metal and wgpu at once without touching their limits.
231pub fn resolve_fusion_limits(target: FusionTarget, opts: &FusionOptions) -> FusionLimits {
232    let mut limits = if opts.fusion_limits == FusionLimits::default() {
233        fusion_limits_for_target(target)
234    } else {
235        opts.fusion_limits
236    };
237    if !opts.unfuse_elementwise_regions {
238        let native = FusionLimits::GPU_NATIVE;
239        limits.max_elementwise_steps = limits
240            .max_elementwise_steps
241            .min(native.max_elementwise_steps);
242        limits.max_elementwise_inputs = limits
243            .max_elementwise_inputs
244            .min(native.max_elementwise_inputs);
245    }
246    limits
247}
248
249/// Elementwise-region caps for `target` (matches GPU kernel encoders).
250pub fn fusion_limits_for_target(target: FusionTarget) -> FusionLimits {
251    match target {
252        FusionTarget::Cpu => FusionLimits::UNBOUNDED,
253        FusionTarget::Tpu => FusionLimits {
254            max_elementwise_steps: 32,
255            max_elementwise_inputs: 16,
256        },
257        _ => FusionLimits::GPU_NATIVE,
258    }
259}
260
261/// True when `supported` is empty (no claim) or contains `kind`.
262#[inline]
263pub fn supports_op(supported: &[OpKind], kind: OpKind) -> bool {
264    supported.is_empty() || supported.contains(&kind)
265}
266
267/// Return the ordered fusion passes allowed for `supported`.
268///
269/// When `supported` is empty every fusion pass runs (legacy "accept
270/// all" backends). When non-empty, each pattern fusion pass is
271/// included only if the backend claims the fused [`OpKind`] it
272/// emits. Lowering passes (`LowerControlFlow`, `LowerDotGeneral`) and
273/// `FuseRmsNormReshape` (topology-only) always run unless
274/// `skip_fusion` is set.
275pub fn fusion_passes_for_supported(
276    supported: &[OpKind],
277    opts: FusionOptions,
278    target: FusionTarget,
279) -> Vec<&'static dyn Pass> {
280    let opts = opts.apply_native_fk_defaults(target);
281    if opts.skip_fusion {
282        return vec![&LowerControlFlow, &LowerDotGeneral];
283    }
284
285    let mut passes: Vec<&'static dyn Pass> = vec![&LowerControlFlow, &LowerDotGeneral];
286
287    // ORDER: FuseMatMulBiasAct first, then FuseAttentionBlock. The block-level
288    // pass matches the post-fusion shape
289    //   FusedMatMulBiasAct(qkv) → narrow×3 → Attention → FusedMatMulBiasAct(out)
290    // which is the pattern BERT-family encoders actually present after the
291    // per-layer matmul+bias fusion has collapsed Q, K, V, and out projections.
292    // `RLX_DISABLE_MATMUL_BIAS_FUSION=1` keeps `matmul → add(bias)` decomposed.
293    // Ablation knob: two ports (`rlx-vieeg` on wgpu, `rlx-tinymyo` on every
294    // accelerator) diverge from CPU at the op immediately *after* such a pair,
295    // and there was no way to take the fusion out of the picture to confirm it.
296    if supports_op(supported, OpKind::FusedMatMulBiasAct)
297        && rlx_ir::env::var("RLX_DISABLE_MATMUL_BIAS_FUSION").as_deref() != Some("1")
298    {
299        passes.push(&FuseMatMulBiasAct);
300    }
301    // Conv + bias + activation → cuDNN's fused conv-bias-activation (CUDA only
302    // claims `FusedConvBiasAct`; every other backend keeps it decomposed).
303    // `RLX_DISABLE_CONV_BIAS_ACT_FUSION=1` skips it (ablation / A-B benchmarking
304    // vs the unfused conv+bias+act path). `FuseConvAffineAct` folds a
305    // host-pre-folded BatchNorm affine (`conv→Mul→Add→relu`) into the same op.
306    if supports_op(supported, OpKind::FusedConvBiasAct) && !opts.disable_conv_bias_act_fusion {
307        passes.push(&FuseConvBiasAct);
308        passes.push(&FuseConvAffineAct);
309    }
310    // Block-level fusion: `Op::FusedAttentionBlock`. All backends that claim
311    // this op now produce parity-correct output (the MLX
312    // `Op::FusedAttentionBlock` lowering at `rlx-mlx/src/lower.rs:1689`
313    // historically diverged on `MaskKind::Custom` BERT masks because it
314    // bypassed the binary→additive conversion and the contiguous
315    // materialization the unfused `Op::Attention` path applies — fixed
316    // alongside this pass landing).
317    if supports_op(supported, OpKind::FusedAttentionBlock) {
318        passes.push(&FuseAttentionBlock);
319    }
320    // Collapse the 3 sibling AttentionBackward ops (dQ/dK/dV) into one
321    // AttentionBackwardAll (single score+softmax recompute). Backend-gated:
322    // only fires where the op is lowered (CPU), leaving other backends' own
323    // attention-backward handling untouched.
324    if supports_op(supported, OpKind::AttentionBackwardAll) {
325        passes.push(&FuseAttentionBackwardAll);
326    }
327    // FuseResidualLN must run BEFORE FuseTransformerLayer: the layer-level
328    // pass matches `FAB → FusedResidualLN → FMBA(GeLU) → FMBA → FusedResidualLN`
329    // and needs the residual+LN ops already collapsed.
330    // `RLX_DISABLE_RESIDUAL_LN_FUSION=1` keeps `residual + LayerNorm` apart.
331    // The fused form *tees*: it writes both the normed output and the residual
332    // sum, so it is the only op in these graphs that writes a slot it does not
333    // own — worth being able to take out of the picture.
334    if rlx_ir::env::var("RLX_DISABLE_RESIDUAL_LN_FUSION").as_deref() != Some("1")
335        && supports_op(supported, OpKind::FusedResidualLN)
336    {
337        passes.push(&FuseResidualLN);
338    }
339    if supports_op(supported, OpKind::FusedResidualRmsNorm) {
340        passes.push(&FuseResidualRmsNorm);
341    }
342    // DiT adaLN-Zero / gated residual — after residual-LN so we don't
343    // compete for Add→LN patterns; gated residual is independent.
344    if supports_op(supported, OpKind::AdaLayerNorm) {
345        passes.push(&FuseAdaLayerNorm);
346    }
347    // `RLX_DISABLE_GATED_RESIDUAL_FUSION=1` keeps the gated residual decomposed.
348    // Ablation knob: it is one of the few fused ops with a distinct kernel per
349    // backend, so it is a candidate whenever CPU disagrees with every
350    // accelerator on an otherwise identical graph.
351    if supports_op(supported, OpKind::GatedResidual)
352        && rlx_ir::env::var("RLX_DISABLE_GATED_RESIDUAL_FUSION").as_deref() != Some("1")
353    {
354        passes.push(&FuseGatedResidual);
355    }
356    passes.push(&FuseRmsNormReshape);
357
358    // Layer-level fusion runs AFTER FuseResidualLN so it can match the
359    // post-fusion shape `FAB → FusedResidualLN → FMBA(GeLU) → FMBA →
360    // FusedResidualLN`. Opt-in via `RLX_ENABLE_FUSE_TRANSFORMER_LAYER`
361    // because backend perf wins are uneven: WGPU un-fuses with no
362    // dispatch reduction; MLX's lowering is correct (per the FAB fix
363    // above) but the MLX `compile()` already collapses sub-ops, so the
364    // extra IR-level fusion doesn't beat the natural pipeline. The pass
365    // exists for backends planning a monolithic transformer-layer kernel.
366    if rlx_ir::env::flag("RLX_ENABLE_FUSE_TRANSFORMER_LAYER")
367        && supports_op(supported, OpKind::FusedTransformerLayer)
368        && supports_op(supported, OpKind::FusedAttentionBlock)
369    {
370        passes.push(&FuseTransformerLayer);
371    }
372
373    // Option B (`RLX_NO_WEIGHT_CONCAT_FUSION`): skip the two fusions that build a
374    // combined weight via a runtime `concat` of the individual weight params
375    // (gate/up here, QKV below). For batch-1 decode that concat re-copies ~1.18GB
376    // of CONSTANT weights every token (≈2× the weight bandwidth) — reading the
377    // weights in place (separate matmuls) is faster. The activation-only
378    // `FuseSwiGLU` (silu·mul) still runs.
379    let no_weight_concat = rlx_ir::env::flag("RLX_NO_WEIGHT_CONCAT_FUSION");
380    if supports_op(supported, OpKind::FusedSwiGLU) && !no_weight_concat {
381        passes.push(&FuseSwiGLUDualMatmul);
382    }
383    // Fold `add(skip, matmul)` residuals into the matmul store. Metal-only
384    // today (one fewer dispatch on the launch-latency-bound decode); other
385    // backends never claim the op, so the pass never runs for them and the
386    // plain MatMul + Add stays.
387    if supports_op(supported, OpKind::FusedMatMulResidual) {
388        passes.push(&FuseMatMulResidual);
389    }
390    // Opt out: `RLX_NO_SHARED_INPUT_MATMUL=1` (debug / parity vs unfused AdaLN)
391    // or `RLX_NO_WEIGHT_CONCAT_FUSION=1` (Option B — read QKV weights in place).
392    if supports_op(supported, OpKind::MatMul)
393        && !rlx_ir::env::flag("RLX_NO_SHARED_INPUT_MATMUL")
394        && !no_weight_concat
395    {
396        passes.push(&FuseSharedInputMatMul);
397    }
398    if supports_op(supported, OpKind::FusedSwiGLU) {
399        passes.push(&FuseSwiGLU);
400    }
401
402    // Mark eligible element-wise chains only when the backend keeps regions.
403    // CPU/Metal unfuse immediately afterward — marking first duplicates the
404    // full graph for no benefit.
405    let keep_regions =
406        supports_op(supported, OpKind::ElementwiseRegion) && !opts.unfuse_elementwise_regions;
407    if keep_regions {
408        passes.push(&MarkElementwiseRegions);
409    }
410    if opts.fk_fusion {
411        passes.push(&MarkBatchSliceRegions);
412        passes.push(&MarkTransformRegions);
413        if opts.fuse_region_prologue {
414            passes.push(&FuseRegionPrologue);
415        }
416        if opts.fuse_batch_preprocess {
417            passes.push(&FuseBatchPreprocess);
418        }
419    }
420    let backend_native_fk = supports_op(supported, OpKind::TransformRegion)
421        && supports_op(supported, OpKind::BatchElementwiseRegion);
422    let keep_native_fk = opts.native_fk_regions && backend_native_fk;
423    if opts.decompose_fusion_regions || !keep_native_fk {
424        passes.push(&DecomposeFusionRegions);
425    }
426    if !keep_regions {
427        let unfuse = if matches!(target, FusionTarget::Cpu) {
428            &UnfuseElementwiseRegions::FOR_CPU
429        } else {
430            &UnfuseElementwiseRegions::FOR_GPU
431        };
432        passes.push(unfuse);
433    }
434
435    if supports_op(supported, OpKind::Fft) && supports_op(supported, OpKind::WelchPeaks) {
436        passes.push(&SelectPeaksOnlyOutputs);
437    }
438
439    finish_pipeline(passes)
440}
441
442/// FKL passes to run after [`MarkElementwiseRegions`] (e.g. `TpuExecutable::compile`).
443pub fn fk_passes_after_elementwise_regions(
444    supported: &[OpKind],
445    opts: FusionOptions,
446) -> Vec<&'static dyn Pass> {
447    let mut passes: Vec<&'static dyn Pass> = Vec::new();
448    if !opts.fk_fusion {
449        let backend_native_fk = supports_op(supported, OpKind::TransformRegion)
450            && supports_op(supported, OpKind::BatchElementwiseRegion);
451        let keep_native_fk = opts.native_fk_regions && backend_native_fk;
452        if opts.decompose_fusion_regions || !keep_native_fk {
453            passes.push(&DecomposeFusionRegions);
454        }
455        return finish_pipeline(passes);
456    }
457    passes.push(&MarkBatchSliceRegions);
458    passes.push(&MarkTransformRegions);
459    if opts.fuse_region_prologue {
460        passes.push(&FuseRegionPrologue);
461    }
462    if opts.fuse_batch_preprocess {
463        passes.push(&FuseBatchPreprocess);
464    }
465    let backend_native_fk = supports_op(supported, OpKind::TransformRegion)
466        && supports_op(supported, OpKind::BatchElementwiseRegion);
467    let keep_native_fk = opts.native_fk_regions && backend_native_fk;
468    if opts.decompose_fusion_regions || !keep_native_fk {
469        passes.push(&DecomposeFusionRegions);
470    }
471    finish_pipeline(passes)
472}
473
474/// IO gate decision for a rewrite on `target` (convenience for compile passes / model crates).
475pub fn should_fuse_with_target(
476    target: FusionTarget,
477    before: &crate::fusion_benefit::GraphIoProfile,
478    after: &crate::fusion_benefit::GraphIoProfile,
479) -> bool {
480    io_fusion_gate_for_target(target).should_fuse(before, after)
481}
482
483/// Phase 3 — IO-aware gate defaults for fusion rewrites on `target`.
484pub fn io_fusion_gate_for_target(target: FusionTarget) -> crate::fusion_benefit::IoFusionGate {
485    use crate::fusion_benefit::IoFusionGate;
486    match target {
487        FusionTarget::Metal | FusionTarget::Mlx => IoFusionGate {
488            dispatch_ns: 500.0,
489            roundtrip_ns: 5_000.0,
490            memory_bw: 200.0,
491            host_readback_bw: 200.0,
492            unified_memory: true,
493            host_thunk_penalty_ns: 2_000_000.0,
494            min_gain_ns: 1_000.0,
495        },
496        FusionTarget::Cuda | FusionTarget::Rocm => IoFusionGate {
497            dispatch_ns: 2_000.0,
498            roundtrip_ns: 20_000.0,
499            memory_bw: 800.0,
500            host_readback_bw: 50.0,
501            unified_memory: false,
502            host_thunk_penalty_ns: 15_000_000.0,
503            min_gain_ns: 5_000.0,
504        },
505        FusionTarget::Wgpu | FusionTarget::Tpu => IoFusionGate {
506            dispatch_ns: 3_000.0,
507            roundtrip_ns: 30_000.0,
508            memory_bw: 100.0,
509            host_readback_bw: 40.0,
510            unified_memory: false,
511            host_thunk_penalty_ns: 25_000_000.0,
512            min_gain_ns: 10_000.0,
513        },
514        FusionTarget::Cpu => IoFusionGate {
515            dispatch_ns: 50.0,
516            roundtrip_ns: 0.0,
517            memory_bw: 50.0,
518            host_readback_bw: 50.0,
519            unified_memory: true,
520            host_thunk_penalty_ns: 0.0,
521            min_gain_ns: 0.0,
522        },
523    }
524}
525
526/// Return the ordered fusion passes for `target`.
527pub fn fusion_passes(target: FusionTarget, opts: FusionOptions) -> Vec<&'static dyn Pass> {
528    let mut opts = opts;
529    // CPU thunks execute element-wise chains per-op. Metal's fused
530    // `ElementwiseRegion` MSL kernel mis-lowers long chains on deep
531    // transformer graphs (NaNs past ~14 blocks); keep FAB/RMSNorm fusions.
532    if !opts.keep_elementwise_regions
533        && matches!(target, FusionTarget::Cpu | FusionTarget::Metal)
534        && !opts.unfuse_elementwise_regions
535    {
536        opts.unfuse_elementwise_regions = true;
537    }
538    if opts.fusion_limits == FusionLimits::default() {
539        opts.fusion_limits = fusion_limits_for_target(target);
540    }
541    opts = opts.apply_native_fk_defaults(target);
542    fusion_passes_for_supported(supported_for_target(target), opts, target)
543}
544
545/// Per-target op claims used when a backend doesn't supply an explicit
546/// `supported_ops` slice. Must stay aligned with each backend's
547/// `*_SUPPORTED_OPS` in `rlx-runtime/src/backend.rs`.
548pub fn supported_for_target(target: FusionTarget) -> &'static [OpKind] {
549    use OpKind::*;
550    match target {
551        FusionTarget::Cpu => &[
552            MatMul,
553            DotGeneral,
554            ElementwiseRegion,
555            FusedSwiGLU,
556            FusedMatMulBiasAct,
557            FusedResidualLN,
558            FusedResidualRmsNorm,
559            FusedAttentionBlock,
560            AdaLayerNorm,
561            GatedResidual,
562        ],
563        FusionTarget::Metal => &[
564            MatMul,
565            DotGeneral,
566            ElementwiseRegion,
567            TransformRegion,
568            BatchElementwiseRegion,
569            FusedSwiGLU,
570            FusedMatMulBiasAct,
571            FusedResidualLN,
572            FusedResidualRmsNorm,
573            AdaLayerNorm,
574            GatedResidual,
575        ],
576        FusionTarget::Mlx => &[
577            MatMul,
578            DotGeneral,
579            ElementwiseRegion,
580            TransformRegion,
581            BatchElementwiseRegion,
582            FusedSwiGLU,
583            FusedMatMulBiasAct,
584            FusedResidualLN,
585            FusedResidualRmsNorm,
586            AdaLayerNorm,
587            GatedResidual,
588        ],
589        FusionTarget::Wgpu => &[
590            MatMul,
591            ElementwiseRegion,
592            TransformRegion,
593            BatchElementwiseRegion,
594            FusedSwiGLU,
595            FusedMatMulBiasAct,
596            FusedResidualLN,
597            FusedResidualRmsNorm,
598            AdaLayerNorm,
599            GatedResidual,
600            FusedAttentionBlock,
601            FusedTransformerLayer,
602        ],
603        // CUDA lowers `FusedConvBiasAct` to cuDNN's fused conv-bias-activation.
604        // ROCm/MIOpen has no fused-conv path yet, so it is NOT claimed there —
605        // its conv-bias-activation stays decomposed until a MIOpen lowering lands.
606        FusionTarget::Cuda => &[
607            MatMul,
608            DotGeneral,
609            ElementwiseRegion,
610            TransformRegion,
611            BatchElementwiseRegion,
612            FusedMatMulBiasAct,
613            FusedConvBiasAct,
614            FusedResidualLN,
615            FusedResidualRmsNorm,
616            AdaLayerNorm,
617            GatedResidual,
618        ],
619        FusionTarget::Rocm => &[
620            MatMul,
621            DotGeneral,
622            ElementwiseRegion,
623            TransformRegion,
624            BatchElementwiseRegion,
625            FusedMatMulBiasAct,
626            FusedResidualLN,
627            FusedResidualRmsNorm,
628            AdaLayerNorm,
629            GatedResidual,
630        ],
631        FusionTarget::Tpu => &[
632            MatMul,
633            ElementwiseRegion,
634            TransformRegion,
635            BatchElementwiseRegion,
636            FusedMatMulBiasAct,
637            FusedResidualLN,
638            AdaLayerNorm,
639            GatedResidual,
640        ],
641    }
642}
643
644fn finish_pipeline(mut passes: Vec<&'static dyn Pass>) -> Vec<&'static dyn Pass> {
645    passes.push(&DeadCodeElimination);
646    passes
647}
648
649/// Run the fusion pipeline for `target` on a MIR graph (IO-gated passes included).
650pub fn run_fusion_pipeline(
651    graph: Graph,
652    target: FusionTarget,
653    supported: &[OpKind],
654    opts: FusionOptions,
655) -> Graph {
656    let mut opts = opts.apply_native_fk_defaults(target);
657    opts.fusion_limits = resolve_fusion_limits(target, &opts);
658    let limits = opts.fusion_limits;
659    let passes = fusion_passes_for_supported(supported, opts, target);
660    with_fusion_target(target, || {
661        with_fusion_limits(limits, || run_passes(graph, &passes, false))
662    })
663}
664
665impl FusionOptions {
666    /// Canonical option set for a target, matching what each backend's
667    /// compile path uses: CPU unfuses element-wise regions (it executes them
668    /// as per-op thunks), Metal/MLX keep matmul/norm fusions but unfuse long
669    /// element-wise chains, wgpu unfuses for its modulus-broadcast kernel, and
670    /// the remaining GPU-class targets take the defaults (native FKL regions
671    /// are then enabled by [`apply_native_fk_defaults`](Self::apply_native_fk_defaults)).
672    /// This is the option set the one-call [`fuse`] / [`Fuse`] entries pick.
673    pub fn for_target(target: FusionTarget) -> Self {
674        match target {
675            FusionTarget::Cpu => Self::for_cpu(),
676            FusionTarget::Metal | FusionTarget::Mlx => Self::for_metal(),
677            FusionTarget::Wgpu => Self::for_wgpu(),
678            FusionTarget::Cuda | FusionTarget::Rocm | FusionTarget::Tpu => Self::default(),
679        }
680    }
681}
682
683/// One-call fusion: optimize `graph` for `target` with canonical defaults.
684///
685/// The ergonomic front door over [`run_fusion_pipeline`]. Callers no longer
686/// assemble the supported-op table or pick a per-target [`FusionOptions`] —
687/// both easy to get wrong. Exactly equivalent to:
688///
689/// ```ignore
690/// run_fusion_pipeline(
691///     graph, target,
692///     supported_for_target(target),
693///     FusionOptions::for_target(target),
694/// )
695/// ```
696///
697/// `Op::Input` / `Op::Param` leaves survive fusion, so recover handles into
698/// the rewritten graph by name with [`rlx_ir::Graph::node_id_by_name`];
699/// outputs stay positionally stable in `graph.outputs`. For custom limits,
700/// skip-fusion, or a change report, use the [`Fuse`] builder.
701pub fn fuse(graph: Graph, target: FusionTarget) -> Graph {
702    Fuse::new(target).run(graph)
703}
704
705/// Fluent fusion builder. `Fuse::new(target)` starts from the canonical
706/// [`FusionOptions::for_target`] defaults; setters override individual toggles
707/// before [`run`](Self::run) / [`run_with_report`](Self::run_with_report).
708///
709/// ```ignore
710/// let (fused, report) = Fuse::new(FusionTarget::Cpu)
711///     .limits(FusionLimits::UNBOUNDED)
712///     .run_with_report(graph);
713/// eprintln!("{}", report.summary_line());
714/// ```
715#[derive(Debug, Clone)]
716pub struct Fuse {
717    target: FusionTarget,
718    options: FusionOptions,
719}
720
721impl Fuse {
722    /// Start from the canonical defaults for `target`.
723    pub fn new(target: FusionTarget) -> Self {
724        Self {
725            target,
726            options: FusionOptions::for_target(target),
727        }
728    }
729
730    /// Replace the whole option set.
731    pub fn options(mut self, options: FusionOptions) -> Self {
732        self.options = options;
733        self
734    }
735
736    /// Override the element-wise fusion limits.
737    pub fn limits(mut self, limits: FusionLimits) -> Self {
738        self.options.fusion_limits = limits;
739        self
740    }
741
742    /// Disable all pattern fusion (lowering legalization only).
743    pub fn skip_fusion(mut self, skip: bool) -> Self {
744        self.options.skip_fusion = skip;
745        self
746    }
747
748    /// Layer compile-time env overrides (`RLX_*`) on top of the builder state.
749    pub fn merge_env(mut self) -> Self {
750        self.options = self.options.merge_env();
751        self
752    }
753
754    /// Run the pipeline, returning the optimized graph.
755    pub fn run(self, graph: Graph) -> Graph {
756        run_fusion_pipeline(
757            graph,
758            self.target,
759            supported_for_target(self.target),
760            self.options,
761        )
762    }
763
764    /// Run the pipeline and also return a [`FusionReport`] describing the
765    /// before→after change (op/region deltas, fusions left on the table) for
766    /// logging or assertions.
767    ///
768    /// [`FusionReport`]: rlx_fusion::fusion_report::FusionReport
769    pub fn run_with_report(self, graph: Graph) -> (Graph, rlx_fusion::fusion_report::FusionReport) {
770        let before = graph.clone();
771        let after = self.run(graph);
772        let report = rlx_fusion::fusion_report::FusionReport::analyze(&before, &after);
773        (after, report)
774    }
775}
776
777#[cfg(test)]
778mod tests {
779    use super::*;
780    use std::sync::Mutex;
781
782    /// Serializes every test that builds a pass list.
783    ///
784    /// `RLX_NO_NATIVE_FK_REGIONS` is a *process-global* env var that
785    /// `apply_native_fk_defaults` reads for **every** target, and
786    /// `tpu_native_fk_region_pass_policy` sets it. Any test calling
787    /// `fusion_passes` or `apply_native_fk_defaults` concurrently with that
788    /// one sees `native_fk_regions = false` and fails on an assertion that
789    /// has nothing to do with what it is testing. Hold this lock in any new
790    /// test here that builds a pass list.
791    static ENV_FK_TEST_LOCK: Mutex<()> = Mutex::new(());
792
793    /// Take [`ENV_FK_TEST_LOCK`], ignoring poisoning.
794    ///
795    /// The mutex orders env-var access; it guards no invariant that a panicking
796    /// test could leave broken. Unwrapping instead would turn one real failure
797    /// into a cascade of `PoisonError` panics in every sibling test.
798    fn env_fk_lock() -> std::sync::MutexGuard<'static, ()> {
799        ENV_FK_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
800    }
801
802    /// The one-call `fuse` / `Fuse` API folds matmul+bias+act on CPU and keeps
803    /// Input/Param leaves recoverable by name in the rewritten graph — the
804    /// ergonomic contract callers rely on instead of threading a remap table.
805    #[test]
806    fn fuse_one_call_collapses_matmul_bias_act_and_keeps_handles() {
807        use rlx_ir::op::{Activation, BinaryOp};
808        use rlx_ir::{DType, Graph, Shape};
809
810        let f = DType::F32;
811        let mut g = Graph::new("mm_bias_act");
812        let x = g.input("x", Shape::new(&[4, 8], f));
813        let w = g.param("w", Shape::new(&[8, 16], f));
814        let bias = g.param("bias", Shape::new(&[16], f));
815        let mm = g.matmul(x, w, Shape::new(&[4, 16], f));
816        let ba = g.binary(BinaryOp::Add, mm, bias, Shape::new(&[4, 16], f));
817        let act = g.activation(Activation::Relu, ba, Shape::new(&[4, 16], f));
818        g.set_outputs(vec![act]);
819        let before = g.len();
820
821        let (fused, report) = Fuse::new(FusionTarget::Cpu).run_with_report(g);
822
823        assert!(
824            fused.len() < before,
825            "matmul+bias+relu should fuse on CPU: {before} -> {} ({})",
826            fused.len(),
827            report.summary_line()
828        );
829        // Leaves survive a fusion rewrite; handles recoverable by name.
830        assert!(fused.input_id("x").is_some(), "input x lost after fusion");
831        assert!(fused.param_id("w").is_some(), "param w lost after fusion");
832        assert!(fused.param_id("bias").is_some(), "param bias lost");
833        assert!(fused.node_id_by_name("w").is_some());
834        // Outputs stay positionally stable.
835        assert_eq!(fused.outputs.len(), 1);
836    }
837
838    #[test]
839    fn cpu_pipeline_includes_attention_block() {
840        let _lock = env_fk_lock();
841        let passes = fusion_passes(FusionTarget::Cpu, FusionOptions::default());
842        assert_eq!(
843            passes.len(),
844            19,
845            "CPU default supported_ops omit Fft/WelchPeaks and mark_elementwise (unfuse-only backends skip mark); includes FuseAdaLayerNorm + FuseGatedResidual"
846        );
847        assert_eq!(passes[2].name(), "fuse_matmul_bias_act");
848        assert_eq!(passes[3].name(), "fuse_attention_block");
849        assert!(
850            passes.iter().any(|p| p.name() == "fuse_region_prologue"),
851            "default CPU pipeline should run FKL prologue fusion"
852        );
853        assert!(
854            !passes
855                .iter()
856                .any(|p| p.name() == "mark_elementwise_regions"),
857            "CPU unfuse backends should not mark elementwise regions before unfusing"
858        );
859        assert_eq!(passes.last().unwrap().name(), "dead_code_elimination");
860    }
861
862    #[test]
863    fn metal_skip_fusion_only_lowers_dot() {
864        let _lock = env_fk_lock();
865        let passes = fusion_passes(
866            FusionTarget::Metal,
867            FusionOptions {
868                skip_fusion: true,
869                ..FusionOptions::default()
870            },
871        );
872        assert_eq!(passes.len(), 2);
873        assert_eq!(passes[0].name(), "LowerControlFlow");
874        assert_eq!(passes[1].name(), "lower_dot_general");
875    }
876
877    #[test]
878    fn metal_supported_ops_omit_attention_block_fusion() {
879        let passes = fusion_passes_for_supported(
880            supported_for_target(FusionTarget::Metal),
881            FusionOptions::default(),
882            FusionTarget::Metal,
883        );
884        assert!(
885            !passes.iter().any(|p| p.name() == "fuse_attention_block"),
886            "Metal should not run FuseAttentionBlock"
887        );
888        assert!(
889            passes.iter().any(|p| p.name() == "fuse_matmul_bias_act"),
890            "Metal should fuse matmul+bias+act"
891        );
892    }
893
894    #[test]
895    fn cuda_supported_ops_fuse_matmul_bias_act() {
896        let passes = fusion_passes_for_supported(
897            supported_for_target(FusionTarget::Cuda),
898            FusionOptions::default(),
899            FusionTarget::Cuda,
900        );
901        assert!(
902            passes.iter().any(|p| p.name() == "fuse_matmul_bias_act"),
903            "CUDA should fuse matmul+bias+act when claimed"
904        );
905        assert!(
906            !passes.iter().any(|p| p.name() == "fuse_swiglu"),
907            "CUDA should not fuse SwiGLU"
908        );
909    }
910
911    #[test]
912    fn cpu_unfuses_elementwise_regions() {
913        let passes = fusion_passes_for_supported(
914            supported_for_target(FusionTarget::Cpu),
915            FusionOptions::for_cpu(),
916            FusionTarget::Cpu,
917        );
918        assert!(
919            passes
920                .iter()
921                .any(|p| p.name() == "unfuse_elementwise_regions")
922        );
923    }
924
925    #[test]
926    fn metal_unfuses_elementwise_regions_by_default() {
927        let _lock = env_fk_lock();
928        let passes = fusion_passes(FusionTarget::Metal, FusionOptions::default());
929        assert!(
930            passes
931                .iter()
932                .any(|p| p.name() == "unfuse_elementwise_regions")
933        );
934    }
935
936    #[test]
937    fn metal_default_unfuse_preserves_prologue_regions() {
938        let _lock = env_fk_lock();
939        let mut g = rlx_ir::Graph::new("t");
940        let shape_in = rlx_ir::Shape::new(&[1, 3, 8, 8], rlx_ir::DType::F32);
941        let shape_out = rlx_ir::Shape::new(&[1, 3, 16, 16], rlx_ir::DType::F32);
942        let x = g.input("x", shape_in);
943        let up = g.add_node(rlx_ir::Op::ResizeNearest2x, vec![x], shape_out.clone());
944        let r = g.add_node(
945            rlx_ir::Op::Activation(rlx_ir::op::Activation::Relu),
946            vec![up],
947            shape_out,
948        );
949        g.set_outputs(vec![r]);
950
951        let passes = fusion_passes(FusionTarget::Metal, FusionOptions::default());
952        let out = rlx_fusion::pass::run_passes(g, &passes, false);
953        assert!(out.nodes().iter().any(|n| {
954            matches!(
955                n.op,
956                rlx_ir::Op::ElementwiseRegion {
957                    prologue: rlx_ir::RegionPrologue::ResizeNearest2x,
958                    ..
959                }
960            )
961        }));
962    }
963
964    #[test]
965    fn fk_passes_after_elementwise_includes_batch_fusion() {
966        let _lock = env_fk_lock();
967        let opts = FusionOptions::default().apply_native_fk_defaults(FusionTarget::Tpu);
968        let passes =
969            fk_passes_after_elementwise_regions(supported_for_target(FusionTarget::Tpu), opts);
970        let names: Vec<_> = passes.iter().map(|p| p.name()).collect();
971        assert!(names.contains(&"mark_batch_slice_regions"));
972        assert!(names.contains(&"fuse_batch_preprocess"));
973        assert!(
974            !names.contains(&"decompose_fusion_regions"),
975            "TPU native FK defaults should keep batch/transform regions"
976        );
977    }
978
979    #[test]
980    fn tpu_native_fk_region_pass_policy() {
981        let _lock = env_fk_lock();
982        let default_passes = fusion_passes(FusionTarget::Tpu, FusionOptions::default());
983        assert!(
984            !default_passes
985                .iter()
986                .any(|p| p.name() == "decompose_fusion_regions"),
987            "default TPU pipeline keeps batch/transform regions via native_fk_defaults"
988        );
989
990        rlx_ir::env::set("RLX_NO_NATIVE_FK_REGIONS", "1");
991        let opt_out = fusion_passes(FusionTarget::Tpu, FusionOptions::default());
992        rlx_ir::env::unset("RLX_NO_NATIVE_FK_REGIONS");
993        assert!(
994            opt_out
995                .iter()
996                .any(|p| p.name() == "decompose_fusion_regions"),
997            "RLX_NO_NATIVE_FK_REGIONS should force decompose on TPU"
998        );
999    }
1000
1001    #[test]
1002    fn native_fk_regions_skips_decompose_on_tpu() {
1003        let _lock = env_fk_lock();
1004        let passes = fusion_passes(
1005            FusionTarget::Tpu,
1006            FusionOptions {
1007                native_fk_regions: true,
1008                decompose_fusion_regions: false,
1009                unfuse_elementwise_regions: false,
1010                ..FusionOptions::default()
1011            },
1012        );
1013        assert!(
1014            !passes
1015                .iter()
1016                .any(|p| p.name() == "decompose_fusion_regions"),
1017            "native_fk_regions should skip decompose on TPU when batch/transform are supported"
1018        );
1019    }
1020
1021    #[test]
1022    fn native_fk_regions_skips_decompose_on_metal() {
1023        let _lock = env_fk_lock();
1024        let passes = fusion_passes(
1025            FusionTarget::Metal,
1026            FusionOptions {
1027                native_fk_regions: true,
1028                decompose_fusion_regions: false,
1029                unfuse_elementwise_regions: false,
1030                ..FusionOptions::default()
1031            },
1032        );
1033        assert!(
1034            !passes
1035                .iter()
1036                .any(|p| p.name() == "decompose_fusion_regions"),
1037            "native_fk_regions should skip decompose when backend claims batch/transform ops"
1038        );
1039    }
1040
1041    #[test]
1042    fn metal_keeps_elementwise_regions_when_requested() {
1043        let _lock = env_fk_lock();
1044        let passes = fusion_passes(
1045            FusionTarget::Metal,
1046            FusionOptions {
1047                keep_elementwise_regions: true,
1048                unfuse_elementwise_regions: false,
1049                ..FusionOptions::default()
1050            },
1051        );
1052        assert!(
1053            !passes
1054                .iter()
1055                .any(|p| p.name() == "unfuse_elementwise_regions"),
1056            "keep_elementwise_regions should skip unfuse pass"
1057        );
1058        assert!(
1059            passes.iter().any(|p| p.name() == "fuse_region_prologue"),
1060            "FKL prologue fusion should still run"
1061        );
1062    }
1063
1064    #[test]
1065    fn metal_audio_ops_pipeline_includes_peaks_output_gate() {
1066        let mut supported = supported_for_target(FusionTarget::Metal).to_vec();
1067        supported.push(OpKind::Fft);
1068        supported.push(OpKind::WelchPeaks);
1069        let passes =
1070            fusion_passes_for_supported(&supported, FusionOptions::default(), FusionTarget::Metal);
1071        assert!(
1072            passes
1073                .iter()
1074                .any(|p| p.name() == "select_peaks_only_outputs"),
1075            "Metal + Fft/WelchPeaks should run IO peaks-only output gate"
1076        );
1077    }
1078
1079    #[test]
1080    fn should_fuse_with_target_matches_gate() {
1081        use crate::fusion_benefit::GraphIoProfile;
1082        let dense = GraphIoProfile {
1083            kernel_launches: 3,
1084            sync_points: 0,
1085            host_output_bytes: 33_554_432,
1086            device_traffic_bytes: 184_549_376,
1087        };
1088        let fused = GraphIoProfile {
1089            kernel_launches: 4,
1090            sync_points: 1,
1091            host_output_bytes: 1_048_576,
1092            device_traffic_bytes: 219_152_384,
1093        };
1094        assert!(should_fuse_with_target(FusionTarget::Metal, &dense, &fused));
1095        assert!(!should_fuse_with_target(FusionTarget::Wgpu, &dense, &fused));
1096    }
1097}