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//! Six 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//! * **representation** — [`rlx_ir::repr_check`]: a consumer whose producer
21//!   declares a different stride / encoding / arity (errors).
22//! * **schedule** — GPU tile utilization from [`rlx_gpu_dispatch::cost`]
23//!   (notes).
24//!
25//! The severities are the design, not decoration. They are the three
26//! dispositions a pre-compile analysis can have, and collapsing them would make
27//! at least one unusable:
28//!
29//! * **gate** (errors) — shape and representation. These do not degrade output,
30//!   they compute a *different function*, so compilation must not proceed.
31//! * **report** (warnings) — fusion and numerics. The program is well-formed
32//!   but something is probably not what the author meant.
33//! * **hint** (notes) — schedule. Nothing is wrong. The answer is correct and
34//!   the machine is being wasted producing it. A finding that could fail a
35//!   build here would mean rejecting correct programs for being slow.
36//!
37//! Execution legality is only reported for backends compiled into the build.
38//! CPU is always available and portable; other backends are opt-in behind the
39//! consuming crate's Cargo features (the claim is read driver-free — backend
40//! factories build unit structs and `supported_ops()` is a const).
41
42use std::collections::HashSet;
43use std::panic::{AssertUnwindSafe, catch_unwind};
44
45use rlx_ir::verify::VerifyError;
46use rlx_ir::{Graph, node_label};
47use rlx_opt::rlx_compile::KernelDispatchConfig;
48use rlx_opt::rlx_compile::dispatch_report::{DispatchPath, prepare_graph_for_backend_with_report};
49use rlx_opt::rlx_compile::fusion_pipeline::{Fuse, FusionTarget};
50use serde::Serialize;
51
52use crate::Device;
53use crate::registry::backend_for;
54
55/// Diagnostic severity, ordered most-severe first for rendering.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
57#[serde(rename_all = "lowercase")]
58pub enum Severity {
59    Error,
60    Warning,
61    Note,
62}
63
64impl Severity {
65    fn rank(self) -> u8 {
66        match self {
67            Severity::Error => 0,
68            Severity::Warning => 1,
69            Severity::Note => 2,
70        }
71    }
72
73    fn label(self) -> &'static str {
74        match self {
75            Severity::Error => "error",
76            Severity::Warning => "warning",
77            Severity::Note => "note",
78        }
79    }
80}
81
82/// A single finding about the graph.
83#[derive(Debug, Clone, Serialize)]
84pub struct Diagnostic {
85    pub severity: Severity,
86    /// Stable kebab-case slug, e.g. `shape`, `unsupported-op`, `missed-fusion`, `numeric`.
87    pub code: String,
88    pub message: String,
89    /// Offending node id (the `%N` in the graph), when one applies.
90    pub node: Option<u32>,
91    /// Node label / name for context (`node_label`).
92    pub context: Option<String>,
93    /// Actionable remedy, reused from the underlying analysis where available.
94    pub hint: Option<String>,
95    /// Backend this pertains to (dispatch findings); `None` = backend-agnostic.
96    pub backend: Option<String>,
97}
98
99/// Execution-legality rollup against a backend's real op claim.
100///
101/// Only present when the backend is compiled into the build. Resolved
102/// statically from the registry — no live driver is needed, since backend
103/// factories build unit structs and `supported_ops()` is a const.
104#[derive(Debug, Clone, Serialize)]
105pub struct Legality {
106    /// True when no op is left unsupported after rewrite.
107    pub compile_ready: bool,
108    pub native_kinds: usize,
109    pub common_ir_kinds: usize,
110    pub rewritten_kinds: usize,
111    pub unsupported_kinds: usize,
112}
113
114/// What one run of this graph costs on one architecture.
115///
116/// Device-free, like the rest of the checker: every number comes from the same
117/// memory planner and dispatch claim the backend will use, not from a probe.
118/// Nothing here is *wrong* — that is the point of reporting it as notes. It is
119/// the difference between a graph that fits a phone and one that does not, and
120/// between a kernel that runs on the accelerator and one that quietly round
121/// trips through the host.
122#[derive(Debug, Clone, Serialize)]
123pub struct Budget {
124    /// Arena bytes the planner asks for under this backend's width policy.
125    pub arena_bytes: usize,
126    /// Bytes liveness-aware slot reuse saved versus one buffer per node.
127    pub reuse_saved_bytes: usize,
128    /// Extra arena bytes this backend's width policy costs over storing every
129    /// tensor at its native dtype width, with every other planner option held
130    /// fixed. Non-zero only on the f32-uniform backends, and only for graphs
131    /// that actually carry F16/BF16 activations. Deliberately *not*
132    /// `arena_bytes` minus the native plan's: those two plans also differ in
133    /// pinning, and that delta would report a saving where there is none.
134    pub width_overhead_bytes: usize,
135    /// Name of the width policy in force (`native` / `f32-uniform` / `hybrid`).
136    pub width_policy: &'static str,
137    /// Bytes that must cross the host boundary every run: graph inputs read in
138    /// plus graph outputs read back. Parameters are excluded — they are
139    /// uploaded once and stay resident.
140    pub host_io_bytes: usize,
141    /// Ops that will not run on this backend's native fast path (portable
142    /// common-IR or still unsupported), and the output bytes they touch. On a
143    /// GPU backend these are the candidates for a host round trip.
144    pub off_fast_path_ops: usize,
145    pub off_fast_path_bytes: usize,
146    /// `Cast` nodes in the graph — every one is a full pass over a tensor whose
147    /// only product is a different dtype.
148    pub cast_nodes: usize,
149    /// Parameter bytes the graph expects to find already on the device.
150    ///
151    /// Separate from `host_io_bytes` on purpose, and reported rather than
152    /// folded in, because "weights are uploaded once and stay resident" is an
153    /// *assumption* — and it is the assumption that fails. When residency
154    /// silently does not hold, this whole number becomes per-run traffic, and a
155    /// boundary figure that already included it could not show the difference.
156    pub param_bytes: usize,
157    /// Ops left after this backend's fusion pipeline, excluding graph leaves.
158    ///
159    /// An estimate of dispatch count, which on a GPU backend is the quantity
160    /// that multiplies the per-dispatch floor. It is an estimate because one op
161    /// is not always one dispatch — a multi-pass reduction is several — so
162    /// treat it as a floor on dispatches, not an exact count.
163    pub dispatches: usize,
164    /// Bytes the dispatched ops read and write, summed over the fused graph.
165    ///
166    /// An upper bound: it assumes nothing survives in cache or registers
167    /// between ops. Useful as a ratio against `arena_bytes` — a graph moving
168    /// many times its own arena is re-reading the same tensors.
169    pub dram_bytes: usize,
170}
171
172/// Per-backend rollup: fusion coverage (always) + execution legality (when the
173/// backend is compiled in) + resource budget.
174#[derive(Debug, Clone, Serialize)]
175pub struct BackendSummary {
176    pub backend: String,
177    /// `None` when this backend isn't compiled into the build — fusion coverage
178    /// is still reported; execution legality is not.
179    pub legality: Option<Legality>,
180    /// Count of fused ops produced by the fusion pipeline for this target.
181    pub fused_ops: usize,
182    pub missed_fusions: usize,
183    /// `None` when the graph did not verify — a budget computed from a graph
184    /// that is not well-formed is a number nobody should act on.
185    pub budget: Option<Budget>,
186}
187
188/// The full result of [`check_graph`].
189#[derive(Debug, Clone, Serialize)]
190pub struct CheckReport {
191    pub graph: String,
192    pub nodes: usize,
193    pub diagnostics: Vec<Diagnostic>,
194    pub backends: Vec<BackendSummary>,
195}
196
197/// What to check and against which backends.
198#[derive(Debug, Clone)]
199pub struct CheckOptions {
200    /// Backends whose op claim + fusion pipeline to analyze against.
201    pub backends: Vec<FusionTarget>,
202    pub dispatch: bool,
203    pub fusion: bool,
204    pub numeric: bool,
205    /// Producer–consumer representation compatibility
206    /// ([`rlx_ir::repr_check`]). On by default: it is a static, device-free
207    /// check whose whole purpose is to catch representation mismatches BEFORE a
208    /// backend computes a wrong number from them.
209    pub repr: bool,
210    /// Physical-schedule quality for GPU dispatch
211    /// ([`rlx_gpu_dispatch::cost`]). Notes only — nothing here is wrong, it is
212    /// work the hardware will do and throw away.
213    pub schedule: bool,
214    /// Program-safety and schedule-semantics gates over the memory plan
215    /// ([`rlx_compile::plan_check`]). Errors: an overlapping live buffer does
216    /// not degrade output, it returns another tensor's bytes.
217    pub plan: bool,
218    /// Per-architecture resource budget: arena bytes, host-boundary traffic and
219    /// precision churn ([`Budget`]). Notes only — this axis reports what a
220    /// correct program will cost, never that it is wrong.
221    pub budget: bool,
222}
223
224impl Default for CheckOptions {
225    fn default() -> Self {
226        Self {
227            backends: default_backends(),
228            dispatch: true,
229            fusion: true,
230            numeric: true,
231            repr: true,
232            schedule: true,
233            plan: true,
234            budget: true,
235        }
236    }
237}
238
239/// A representative cross-vendor default set (CPU + the three big GPU families).
240pub fn default_backends() -> Vec<FusionTarget> {
241    vec![
242        FusionTarget::Cpu,
243        FusionTarget::Metal,
244        FusionTarget::Cuda,
245        FusionTarget::Wgpu,
246    ]
247}
248
249/// Every fusion target rlx models a static op claim for.
250pub fn all_backends() -> Vec<FusionTarget> {
251    vec![
252        FusionTarget::Cpu,
253        FusionTarget::Metal,
254        FusionTarget::Mlx,
255        FusionTarget::Wgpu,
256        FusionTarget::Cuda,
257        FusionTarget::Rocm,
258        FusionTarget::Tpu,
259    ]
260}
261
262/// Stable lowercase name for a backend target.
263pub fn backend_name(t: FusionTarget) -> &'static str {
264    match t {
265        FusionTarget::Cpu => "cpu",
266        FusionTarget::Metal => "metal",
267        FusionTarget::Mlx => "mlx",
268        FusionTarget::Wgpu => "wgpu",
269        FusionTarget::Cuda => "cuda",
270        FusionTarget::Rocm => "rocm",
271        FusionTarget::Tpu => "tpu",
272    }
273}
274
275/// Parse a backend name (with common aliases) into a [`FusionTarget`].
276pub fn parse_backend(s: &str) -> Option<FusionTarget> {
277    match s.trim().to_ascii_lowercase().as_str() {
278        "cpu" => Some(FusionTarget::Cpu),
279        "metal" | "mps" | "mtl" => Some(FusionTarget::Metal),
280        "mlx" => Some(FusionTarget::Mlx),
281        "wgpu" | "gpu" | "webgpu" => Some(FusionTarget::Wgpu),
282        "cuda" | "nvidia" => Some(FusionTarget::Cuda),
283        "rocm" | "hip" | "amd" => Some(FusionTarget::Rocm),
284        "tpu" => Some(FusionTarget::Tpu),
285        _ => None,
286    }
287}
288
289/// The execution [`Device`] whose op claim answers legality for a fusion target.
290pub fn backend_device(t: FusionTarget) -> Device {
291    match t {
292        FusionTarget::Cpu => Device::Cpu,
293        FusionTarget::Metal => Device::Metal,
294        FusionTarget::Mlx => Device::Mlx,
295        FusionTarget::Wgpu => Device::Gpu,
296        FusionTarget::Cuda => Device::Cuda,
297        FusionTarget::Rocm => Device::Rocm,
298        FusionTarget::Tpu => Device::Tpu,
299    }
300}
301
302/// The backend's real execution op-claim, or `None` if it isn't compiled in.
303///
304/// Driver-free: the factory builds a unit struct and `supported_ops()` is a
305/// const, so this works for any compiled-in backend even without its hardware.
306fn execution_claim(device: Device) -> Option<&'static [rlx_ir::OpKind]> {
307    catch_unwind(AssertUnwindSafe(|| {
308        backend_for(device).map(|be| be.supported_ops())
309    }))
310    .ok()
311    .flatten()
312}
313
314impl CheckReport {
315    pub fn count(&self, sev: Severity) -> usize {
316        self.diagnostics
317            .iter()
318            .filter(|d| d.severity == sev)
319            .count()
320    }
321
322    pub fn errors(&self) -> usize {
323        self.count(Severity::Error)
324    }
325
326    pub fn warnings(&self) -> usize {
327        self.count(Severity::Warning)
328    }
329
330    pub fn has_errors(&self) -> bool {
331        self.errors() > 0
332    }
333
334    /// Compact JSON for machine consumers / editors.
335    pub fn to_json(&self) -> String {
336        serde_json::to_string_pretty(self).unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}"))
337    }
338
339    /// rustc-style human report.
340    pub fn render(&self) -> String {
341        use std::fmt::Write as _;
342        let mut s = String::new();
343        let _ = writeln!(
344            s,
345            "rlx check — graph \"{}\" ({} node{})",
346            self.graph,
347            self.nodes,
348            if self.nodes == 1 { "" } else { "s" }
349        );
350
351        let mut diags: Vec<&Diagnostic> = self.diagnostics.iter().collect();
352        diags.sort_by_key(|d| d.severity.rank());
353
354        if diags.is_empty() {
355            let _ = writeln!(s, "\n  no findings.");
356        } else {
357            let _ = writeln!(s);
358            for d in diags {
359                let on = d
360                    .backend
361                    .as_deref()
362                    .map(|b| format!(" on {b}"))
363                    .unwrap_or_default();
364                let _ = writeln!(s, "{}[{}]{on}: {}", d.severity.label(), d.code, d.message);
365                if let Some(n) = d.node {
366                    // Skip the label when it's just the node id again (unnamed node).
367                    match &d.context {
368                        Some(c) if !c.is_empty() && *c != format!("%{n}") => {
369                            let _ = writeln!(s, "  --> %{n} ({c})");
370                        }
371                        _ => {
372                            let _ = writeln!(s, "  --> %{n}");
373                        }
374                    }
375                }
376                if let Some(h) = &d.hint {
377                    let _ = writeln!(s, "  = help: {h}");
378                }
379            }
380        }
381
382        if !self.backends.is_empty() {
383            let _ = writeln!(s, "\nbackends:");
384            for b in &self.backends {
385                match &b.legality {
386                    Some(l) => {
387                        let status = if l.compile_ready {
388                            "ready  "
389                        } else {
390                            "BLOCKED"
391                        };
392                        let _ = writeln!(
393                            s,
394                            "  {:<6} {status}  native={} common-ir={} rewritten={} unsupported={}  fused={} missed={}",
395                            b.backend,
396                            l.native_kinds,
397                            l.common_ir_kinds,
398                            l.rewritten_kinds,
399                            l.unsupported_kinds,
400                            b.fused_ops,
401                            b.missed_fusions,
402                        );
403                    }
404                    None => {
405                        let _ = writeln!(
406                            s,
407                            "  {:<6} legality n/a (build --features {})           fused={} missed={}",
408                            b.backend, b.backend, b.fused_ops, b.missed_fusions,
409                        );
410                    }
411                }
412                // Budget on its own line: it answers a different question from
413                // the legality row above it — not "will this run" but "what
414                // will it spend to run".
415                if let Some(bu) = &b.budget {
416                    let _ = write!(
417                        s,
418                        "         arena={} ({}, reuse saved {})  params={}  host-io/run={}  dispatch={} moving {}",
419                        human_bytes(bu.arena_bytes),
420                        bu.width_policy,
421                        human_bytes(bu.reuse_saved_bytes),
422                        human_bytes(bu.param_bytes),
423                        human_bytes(bu.host_io_bytes),
424                        bu.dispatches,
425                        human_bytes(bu.dram_bytes),
426                    );
427                    if bu.width_overhead_bytes > 0 {
428                        let _ = write!(s, "  width cost={}", human_bytes(bu.width_overhead_bytes));
429                    }
430                    if bu.off_fast_path_ops > 0 {
431                        let _ = write!(
432                            s,
433                            "  off-fast-path={} ops/{}",
434                            bu.off_fast_path_ops,
435                            human_bytes(bu.off_fast_path_bytes)
436                        );
437                    }
438                    if bu.cast_nodes > 0 {
439                        let _ = write!(s, "  casts={}", bu.cast_nodes);
440                    }
441                    let _ = writeln!(s);
442                }
443            }
444        }
445
446        let _ = writeln!(
447            s,
448            "\nsummary: {} error(s), {} warning(s), {} note(s) across {} backend(s)",
449            self.errors(),
450            self.warnings(),
451            self.count(Severity::Note),
452            self.backends.len(),
453        );
454        s
455    }
456}
457
458/// Bytes at a glance. Budget rows sit next to each other and the reader is
459/// comparing magnitudes, not auditing exact counts — the JSON carries those.
460fn human_bytes(n: usize) -> String {
461    const UNITS: [(&str, usize); 3] = [("GB", 1 << 30), ("MB", 1 << 20), ("KB", 1 << 10)];
462    for (suffix, scale) in UNITS {
463        if n >= scale {
464            return format!("{:.1} {suffix}", n as f64 / scale as f64);
465        }
466    }
467    format!("{n} B")
468}
469
470/// `Some(n)` for a statically-known extent, `None` for a dynamic one.
471fn static_dim(d: rlx_ir::Dim) -> Option<usize> {
472    match d {
473        rlx_ir::Dim::Static(n) => Some(n),
474        rlx_ir::Dim::Dynamic(_) => None,
475    }
476}
477
478/// Whether this target dispatches through the shared GPU tile table at all.
479/// CPU and TPU do not, so a tile note would be meaningless for them.
480fn is_gpu_target(t: FusionTarget) -> bool {
481    matches!(
482        t,
483        FusionTarget::Metal
484            | FusionTarget::Mlx
485            | FusionTarget::Wgpu
486            | FusionTarget::Cuda
487            | FusionTarget::Rocm
488    )
489}
490
491/// Modelled speedup a non-default tile must show before the graph is worth
492/// mentioning at all.
493///
494/// Higher than the tuner's own `MIN_SPEEDUP` (1.02) on purpose. The tuner is
495/// deciding what to install from real timings and can afford a thin margin; a
496/// static hint is spending the reader's attention on an *uncalibrated* ranking,
497/// so it should only speak when the prize is clearly worth a tuning run.
498const MIN_MODELLED_GAIN: f64 = 1.25;
499
500/// A single note on GPU tile fit for the whole graph.
501///
502/// Device-free: it compares the compile-time default tile against the rest of
503/// the candidate set, which is what runs on every arch absent a measured
504/// override.
505///
506/// **One note, not one per node — and not one per shape.** Two earlier shapes
507/// of this analysis were both noise. Per-node reporting emitted 196 identical
508/// lines for a 28-layer decode graph. Per-shape reporting cut that to 3, but
509/// the modelled gain turns out to be near-constant within a regime (~1.56x
510/// across every decode shape, ~1.27x across every prefill shape), so those 3
511/// lines were one fact about rlx's default tile restated per shape. The
512/// information content is "this graph has untuned GEMM shapes, run the tuner",
513/// which is worth saying once.
514///
515/// It also reports *available gain*, not *wasted arithmetic*, because the two
516/// are not related the way the wording implied: a shape using 1.6% of the
517/// tile's MACs has 1.56x available, while one using 93.8% has 1.16x. "98.4%
518/// discarded" reads like a 60x opportunity and is not one.
519fn schedule_notes(graph: &Graph) -> Vec<Diagnostic> {
520    use rlx_gpu_dispatch::cost::TileCostModel;
521    use rlx_gpu_dispatch::dispatch::Workload;
522    use rlx_gpu_dispatch::tiles::{MATMUL_TILE_CANDIDATES, TileParams};
523
524    let default_tile = TileParams::DEFAULT_MATMUL;
525    // `structural()`, not `sm86()`: this walks a graph with no device in sight,
526    // so claiming a calibrated model would claim a measurement never taken.
527    let model = TileCostModel::structural();
528
529    // (shape, gain, winning tile, node count, first node) per distinct shape.
530    let mut shapes: Vec<(
531        (usize, usize, usize),
532        f64,
533        TileParams,
534        usize,
535        rlx_ir::NodeId,
536    )> = Vec::new();
537
538    for node in graph.nodes() {
539        if !matches!(node.op, rlx_ir::Op::MatMul) || node.inputs.len() != 2 {
540            continue;
541        }
542        // A @ B: the output's trailing dims are [m, n]; A's trailing dim is k.
543        let out_dims = node.shape.dims();
544        let lhs_dims = graph.shape(node.inputs[0]).dims();
545        if out_dims.len() < 2 || lhs_dims.is_empty() {
546            continue;
547        }
548        // A dynamic extent has no schedule to assess — the tile that runs
549        // depends on a value this analysis cannot see. Skipping is the honest
550        // answer; guessing a size would produce a confident claim about a shape
551        // that may never occur.
552        let (Some(m), Some(n), Some(k)) = (
553            static_dim(out_dims[out_dims.len() - 2]),
554            static_dim(out_dims[out_dims.len() - 1]),
555            static_dim(lhs_dims[lhs_dims.len() - 1]),
556        ) else {
557            continue;
558        };
559
560        if let Some(slot) = shapes.iter_mut().find(|(sh, ..)| *sh == (m, k, n)) {
561            slot.3 += 1;
562            continue;
563        }
564
565        let w = Workload::Matmul { m, k, n };
566        let Some(base) = model.estimate(&default_tile, &w) else {
567            continue;
568        };
569        let Some((best_tile, best)) = MATMUL_TILE_CANDIDATES
570            .iter()
571            .filter_map(|t| model.estimate(t, &w).map(|c| (*t, c)))
572            .min_by(|a, b| {
573                a.1.score
574                    .partial_cmp(&b.1.score)
575                    .unwrap_or(std::cmp::Ordering::Equal)
576            })
577        else {
578            continue;
579        };
580        if best.score <= 0.0 {
581            continue;
582        }
583        shapes.push(((m, k, n), base.score / best.score, best_tile, 1, node.id));
584    }
585
586    let worth_tuning: Vec<_> = shapes
587        .iter()
588        .filter(|(_, gain, ..)| *gain >= MIN_MODELLED_GAIN)
589        .collect();
590    let Some(worst) = worth_tuning
591        .iter()
592        .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
593    else {
594        return Vec::new();
595    };
596
597    let nodes: usize = worth_tuning.iter().map(|(_, _, _, c, _)| *c).sum();
598    let ((wm, wk, wn), gain, best_tile, _, anchor) = **worst;
599
600    vec![Diagnostic {
601        severity: Severity::Note,
602        code: "schedule-tile".to_string(),
603        message: format!(
604            "{} matmul shape(s) across {nodes} node(s) model faster on a non-default tile; \
605             largest modelled gain {gain:.2}x at {wm}x{wk}x{wn} ({} vs the default {})",
606            worth_tuning.len(),
607            best_tile.label(),
608            default_tile.label()
609        ),
610        node: Some(anchor.0),
611        context: Some(node_label(graph, anchor)),
612        hint: Some(
613            "these are UNCALIBRATED model rankings, not measurements — run \
614             `tune_dispatch` on the target device to confirm and install; the dispatch \
615             table only accepts a measured winner that also clears a held-out check"
616                .to_string(),
617        ),
618        backend: None,
619    }]
620}
621
622fn verify_diag(graph: &Graph, e: &VerifyError, code: &str) -> Diagnostic {
623    let hint = if e.message.contains("shape mismatch") {
624        Some(
625            "declared out-shape disagrees with the inferred shape — fix the builder's \
626             out_shape argument (or a dtype mismatch between the operands)"
627                .to_string(),
628        )
629    } else if e.message.contains("not a DAG") {
630        Some("an input references a later node — build nodes in topological order".to_string())
631    } else if e.message.contains("expects") && e.message.contains("inputs") {
632        Some("wrong operand count for this op".to_string())
633    } else {
634        None
635    };
636    Diagnostic {
637        severity: Severity::Error,
638        code: code.to_string(),
639        message: e.message.clone(),
640        node: e.node.map(|n| n.0),
641        context: e.node.map(|n| node_label(graph, n)),
642        hint,
643        backend: None,
644    }
645}
646
647/// Run every enabled check against `graph` and collect the findings.
648///
649/// Pure: no backend is instantiated beyond reading its (driver-free) op claim.
650/// Backend dispatch + fusion analysis are skipped when the graph fails
651/// structural or shape verification — fix those first, since downstream passes
652/// assume a well-formed, shape-consistent graph.
653/// Bytes a node's tensor occupies at its declared dtype.
654fn node_bytes(graph: &Graph, id: rlx_ir::NodeId) -> usize {
655    graph.node(id).shape.size_bytes().unwrap_or(0)
656}
657
658/// Per-architecture resource budget for one graph. See [`Budget`].
659///
660/// `off_path_kinds` are the `OpKind` debug names the dispatch claim resolved to
661/// common-IR or left unsupported, passed in rather than re-derived so the bytes
662/// below are the bytes of those exact nodes, not a share-of-kinds estimate.
663fn compute_budget(
664    graph: &Graph,
665    t: FusionTarget,
666    off_path_kinds: &HashSet<String>,
667    fused: Option<&Graph>,
668) -> Budget {
669    use rlx_opt::rlx_compile::memory::{
670        ArenaWidthPolicy, arena_width_policy, plan_memory_aligned, plan_memory_f32_uniform,
671        plan_memory_hybrid, plan_memory_with_policy,
672    };
673
674    let policy = arena_width_policy(t);
675    // 64 B is what `plan_memory` uses; holding it fixed keeps the three plans
676    // below comparable, which is the whole point of `width_overhead_bytes`.
677    const ALIGN: usize = 64;
678    let plan = match policy {
679        ArenaWidthPolicy::Native => plan_memory_aligned(graph, ALIGN),
680        ArenaWidthPolicy::F32Uniform => plan_memory_f32_uniform(graph, ALIGN),
681        ArenaWidthPolicy::Hybrid => plan_memory_hybrid(graph, ALIGN),
682        ArenaWidthPolicy::NativeHalfWidened => {
683            plan_memory_with_policy(graph, ALIGN, ArenaWidthPolicy::NativeHalfWidened)
684        }
685        ArenaWidthPolicy::NativeBf16Widened => {
686            plan_memory_with_policy(graph, ALIGN, ArenaWidthPolicy::NativeBf16Widened)
687        }
688    };
689    // What this architecture's storage convention costs, isolated from every
690    // other planner decision: `plan_memory_with_policy` holds the options fixed
691    // and varies only the width. Differencing the real plans instead would fold
692    // in `pin_output_ancestors` and report a saving on f32 graphs, where the
693    // width policy is free by construction. A native-policy backend is its own
694    // baseline, so skip both plans.
695    let width_overhead_bytes = if policy == ArenaWidthPolicy::Native {
696        0
697    } else {
698        plan_memory_with_policy(graph, ALIGN, policy)
699            .arena_size
700            .saturating_sub(
701                plan_memory_with_policy(graph, ALIGN, ArenaWidthPolicy::Native).arena_size,
702            )
703    };
704
705    // Host boundary: inputs in, declared outputs back. `Param` is excluded —
706    // a weight is uploaded once and stays resident, so counting it here would
707    // swamp the per-run number with a one-off.
708    let mut host_io_bytes = 0usize;
709    for node in graph.nodes() {
710        if matches!(node.op, rlx_ir::Op::Input { .. }) {
711            host_io_bytes += node.shape.size_bytes().unwrap_or(0);
712        }
713    }
714    for id in &graph.outputs {
715        host_io_bytes += node_bytes(graph, *id);
716    }
717
718    // Off the native fast path: ops the backend lowers through portable
719    // common-IR or cannot lower at all. Counted in bytes as well as ops
720    // because one big tensor off the fast path costs more than ten small ones.
721    let mut off_fast_path_ops = 0usize;
722    let mut off_fast_path_bytes = 0usize;
723    if !off_path_kinds.is_empty() {
724        for node in graph.nodes() {
725            if off_path_kinds.contains(&format!("{:?}", node.op.kind())) {
726                off_fast_path_ops += 1;
727                off_fast_path_bytes += node.shape.size_bytes().unwrap_or(0);
728            }
729        }
730    }
731
732    let cast_nodes = graph
733        .nodes()
734        .iter()
735        .filter(|n| matches!(n.op, rlx_ir::Op::Cast { .. }))
736        .count();
737
738    let param_bytes: usize = graph
739        .nodes()
740        .iter()
741        .filter(|n| matches!(n.op, rlx_ir::Op::Param { .. }))
742        .map(|n| n.shape.size_bytes().unwrap_or(0))
743        .sum();
744
745    // Leaves are not dispatched: they are where data already is.
746    let is_leaf = |op: &rlx_ir::Op| {
747        matches!(
748            op,
749            rlx_ir::Op::Input { .. } | rlx_ir::Op::Param { .. } | rlx_ir::Op::Constant { .. }
750        )
751    };
752    // Fall back to the unfused graph when fusion did not run (it is wrapped in
753    // `catch_unwind`), so the figure is conservative rather than absent.
754    let sched = fused.unwrap_or(graph);
755    let dispatches = sched.nodes().iter().filter(|n| !is_leaf(&n.op)).count();
756    let dram_bytes: usize = sched
757        .nodes()
758        .iter()
759        .filter(|n| !is_leaf(&n.op))
760        .map(|n| {
761            let out = n.shape.size_bytes().unwrap_or(0);
762            let ins: usize = n
763                .inputs
764                .iter()
765                .map(|id| sched.node(*id).shape.size_bytes().unwrap_or(0))
766                .sum();
767            out + ins
768        })
769        .sum();
770
771    Budget {
772        arena_bytes: plan.arena_size,
773        reuse_saved_bytes: plan.bytes_saved(),
774        width_overhead_bytes,
775        width_policy: match policy {
776            ArenaWidthPolicy::Native => "native",
777            ArenaWidthPolicy::F32Uniform => "f32-uniform",
778            ArenaWidthPolicy::Hybrid => "hybrid",
779            ArenaWidthPolicy::NativeHalfWidened => "native-half-widened",
780            ArenaWidthPolicy::NativeBf16Widened => "native-bf16-widened",
781        },
782        host_io_bytes,
783        off_fast_path_ops,
784        off_fast_path_bytes,
785        cast_nodes,
786        param_bytes,
787        dispatches,
788        dram_bytes,
789    }
790}
791
792/// Notes — never errors. A budget finding says the answer is correct and
793/// something is being spent to get it.
794fn budget_notes(backend: &str, b: &Budget) -> Vec<Diagnostic> {
795    let mut out = Vec::new();
796    let note = |code: &str, message: String, hint: &str| Diagnostic {
797        severity: Severity::Note,
798        code: code.to_string(),
799        message,
800        node: None,
801        context: None,
802        hint: Some(hint.to_string()),
803        backend: Some(backend.to_string()),
804    };
805
806    // Only worth saying when it is a material share of the arena; a few bytes
807    // of alignment padding is not a finding.
808    if b.width_overhead_bytes * 10 > b.arena_bytes && b.width_overhead_bytes > 0 {
809        out.push(note(
810            "budget-width",
811            format!(
812                "{}'s {} arena costs {} extra bytes ({:.0}% of {}) over native dtype widths",
813                backend,
814                b.width_policy,
815                b.width_overhead_bytes,
816                100.0 * b.width_overhead_bytes as f64 / b.arena_bytes.max(1) as f64,
817                b.arena_bytes,
818            ),
819            "this architecture stores activations f32-uniform; a hybrid arena would pack              F16/BF16 activations natively, at the cost of a layout change for anything              that indexes the arena by f32 element",
820        ));
821    }
822    if b.off_fast_path_ops > 0 {
823        out.push(note(
824            "budget-io",
825            format!(
826                "{} node(s) run off {backend}'s native fast path ({} bytes of tensor traffic)",
827                b.off_fast_path_ops, b.off_fast_path_bytes,
828            ),
829            "a kind lowered through common-IR or left unsupported is where a host round              trip appears; `RLX_DISPATCH_REPORT=1` names them per node",
830        ));
831    }
832    // Residency is a claim, not a guarantee. When the weights dwarf the per-run
833    // boundary, whether they actually stay put is the only IO question that
834    // matters — re-uploading them per step costs orders of magnitude more than
835    // anything at the input/output edge.
836    if b.param_bytes > 4 * b.host_io_bytes.max(1) {
837        out.push(note(
838            "budget-residency",
839            format!(
840                "{} of parameters vs {} of per-run boundary traffic on {backend}",
841                human_bytes(b.param_bytes),
842                human_bytes(b.host_io_bytes),
843            ),
844            "the per-run figure assumes these stay resident; if residency does not hold they \
845             become per-step uploads, which is the difference between a fast decode and a \
846             host-bound one",
847        ));
848    }
849    // A graph moving many times its own arena is re-reading the same tensors.
850    if b.arena_bytes > 0 && b.dram_bytes > 4 * b.arena_bytes {
851        out.push(note(
852            "budget-traffic",
853            format!(
854                "{} dispatch(es) move {} against a {} arena ({:.1}x) on {backend}",
855                b.dispatches,
856                human_bytes(b.dram_bytes),
857                human_bytes(b.arena_bytes),
858                b.dram_bytes as f64 / b.arena_bytes as f64,
859            ),
860            "fusion is what removes a round trip through memory between two ops; the \
861             missed-fusion warnings above name the ones that did not collapse",
862        ));
863    }
864    if b.cast_nodes > 0 {
865        out.push(note(
866            "budget-precision",
867            format!(
868                "{} Cast node(s): full passes over a tensor whose only product is a dtype",
869                b.cast_nodes
870            ),
871            "casts at a precision boundary are expected; a cast that immediately feeds its              own inverse is not, and folds away",
872        ));
873    }
874    out
875}
876
877pub fn check_graph(graph: &Graph, opts: &CheckOptions) -> CheckReport {
878    let mut diagnostics = Vec::new();
879
880    // 1. Structural integrity (DAG, arity, output refs) — device-independent.
881    let structural = rlx_ir::verify::verify(graph);
882    for e in &structural {
883        diagnostics.push(verify_diag(graph, e, "graph-structure"));
884    }
885
886    // 2. Shape / dtype consistency — device-independent.
887    let shape_errors = if structural.is_empty() {
888        rlx_ir::verify::verify_shapes(graph)
889    } else {
890        Vec::new()
891    };
892    for e in &shape_errors {
893        diagnostics.push(verify_diag(graph, e, "shape"));
894    }
895
896    let graph_ok = structural.is_empty() && shape_errors.is_empty();
897
898    // 3. Provable numeric blow-ups (constant folds to NaN/Inf) — device-independent.
899    if opts.numeric && graph_ok {
900        for l in rlx_opt::rlx_compile::lint_numerics(graph) {
901            diagnostics.push(Diagnostic {
902                severity: Severity::Warning,
903                code: "numeric".to_string(),
904                message: format!("{} produced here — {}", l.kind.as_str(), l.reason),
905                node: Some(l.node.0),
906                context: Some(l.label),
907                hint: l.fix.map(str::to_string),
908                backend: None,
909            });
910        }
911    }
912
913    // 3b. Producer–consumer representation compatibility — device-free.
914    //
915    // Reported as ERRORS, not warnings: a stride/encoding/arity mismatch does
916    // not degrade output, it produces a different function. Every finding here
917    // corresponds to a class of defect this tree has actually shipped.
918    if opts.repr && graph_ok {
919        let report = rlx_ir::repr_check::check_graph(graph);
920        for f in &report.findings {
921            diagnostics.push(Diagnostic {
922                severity: Severity::Error,
923                code: format!("repr-{}", f.kind.as_str()),
924                message: format!(
925                    "{:?} expects {} but its producer declares {}",
926                    f.op, f.expected, f.actual
927                ),
928                node: Some(f.node.0),
929                context: f.input.map(|i| format!("input[{i}]")),
930                hint: Some(f.why.to_string()),
931                backend: None,
932            });
933        }
934    }
935
936    // 3c. Physical-schedule quality for GPU dispatch — device-free.
937    //
938    // Reported as NOTES, and the distinction from 3b is the point. A repr
939    // mismatch computes a different function; a bad tile computes the right
940    // one and wastes the machine doing it. CAKE separates these dispositions
941    // for the same reason: something that must block compilation and something
942    // that should tell you what to change are not the same signal, and folding
943    // them together makes one of them unusable.
944    if opts.schedule && graph_ok && opts.backends.iter().any(|t| is_gpu_target(*t)) {
945        diagnostics.extend(schedule_notes(graph));
946    }
947
948    // 3d. Program safety + schedule semantics over the memory plan.
949    //
950    // Errors, like 3b: a buffer that overlaps a live neighbour does not
951    // produce a worse answer, it produces another tensor's bytes. Both
952    // categories are reported under distinct codes because they name
953    // different repair targets — one is where a buffer sits, the other is
954    // what order the schedule runs in.
955    if opts.plan && graph_ok {
956        let plan = rlx_opt::rlx_compile::memory::plan_memory(graph);
957        let report = rlx_opt::rlx_compile::plan_check::check_plan(graph, &plan);
958        for f in &report.findings {
959            diagnostics.push(Diagnostic {
960                severity: Severity::Error,
961                code: format!("plan-{}", f.kind.as_str()),
962                message: f.detail.clone(),
963                node: Some(f.node.0),
964                context: Some(node_label(graph, f.node)),
965                hint: Some(
966                    if f.kind.is_program_safety() {
967                        "a memory-use hazard: the arena assignment lets one tensor's bytes \
968                         be read or written as another's"
969                    } else {
970                        "a schedule-structure violation: the declared execution order does \
971                         not satisfy the graph's dependencies"
972                    }
973                    .to_string(),
974                ),
975                backend: None,
976            });
977        }
978    }
979
980    // 4. Per-backend: fusion coverage (device-free) + execution legality
981    //    (only for backends compiled into this build).
982    let mut backends = Vec::new();
983    // Missed fusions are largely authoring-level (structural); dedup across
984    // targets so the same miss isn't reported once per backend.
985    let mut seen_miss: HashSet<(String, u32, String)> = HashSet::new();
986    let mut legality_gaps: Vec<&'static str> = Vec::new();
987
988    if graph_ok {
989        for &t in &opts.backends {
990            let name = backend_name(t);
991            let mut summary = BackendSummary {
992                backend: name.to_string(),
993                legality: None,
994                fused_ops: 0,
995                missed_fusions: 0,
996                budget: None,
997            };
998            // Filled by the legality pass below when the backend is compiled in;
999            // empty otherwise, which makes the budget's fast-path figures zero
1000            // rather than wrong.
1001            let mut off_path_kinds: HashSet<String> = HashSet::new();
1002
1003            // -- execution legality against the backend's REAL op claim --
1004            match execution_claim(backend_device(t)) {
1005                Some(claim) => {
1006                    let (_g, report) = prepare_graph_for_backend_with_report(
1007                        graph.clone(),
1008                        name,
1009                        claim,
1010                        KernelDispatchConfig::default(),
1011                    );
1012                    let mut leg = Legality {
1013                        compile_ready: report.compile_ready,
1014                        native_kinds: 0,
1015                        common_ir_kinds: 0,
1016                        rewritten_kinds: 0,
1017                        unsupported_kinds: 0,
1018                    };
1019                    for kind_summary in &report.summaries {
1020                        match kind_summary.path {
1021                            DispatchPath::Native => leg.native_kinds += 1,
1022                            DispatchPath::CommonIr => leg.common_ir_kinds += 1,
1023                            DispatchPath::Rewritten => leg.rewritten_kinds += 1,
1024                            DispatchPath::Unsupported => leg.unsupported_kinds += 1,
1025                        }
1026                        // Keyed on the debug name: `OpKind` is not `Ord`, and
1027                        // the name is what the budget has to match nodes on.
1028                        if matches!(
1029                            kind_summary.path,
1030                            DispatchPath::CommonIr | DispatchPath::Unsupported
1031                        ) {
1032                            off_path_kinds.insert(format!("{:?}", kind_summary.kind));
1033                        }
1034                    }
1035                    if opts.dispatch {
1036                        for (id, kind) in &report.still_unsupported {
1037                            diagnostics.push(Diagnostic {
1038                                severity: Severity::Error,
1039                                code: "unsupported-op".to_string(),
1040                                message: format!(
1041                                    "{kind:?} cannot be lowered on {name} (still unsupported after rewrite)"
1042                                ),
1043                                node: Some(id.0),
1044                                context: Some(node_label(graph, *id)),
1045                                hint: Some(
1046                                    "add a native thunk + list the OpKind in Backend::supported_ops, \
1047                                     or add a rewrite/common-IR body in rlx-fusion"
1048                                        .to_string(),
1049                                ),
1050                                backend: Some(name.to_string()),
1051                            });
1052                        }
1053                        for kind in &report.common_lowered_kinds {
1054                            diagnostics.push(Diagnostic {
1055                                severity: Severity::Note,
1056                                code: "common-ir".to_string(),
1057                                message: format!(
1058                                    "{kind:?} runs via portable common-IR on {name} (correct, but off the native fast path)"
1059                                ),
1060                                node: None,
1061                                context: None,
1062                                hint: Some(
1063                                    "list this OpKind in the backend's supported_ops to dispatch a native kernel"
1064                                        .to_string(),
1065                                ),
1066                                backend: Some(name.to_string()),
1067                            });
1068                        }
1069                    }
1070                    summary.legality = Some(leg);
1071                }
1072                None => legality_gaps.push(name),
1073            }
1074
1075            // -- fusion: what fused, what was left on the table (device-free) --
1076            let fusion = catch_unwind(AssertUnwindSafe(|| {
1077                Fuse::new(t).run_with_report(graph.clone())
1078            }));
1079            let mut fused_graph: Option<Graph> = None;
1080            if let Ok((fused, freport)) = fusion {
1081                fused_graph = Some(fused);
1082                summary.fused_ops = freport.fused_matmul_bias_act
1083                    + freport.fused_swiglu
1084                    + freport.fused_residual_ln
1085                    + freport.fused_residual_rms_norm
1086                    + freport.fused_attention_block
1087                    + freport.fused_transformer_layer;
1088                summary.missed_fusions = freport.missed.len();
1089                if opts.fusion {
1090                    for m in &freport.missed {
1091                        let key = (m.pattern.to_string(), m.node.0, format!("{:?}", m.reason));
1092                        if seen_miss.insert(key) {
1093                            diagnostics.push(Diagnostic {
1094                                severity: Severity::Warning,
1095                                code: "missed-fusion".to_string(),
1096                                message: format!(
1097                                    "{} fusion not applied ({:?})",
1098                                    m.pattern, m.reason
1099                                ),
1100                                node: Some(m.node.0),
1101                                context: m.context.clone(),
1102                                hint: m.hint.clone(),
1103                                backend: None,
1104                            });
1105                        }
1106                    }
1107                }
1108            }
1109
1110            // -- resource budget: what this graph costs on this architecture --
1111            //
1112            // Device-free by construction: the same planner the backend will
1113            // run, keyed on the same width policy it plans with, and the op
1114            // claim already resolved above.
1115            if graph_ok {
1116                let b = compute_budget(graph, t, &off_path_kinds, fused_graph.as_ref());
1117                if opts.budget {
1118                    diagnostics.extend(budget_notes(name, &b));
1119                }
1120                summary.budget = Some(b);
1121            }
1122
1123            backends.push(summary);
1124        }
1125
1126        // One note (not one-per-backend) for backends whose real op claim isn't
1127        // compiled in — those rows show fusion only.
1128        if !legality_gaps.is_empty() {
1129            diagnostics.push(Diagnostic {
1130                severity: Severity::Note,
1131                code: "legality-unavailable".to_string(),
1132                message: format!(
1133                    "execution legality not checked for [{}] — those backends aren't compiled \
1134                     into this build (fusion coverage is still shown)",
1135                    legality_gaps.join(", ")
1136                ),
1137                node: None,
1138                context: None,
1139                hint: Some(
1140                    "rebuild with the matching backend feature to check native/unsupported dispatch"
1141                        .to_string(),
1142                ),
1143                backend: None,
1144            });
1145        }
1146    }
1147
1148    CheckReport {
1149        graph: graph.name.clone(),
1150        nodes: graph.len(),
1151        diagnostics,
1152        backends,
1153    }
1154}
1155
1156/// Self-check hook injected by `#[rlx_model(check)]` right after the model's
1157/// graph is traced. Runs [`check_graph`] and reports findings to stderr.
1158///
1159/// Controlled at runtime by `RLX_CHECK`:
1160/// * unset / `warn` / `1` — print the report when there are findings (default).
1161/// * `off` / `0` / `false` — do nothing (production escape hatch).
1162/// * `all` — check every backend target and always print.
1163/// * `strict` — additionally **panic** on any error-level finding.
1164///
1165/// Focused on the CPU reference backend by default (that's what `#[rlx_model]`
1166/// compiles for); `RLX_CHECK=all` widens to every target.
1167pub fn model_self_check(name: &str, graph: &Graph) {
1168    let mode = rlx_ir::env::var("RLX_CHECK").unwrap_or_default();
1169    let mode = mode.trim().to_ascii_lowercase();
1170    if matches!(mode.as_str(), "0" | "off" | "false") {
1171        return;
1172    }
1173    let strict = mode == "strict";
1174    let all = mode == "all";
1175    let verbose = all || mode == "verbose";
1176
1177    let opts = CheckOptions {
1178        backends: if all {
1179            all_backends()
1180        } else {
1181            vec![FusionTarget::Cpu]
1182        },
1183        ..CheckOptions::default()
1184    };
1185    let report = check_graph(graph, &opts);
1186
1187    if verbose || report.errors() > 0 || report.warnings() > 0 {
1188        eprint!("rlx-check [{name}]\n{}", report.render());
1189    }
1190    if strict && report.has_errors() {
1191        panic!(
1192            "rlx-check: model `{name}` has {} error-level finding(s) — see report above \
1193             (RLX_CHECK=strict)",
1194            report.errors()
1195        );
1196    }
1197}
1198
1199#[cfg(test)]
1200mod tests {
1201    use super::*;
1202    use rlx_ir::infer::GraphExt;
1203    use rlx_ir::{DType, Shape};
1204
1205    fn f32s(d: &[usize]) -> Shape {
1206        Shape::new(d, DType::F32)
1207    }
1208
1209    #[test]
1210    fn clean_mlp_is_cpu_ready() {
1211        let mut g = Graph::new("mlp");
1212        let x = g.input("x", f32s(&[4, 16]));
1213        let w = g.param("w", f32s(&[16, 8]));
1214        let h = g.mm(x, w);
1215        let y = g.gelu(h);
1216        g.set_outputs(vec![y]);
1217
1218        let r = check_graph(&g, &CheckOptions::default());
1219        assert_eq!(r.errors(), 0, "unexpected errors: {:#?}", r.diagnostics);
1220        let cpu = r.backends.iter().find(|b| b.backend == "cpu").unwrap();
1221        let leg = cpu.legality.as_ref().expect("cpu legality");
1222        assert!(leg.compile_ready);
1223        assert_eq!(leg.unsupported_kinds, 0);
1224    }
1225
1226    #[test]
1227    fn self_check_hook_runs_without_panic() {
1228        // Default RLX_CHECK behavior must never panic on a clean graph.
1229        let mut g = Graph::new("hooked");
1230        let x = g.input("x", f32s(&[2, 4]));
1231        let w = g.param("w", f32s(&[4, 4]));
1232        let y = g.mm(x, w);
1233        g.set_outputs(vec![y]);
1234        model_self_check("hooked", &g);
1235    }
1236}