1use 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#[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#[derive(Debug, Clone, Serialize)]
84pub struct Diagnostic {
85 pub severity: Severity,
86 pub code: String,
88 pub message: String,
89 pub node: Option<u32>,
91 pub context: Option<String>,
93 pub hint: Option<String>,
95 pub backend: Option<String>,
97}
98
99#[derive(Debug, Clone, Serialize)]
105pub struct Legality {
106 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#[derive(Debug, Clone, Serialize)]
117pub struct BackendSummary {
118 pub backend: String,
119 pub legality: Option<Legality>,
122 pub fused_ops: usize,
124 pub missed_fusions: usize,
125}
126
127#[derive(Debug, Clone, Serialize)]
129pub struct CheckReport {
130 pub graph: String,
131 pub nodes: usize,
132 pub diagnostics: Vec<Diagnostic>,
133 pub backends: Vec<BackendSummary>,
134}
135
136#[derive(Debug, Clone)]
138pub struct CheckOptions {
139 pub backends: Vec<FusionTarget>,
141 pub dispatch: bool,
142 pub fusion: bool,
143 pub numeric: bool,
144 pub repr: bool,
149 pub schedule: bool,
153 pub plan: bool,
157}
158
159impl Default for CheckOptions {
160 fn default() -> Self {
161 Self {
162 backends: default_backends(),
163 dispatch: true,
164 fusion: true,
165 numeric: true,
166 repr: true,
167 schedule: true,
168 plan: true,
169 }
170 }
171}
172
173pub fn default_backends() -> Vec<FusionTarget> {
175 vec![
176 FusionTarget::Cpu,
177 FusionTarget::Metal,
178 FusionTarget::Cuda,
179 FusionTarget::Wgpu,
180 ]
181}
182
183pub fn all_backends() -> Vec<FusionTarget> {
185 vec![
186 FusionTarget::Cpu,
187 FusionTarget::Metal,
188 FusionTarget::Mlx,
189 FusionTarget::Wgpu,
190 FusionTarget::Cuda,
191 FusionTarget::Rocm,
192 FusionTarget::Tpu,
193 ]
194}
195
196pub fn backend_name(t: FusionTarget) -> &'static str {
198 match t {
199 FusionTarget::Cpu => "cpu",
200 FusionTarget::Metal => "metal",
201 FusionTarget::Mlx => "mlx",
202 FusionTarget::Wgpu => "wgpu",
203 FusionTarget::Cuda => "cuda",
204 FusionTarget::Rocm => "rocm",
205 FusionTarget::Tpu => "tpu",
206 }
207}
208
209pub fn parse_backend(s: &str) -> Option<FusionTarget> {
211 match s.trim().to_ascii_lowercase().as_str() {
212 "cpu" => Some(FusionTarget::Cpu),
213 "metal" | "mps" | "mtl" => Some(FusionTarget::Metal),
214 "mlx" => Some(FusionTarget::Mlx),
215 "wgpu" | "gpu" | "webgpu" => Some(FusionTarget::Wgpu),
216 "cuda" | "nvidia" => Some(FusionTarget::Cuda),
217 "rocm" | "hip" | "amd" => Some(FusionTarget::Rocm),
218 "tpu" => Some(FusionTarget::Tpu),
219 _ => None,
220 }
221}
222
223pub fn backend_device(t: FusionTarget) -> Device {
225 match t {
226 FusionTarget::Cpu => Device::Cpu,
227 FusionTarget::Metal => Device::Metal,
228 FusionTarget::Mlx => Device::Mlx,
229 FusionTarget::Wgpu => Device::Gpu,
230 FusionTarget::Cuda => Device::Cuda,
231 FusionTarget::Rocm => Device::Rocm,
232 FusionTarget::Tpu => Device::Tpu,
233 }
234}
235
236fn execution_claim(device: Device) -> Option<&'static [rlx_ir::OpKind]> {
241 catch_unwind(AssertUnwindSafe(|| {
242 backend_for(device).map(|be| be.supported_ops())
243 }))
244 .ok()
245 .flatten()
246}
247
248impl CheckReport {
249 pub fn count(&self, sev: Severity) -> usize {
250 self.diagnostics
251 .iter()
252 .filter(|d| d.severity == sev)
253 .count()
254 }
255
256 pub fn errors(&self) -> usize {
257 self.count(Severity::Error)
258 }
259
260 pub fn warnings(&self) -> usize {
261 self.count(Severity::Warning)
262 }
263
264 pub fn has_errors(&self) -> bool {
265 self.errors() > 0
266 }
267
268 pub fn to_json(&self) -> String {
270 serde_json::to_string_pretty(self).unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}"))
271 }
272
273 pub fn render(&self) -> String {
275 use std::fmt::Write as _;
276 let mut s = String::new();
277 let _ = writeln!(
278 s,
279 "rlx check — graph \"{}\" ({} node{})",
280 self.graph,
281 self.nodes,
282 if self.nodes == 1 { "" } else { "s" }
283 );
284
285 let mut diags: Vec<&Diagnostic> = self.diagnostics.iter().collect();
286 diags.sort_by_key(|d| d.severity.rank());
287
288 if diags.is_empty() {
289 let _ = writeln!(s, "\n no findings.");
290 } else {
291 let _ = writeln!(s);
292 for d in diags {
293 let on = d
294 .backend
295 .as_deref()
296 .map(|b| format!(" on {b}"))
297 .unwrap_or_default();
298 let _ = writeln!(s, "{}[{}]{on}: {}", d.severity.label(), d.code, d.message);
299 if let Some(n) = d.node {
300 match &d.context {
302 Some(c) if !c.is_empty() && *c != format!("%{n}") => {
303 let _ = writeln!(s, " --> %{n} ({c})");
304 }
305 _ => {
306 let _ = writeln!(s, " --> %{n}");
307 }
308 }
309 }
310 if let Some(h) = &d.hint {
311 let _ = writeln!(s, " = help: {h}");
312 }
313 }
314 }
315
316 if !self.backends.is_empty() {
317 let _ = writeln!(s, "\nbackends:");
318 for b in &self.backends {
319 match &b.legality {
320 Some(l) => {
321 let status = if l.compile_ready {
322 "ready "
323 } else {
324 "BLOCKED"
325 };
326 let _ = writeln!(
327 s,
328 " {:<6} {status} native={} common-ir={} rewritten={} unsupported={} fused={} missed={}",
329 b.backend,
330 l.native_kinds,
331 l.common_ir_kinds,
332 l.rewritten_kinds,
333 l.unsupported_kinds,
334 b.fused_ops,
335 b.missed_fusions,
336 );
337 }
338 None => {
339 let _ = writeln!(
340 s,
341 " {:<6} legality n/a (build --features {}) fused={} missed={}",
342 b.backend, b.backend, b.fused_ops, b.missed_fusions,
343 );
344 }
345 }
346 }
347 }
348
349 let _ = writeln!(
350 s,
351 "\nsummary: {} error(s), {} warning(s), {} note(s) across {} backend(s)",
352 self.errors(),
353 self.warnings(),
354 self.count(Severity::Note),
355 self.backends.len(),
356 );
357 s
358 }
359}
360
361fn static_dim(d: rlx_ir::Dim) -> Option<usize> {
363 match d {
364 rlx_ir::Dim::Static(n) => Some(n),
365 rlx_ir::Dim::Dynamic(_) => None,
366 }
367}
368
369fn is_gpu_target(t: FusionTarget) -> bool {
372 matches!(
373 t,
374 FusionTarget::Metal
375 | FusionTarget::Mlx
376 | FusionTarget::Wgpu
377 | FusionTarget::Cuda
378 | FusionTarget::Rocm
379 )
380}
381
382const MIN_MODELLED_GAIN: f64 = 1.25;
390
391fn schedule_notes(graph: &Graph) -> Vec<Diagnostic> {
411 use rlx_gpu_dispatch::cost::TileCostModel;
412 use rlx_gpu_dispatch::dispatch::Workload;
413 use rlx_gpu_dispatch::tiles::{MATMUL_TILE_CANDIDATES, TileParams};
414
415 let default_tile = TileParams::DEFAULT_MATMUL;
416 let model = TileCostModel::structural();
419
420 let mut shapes: Vec<(
422 (usize, usize, usize),
423 f64,
424 TileParams,
425 usize,
426 rlx_ir::NodeId,
427 )> = Vec::new();
428
429 for node in graph.nodes() {
430 if !matches!(node.op, rlx_ir::Op::MatMul) || node.inputs.len() != 2 {
431 continue;
432 }
433 let out_dims = node.shape.dims();
435 let lhs_dims = graph.shape(node.inputs[0]).dims();
436 if out_dims.len() < 2 || lhs_dims.is_empty() {
437 continue;
438 }
439 let (Some(m), Some(n), Some(k)) = (
444 static_dim(out_dims[out_dims.len() - 2]),
445 static_dim(out_dims[out_dims.len() - 1]),
446 static_dim(lhs_dims[lhs_dims.len() - 1]),
447 ) else {
448 continue;
449 };
450
451 if let Some(slot) = shapes.iter_mut().find(|(sh, ..)| *sh == (m, k, n)) {
452 slot.3 += 1;
453 continue;
454 }
455
456 let w = Workload::Matmul { m, k, n };
457 let Some(base) = model.estimate(&default_tile, &w) else {
458 continue;
459 };
460 let Some((best_tile, best)) = MATMUL_TILE_CANDIDATES
461 .iter()
462 .filter_map(|t| model.estimate(t, &w).map(|c| (*t, c)))
463 .min_by(|a, b| {
464 a.1.score
465 .partial_cmp(&b.1.score)
466 .unwrap_or(std::cmp::Ordering::Equal)
467 })
468 else {
469 continue;
470 };
471 if best.score <= 0.0 {
472 continue;
473 }
474 shapes.push(((m, k, n), base.score / best.score, best_tile, 1, node.id));
475 }
476
477 let worth_tuning: Vec<_> = shapes
478 .iter()
479 .filter(|(_, gain, ..)| *gain >= MIN_MODELLED_GAIN)
480 .collect();
481 let Some(worst) = worth_tuning
482 .iter()
483 .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
484 else {
485 return Vec::new();
486 };
487
488 let nodes: usize = worth_tuning.iter().map(|(_, _, _, c, _)| *c).sum();
489 let ((wm, wk, wn), gain, best_tile, _, anchor) = **worst;
490
491 vec![Diagnostic {
492 severity: Severity::Note,
493 code: "schedule-tile".to_string(),
494 message: format!(
495 "{} matmul shape(s) across {nodes} node(s) model faster on a non-default tile; \
496 largest modelled gain {gain:.2}x at {wm}x{wk}x{wn} ({} vs the default {})",
497 worth_tuning.len(),
498 best_tile.label(),
499 default_tile.label()
500 ),
501 node: Some(anchor.0),
502 context: Some(node_label(graph, anchor)),
503 hint: Some(
504 "these are UNCALIBRATED model rankings, not measurements — run \
505 `tune_dispatch` on the target device to confirm and install; the dispatch \
506 table only accepts a measured winner that also clears a held-out check"
507 .to_string(),
508 ),
509 backend: None,
510 }]
511}
512
513fn verify_diag(graph: &Graph, e: &VerifyError, code: &str) -> Diagnostic {
514 let hint = if e.message.contains("shape mismatch") {
515 Some(
516 "declared out-shape disagrees with the inferred shape — fix the builder's \
517 out_shape argument (or a dtype mismatch between the operands)"
518 .to_string(),
519 )
520 } else if e.message.contains("not a DAG") {
521 Some("an input references a later node — build nodes in topological order".to_string())
522 } else if e.message.contains("expects") && e.message.contains("inputs") {
523 Some("wrong operand count for this op".to_string())
524 } else {
525 None
526 };
527 Diagnostic {
528 severity: Severity::Error,
529 code: code.to_string(),
530 message: e.message.clone(),
531 node: e.node.map(|n| n.0),
532 context: e.node.map(|n| node_label(graph, n)),
533 hint,
534 backend: None,
535 }
536}
537
538pub fn check_graph(graph: &Graph, opts: &CheckOptions) -> CheckReport {
545 let mut diagnostics = Vec::new();
546
547 let structural = rlx_ir::verify::verify(graph);
549 for e in &structural {
550 diagnostics.push(verify_diag(graph, e, "graph-structure"));
551 }
552
553 let shape_errors = if structural.is_empty() {
555 rlx_ir::verify::verify_shapes(graph)
556 } else {
557 Vec::new()
558 };
559 for e in &shape_errors {
560 diagnostics.push(verify_diag(graph, e, "shape"));
561 }
562
563 let graph_ok = structural.is_empty() && shape_errors.is_empty();
564
565 if opts.numeric && graph_ok {
567 for l in rlx_opt::rlx_compile::lint_numerics(graph) {
568 diagnostics.push(Diagnostic {
569 severity: Severity::Warning,
570 code: "numeric".to_string(),
571 message: format!("{} produced here — {}", l.kind.as_str(), l.reason),
572 node: Some(l.node.0),
573 context: Some(l.label),
574 hint: l.fix.map(str::to_string),
575 backend: None,
576 });
577 }
578 }
579
580 if opts.repr && graph_ok {
586 let report = rlx_ir::repr_check::check_graph(graph);
587 for f in &report.findings {
588 diagnostics.push(Diagnostic {
589 severity: Severity::Error,
590 code: format!("repr-{}", f.kind.as_str()),
591 message: format!(
592 "{:?} expects {} but its producer declares {}",
593 f.op, f.expected, f.actual
594 ),
595 node: Some(f.node.0),
596 context: f.input.map(|i| format!("input[{i}]")),
597 hint: Some(f.why.to_string()),
598 backend: None,
599 });
600 }
601 }
602
603 if opts.schedule && graph_ok && opts.backends.iter().any(|t| is_gpu_target(*t)) {
612 diagnostics.extend(schedule_notes(graph));
613 }
614
615 if opts.plan && graph_ok {
623 let plan = rlx_opt::rlx_compile::memory::plan_memory(graph);
624 let report = rlx_opt::rlx_compile::plan_check::check_plan(graph, &plan);
625 for f in &report.findings {
626 diagnostics.push(Diagnostic {
627 severity: Severity::Error,
628 code: format!("plan-{}", f.kind.as_str()),
629 message: f.detail.clone(),
630 node: Some(f.node.0),
631 context: Some(node_label(graph, f.node)),
632 hint: Some(
633 if f.kind.is_program_safety() {
634 "a memory-use hazard: the arena assignment lets one tensor's bytes \
635 be read or written as another's"
636 } else {
637 "a schedule-structure violation: the declared execution order does \
638 not satisfy the graph's dependencies"
639 }
640 .to_string(),
641 ),
642 backend: None,
643 });
644 }
645 }
646
647 let mut backends = Vec::new();
650 let mut seen_miss: HashSet<(String, u32, String)> = HashSet::new();
653 let mut legality_gaps: Vec<&'static str> = Vec::new();
654
655 if graph_ok {
656 for &t in &opts.backends {
657 let name = backend_name(t);
658 let mut summary = BackendSummary {
659 backend: name.to_string(),
660 legality: None,
661 fused_ops: 0,
662 missed_fusions: 0,
663 };
664
665 match execution_claim(backend_device(t)) {
667 Some(claim) => {
668 let (_g, report) = prepare_graph_for_backend_with_report(
669 graph.clone(),
670 name,
671 claim,
672 KernelDispatchConfig::default(),
673 );
674 let mut leg = Legality {
675 compile_ready: report.compile_ready,
676 native_kinds: 0,
677 common_ir_kinds: 0,
678 rewritten_kinds: 0,
679 unsupported_kinds: 0,
680 };
681 for kind_summary in &report.summaries {
682 match kind_summary.path {
683 DispatchPath::Native => leg.native_kinds += 1,
684 DispatchPath::CommonIr => leg.common_ir_kinds += 1,
685 DispatchPath::Rewritten => leg.rewritten_kinds += 1,
686 DispatchPath::Unsupported => leg.unsupported_kinds += 1,
687 }
688 }
689 if opts.dispatch {
690 for (id, kind) in &report.still_unsupported {
691 diagnostics.push(Diagnostic {
692 severity: Severity::Error,
693 code: "unsupported-op".to_string(),
694 message: format!(
695 "{kind:?} cannot be lowered on {name} (still unsupported after rewrite)"
696 ),
697 node: Some(id.0),
698 context: Some(node_label(graph, *id)),
699 hint: Some(
700 "add a native thunk + list the OpKind in Backend::supported_ops, \
701 or add a rewrite/common-IR body in rlx-fusion"
702 .to_string(),
703 ),
704 backend: Some(name.to_string()),
705 });
706 }
707 for kind in &report.common_lowered_kinds {
708 diagnostics.push(Diagnostic {
709 severity: Severity::Note,
710 code: "common-ir".to_string(),
711 message: format!(
712 "{kind:?} runs via portable common-IR on {name} (correct, but off the native fast path)"
713 ),
714 node: None,
715 context: None,
716 hint: Some(
717 "list this OpKind in the backend's supported_ops to dispatch a native kernel"
718 .to_string(),
719 ),
720 backend: Some(name.to_string()),
721 });
722 }
723 }
724 summary.legality = Some(leg);
725 }
726 None => legality_gaps.push(name),
727 }
728
729 let fusion = catch_unwind(AssertUnwindSafe(|| {
731 Fuse::new(t).run_with_report(graph.clone())
732 }));
733 if let Ok((_fused, freport)) = fusion {
734 summary.fused_ops = freport.fused_matmul_bias_act
735 + freport.fused_swiglu
736 + freport.fused_residual_ln
737 + freport.fused_residual_rms_norm
738 + freport.fused_attention_block
739 + freport.fused_transformer_layer;
740 summary.missed_fusions = freport.missed.len();
741 if opts.fusion {
742 for m in &freport.missed {
743 let key = (m.pattern.to_string(), m.node.0, format!("{:?}", m.reason));
744 if seen_miss.insert(key) {
745 diagnostics.push(Diagnostic {
746 severity: Severity::Warning,
747 code: "missed-fusion".to_string(),
748 message: format!(
749 "{} fusion not applied ({:?})",
750 m.pattern, m.reason
751 ),
752 node: Some(m.node.0),
753 context: m.context.clone(),
754 hint: m.hint.clone(),
755 backend: None,
756 });
757 }
758 }
759 }
760 }
761
762 backends.push(summary);
763 }
764
765 if !legality_gaps.is_empty() {
768 diagnostics.push(Diagnostic {
769 severity: Severity::Note,
770 code: "legality-unavailable".to_string(),
771 message: format!(
772 "execution legality not checked for [{}] — those backends aren't compiled \
773 into this build (fusion coverage is still shown)",
774 legality_gaps.join(", ")
775 ),
776 node: None,
777 context: None,
778 hint: Some(
779 "rebuild with the matching backend feature to check native/unsupported dispatch"
780 .to_string(),
781 ),
782 backend: None,
783 });
784 }
785 }
786
787 CheckReport {
788 graph: graph.name.clone(),
789 nodes: graph.len(),
790 diagnostics,
791 backends,
792 }
793}
794
795pub fn model_self_check(name: &str, graph: &Graph) {
807 let mode = rlx_ir::env::var("RLX_CHECK").unwrap_or_default();
808 let mode = mode.trim().to_ascii_lowercase();
809 if matches!(mode.as_str(), "0" | "off" | "false") {
810 return;
811 }
812 let strict = mode == "strict";
813 let all = mode == "all";
814 let verbose = all || mode == "verbose";
815
816 let opts = CheckOptions {
817 backends: if all {
818 all_backends()
819 } else {
820 vec![FusionTarget::Cpu]
821 },
822 ..CheckOptions::default()
823 };
824 let report = check_graph(graph, &opts);
825
826 if verbose || report.errors() > 0 || report.warnings() > 0 {
827 eprint!("rlx-check [{name}]\n{}", report.render());
828 }
829 if strict && report.has_errors() {
830 panic!(
831 "rlx-check: model `{name}` has {} error-level finding(s) — see report above \
832 (RLX_CHECK=strict)",
833 report.errors()
834 );
835 }
836}
837
838#[cfg(test)]
839mod tests {
840 use super::*;
841 use rlx_ir::infer::GraphExt;
842 use rlx_ir::{DType, Shape};
843
844 fn f32s(d: &[usize]) -> Shape {
845 Shape::new(d, DType::F32)
846 }
847
848 #[test]
849 fn clean_mlp_is_cpu_ready() {
850 let mut g = Graph::new("mlp");
851 let x = g.input("x", f32s(&[4, 16]));
852 let w = g.param("w", f32s(&[16, 8]));
853 let h = g.mm(x, w);
854 let y = g.gelu(h);
855 g.set_outputs(vec![y]);
856
857 let r = check_graph(&g, &CheckOptions::default());
858 assert_eq!(r.errors(), 0, "unexpected errors: {:#?}", r.diagnostics);
859 let cpu = r.backends.iter().find(|b| b.backend == "cpu").unwrap();
860 let leg = cpu.legality.as_ref().expect("cpu legality");
861 assert!(leg.compile_ready);
862 assert_eq!(leg.unsupported_kinds, 0);
863 }
864
865 #[test]
866 fn self_check_hook_runs_without_panic() {
867 let mut g = Graph::new("hooked");
869 let x = g.input("x", f32s(&[2, 4]));
870 let w = g.param("w", f32s(&[4, 4]));
871 let y = g.mm(x, w);
872 g.set_outputs(vec![y]);
873 model_self_check("hooked", &g);
874 }
875}