1use 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub struct FusionOptions {
49 pub skip_fusion: bool,
51 pub unfuse_elementwise_regions: bool,
53 pub keep_elementwise_regions: bool,
55 pub decompose_fusion_regions: bool,
57 pub fk_fusion: bool,
59 pub fuse_region_prologue: bool,
61 pub fuse_batch_preprocess: bool,
63 pub native_fk_regions: bool,
65 pub fusion_limits: FusionLimits,
67 pub disable_conv_bias_act_fusion: bool,
69 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 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 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 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 pub fn for_cpu() -> Self {
179 Self {
180 unfuse_elementwise_regions: true,
181 fusion_limits: FusionLimits::UNBOUNDED,
182 ..Self::default()
183 }
184 }
185
186 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 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
210pub 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
249pub 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#[inline]
263pub fn supports_op(supported: &[OpKind], kind: OpKind) -> bool {
264 supported.is_empty() || supported.contains(&kind)
265}
266
267pub 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 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 if supports_op(supported, OpKind::FusedConvBiasAct) && !opts.disable_conv_bias_act_fusion {
307 passes.push(&FuseConvBiasAct);
308 passes.push(&FuseConvAffineAct);
309 }
310 if supports_op(supported, OpKind::FusedAttentionBlock) {
318 passes.push(&FuseAttentionBlock);
319 }
320 if supports_op(supported, OpKind::AttentionBackwardAll) {
325 passes.push(&FuseAttentionBackwardAll);
326 }
327 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 if supports_op(supported, OpKind::AdaLayerNorm) {
345 passes.push(&FuseAdaLayerNorm);
346 }
347 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 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 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 if supports_op(supported, OpKind::FusedMatMulResidual) {
388 passes.push(&FuseMatMulResidual);
389 }
390 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 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
442pub 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
474pub 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
483pub 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
526pub fn fusion_passes(target: FusionTarget, opts: FusionOptions) -> Vec<&'static dyn Pass> {
528 let mut opts = opts;
529 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
545pub 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 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
649pub 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 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
683pub fn fuse(graph: Graph, target: FusionTarget) -> Graph {
702 Fuse::new(target).run(graph)
703}
704
705#[derive(Debug, Clone)]
716pub struct Fuse {
717 target: FusionTarget,
718 options: FusionOptions,
719}
720
721impl Fuse {
722 pub fn new(target: FusionTarget) -> Self {
724 Self {
725 target,
726 options: FusionOptions::for_target(target),
727 }
728 }
729
730 pub fn options(mut self, options: FusionOptions) -> Self {
732 self.options = options;
733 self
734 }
735
736 pub fn limits(mut self, limits: FusionLimits) -> Self {
738 self.options.fusion_limits = limits;
739 self
740 }
741
742 pub fn skip_fusion(mut self, skip: bool) -> Self {
744 self.options.skip_fusion = skip;
745 self
746 }
747
748 pub fn merge_env(mut self) -> Self {
750 self.options = self.options.merge_env();
751 self
752 }
753
754 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 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 static ENV_FK_TEST_LOCK: Mutex<()> = Mutex::new(());
792
793 fn env_fk_lock() -> std::sync::MutexGuard<'static, ()> {
799 ENV_FK_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
800 }
801
802 #[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 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 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}