Skip to main content

rlx_runtime/
check.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//! Static graph checker — the analysis behind `cargo rlx check` and the
17//! `#[rlx_model(check)]` self-check hook.
18//!
19//! rlx already *computes* everything a language server would surface, but only
20//! exposes it through env-gated `eprintln!` (`RLX_DISPATCH_REPORT`,
21//! `RLX_FUSION_REPORT`, `RLX_LINT_NUMERICS`) or compile-time panics. This module
22//! folds those same pure functions into one structured [`CheckReport`].
23//!
24//! Four axes, all but execution-legality fully device-free (no GPU, no driver):
25//! * **shape / dtype** — [`rlx_ir::verify::verify_all`] (errors).
26//! * **backend dispatch** — per backend, ops that run native vs. portable
27//!   common-IR (a perf note) vs. can't be lowered (an error), resolved from the
28//!   backend's real op claim via the registry.
29//! * **fusion** — patterns that should have collapsed but didn't (warnings).
30//! * **numerics** — constant subgraphs that provably fold to NaN/Inf (warnings).
31//!
32//! Execution legality is only reported for backends compiled into the build.
33//! CPU is always available and portable; other backends are opt-in behind the
34//! consuming crate's Cargo features (the claim is read driver-free — backend
35//! factories build unit structs and `supported_ops()` is a const).
36
37use std::collections::HashSet;
38use std::panic::{AssertUnwindSafe, catch_unwind};
39
40use rlx_ir::verify::VerifyError;
41use rlx_ir::{Graph, node_label};
42use rlx_opt::rlx_compile::KernelDispatchConfig;
43use rlx_opt::rlx_compile::dispatch_report::{DispatchPath, prepare_graph_for_backend_with_report};
44use rlx_opt::rlx_compile::fusion_pipeline::{Fuse, FusionTarget};
45use serde::Serialize;
46
47use crate::Device;
48use crate::registry::backend_for;
49
50/// Diagnostic severity, ordered most-severe first for rendering.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
52#[serde(rename_all = "lowercase")]
53pub enum Severity {
54    Error,
55    Warning,
56    Note,
57}
58
59impl Severity {
60    fn rank(self) -> u8 {
61        match self {
62            Severity::Error => 0,
63            Severity::Warning => 1,
64            Severity::Note => 2,
65        }
66    }
67
68    fn label(self) -> &'static str {
69        match self {
70            Severity::Error => "error",
71            Severity::Warning => "warning",
72            Severity::Note => "note",
73        }
74    }
75}
76
77/// A single finding about the graph.
78#[derive(Debug, Clone, Serialize)]
79pub struct Diagnostic {
80    pub severity: Severity,
81    /// Stable kebab-case slug, e.g. `shape`, `unsupported-op`, `missed-fusion`, `numeric`.
82    pub code: String,
83    pub message: String,
84    /// Offending node id (the `%N` in the graph), when one applies.
85    pub node: Option<u32>,
86    /// Node label / name for context (`node_label`).
87    pub context: Option<String>,
88    /// Actionable remedy, reused from the underlying analysis where available.
89    pub hint: Option<String>,
90    /// Backend this pertains to (dispatch findings); `None` = backend-agnostic.
91    pub backend: Option<String>,
92}
93
94/// Execution-legality rollup against a backend's real op claim.
95///
96/// Only present when the backend is compiled into the build. Resolved
97/// statically from the registry — no live driver is needed, since backend
98/// factories build unit structs and `supported_ops()` is a const.
99#[derive(Debug, Clone, Serialize)]
100pub struct Legality {
101    /// True when no op is left unsupported after rewrite.
102    pub compile_ready: bool,
103    pub native_kinds: usize,
104    pub common_ir_kinds: usize,
105    pub rewritten_kinds: usize,
106    pub unsupported_kinds: usize,
107}
108
109/// Per-backend rollup: fusion coverage (always) + execution legality (when the
110/// backend is compiled in).
111#[derive(Debug, Clone, Serialize)]
112pub struct BackendSummary {
113    pub backend: String,
114    /// `None` when this backend isn't compiled into the build — fusion coverage
115    /// is still reported; execution legality is not.
116    pub legality: Option<Legality>,
117    /// Count of fused ops produced by the fusion pipeline for this target.
118    pub fused_ops: usize,
119    pub missed_fusions: usize,
120}
121
122/// The full result of [`check_graph`].
123#[derive(Debug, Clone, Serialize)]
124pub struct CheckReport {
125    pub graph: String,
126    pub nodes: usize,
127    pub diagnostics: Vec<Diagnostic>,
128    pub backends: Vec<BackendSummary>,
129}
130
131/// What to check and against which backends.
132#[derive(Debug, Clone)]
133pub struct CheckOptions {
134    /// Backends whose op claim + fusion pipeline to analyze against.
135    pub backends: Vec<FusionTarget>,
136    pub dispatch: bool,
137    pub fusion: bool,
138    pub numeric: bool,
139}
140
141impl Default for CheckOptions {
142    fn default() -> Self {
143        Self {
144            backends: default_backends(),
145            dispatch: true,
146            fusion: true,
147            numeric: true,
148        }
149    }
150}
151
152/// A representative cross-vendor default set (CPU + the three big GPU families).
153pub fn default_backends() -> Vec<FusionTarget> {
154    vec![
155        FusionTarget::Cpu,
156        FusionTarget::Metal,
157        FusionTarget::Cuda,
158        FusionTarget::Wgpu,
159    ]
160}
161
162/// Every fusion target rlx models a static op claim for.
163pub fn all_backends() -> Vec<FusionTarget> {
164    vec![
165        FusionTarget::Cpu,
166        FusionTarget::Metal,
167        FusionTarget::Mlx,
168        FusionTarget::Wgpu,
169        FusionTarget::Cuda,
170        FusionTarget::Rocm,
171        FusionTarget::Tpu,
172    ]
173}
174
175/// Stable lowercase name for a backend target.
176pub fn backend_name(t: FusionTarget) -> &'static str {
177    match t {
178        FusionTarget::Cpu => "cpu",
179        FusionTarget::Metal => "metal",
180        FusionTarget::Mlx => "mlx",
181        FusionTarget::Wgpu => "wgpu",
182        FusionTarget::Cuda => "cuda",
183        FusionTarget::Rocm => "rocm",
184        FusionTarget::Tpu => "tpu",
185    }
186}
187
188/// Parse a backend name (with common aliases) into a [`FusionTarget`].
189pub fn parse_backend(s: &str) -> Option<FusionTarget> {
190    match s.trim().to_ascii_lowercase().as_str() {
191        "cpu" => Some(FusionTarget::Cpu),
192        "metal" | "mps" | "mtl" => Some(FusionTarget::Metal),
193        "mlx" => Some(FusionTarget::Mlx),
194        "wgpu" | "gpu" | "webgpu" => Some(FusionTarget::Wgpu),
195        "cuda" | "nvidia" => Some(FusionTarget::Cuda),
196        "rocm" | "hip" | "amd" => Some(FusionTarget::Rocm),
197        "tpu" => Some(FusionTarget::Tpu),
198        _ => None,
199    }
200}
201
202/// The execution [`Device`] whose op claim answers legality for a fusion target.
203pub fn backend_device(t: FusionTarget) -> Device {
204    match t {
205        FusionTarget::Cpu => Device::Cpu,
206        FusionTarget::Metal => Device::Metal,
207        FusionTarget::Mlx => Device::Mlx,
208        FusionTarget::Wgpu => Device::Gpu,
209        FusionTarget::Cuda => Device::Cuda,
210        FusionTarget::Rocm => Device::Rocm,
211        FusionTarget::Tpu => Device::Tpu,
212    }
213}
214
215/// The backend's real execution op-claim, or `None` if it isn't compiled in.
216///
217/// Driver-free: the factory builds a unit struct and `supported_ops()` is a
218/// const, so this works for any compiled-in backend even without its hardware.
219fn execution_claim(device: Device) -> Option<&'static [rlx_ir::OpKind]> {
220    catch_unwind(AssertUnwindSafe(|| {
221        backend_for(device).map(|be| be.supported_ops())
222    }))
223    .ok()
224    .flatten()
225}
226
227impl CheckReport {
228    pub fn count(&self, sev: Severity) -> usize {
229        self.diagnostics
230            .iter()
231            .filter(|d| d.severity == sev)
232            .count()
233    }
234
235    pub fn errors(&self) -> usize {
236        self.count(Severity::Error)
237    }
238
239    pub fn warnings(&self) -> usize {
240        self.count(Severity::Warning)
241    }
242
243    pub fn has_errors(&self) -> bool {
244        self.errors() > 0
245    }
246
247    /// Compact JSON for machine consumers / editors.
248    pub fn to_json(&self) -> String {
249        serde_json::to_string_pretty(self).unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}"))
250    }
251
252    /// rustc-style human report.
253    pub fn render(&self) -> String {
254        use std::fmt::Write as _;
255        let mut s = String::new();
256        let _ = writeln!(
257            s,
258            "rlx check — graph \"{}\" ({} node{})",
259            self.graph,
260            self.nodes,
261            if self.nodes == 1 { "" } else { "s" }
262        );
263
264        let mut diags: Vec<&Diagnostic> = self.diagnostics.iter().collect();
265        diags.sort_by_key(|d| d.severity.rank());
266
267        if diags.is_empty() {
268            let _ = writeln!(s, "\n  no findings.");
269        } else {
270            let _ = writeln!(s);
271            for d in diags {
272                let on = d
273                    .backend
274                    .as_deref()
275                    .map(|b| format!(" on {b}"))
276                    .unwrap_or_default();
277                let _ = writeln!(s, "{}[{}]{on}: {}", d.severity.label(), d.code, d.message);
278                if let Some(n) = d.node {
279                    // Skip the label when it's just the node id again (unnamed node).
280                    match &d.context {
281                        Some(c) if !c.is_empty() && *c != format!("%{n}") => {
282                            let _ = writeln!(s, "  --> %{n} ({c})");
283                        }
284                        _ => {
285                            let _ = writeln!(s, "  --> %{n}");
286                        }
287                    }
288                }
289                if let Some(h) = &d.hint {
290                    let _ = writeln!(s, "  = help: {h}");
291                }
292            }
293        }
294
295        if !self.backends.is_empty() {
296            let _ = writeln!(s, "\nbackends:");
297            for b in &self.backends {
298                match &b.legality {
299                    Some(l) => {
300                        let status = if l.compile_ready {
301                            "ready  "
302                        } else {
303                            "BLOCKED"
304                        };
305                        let _ = writeln!(
306                            s,
307                            "  {:<6} {status}  native={} common-ir={} rewritten={} unsupported={}  fused={} missed={}",
308                            b.backend,
309                            l.native_kinds,
310                            l.common_ir_kinds,
311                            l.rewritten_kinds,
312                            l.unsupported_kinds,
313                            b.fused_ops,
314                            b.missed_fusions,
315                        );
316                    }
317                    None => {
318                        let _ = writeln!(
319                            s,
320                            "  {:<6} legality n/a (build --features {})           fused={} missed={}",
321                            b.backend, b.backend, b.fused_ops, b.missed_fusions,
322                        );
323                    }
324                }
325            }
326        }
327
328        let _ = writeln!(
329            s,
330            "\nsummary: {} error(s), {} warning(s), {} note(s) across {} backend(s)",
331            self.errors(),
332            self.warnings(),
333            self.count(Severity::Note),
334            self.backends.len(),
335        );
336        s
337    }
338}
339
340fn verify_diag(graph: &Graph, e: &VerifyError, code: &str) -> Diagnostic {
341    let hint = if e.message.contains("shape mismatch") {
342        Some(
343            "declared out-shape disagrees with the inferred shape — fix the builder's \
344             out_shape argument (or a dtype mismatch between the operands)"
345                .to_string(),
346        )
347    } else if e.message.contains("not a DAG") {
348        Some("an input references a later node — build nodes in topological order".to_string())
349    } else if e.message.contains("expects") && e.message.contains("inputs") {
350        Some("wrong operand count for this op".to_string())
351    } else {
352        None
353    };
354    Diagnostic {
355        severity: Severity::Error,
356        code: code.to_string(),
357        message: e.message.clone(),
358        node: e.node.map(|n| n.0),
359        context: e.node.map(|n| node_label(graph, n)),
360        hint,
361        backend: None,
362    }
363}
364
365/// Run every enabled check against `graph` and collect the findings.
366///
367/// Pure: no backend is instantiated beyond reading its (driver-free) op claim.
368/// Backend dispatch + fusion analysis are skipped when the graph fails
369/// structural or shape verification — fix those first, since downstream passes
370/// assume a well-formed, shape-consistent graph.
371pub fn check_graph(graph: &Graph, opts: &CheckOptions) -> CheckReport {
372    let mut diagnostics = Vec::new();
373
374    // 1. Structural integrity (DAG, arity, output refs) — device-independent.
375    let structural = rlx_ir::verify::verify(graph);
376    for e in &structural {
377        diagnostics.push(verify_diag(graph, e, "graph-structure"));
378    }
379
380    // 2. Shape / dtype consistency — device-independent.
381    let shape_errors = if structural.is_empty() {
382        rlx_ir::verify::verify_shapes(graph)
383    } else {
384        Vec::new()
385    };
386    for e in &shape_errors {
387        diagnostics.push(verify_diag(graph, e, "shape"));
388    }
389
390    let graph_ok = structural.is_empty() && shape_errors.is_empty();
391
392    // 3. Provable numeric blow-ups (constant folds to NaN/Inf) — device-independent.
393    if opts.numeric && graph_ok {
394        for l in rlx_opt::rlx_compile::lint_numerics(graph) {
395            diagnostics.push(Diagnostic {
396                severity: Severity::Warning,
397                code: "numeric".to_string(),
398                message: format!("{} produced here — {}", l.kind.as_str(), l.reason),
399                node: Some(l.node.0),
400                context: Some(l.label),
401                hint: l.fix.map(str::to_string),
402                backend: None,
403            });
404        }
405    }
406
407    // 4. Per-backend: fusion coverage (device-free) + execution legality
408    //    (only for backends compiled into this build).
409    let mut backends = Vec::new();
410    // Missed fusions are largely authoring-level (structural); dedup across
411    // targets so the same miss isn't reported once per backend.
412    let mut seen_miss: HashSet<(String, u32, String)> = HashSet::new();
413    let mut legality_gaps: Vec<&'static str> = Vec::new();
414
415    if graph_ok {
416        for &t in &opts.backends {
417            let name = backend_name(t);
418            let mut summary = BackendSummary {
419                backend: name.to_string(),
420                legality: None,
421                fused_ops: 0,
422                missed_fusions: 0,
423            };
424
425            // -- execution legality against the backend's REAL op claim --
426            match execution_claim(backend_device(t)) {
427                Some(claim) => {
428                    let (_g, report) = prepare_graph_for_backend_with_report(
429                        graph.clone(),
430                        name,
431                        claim,
432                        KernelDispatchConfig::default(),
433                    );
434                    let mut leg = Legality {
435                        compile_ready: report.compile_ready,
436                        native_kinds: 0,
437                        common_ir_kinds: 0,
438                        rewritten_kinds: 0,
439                        unsupported_kinds: 0,
440                    };
441                    for kind_summary in &report.summaries {
442                        match kind_summary.path {
443                            DispatchPath::Native => leg.native_kinds += 1,
444                            DispatchPath::CommonIr => leg.common_ir_kinds += 1,
445                            DispatchPath::Rewritten => leg.rewritten_kinds += 1,
446                            DispatchPath::Unsupported => leg.unsupported_kinds += 1,
447                        }
448                    }
449                    if opts.dispatch {
450                        for (id, kind) in &report.still_unsupported {
451                            diagnostics.push(Diagnostic {
452                                severity: Severity::Error,
453                                code: "unsupported-op".to_string(),
454                                message: format!(
455                                    "{kind:?} cannot be lowered on {name} (still unsupported after rewrite)"
456                                ),
457                                node: Some(id.0),
458                                context: Some(node_label(graph, *id)),
459                                hint: Some(
460                                    "add a native thunk + list the OpKind in Backend::supported_ops, \
461                                     or add a rewrite/common-IR body in rlx-fusion"
462                                        .to_string(),
463                                ),
464                                backend: Some(name.to_string()),
465                            });
466                        }
467                        for kind in &report.common_lowered_kinds {
468                            diagnostics.push(Diagnostic {
469                                severity: Severity::Note,
470                                code: "common-ir".to_string(),
471                                message: format!(
472                                    "{kind:?} runs via portable common-IR on {name} (correct, but off the native fast path)"
473                                ),
474                                node: None,
475                                context: None,
476                                hint: Some(
477                                    "list this OpKind in the backend's supported_ops to dispatch a native kernel"
478                                        .to_string(),
479                                ),
480                                backend: Some(name.to_string()),
481                            });
482                        }
483                    }
484                    summary.legality = Some(leg);
485                }
486                None => legality_gaps.push(name),
487            }
488
489            // -- fusion: what fused, what was left on the table (device-free) --
490            let fusion = catch_unwind(AssertUnwindSafe(|| {
491                Fuse::new(t).run_with_report(graph.clone())
492            }));
493            if let Ok((_fused, freport)) = fusion {
494                summary.fused_ops = freport.fused_matmul_bias_act
495                    + freport.fused_swiglu
496                    + freport.fused_residual_ln
497                    + freport.fused_residual_rms_norm
498                    + freport.fused_attention_block
499                    + freport.fused_transformer_layer;
500                summary.missed_fusions = freport.missed.len();
501                if opts.fusion {
502                    for m in &freport.missed {
503                        let key = (m.pattern.to_string(), m.node.0, format!("{:?}", m.reason));
504                        if seen_miss.insert(key) {
505                            diagnostics.push(Diagnostic {
506                                severity: Severity::Warning,
507                                code: "missed-fusion".to_string(),
508                                message: format!(
509                                    "{} fusion not applied ({:?})",
510                                    m.pattern, m.reason
511                                ),
512                                node: Some(m.node.0),
513                                context: m.context.clone(),
514                                hint: m.hint.clone(),
515                                backend: None,
516                            });
517                        }
518                    }
519                }
520            }
521
522            backends.push(summary);
523        }
524
525        // One note (not one-per-backend) for backends whose real op claim isn't
526        // compiled in — those rows show fusion only.
527        if !legality_gaps.is_empty() {
528            diagnostics.push(Diagnostic {
529                severity: Severity::Note,
530                code: "legality-unavailable".to_string(),
531                message: format!(
532                    "execution legality not checked for [{}] — those backends aren't compiled \
533                     into this build (fusion coverage is still shown)",
534                    legality_gaps.join(", ")
535                ),
536                node: None,
537                context: None,
538                hint: Some(
539                    "rebuild with the matching backend feature to check native/unsupported dispatch"
540                        .to_string(),
541                ),
542                backend: None,
543            });
544        }
545    }
546
547    CheckReport {
548        graph: graph.name.clone(),
549        nodes: graph.len(),
550        diagnostics,
551        backends,
552    }
553}
554
555/// Self-check hook injected by `#[rlx_model(check)]` right after the model's
556/// graph is traced. Runs [`check_graph`] and reports findings to stderr.
557///
558/// Controlled at runtime by `RLX_CHECK`:
559/// * unset / `warn` / `1` — print the report when there are findings (default).
560/// * `off` / `0` / `false` — do nothing (production escape hatch).
561/// * `all` — check every backend target and always print.
562/// * `strict` — additionally **panic** on any error-level finding.
563///
564/// Focused on the CPU reference backend by default (that's what `#[rlx_model]`
565/// compiles for); `RLX_CHECK=all` widens to every target.
566pub fn model_self_check(name: &str, graph: &Graph) {
567    let mode = rlx_ir::env::var("RLX_CHECK").unwrap_or_default();
568    let mode = mode.trim().to_ascii_lowercase();
569    if matches!(mode.as_str(), "0" | "off" | "false") {
570        return;
571    }
572    let strict = mode == "strict";
573    let all = mode == "all";
574    let verbose = all || mode == "verbose";
575
576    let opts = CheckOptions {
577        backends: if all {
578            all_backends()
579        } else {
580            vec![FusionTarget::Cpu]
581        },
582        ..CheckOptions::default()
583    };
584    let report = check_graph(graph, &opts);
585
586    if verbose || report.errors() > 0 || report.warnings() > 0 {
587        eprint!("rlx-check [{name}]\n{}", report.render());
588    }
589    if strict && report.has_errors() {
590        panic!(
591            "rlx-check: model `{name}` has {} error-level finding(s) — see report above \
592             (RLX_CHECK=strict)",
593            report.errors()
594        );
595    }
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601    use rlx_ir::infer::GraphExt;
602    use rlx_ir::{DType, Shape};
603
604    fn f32s(d: &[usize]) -> Shape {
605        Shape::new(d, DType::F32)
606    }
607
608    #[test]
609    fn clean_mlp_is_cpu_ready() {
610        let mut g = Graph::new("mlp");
611        let x = g.input("x", f32s(&[4, 16]));
612        let w = g.param("w", f32s(&[16, 8]));
613        let h = g.mm(x, w);
614        let y = g.gelu(h);
615        g.set_outputs(vec![y]);
616
617        let r = check_graph(&g, &CheckOptions::default());
618        assert_eq!(r.errors(), 0, "unexpected errors: {:#?}", r.diagnostics);
619        let cpu = r.backends.iter().find(|b| b.backend == "cpu").unwrap();
620        let leg = cpu.legality.as_ref().expect("cpu legality");
621        assert!(leg.compile_ready);
622        assert_eq!(leg.unsupported_kinds, 0);
623    }
624
625    #[test]
626    fn self_check_hook_runs_without_panic() {
627        // Default RLX_CHECK behavior must never panic on a clean graph.
628        let mut g = Graph::new("hooked");
629        let x = g.input("x", f32s(&[2, 4]));
630        let w = g.param("w", f32s(&[4, 4]));
631        let y = g.mm(x, w);
632        g.set_outputs(vec![y]);
633        model_self_check("hooked", &g);
634    }
635}