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)]
123pub struct Budget {
124 pub arena_bytes: usize,
126 pub reuse_saved_bytes: usize,
128 pub width_overhead_bytes: usize,
135 pub width_policy: &'static str,
137 pub host_io_bytes: usize,
141 pub off_fast_path_ops: usize,
145 pub off_fast_path_bytes: usize,
146 pub cast_nodes: usize,
149 pub param_bytes: usize,
157 pub dispatches: usize,
164 pub dram_bytes: usize,
170}
171
172#[derive(Debug, Clone, Serialize)]
175pub struct BackendSummary {
176 pub backend: String,
177 pub legality: Option<Legality>,
180 pub fused_ops: usize,
182 pub missed_fusions: usize,
183 pub budget: Option<Budget>,
186}
187
188#[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#[derive(Debug, Clone)]
199pub struct CheckOptions {
200 pub backends: Vec<FusionTarget>,
202 pub dispatch: bool,
203 pub fusion: bool,
204 pub numeric: bool,
205 pub repr: bool,
210 pub schedule: bool,
214 pub plan: bool,
218 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
239pub fn default_backends() -> Vec<FusionTarget> {
241 vec![
242 FusionTarget::Cpu,
243 FusionTarget::Metal,
244 FusionTarget::Cuda,
245 FusionTarget::Wgpu,
246 ]
247}
248
249pub 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
262pub 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
275pub 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
289pub 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
302fn 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 pub fn to_json(&self) -> String {
336 serde_json::to_string_pretty(self).unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}"))
337 }
338
339 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 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 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
458fn 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
470fn 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
478fn 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
491const MIN_MODELLED_GAIN: f64 = 1.25;
499
500fn 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 let model = TileCostModel::structural();
528
529 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 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 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
647fn node_bytes(graph: &Graph, id: rlx_ir::NodeId) -> usize {
655 graph.node(id).shape.size_bytes().unwrap_or(0)
656}
657
658fn 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 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 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 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 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 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 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
792fn 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 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 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 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 let structural = rlx_ir::verify::verify(graph);
882 for e in &structural {
883 diagnostics.push(verify_diag(graph, e, "graph-structure"));
884 }
885
886 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 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 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 if opts.schedule && graph_ok && opts.backends.iter().any(|t| is_gpu_target(*t)) {
945 diagnostics.extend(schedule_notes(graph));
946 }
947
948 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 let mut backends = Vec::new();
983 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 let mut off_path_kinds: HashSet<String> = HashSet::new();
1002
1003 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 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 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 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 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
1156pub 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 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}