Skip to main content

rlx_runtime/
check.rs

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