Skip to main content

rlx_compile/
compiler.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! HIR → MIR → LIR compiler pipeline.
17//!
18//! Explicit staging for the RLX compiler:
19//!
20//! ```text
21//! HIR (blocks)  ──lower──▶  MIR (tensor DAG)  ──opt──▶  MIR  ──plan──▶  LIR
22//! ```
23//!
24//! Backends consume [`CompileResult`] / [`LirModule`] (optimized MIR +
25//! buffer plan + fusion report) and lower to device-specific thunks.
26
27use rlx_ir::dynamic::collect_dynamic_symbols;
28use rlx_ir::hir::HirModule;
29use rlx_ir::lir::{LirBufferPlan, LirBufferSlot, LirIoManifest, LirModule, LirViewAlias};
30use rlx_ir::mir::MirModule;
31use rlx_ir::phase::derive_phases;
32use rlx_ir::{Graph, GraphModule, GraphStage, NodeId};
33
34use crate::DeadCodeElimination;
35use crate::debug_assert_graph;
36use crate::fusion_pipeline::{
37    FusionOptions, FusionTarget, fusion_limits_for_target, fusion_passes_for_supported,
38    supported_for_target,
39};
40use crate::fusion_target::with_fusion_target;
41use crate::legalize::{format_legalize_error, legalize_for_backend};
42use crate::memory::{self, MemoryPlan};
43use crate::rewrite::rewrite_for_backend_with_config;
44use rlx_fusion::fusion_report::FusionReport;
45use rlx_fusion::pass::run_passes;
46use rlx_fusion::{clip_elementwise_regions, with_fusion_limits};
47use rlx_ir::OpKind;
48use rlx_ir::logical_kernel::KernelDispatchConfig;
49
50/// End-to-end compiler output: optimized LIR + fusion diagnostics.
51#[derive(Debug, Clone)]
52pub struct CompileResult {
53    pub lir: LirModule,
54    pub fusion: FusionReport,
55}
56
57impl CompileResult {
58    pub fn has_dynamic_dims(&self) -> bool {
59        self.lir.has_dynamic_dims()
60    }
61
62    pub fn dynamic_symbols(&self) -> &[u32] {
63        self.lir.dynamic_symbols()
64    }
65
66    /// Re-plan buffers after binding symbolic dims to concrete sizes.
67    pub fn specialize(&self, pipeline: &CompilePipeline, binding: &rlx_ir::DimBinding) -> Self {
68        Self {
69            lir: pipeline.specialize_lir(&self.lir, binding),
70            fusion: self.fusion.clone(),
71        }
72    }
73}
74
75/// End-to-end compiler pipeline configuration.
76#[derive(Debug, Clone, Copy)]
77pub struct CompilePipeline {
78    pub target: FusionTarget,
79    pub opts: FusionOptions,
80    pub arena_alignment: usize,
81    /// When true, [`compile_hir`] / [`compile_graph`] panic if fusion
82    /// diagnostics report missed block-level patterns.
83    pub assert_fusion_clean: bool,
84    /// Backend op claim set. When `Some` and non-empty, fusion passes
85    /// are gated on these kinds and the optimized graph is legalized
86    /// afterward. When `None`, [`supported_for_target`] is used.
87    pub supported_ops: Option<&'static [OpKind]>,
88    /// Override the legalize-error backend label (e.g. `"vulkan"` /
89    /// `"oneapi"` when [`FusionTarget`] is still [`FusionTarget::Wgpu`]
90    /// for shared fusion patterns). When `None`, derived from `target`.
91    pub backend_label: Option<&'static str>,
92    /// Native vs common IR lowering for logical kernels (see `rlx_ir::logical_kernel`).
93    pub kernel_dispatch: KernelDispatchConfig,
94}
95
96impl Default for CompilePipeline {
97    fn default() -> Self {
98        Self {
99            target: FusionTarget::Cpu,
100            opts: FusionOptions::for_cpu(),
101            arena_alignment: 64,
102            assert_fusion_clean: false,
103            supported_ops: None,
104            backend_label: None,
105            kernel_dispatch: KernelDispatchConfig::from_env(),
106        }
107    }
108}
109
110fn lstm_y_shape(x: &rlx_ir::Shape, hidden_size: usize, bidirectional: bool) -> rlx_ir::Shape {
111    let dirs = if bidirectional { 2 } else { 1 };
112    if x.rank() == 3 {
113        let seq = x.dim(0).unwrap_static();
114        let batch = x.dim(1).unwrap_static().max(1);
115        return rlx_ir::Shape::new(&[seq, dirs, batch, hidden_size], x.dtype());
116    }
117    rlx_ir::Shape::new(&[1, dirs, 1, hidden_size], x.dtype())
118}
119
120/// `sync_graph_shapes` can collapse `[seq,1,C]` LSTM inputs to `[1,1,C]`; restore seq.
121fn fix_import_lstm_x_shape(x: &rlx_ir::Shape) -> rlx_ir::Shape {
122    if x.rank() != 3 {
123        return x.clone();
124    }
125    let d0 = x.dim(0).unwrap_static();
126    let d1 = x.dim(1).unwrap_static();
127    let d2 = x.dim(2).unwrap_static();
128    if d0 == 1 && d1 <= 1 && (d2 == 640 || d2 == 512) {
129        let seq = std::env::var("RLX_ONNX_SEQUENCE_LENGTH")
130            .ok()
131            .and_then(|s| s.parse().ok())
132            .unwrap_or(128);
133        return rlx_ir::Shape::new(&[seq, d1.max(1), d2], x.dtype());
134    }
135    x.clone()
136}
137
138fn fix_lstm_output_shapes(graph: &mut Graph) {
139    use rlx_ir::Op;
140    let ids: Vec<NodeId> = graph.nodes().iter().map(|n| n.id).collect();
141    for id in ids {
142        let node = graph.node(id).clone();
143        let Op::Custom { name, attrs, .. } = &node.op else {
144            continue;
145        };
146        if !name.contains("LSTM") {
147            continue;
148        }
149        let hidden_size = if attrs.len() >= 4 {
150            u32::from_le_bytes(attrs[0..4].try_into().unwrap()) as usize
151        } else {
152            256
153        };
154        let bidirectional = attrs.len() > 4 && attrs[4] != 0;
155        let x_id = node.inputs[0];
156        let x = fix_import_lstm_x_shape(&graph.node(x_id).shape);
157        graph.node_mut(x_id).shape = x.clone();
158        graph.node_mut(id).shape = lstm_y_shape(&x, hidden_size, bidirectional);
159    }
160}
161
162/// `sync_graph_shapes` can collapse `[1, seq, C]` activations to `[1, 1, C]`
163/// when seq>1; restore from `RLX_ONNX_SEQUENCE_LENGTH` and propagate once.
164///
165/// Only runs when `RLX_ONNX_SEQUENCE_LENGTH` is set explicitly — decode graphs such as
166/// Qwen3 talker use legitimate `[1, 1, H]` hidden states and must not be expanded.
167fn fix_import_sequence_axis(graph: &mut Graph) {
168    let Ok(seq_str) = std::env::var("RLX_ONNX_SEQUENCE_LENGTH") else {
169        return;
170    };
171    let seq: usize = match seq_str.parse() {
172        Ok(s) if s > 1 => s,
173        _ => return,
174    };
175    for id in graph.nodes().iter().map(|n| n.id).collect::<Vec<_>>() {
176        let node = graph.node(id);
177        if node.shape.rank() != 3 {
178            continue;
179        }
180        let dims: Vec<_> = node
181            .shape
182            .dims()
183            .iter()
184            .map(|d| d.unwrap_static())
185            .collect();
186        if dims[0] == 1 && dims[1] == 1 && dims[2] >= 64 {
187            graph.node_mut(id).shape = rlx_ir::Shape::new(&[1, seq, dims[2]], node.shape.dtype());
188        }
189    }
190    for id in graph.topo_order().collect::<Vec<_>>() {
191        let node = graph.node(id).clone();
192        if let Some(shape) = rlx_ir::infer_shape::infer_output_shape(graph, &node) {
193            graph.node_mut(id).shape = shape;
194        }
195    }
196}
197
198impl CompilePipeline {
199    pub fn new(target: FusionTarget) -> Self {
200        let mut opts = match target {
201            FusionTarget::Cpu => FusionOptions::for_cpu(),
202            FusionTarget::Metal => FusionOptions::for_metal(),
203            FusionTarget::Wgpu => FusionOptions::for_wgpu(),
204            _ => FusionOptions::default(),
205        };
206        opts.fusion_limits = fusion_limits_for_target(target);
207        Self {
208            target,
209            opts,
210            ..Self::default()
211        }
212    }
213
214    pub fn with_assert_fusion_clean(mut self, assert: bool) -> Self {
215        self.assert_fusion_clean = assert;
216        self
217    }
218
219    /// HIR → MIR (block lowering only).
220    pub fn lower_hir(hir: HirModule) -> Result<MirModule, rlx_ir::hir::LowerError> {
221        let mut mir = hir.lower_to_mir()?;
222        rlx_ir::dynamic::sync_graph_shapes(mir.as_graph_mut());
223        debug_assert_graph!(mir.as_graph(), "hir→mir");
224        Ok(mir)
225    }
226
227    /// Optional cleanup before fusion (DCE + control-flow lowering).
228    pub fn preprocess_mir(mir: MirModule) -> MirModule {
229        use rlx_fusion::pass::Pass as _;
230        let graph = rlx_fusion::control_flow::LowerControlFlow.run(mir.into_graph());
231        let graph = DeadCodeElimination.run(graph);
232        MirModule::from_graph(graph)
233    }
234
235    pub fn with_supported_ops(mut self, ops: &'static [OpKind]) -> Self {
236        self.supported_ops = Some(ops);
237        self
238    }
239
240    pub fn with_backend_label(mut self, label: &'static str) -> Self {
241        self.backend_label = Some(label);
242        self
243    }
244
245    pub fn with_kernel_dispatch(
246        mut self,
247        policy: rlx_ir::logical_kernel::KernelDispatchPolicy,
248    ) -> Self {
249        self.kernel_dispatch.policy = policy;
250        self
251    }
252
253    pub fn with_kernel_dispatch_config(mut self, config: KernelDispatchConfig) -> Self {
254        self.kernel_dispatch = config;
255        self
256    }
257
258    fn effective_supported(&self) -> &'static [OpKind] {
259        self.supported_ops
260            .unwrap_or_else(|| supported_for_target(self.target))
261    }
262
263    fn backend_name(&self) -> &'static str {
264        if let Some(label) = self.backend_label {
265            return label;
266        }
267        match self.target {
268            FusionTarget::Cpu => "cpu",
269            FusionTarget::Metal => "metal",
270            FusionTarget::Mlx => "mlx",
271            FusionTarget::Wgpu => "wgpu",
272            FusionTarget::Cuda => "cuda",
273            FusionTarget::Rocm => "rocm",
274            FusionTarget::Tpu => "tpu",
275        }
276    }
277
278    /// Run fusion + cleanup passes on MIR, returning fusion diagnostics.
279    pub fn optimize_with_report(&self, mir: MirModule) -> (MirModule, FusionReport) {
280        let need_fusion_diff = self.assert_fusion_clean || rlx_ir::env::flag("RLX_FUSION_REPORT");
281        let before = need_fusion_diff.then(|| mir.as_graph().clone());
282        let passes =
283            fusion_passes_for_supported(self.effective_supported(), self.opts, self.target);
284        let limits = self.opts.fusion_limits;
285        let graph = with_fusion_target(self.target, || {
286            with_fusion_limits(limits, || run_passes(mir.into_graph(), &passes, false))
287        });
288        let graph = clip_elementwise_regions(graph, limits);
289        debug_assert_graph!(&graph, "fusion");
290        // Downstream-registered fusion / rewrite passes (empty by default). Run
291        // after the built-in fusion pipeline so core invariants hold, but before
292        // legalize so their output is still lowered/legalized for the backend.
293        let graph = rlx_fusion::pass::run_registered_ir_passes(graph);
294        let mut graph = self.legalize_after_fusion(graph);
295        rlx_ir::dynamic::sync_graph_shapes(&mut graph);
296        fix_import_sequence_axis(&mut graph);
297        fix_lstm_output_shapes(&mut graph);
298        debug_assert_graph!(&graph, "legalize");
299        let mir = MirModule::from_graph(graph);
300        // Static NaN-source lint (opt-in): report provable compile-time
301        // non-finite values with provenance before the graph ever runs.
302        if rlx_ir::env::flag("RLX_LINT_NUMERICS") {
303            for lint in crate::numeric_lint::lint_numerics(mir.as_graph()) {
304                eprintln!("rlx numeric-lint: {lint}");
305            }
306        }
307        let fusion = if let Some(ref before) = before {
308            rlx_fusion::FusionReport::analyze(before, mir.as_graph())
309        } else {
310            rlx_fusion::FusionReport::scan(mir.as_graph())
311        };
312        (mir, fusion)
313    }
314
315    /// Rewrite / legalize fused IR against the backend op claim set.
316    /// Runs when [`supported_ops`](Self::supported_ops) is set (including
317    /// auto-wiring from [`Backend::supported_ops`] in [`crate::stages::pipeline_for`]).
318    pub(crate) fn legalize_after_fusion(&self, graph: Graph) -> Graph {
319        let Some(supported) = self.supported_ops else {
320            if self.kernel_dispatch.force_common_kinds.is_empty()
321                && self.kernel_dispatch.policy
322                    == rlx_ir::logical_kernel::KernelDispatchPolicy::PreferNative
323            {
324                return graph;
325            }
326            return rewrite_for_backend_with_config(graph, &[], self.kernel_dispatch);
327        };
328        if supported.is_empty() {
329            return graph;
330        }
331        let graph = rewrite_for_backend_with_config(graph, supported, self.kernel_dispatch);
332        if let Err(errors) = legalize_for_backend(&graph, supported) {
333            panic!("{}", format_legalize_error(self.backend_name(), &errors));
334        }
335        graph
336    }
337
338    /// Run fusion + cleanup passes on MIR.
339    pub fn optimize(&self, mir: MirModule) -> MirModule {
340        self.optimize_with_report(mir).0
341    }
342
343    /// MIR → LIR (memory plan + schedule + phases + I/O manifest).
344    pub fn plan_lir(&self, mir: MirModule) -> LirModule {
345        self.plan_lir_with_options(mir, memory::MemoryPlanOptions::default())
346    }
347
348    /// MIR → LIR with explicit boundary allocation policy.
349    pub fn plan_lir_with_options(
350        &self,
351        mir: MirModule,
352        opts: memory::MemoryPlanOptions,
353    ) -> LirModule {
354        let graph = mir.as_graph();
355        let plan = memory::plan_memory_with_options(graph, self.arena_alignment, opts);
356        let buffers = lir_buffer_plan_from_memory(graph, &plan, self.arena_alignment);
357        LirModule::new(mir, buffers)
358    }
359
360    /// Bind symbolic dims and re-run buffer planning on specialized MIR.
361    pub fn specialize_lir(&self, lir: &LirModule, binding: &rlx_ir::DimBinding) -> LirModule {
362        use rlx_ir::dynamic::{
363            bind_graph, sync_concat_shapes, sync_expand_ops, sync_graph_shapes, sync_narrow_ops,
364            sync_reshape_ops,
365        };
366        let mut bound = bind_graph(lir.as_graph(), binding);
367        sync_reshape_ops(&mut bound);
368        sync_concat_shapes(&mut bound);
369        sync_narrow_ops(&mut bound);
370        sync_expand_ops(&mut bound);
371        sync_graph_shapes(&mut bound);
372        debug_assert_graph!(&bound, "specialize");
373        self.plan_lir(MirModule::from_graph(bound))
374    }
375
376    fn finish(&self, mir: MirModule, fusion: FusionReport) -> CompileResult {
377        debug_assert_graph!(mir.as_graph(), "pre-lir");
378        if self.assert_fusion_clean && !fusion.missed.is_empty() {
379            panic!(
380                "fusion contract violated: {} missed patterns\n{fusion}",
381                fusion.missed.len()
382            );
383        }
384        CompileResult {
385            lir: self.plan_lir(mir),
386            fusion,
387        }
388    }
389
390    /// HIR → LIR in one call with fusion report.
391    pub fn compile_hir(&self, hir: HirModule) -> Result<CompileResult, rlx_ir::hir::LowerError> {
392        if rlx_ir::env::var("RLX_IR_DUMP").is_some() {
393            let name = hir.name.clone();
394            let dump = crate::inspect::inspect_pipeline(self, hir.clone())?;
395            crate::inspect::maybe_dump_pipeline(&dump, &name);
396        }
397        let dbg = rlx_ir::env::var("RLX_PHASE_TIMING").is_some();
398        #[cfg(not(target_arch = "wasm32"))]
399        let t = if dbg {
400            Some(std::time::Instant::now())
401        } else {
402            None
403        };
404        let mir = Self::lower_hir(hir)?;
405        #[cfg(not(target_arch = "wasm32"))]
406        if let Some(t) = t {
407            eprintln!("[phase]   lower_hir = {}ms", t.elapsed().as_millis());
408        }
409        #[cfg(not(target_arch = "wasm32"))]
410        let t = if dbg {
411            Some(std::time::Instant::now())
412        } else {
413            None
414        };
415        let (mir, fusion) = self.optimize_with_report(mir);
416        #[cfg(not(target_arch = "wasm32"))]
417        if let Some(t) = t {
418            eprintln!("[phase]   optimize = {}ms", t.elapsed().as_millis());
419        }
420        #[cfg(not(target_arch = "wasm32"))]
421        let t = if dbg {
422            Some(std::time::Instant::now())
423        } else {
424            None
425        };
426        let r = self.finish(mir, fusion);
427        #[cfg(not(target_arch = "wasm32"))]
428        if let Some(t) = t {
429            eprintln!("[phase]   finish(plan+lir) = {}ms", t.elapsed().as_millis());
430        }
431        Ok(r)
432    }
433
434    /// Legacy MIR entry: optimize + plan with fusion report.
435    pub fn compile_mir(&self, mir: MirModule) -> CompileResult {
436        let (mir, fusion) = self.optimize_with_report(mir);
437        self.finish(mir, fusion)
438    }
439
440    /// Legacy entry: optimize an existing graph and plan buffers.
441    pub fn compile_graph(&self, graph: Graph) -> CompileResult {
442        self.compile_mir(MirModule::from_graph(graph))
443    }
444
445    /// Unified entry for [`GraphModule`] at any pipeline stage.
446    pub fn compile_module(
447        &self,
448        module: GraphModule,
449    ) -> Result<CompileResult, rlx_ir::hir::LowerError> {
450        match module.stage() {
451            GraphStage::Hir => {
452                let hir = module
453                    .into_hir()
454                    .expect("GraphModule stage() / into_hir mismatch");
455                self.compile_hir(hir)
456            }
457            GraphStage::Mir => {
458                let mir = module.into_mir()?;
459                Ok(self.compile_mir(mir))
460            }
461            GraphStage::Lir => Ok(CompileResult {
462                lir: module
463                    .into_lir()
464                    .expect("GraphModule stage() / into_lir mismatch"),
465                fusion: FusionReport::default(),
466            }),
467        }
468    }
469}
470
471impl From<&MemoryPlan> for LirBufferPlan {
472    fn from(plan: &MemoryPlan) -> Self {
473        LirBufferPlan {
474            arena_size: plan.arena_size,
475            assignments: plan
476                .assignments
477                .iter()
478                .map(|(id, slot)| {
479                    (
480                        *id,
481                        LirBufferSlot {
482                            offset: slot.offset,
483                            size: slot.size,
484                        },
485                    )
486                })
487                .collect(),
488            schedule: plan.schedule.clone(),
489            ..Default::default()
490        }
491    }
492}
493
494impl From<&LirBufferPlan> for MemoryPlan {
495    fn from(plan: &LirBufferPlan) -> Self {
496        MemoryPlan {
497            arena_size: plan.arena_size,
498            assignments: plan
499                .assignments
500                .iter()
501                .map(|(id, slot)| {
502                    (
503                        *id,
504                        memory::BufferSlot {
505                            offset: slot.offset,
506                            size: slot.size,
507                        },
508                    )
509                })
510                .collect(),
511            schedule: plan.schedule.clone(),
512        }
513    }
514}
515
516pub(crate) fn lir_buffer_plan_from_memory(
517    graph: &Graph,
518    plan: &MemoryPlan,
519    alignment: usize,
520) -> LirBufferPlan {
521    let view_aliases = memory::collect_view_aliases(graph)
522        .into_iter()
523        .map(|(id, (root, byte_offset))| (id, LirViewAlias { root, byte_offset }))
524        .collect();
525    LirBufferPlan {
526        arena_size: plan.arena_size,
527        assignments: plan
528            .assignments
529            .iter()
530            .map(|(id, slot)| {
531                (
532                    *id,
533                    LirBufferSlot {
534                        offset: slot.offset,
535                        size: slot.size,
536                    },
537                )
538            })
539            .collect(),
540        schedule: plan.schedule.clone(),
541        view_aliases,
542        phases: derive_phases(graph),
543        io: LirIoManifest::collect(graph),
544        alignment,
545        dynamic_symbols: collect_dynamic_symbols(graph),
546    }
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552    use rlx_ir::DType;
553    use rlx_ir::Op;
554    use rlx_ir::Shape;
555    use rlx_ir::hir::FusionPolicy;
556
557    fn f32_shape(d: &[usize]) -> Shape {
558        Shape::new(d, DType::F32)
559    }
560
561    #[test]
562    fn pipeline_hir_to_lir() {
563        let mut hir = HirModule::new("layer");
564        let x = hir.input("x", f32_shape(&[2, 128]));
565        let w = hir.param("w", f32_shape(&[128, 128]));
566        let b = hir.param("b", f32_shape(&[128]));
567        let h = hir.linear(x, w, Some(b), None, f32_shape(&[2, 128]));
568        hir.outputs = vec![h];
569
570        let pipe = CompilePipeline::new(FusionTarget::Cpu);
571        let result = pipe.compile_hir(hir).expect("compile");
572        assert!(result.lir.mir.len() <= 5);
573        assert!(result.lir.arena_size() > 0);
574        assert!(result.lir.buffers.bytes_saved() <= result.lir.buffers.total_unshared_bytes());
575        assert!(result.fusion.fused_matmul_bias_act >= 1 || result.lir.mir.len() <= 5);
576    }
577
578    #[test]
579    fn direct_hir_swiglu_emits_fused_op() {
580        let mut hir = HirModule::new("ffn");
581        let x = hir.input("x", f32_shape(&[4, 768]));
582        let up_w = hir.param("up", f32_shape(&[768, 2048]));
583        let gate_w = hir.param("gate", f32_shape(&[768, 2048]));
584        let down_w = hir.param("down", f32_shape(&[2048, 768]));
585        let out = hir.swiglu_ffn(x, up_w, gate_w, down_w, f32_shape(&[4, 768]));
586        hir.outputs = vec![out];
587
588        let pipe = CompilePipeline::new(FusionTarget::Cpu);
589        let result = pipe.compile_hir(hir).expect("compile");
590        let g = result.lir.mir.as_graph();
591        assert!(
592            g.nodes()
593                .iter()
594                .any(|n| matches!(n.op, Op::FusedSwiGLU { .. })),
595            "direct HIR SwiGLU should lower to FusedSwiGLU"
596        );
597        assert!(result.fusion.missed_matmul_bias_act() == 0 || result.fusion.fused_swiglu >= 1);
598    }
599
600    #[test]
601    fn compile_module_from_graph_define() {
602        let module = GraphModule::define("ffn", |m| {
603            let x = m.input("x", f32_shape(&[2, 64]));
604            let w = m.param("w", f32_shape(&[64, 64]));
605            m.linear(x, w, None, None, f32_shape(&[2, 64]))
606        });
607        assert_eq!(module.stage(), GraphStage::Hir);
608
609        let pipe = CompilePipeline::new(FusionTarget::Cpu);
610        let result = pipe.compile_module(module).expect("compile_module");
611        assert!(result.lir.arena_size() > 0);
612    }
613
614    #[test]
615    fn fusable_policy_leaves_room_for_passes() {
616        let mut hir = HirModule::new("ffn").with_fusion_policy(FusionPolicy::Fusable);
617        let x = hir.input("x", f32_shape(&[4, 768]));
618        let up_w = hir.param("up", f32_shape(&[768, 2048]));
619        let gate_w = hir.param("gate", f32_shape(&[768, 2048]));
620        let down_w = hir.param("down", f32_shape(&[2048, 768]));
621        let out = hir.swiglu_ffn(x, up_w, gate_w, down_w, f32_shape(&[4, 768]));
622        hir.outputs = vec![out];
623
624        let mir = CompilePipeline::lower_hir(hir).expect("lower");
625        let g = mir.as_graph();
626        assert!(g.nodes().iter().any(|n| matches!(n.op, Op::MatMul)));
627        assert_eq!(g.len(), 9);
628
629        let pipe = CompilePipeline::new(FusionTarget::Cpu);
630        let result = pipe.compile_mir(mir);
631        assert!(result.fusion.fused_swiglu >= 1);
632    }
633
634    #[test]
635    fn lir_plan_includes_phases_io_and_fingerprint() {
636        use rlx_ir::phase::Phase;
637
638        let mut hir = HirModule::new("stream");
639        let x = hir.input("x", f32_shape(&[1, 8]));
640        let w = hir.param("w", f32_shape(&[8, 4]));
641        let mm = hir.linear(x, w, None, None, f32_shape(&[1, 4]));
642        hir.set_outputs(vec![mm]);
643
644        let result = CompilePipeline::new(FusionTarget::Cpu)
645            .compile_hir(hir)
646            .expect("compile");
647        assert!(!result.lir.buffers.phases.is_empty());
648        let input_id = result.lir.buffers.io.inputs[0].1;
649        assert_eq!(
650            result.lir.buffers.phases.get(input_id),
651            Some(Phase::Prologue)
652        );
653        assert_eq!(result.lir.buffers.io.inputs.len(), 1);
654        assert_eq!(result.lir.fingerprint(), result.lir.fingerprint());
655        assert_eq!(result.lir.buffers.alignment, 64);
656    }
657
658    #[test]
659    fn numeric_lint_catches_const_div_by_zero_through_pipeline() {
660        // A constant `1.0 / 0.0` must be reported by the static lint whether
661        // const-folding bakes it into an inf Constant or leaves the Div in
662        // place — both are provable NaN sources with provenance.
663        let mut g = Graph::new("bad");
664        let one = g.add_node(
665            Op::Constant {
666                data: 1.0f32.to_le_bytes().to_vec(),
667            },
668            vec![],
669            f32_shape(&[1]),
670        );
671        let zero = g.add_node(
672            Op::Constant {
673                data: 0.0f32.to_le_bytes().to_vec(),
674            },
675            vec![],
676            f32_shape(&[1]),
677        );
678        let d = g.binary(rlx_ir::op::BinaryOp::Div, one, zero, f32_shape(&[1]));
679        g.set_outputs(vec![d]);
680
681        let result = CompilePipeline::new(FusionTarget::Cpu).compile_graph(g);
682        let lints = crate::numeric_lint::lint_numerics(result.lir.mir.as_graph());
683        assert!(
684            !lints.is_empty(),
685            "compiled graph should surface the div-by-zero as a numeric lint"
686        );
687    }
688
689    #[test]
690    fn decode_hidden_shape_not_expanded_without_env() {
691        // Qwen3 talker decode uses [1, 1, H] hidden states; must not be expanded to
692        // [1, RLX_ONNX_SEQUENCE_LENGTH, H] unless that env is set explicitly.
693        let mut g = Graph::new("decode_out");
694        let x = g.input("x", f32_shape(&[1, 1, 1024]));
695        g.set_outputs(vec![x]);
696        let pipe = CompilePipeline::new(FusionTarget::Cpu);
697        let result = pipe.compile_graph(g);
698        let out = result
699            .lir
700            .mir
701            .as_graph()
702            .node(result.lir.mir.as_graph().outputs[0]);
703        assert_eq!(out.shape.dims()[1].unwrap_static(), 1);
704        assert_eq!(out.shape.num_elements(), Some(1024));
705    }
706
707    #[test]
708    fn dynamic_graph_compiles_and_specializes() {
709        use rlx_ir::DimBinding;
710        use rlx_ir::infer::GraphExt as _;
711        use rlx_ir::sym;
712
713        let mut g = Graph::new("dyn");
714        let x = g.input("x", Shape::batch_seq_2d(sym::BATCH, sym::SEQ, DType::F32));
715        let w = g.param("w", Shape::new(&[4, 8], DType::F32));
716        let y = g.mm(x, w);
717        g.set_outputs(vec![y]);
718
719        let pipe = CompilePipeline::new(FusionTarget::Cpu);
720        let result = pipe.compile_graph(g);
721        assert!(result.has_dynamic_dims());
722        assert!(result.lir.buffers.dynamic_symbols.contains(&sym::SEQ));
723
724        let bound = result.specialize(&pipe, &DimBinding::batch_seq(2, 16));
725        assert!(bound.lir.is_fully_static());
726        assert!(bound.lir.arena_size() > 0);
727    }
728}