Skip to main content

rlx_ir/
inspect.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//! Text exporters for inspecting HIR / MIR / LIR during lowering.
17//!
18//! Use [`inspect_hir`], [`inspect_mir`], and [`inspect_lir`] to dump
19//! each pipeline stage as human-readable text (similar to LLVM `-print-*`
20//! flags). [`inspect_graph`] is the MIR body formatter shared by MIR and
21//! LIR dumps.
22
23use std::collections::BTreeMap;
24use std::fmt::Write as _;
25
26use crate::hir::{HirModule, HirNode, HirOp};
27use crate::lir::{LirBufferPlan, LirModule, LirViewAlias};
28use crate::mir::MirModule;
29use crate::phase::Phase;
30use crate::pretty::{header_line, op_kinds_line, pretty_print};
31use crate::{Graph, NodeId};
32
33/// Annotated HIR module dump.
34pub fn inspect_hir(hir: &HirModule) -> String {
35    let mut out = String::new();
36    writeln!(
37        out,
38        "hir @{} ({} nodes, {} outputs, fusion={:?})",
39        hir.name,
40        hir.len(),
41        hir.outputs.len(),
42        hir.fusion_policy,
43    )
44    .unwrap();
45    writeln!(out, "{}", hir_op_kinds_line(hir)).unwrap();
46    writeln!(out).unwrap();
47
48    let mut tag_w = 0usize;
49    for node in hir.nodes() {
50        let t = hir_node_tag(node);
51        tag_w = tag_w.max(t.len());
52    }
53
54    for node in hir.nodes() {
55        let tag = hir_node_tag(node);
56        write!(out, "  {tag:<width$} = ", width = tag_w).unwrap();
57        write!(out, "{}", format_hir_op(&node.op)).unwrap();
58        if !node.inputs.is_empty() {
59            write!(out, "(").unwrap();
60            for (i, inp) in node.inputs.iter().enumerate() {
61                if i > 0 {
62                    write!(out, ", ").unwrap();
63                }
64                write!(out, "{inp}").unwrap();
65            }
66            write!(out, ")").unwrap();
67        }
68        write!(out, " : {}", node.shape).unwrap();
69        if hir.outputs.contains(&node.id) {
70            write!(out, "  ← output").unwrap();
71        }
72        writeln!(out).unwrap();
73    }
74    if !hir.outputs.is_empty() {
75        write!(out, "  return ").unwrap();
76        for (i, o) in hir.outputs.iter().enumerate() {
77            if i > 0 {
78                write!(out, ", ").unwrap();
79            }
80            write!(out, "{o}").unwrap();
81        }
82        writeln!(out).unwrap();
83    }
84    out
85}
86
87/// Annotated MIR module dump (optimized tensor DAG).
88pub fn inspect_mir(mir: &MirModule) -> String {
89    inspect_mir_with_diff(mir, None)
90}
91
92/// MIR dump with optional fusion diff against a pre-optimize snapshot.
93pub fn inspect_mir_with_diff(mir: &MirModule, before: Option<&MirModule>) -> String {
94    let g = mir.as_graph();
95    let mut out = String::new();
96    writeln!(out, "mir @{} {{", mir.name()).unwrap();
97    if let Some(b) = before {
98        writeln!(out).unwrap();
99        out.push_str(&inspect_graph_diff(b.as_graph(), g));
100        writeln!(out).unwrap();
101        writeln!(out, "--- graph ---").unwrap();
102    }
103    writeln!(out).unwrap();
104    out.push_str(&pretty_print(g));
105    if !out.ends_with('\n') {
106        out.push('\n');
107    }
108    write!(out, "}}").unwrap();
109    out
110}
111
112/// Diff two MIR snapshots (typically pre/post fusion).
113pub fn inspect_mir_diff(before: &MirModule, after: &MirModule) -> String {
114    inspect_graph_diff(before.as_graph(), after.as_graph())
115}
116
117/// Summarize graph changes between pipeline stages.
118pub fn inspect_graph_diff(before: &Graph, after: &Graph) -> String {
119    use std::collections::BTreeMap;
120
121    let mut out = String::new();
122    writeln!(
123        out,
124        "  diff: {} → {} nodes ({} → {} outputs)",
125        before.len(),
126        after.len(),
127        before.outputs.len(),
128        after.outputs.len(),
129    )
130    .unwrap();
131
132    let count_kinds = |g: &Graph| {
133        let mut h: BTreeMap<String, i32> = BTreeMap::new();
134        for n in g.nodes() {
135            *h.entry(format!("{:?}", n.op.kind())).or_insert(0) += 1;
136        }
137        h
138    };
139    let b = count_kinds(before);
140    let a = count_kinds(after);
141    let mut keys: Vec<String> = b.keys().chain(a.keys()).cloned().collect();
142    keys.sort();
143    keys.dedup();
144    let mut changes = Vec::new();
145    for k in keys {
146        let d = a.get(&k).copied().unwrap_or(0) - b.get(&k).copied().unwrap_or(0);
147        if d != 0 {
148            changes.push(format!("{k}{d:+}"));
149        }
150    }
151    if !changes.is_empty() {
152        writeln!(out, "  op delta: {}", changes.join(", ")).unwrap();
153    }
154    out
155}
156
157/// Annotated LIR dump: optimized MIR + buffer plan + schedule.
158pub fn inspect_lir(lir: &LirModule) -> String {
159    let mut out = String::new();
160    writeln!(out, "lir @{} {{", lir.name()).unwrap();
161    writeln!(out, "  fingerprint: {:016x}", lir.fingerprint().0).unwrap();
162    writeln!(out).unwrap();
163    out.push_str(&inspect_buffer_plan(&lir.buffers));
164    if !lir.buffers.phases.is_empty() {
165        writeln!(out).unwrap();
166        out.push_str(&inspect_phases(&lir.buffers));
167    }
168    if !lir.buffers.io.inputs.is_empty() || !lir.buffers.io.params.is_empty() {
169        writeln!(out).unwrap();
170        out.push_str(&inspect_io_manifest(&lir.buffers));
171    }
172    writeln!(out).unwrap();
173    writeln!(out, "--- mir ---").unwrap();
174    out.push_str(&pretty_print(lir.as_graph()));
175    if !out.ends_with('\n') {
176        out.push('\n');
177    }
178    write!(out, "}}").unwrap();
179    out
180}
181
182/// Annotated graph dump (MIR body). Alias for [`pretty_print`].
183pub fn inspect_graph(g: &Graph) -> String {
184    pretty_print(g)
185}
186
187/// One-line HIR summary (header + op histogram).
188pub fn inspect_hir_stats(hir: &HirModule) -> String {
189    format!(
190        "hir @{} ({} nodes, {} outputs, fusion={:?})\n{}",
191        hir.name,
192        hir.len(),
193        hir.outputs.len(),
194        hir.fusion_policy,
195        hir_op_kinds_line(hir),
196    )
197}
198
199/// One-line MIR summary.
200pub fn inspect_mir_stats(mir: &MirModule) -> String {
201    let g = mir.as_graph();
202    format!(
203        "mir @{} — {}\n{}",
204        mir.name(),
205        header_line(g),
206        op_kinds_line(g),
207    )
208}
209
210/// Buffer plan section for LIR inspection.
211pub fn inspect_buffer_plan(plan: &LirBufferPlan) -> String {
212    let mut out = String::new();
213    let saved = plan.bytes_saved();
214    let naive = plan.total_unshared_bytes();
215    writeln!(
216        out,
217        "  arena: {} bytes (saved {} vs {} naive, align={})",
218        plan.arena_size, saved, naive, plan.alignment,
219    )
220    .unwrap();
221    writeln!(
222        out,
223        "  schedule: {} nodes, {} views",
224        plan.schedule.len(),
225        plan.view_aliases.len(),
226    )
227    .unwrap();
228    if !plan.dynamic_symbols.is_empty() {
229        let syms: Vec<String> = plan
230            .dynamic_symbols
231            .iter()
232            .map(|s| format!("?{s}"))
233            .collect();
234        writeln!(out, "  dynamic: {}", syms.join(", ")).unwrap();
235    }
236    writeln!(out).unwrap();
237    writeln!(out, "  # offset\tsize\tnode").unwrap();
238
239    let mut rows: Vec<(usize, usize, NodeId)> = plan
240        .assignments
241        .iter()
242        .map(|(id, slot)| (slot.offset, slot.size, *id))
243        .collect();
244    rows.sort_by_key(|(off, _, _)| *off);
245    for (off, sz, id) in rows {
246        let sched = plan
247            .schedule
248            .iter()
249            .position(|&n| n == id)
250            .map(|i| format!(" sched={i}"))
251            .unwrap_or_default();
252        let view = plan
253            .view_aliases
254            .get(&id)
255            .map(|LirViewAlias { root, byte_offset }| format!(" view→{root}+{byte_offset}"))
256            .unwrap_or_default();
257        let phase = plan
258            .phases
259            .get(id)
260            .map(|p| format!(" {p:?}"))
261            .unwrap_or_default();
262        writeln!(out, "  {off}\t{sz}\t{id}{sched}{view}{phase}").unwrap();
263    }
264    out
265}
266
267fn inspect_phases(plan: &LirBufferPlan) -> String {
268    let mut out = String::from("  phases:\n");
269    for phase in [Phase::Prologue, Phase::SteadyState, Phase::Epilogue] {
270        let nodes = plan.nodes_in_phase(phase);
271        if !nodes.is_empty() {
272            writeln!(out, "    {phase:?}: {nodes:?}").unwrap();
273        }
274    }
275    out
276}
277
278fn inspect_io_manifest(plan: &LirBufferPlan) -> String {
279    let mut out = String::from("  io:\n");
280    for (name, id) in &plan.io.inputs {
281        writeln!(out, "    input \"{name}\" → {id}").unwrap();
282    }
283    for (name, id) in &plan.io.params {
284        writeln!(out, "    param \"{name}\" → {id}").unwrap();
285    }
286    if !plan.io.outputs.is_empty() {
287        write!(out, "    outputs: {:?}", plan.io.outputs).unwrap();
288        out.push('\n');
289    }
290    out
291}
292
293fn hir_op_kinds_line(hir: &HirModule) -> String {
294    let mut hist: BTreeMap<String, usize> = BTreeMap::new();
295    for node in hir.nodes() {
296        *hist.entry(hir_op_kind(&node.op)).or_insert(0) += 1;
297    }
298    let parts: Vec<String> = hist.into_iter().map(|(k, c)| format!("{k}={c}")).collect();
299    format!("  block ops: {}", parts.join(", "))
300}
301
302fn hir_op_kind(op: &HirOp) -> String {
303    match op {
304        HirOp::Input { .. } => "Input".into(),
305        HirOp::Param { .. } => "Param".into(),
306        HirOp::Constant { .. } => "Constant".into(),
307        HirOp::Linear { .. } => "Linear".into(),
308        HirOp::LinearFused { .. } => "LinearFused".into(),
309        HirOp::SharedLinearPair { .. } => "SharedLinearPair".into(),
310        HirOp::SwiGLU => "SwiGLU".into(),
311        HirOp::ResidualRmsNorm { .. } => "ResidualRmsNorm".into(),
312        HirOp::Attention { .. } => "Attention".into(),
313        HirOp::DepthwiseConv1dCausal { .. } => "DepthwiseConv1dCausal".into(),
314        HirOp::DequantMatMul { .. } => "DequantMatMul".into(),
315        HirOp::GatedDeltaNet { .. } => "GatedDeltaNet".into(),
316        HirOp::Lstm { .. } => "Lstm".into(),
317        HirOp::Gru { .. } => "Gru".into(),
318        HirOp::RoPE { .. } => "RoPE".into(),
319        HirOp::RmsNorm { .. } => "RmsNorm".into(),
320        HirOp::Mir(_) => "Mir".into(),
321        HirOp::LlamaDecoderBlock { .. } => "LlamaDecoderBlock".into(),
322        HirOp::Qwen35MtpHead { .. } => "Qwen35MtpHead".into(),
323    }
324}
325
326fn hir_node_tag(node: &HirNode) -> String {
327    let label: Option<String> = match &node.op {
328        HirOp::Input { name } => Some(format!("input \"{name}\"")),
329        HirOp::Param { name } => Some(format!("param \"{name}\"")),
330        _ => node.name.as_deref().map(|s| format!("\"{s}\"")),
331    };
332    match label {
333        Some(s) => format!("{} [{s}]", node.id),
334        None => format!("{}", node.id),
335    }
336}
337
338fn format_hir_op(op: &HirOp) -> String {
339    match op {
340        HirOp::Input { name } => format!("input(\"{name}\")"),
341        HirOp::Param { name } => format!("param(\"{name}\")"),
342        HirOp::Constant { data } => format!("constant({} bytes)", data.len()),
343        HirOp::Linear {
344            activation,
345            has_bias,
346        } => {
347            let mut s = String::from("linear");
348            if *has_bias {
349                s.push_str("+bias");
350            }
351            if let Some(act) = activation {
352                write!(s, "+{act:?}").unwrap();
353            }
354            s
355        }
356        HirOp::LinearFused { activation } => match activation {
357            Some(act) => format!("linear_fused({act:?})"),
358            None => "linear_fused".into(),
359        },
360        HirOp::SharedLinearPair { slot } => format!("shared_linear_pair(out={slot})"),
361        HirOp::SwiGLU => "swiglu_ffn".into(),
362        HirOp::ResidualRmsNorm { eps } => format!("residual_rms_norm(eps={eps})"),
363        HirOp::Attention {
364            num_heads,
365            head_dim,
366            mask,
367        } => format!("attention(heads={num_heads}, dim={head_dim}, mask={mask:?})"),
368        HirOp::DepthwiseConv1dCausal { kernel_size } => {
369            format!("depthwise_conv1d_causal(k={kernel_size})")
370        }
371        HirOp::DequantMatMul { scheme } => format!("dequant_matmul({scheme})"),
372        HirOp::GatedDeltaNet {
373            state_size,
374            carry_state,
375        } => {
376            if *carry_state {
377                format!("gated_delta_net(n={state_size},carry)")
378            } else {
379                format!("gated_delta_net(n={state_size})")
380            }
381        }
382        HirOp::Lstm {
383            hidden_size,
384            num_layers,
385            bidirectional,
386            ..
387        } => {
388            let dir = if *bidirectional { "bi" } else { "uni" };
389            format!("lstm(h={hidden_size},layers={num_layers},{dir})")
390        }
391        HirOp::Gru {
392            hidden_size,
393            num_layers,
394            bidirectional,
395            ..
396        } => {
397            let dir = if *bidirectional { "bi" } else { "uni" };
398            format!("gru(h={hidden_size},layers={num_layers},{dir})")
399        }
400        HirOp::RoPE { head_dim, n_rot } => format!("rope(d={head_dim}, n_rot={n_rot})"),
401        HirOp::RmsNorm { eps } => format!("rms_norm(eps={eps})"),
402        HirOp::LlamaDecoderBlock {
403            num_heads,
404            head_dim,
405            num_kv_heads,
406            eps,
407            mask,
408            rope_style,
409        } => format!(
410            "llama_decoder_block(heads={num_heads}, dim={head_dim}, kv={num_kv_heads}, eps={eps}, mask={mask:?}, rope={rope_style:?})"
411        ),
412        HirOp::Qwen35MtpHead {
413            num_heads,
414            head_dim,
415            mtp_vocab,
416            ..
417        } => format!("qwen35_mtp_head(heads={num_heads}, dim={head_dim}, vocab={mtp_vocab})"),
418        HirOp::Mir(inner) => format!("mir({inner})"),
419    }
420}
421
422// ── convenience methods on pipeline types ───────────────────────────────
423
424impl HirModule {
425    /// Text dump for inspection. Alias for [`inspect_hir`].
426    pub fn inspect(&self) -> String {
427        inspect_hir(self)
428    }
429}
430
431impl MirModule {
432    /// Text dump for inspection. Alias for [`inspect_mir`].
433    pub fn inspect(&self) -> String {
434        inspect_mir(self)
435    }
436}
437
438impl LirModule {
439    /// Text dump for inspection. Alias for [`inspect_lir`].
440    pub fn inspect(&self) -> String {
441        inspect_lir(self)
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448    use crate::DType;
449    use crate::Shape;
450
451    fn f32_shape(d: &[usize]) -> Shape {
452        Shape::new(d, DType::F32)
453    }
454
455    #[test]
456    fn inspect_hir_includes_blocks_and_outputs() {
457        let mut hir = HirModule::new("layer");
458        let x = hir.input("x", f32_shape(&[2, 128]));
459        let w = hir.param("w", f32_shape(&[128, 128]));
460        let h = hir.linear(x, w, None, None, f32_shape(&[2, 128]));
461        hir.outputs = vec![h];
462
463        let text = inspect_hir(&hir);
464        assert!(text.contains("hir @layer"));
465        assert!(text.contains("linear"));
466        assert!(text.contains("← output"));
467        assert!(text.contains("fusion=Direct"));
468    }
469
470    #[test]
471    fn inspect_mir_wraps_pretty_print() {
472        let mut hir = HirModule::new("m");
473        let x = hir.input("x", f32_shape(&[4]));
474        hir.outputs = vec![x];
475        let mir = hir.lower_to_mir().expect("lower");
476
477        let text = inspect_mir(&mir);
478        assert!(text.contains("mir @m"));
479        assert!(text.contains("graph @m"));
480        assert!(text.contains("input(\"x\")"));
481    }
482
483    #[test]
484    fn named_block_appears_in_hir_dump() {
485        let mut hir = HirModule::new("layer");
486        let x = hir.input("x", f32_shape(&[2, 8]));
487        let w = hir.param("w", f32_shape(&[8, 8]));
488        let out = hir.named("layer0.ffn", |h| {
489            h.linear(x, w, None, None, f32_shape(&[2, 8]))
490        });
491        hir.outputs = vec![out];
492
493        let text = inspect_hir(&hir);
494        assert!(text.contains("layer0.ffn"));
495    }
496
497    #[test]
498    fn provenance_survives_lower() {
499        let mut hir = HirModule::new("m");
500        let x = hir.input("x", f32_shape(&[2, 8]));
501        let w = hir.param("w", f32_shape(&[8, 8]));
502        let out = hir.named("block", |h| h.linear(x, w, None, None, f32_shape(&[2, 8])));
503        hir.outputs = vec![out];
504
505        let mir = hir.lower_to_mir().expect("lower");
506        let text = inspect_mir(&mir);
507        assert!(text.contains("hir=h"));
508        assert!(text.contains("block"));
509    }
510
511    #[test]
512    fn inspect_lir_includes_buffer_plan() {
513        use crate::lir::{LirBufferPlan, LirBufferSlot, LirIoManifest};
514
515        let mut hir = HirModule::new("l");
516        let x = hir.input("x", f32_shape(&[4]));
517        hir.outputs = vec![x];
518        let mir = hir.lower_to_mir().expect("lower");
519        let plan = LirBufferPlan {
520            arena_size: 16,
521            assignments: [(
522                NodeId(0),
523                LirBufferSlot {
524                    offset: 0,
525                    size: 16,
526                },
527            )]
528            .into_iter()
529            .collect(),
530            schedule: vec![NodeId(0)],
531            io: LirIoManifest {
532                inputs: vec![("x".into(), NodeId(0))],
533                ..Default::default()
534            },
535            ..Default::default()
536        };
537        let lir = LirModule::new(mir, plan);
538
539        let text = inspect_lir(&lir);
540        assert!(text.contains("lir @l"));
541        assert!(text.contains("arena: 16 bytes"));
542        assert!(text.contains("fingerprint:"));
543        assert!(text.contains("--- mir ---"));
544    }
545}