1use std::collections::HashSet;
27use std::panic::{AssertUnwindSafe, catch_unwind};
28
29use rlx_ir::verify::VerifyError;
30use rlx_ir::{Graph, node_label};
31use rlx_opt::rlx_compile::KernelDispatchConfig;
32use rlx_opt::rlx_compile::dispatch_report::{DispatchPath, prepare_graph_for_backend_with_report};
33use rlx_opt::rlx_compile::fusion_pipeline::{Fuse, FusionTarget};
34use serde::Serialize;
35
36use crate::Device;
37use crate::registry::backend_for;
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
41#[serde(rename_all = "lowercase")]
42pub enum Severity {
43 Error,
44 Warning,
45 Note,
46}
47
48impl Severity {
49 fn rank(self) -> u8 {
50 match self {
51 Severity::Error => 0,
52 Severity::Warning => 1,
53 Severity::Note => 2,
54 }
55 }
56
57 fn label(self) -> &'static str {
58 match self {
59 Severity::Error => "error",
60 Severity::Warning => "warning",
61 Severity::Note => "note",
62 }
63 }
64}
65
66#[derive(Debug, Clone, Serialize)]
68pub struct Diagnostic {
69 pub severity: Severity,
70 pub code: String,
72 pub message: String,
73 pub node: Option<u32>,
75 pub context: Option<String>,
77 pub hint: Option<String>,
79 pub backend: Option<String>,
81}
82
83#[derive(Debug, Clone, Serialize)]
89pub struct Legality {
90 pub compile_ready: bool,
92 pub native_kinds: usize,
93 pub common_ir_kinds: usize,
94 pub rewritten_kinds: usize,
95 pub unsupported_kinds: usize,
96}
97
98#[derive(Debug, Clone, Serialize)]
101pub struct BackendSummary {
102 pub backend: String,
103 pub legality: Option<Legality>,
106 pub fused_ops: usize,
108 pub missed_fusions: usize,
109}
110
111#[derive(Debug, Clone, Serialize)]
113pub struct CheckReport {
114 pub graph: String,
115 pub nodes: usize,
116 pub diagnostics: Vec<Diagnostic>,
117 pub backends: Vec<BackendSummary>,
118}
119
120#[derive(Debug, Clone)]
122pub struct CheckOptions {
123 pub backends: Vec<FusionTarget>,
125 pub dispatch: bool,
126 pub fusion: bool,
127 pub numeric: bool,
128}
129
130impl Default for CheckOptions {
131 fn default() -> Self {
132 Self {
133 backends: default_backends(),
134 dispatch: true,
135 fusion: true,
136 numeric: true,
137 }
138 }
139}
140
141pub fn default_backends() -> Vec<FusionTarget> {
143 vec![
144 FusionTarget::Cpu,
145 FusionTarget::Metal,
146 FusionTarget::Cuda,
147 FusionTarget::Wgpu,
148 ]
149}
150
151pub fn all_backends() -> Vec<FusionTarget> {
153 vec![
154 FusionTarget::Cpu,
155 FusionTarget::Metal,
156 FusionTarget::Mlx,
157 FusionTarget::Wgpu,
158 FusionTarget::Cuda,
159 FusionTarget::Rocm,
160 FusionTarget::Tpu,
161 ]
162}
163
164pub fn backend_name(t: FusionTarget) -> &'static str {
166 match t {
167 FusionTarget::Cpu => "cpu",
168 FusionTarget::Metal => "metal",
169 FusionTarget::Mlx => "mlx",
170 FusionTarget::Wgpu => "wgpu",
171 FusionTarget::Cuda => "cuda",
172 FusionTarget::Rocm => "rocm",
173 FusionTarget::Tpu => "tpu",
174 }
175}
176
177pub fn parse_backend(s: &str) -> Option<FusionTarget> {
179 match s.trim().to_ascii_lowercase().as_str() {
180 "cpu" => Some(FusionTarget::Cpu),
181 "metal" | "mps" | "mtl" => Some(FusionTarget::Metal),
182 "mlx" => Some(FusionTarget::Mlx),
183 "wgpu" | "gpu" | "webgpu" => Some(FusionTarget::Wgpu),
184 "cuda" | "nvidia" => Some(FusionTarget::Cuda),
185 "rocm" | "hip" | "amd" => Some(FusionTarget::Rocm),
186 "tpu" => Some(FusionTarget::Tpu),
187 _ => None,
188 }
189}
190
191pub fn backend_device(t: FusionTarget) -> Device {
193 match t {
194 FusionTarget::Cpu => Device::Cpu,
195 FusionTarget::Metal => Device::Metal,
196 FusionTarget::Mlx => Device::Mlx,
197 FusionTarget::Wgpu => Device::Gpu,
198 FusionTarget::Cuda => Device::Cuda,
199 FusionTarget::Rocm => Device::Rocm,
200 FusionTarget::Tpu => Device::Tpu,
201 }
202}
203
204fn execution_claim(device: Device) -> Option<&'static [rlx_ir::OpKind]> {
209 catch_unwind(AssertUnwindSafe(|| {
210 backend_for(device).map(|be| be.supported_ops())
211 }))
212 .ok()
213 .flatten()
214}
215
216impl CheckReport {
217 pub fn count(&self, sev: Severity) -> usize {
218 self.diagnostics
219 .iter()
220 .filter(|d| d.severity == sev)
221 .count()
222 }
223
224 pub fn errors(&self) -> usize {
225 self.count(Severity::Error)
226 }
227
228 pub fn warnings(&self) -> usize {
229 self.count(Severity::Warning)
230 }
231
232 pub fn has_errors(&self) -> bool {
233 self.errors() > 0
234 }
235
236 pub fn to_json(&self) -> String {
238 serde_json::to_string_pretty(self).unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}"))
239 }
240
241 pub fn render(&self) -> String {
243 use std::fmt::Write as _;
244 let mut s = String::new();
245 let _ = writeln!(
246 s,
247 "rlx check — graph \"{}\" ({} node{})",
248 self.graph,
249 self.nodes,
250 if self.nodes == 1 { "" } else { "s" }
251 );
252
253 let mut diags: Vec<&Diagnostic> = self.diagnostics.iter().collect();
254 diags.sort_by_key(|d| d.severity.rank());
255
256 if diags.is_empty() {
257 let _ = writeln!(s, "\n no findings.");
258 } else {
259 let _ = writeln!(s);
260 for d in diags {
261 let on = d
262 .backend
263 .as_deref()
264 .map(|b| format!(" on {b}"))
265 .unwrap_or_default();
266 let _ = writeln!(s, "{}[{}]{on}: {}", d.severity.label(), d.code, d.message);
267 if let Some(n) = d.node {
268 match &d.context {
270 Some(c) if !c.is_empty() && *c != format!("%{n}") => {
271 let _ = writeln!(s, " --> %{n} ({c})");
272 }
273 _ => {
274 let _ = writeln!(s, " --> %{n}");
275 }
276 }
277 }
278 if let Some(h) = &d.hint {
279 let _ = writeln!(s, " = help: {h}");
280 }
281 }
282 }
283
284 if !self.backends.is_empty() {
285 let _ = writeln!(s, "\nbackends:");
286 for b in &self.backends {
287 match &b.legality {
288 Some(l) => {
289 let status = if l.compile_ready {
290 "ready "
291 } else {
292 "BLOCKED"
293 };
294 let _ = writeln!(
295 s,
296 " {:<6} {status} native={} common-ir={} rewritten={} unsupported={} fused={} missed={}",
297 b.backend,
298 l.native_kinds,
299 l.common_ir_kinds,
300 l.rewritten_kinds,
301 l.unsupported_kinds,
302 b.fused_ops,
303 b.missed_fusions,
304 );
305 }
306 None => {
307 let _ = writeln!(
308 s,
309 " {:<6} legality n/a (build --features {}) fused={} missed={}",
310 b.backend, b.backend, b.fused_ops, b.missed_fusions,
311 );
312 }
313 }
314 }
315 }
316
317 let _ = writeln!(
318 s,
319 "\nsummary: {} error(s), {} warning(s), {} note(s) across {} backend(s)",
320 self.errors(),
321 self.warnings(),
322 self.count(Severity::Note),
323 self.backends.len(),
324 );
325 s
326 }
327}
328
329fn verify_diag(graph: &Graph, e: &VerifyError, code: &str) -> Diagnostic {
330 let hint = if e.message.contains("shape mismatch") {
331 Some(
332 "declared out-shape disagrees with the inferred shape — fix the builder's \
333 out_shape argument (or a dtype mismatch between the operands)"
334 .to_string(),
335 )
336 } else if e.message.contains("not a DAG") {
337 Some("an input references a later node — build nodes in topological order".to_string())
338 } else if e.message.contains("expects") && e.message.contains("inputs") {
339 Some("wrong operand count for this op".to_string())
340 } else {
341 None
342 };
343 Diagnostic {
344 severity: Severity::Error,
345 code: code.to_string(),
346 message: e.message.clone(),
347 node: e.node.map(|n| n.0),
348 context: e.node.map(|n| node_label(graph, n)),
349 hint,
350 backend: None,
351 }
352}
353
354pub fn check_graph(graph: &Graph, opts: &CheckOptions) -> CheckReport {
361 let mut diagnostics = Vec::new();
362
363 let structural = rlx_ir::verify::verify(graph);
365 for e in &structural {
366 diagnostics.push(verify_diag(graph, e, "graph-structure"));
367 }
368
369 let shape_errors = if structural.is_empty() {
371 rlx_ir::verify::verify_shapes(graph)
372 } else {
373 Vec::new()
374 };
375 for e in &shape_errors {
376 diagnostics.push(verify_diag(graph, e, "shape"));
377 }
378
379 let graph_ok = structural.is_empty() && shape_errors.is_empty();
380
381 if opts.numeric && graph_ok {
383 for l in rlx_opt::rlx_compile::lint_numerics(graph) {
384 diagnostics.push(Diagnostic {
385 severity: Severity::Warning,
386 code: "numeric".to_string(),
387 message: format!("{} produced here — {}", l.kind.as_str(), l.reason),
388 node: Some(l.node.0),
389 context: Some(l.label),
390 hint: l.fix.map(str::to_string),
391 backend: None,
392 });
393 }
394 }
395
396 let mut backends = Vec::new();
399 let mut seen_miss: HashSet<(String, u32, String)> = HashSet::new();
402 let mut legality_gaps: Vec<&'static str> = Vec::new();
403
404 if graph_ok {
405 for &t in &opts.backends {
406 let name = backend_name(t);
407 let mut summary = BackendSummary {
408 backend: name.to_string(),
409 legality: None,
410 fused_ops: 0,
411 missed_fusions: 0,
412 };
413
414 match execution_claim(backend_device(t)) {
416 Some(claim) => {
417 let (_g, report) = prepare_graph_for_backend_with_report(
418 graph.clone(),
419 name,
420 claim,
421 KernelDispatchConfig::default(),
422 );
423 let mut leg = Legality {
424 compile_ready: report.compile_ready,
425 native_kinds: 0,
426 common_ir_kinds: 0,
427 rewritten_kinds: 0,
428 unsupported_kinds: 0,
429 };
430 for kind_summary in &report.summaries {
431 match kind_summary.path {
432 DispatchPath::Native => leg.native_kinds += 1,
433 DispatchPath::CommonIr => leg.common_ir_kinds += 1,
434 DispatchPath::Rewritten => leg.rewritten_kinds += 1,
435 DispatchPath::Unsupported => leg.unsupported_kinds += 1,
436 }
437 }
438 if opts.dispatch {
439 for (id, kind) in &report.still_unsupported {
440 diagnostics.push(Diagnostic {
441 severity: Severity::Error,
442 code: "unsupported-op".to_string(),
443 message: format!(
444 "{kind:?} cannot be lowered on {name} (still unsupported after rewrite)"
445 ),
446 node: Some(id.0),
447 context: Some(node_label(graph, *id)),
448 hint: Some(
449 "add a native thunk + list the OpKind in Backend::supported_ops, \
450 or add a rewrite/common-IR body in rlx-fusion"
451 .to_string(),
452 ),
453 backend: Some(name.to_string()),
454 });
455 }
456 for kind in &report.common_lowered_kinds {
457 diagnostics.push(Diagnostic {
458 severity: Severity::Note,
459 code: "common-ir".to_string(),
460 message: format!(
461 "{kind:?} runs via portable common-IR on {name} (correct, but off the native fast path)"
462 ),
463 node: None,
464 context: None,
465 hint: Some(
466 "list this OpKind in the backend's supported_ops to dispatch a native kernel"
467 .to_string(),
468 ),
469 backend: Some(name.to_string()),
470 });
471 }
472 }
473 summary.legality = Some(leg);
474 }
475 None => legality_gaps.push(name),
476 }
477
478 let fusion = catch_unwind(AssertUnwindSafe(|| {
480 Fuse::new(t).run_with_report(graph.clone())
481 }));
482 if let Ok((_fused, freport)) = fusion {
483 summary.fused_ops = freport.fused_matmul_bias_act
484 + freport.fused_swiglu
485 + freport.fused_residual_ln
486 + freport.fused_residual_rms_norm
487 + freport.fused_attention_block
488 + freport.fused_transformer_layer;
489 summary.missed_fusions = freport.missed.len();
490 if opts.fusion {
491 for m in &freport.missed {
492 let key = (m.pattern.to_string(), m.node.0, format!("{:?}", m.reason));
493 if seen_miss.insert(key) {
494 diagnostics.push(Diagnostic {
495 severity: Severity::Warning,
496 code: "missed-fusion".to_string(),
497 message: format!(
498 "{} fusion not applied ({:?})",
499 m.pattern, m.reason
500 ),
501 node: Some(m.node.0),
502 context: m.context.clone(),
503 hint: m.hint.clone(),
504 backend: None,
505 });
506 }
507 }
508 }
509 }
510
511 backends.push(summary);
512 }
513
514 if !legality_gaps.is_empty() {
517 diagnostics.push(Diagnostic {
518 severity: Severity::Note,
519 code: "legality-unavailable".to_string(),
520 message: format!(
521 "execution legality not checked for [{}] — those backends aren't compiled \
522 into this build (fusion coverage is still shown)",
523 legality_gaps.join(", ")
524 ),
525 node: None,
526 context: None,
527 hint: Some(
528 "rebuild with the matching backend feature to check native/unsupported dispatch"
529 .to_string(),
530 ),
531 backend: None,
532 });
533 }
534 }
535
536 CheckReport {
537 graph: graph.name.clone(),
538 nodes: graph.len(),
539 diagnostics,
540 backends,
541 }
542}
543
544pub fn model_self_check(name: &str, graph: &Graph) {
556 let mode = rlx_ir::env::var("RLX_CHECK").unwrap_or_default();
557 let mode = mode.trim().to_ascii_lowercase();
558 if matches!(mode.as_str(), "0" | "off" | "false") {
559 return;
560 }
561 let strict = mode == "strict";
562 let all = mode == "all";
563 let verbose = all || mode == "verbose";
564
565 let opts = CheckOptions {
566 backends: if all {
567 all_backends()
568 } else {
569 vec![FusionTarget::Cpu]
570 },
571 ..CheckOptions::default()
572 };
573 let report = check_graph(graph, &opts);
574
575 if verbose || report.errors() > 0 || report.warnings() > 0 {
576 eprint!("rlx-check [{name}]\n{}", report.render());
577 }
578 if strict && report.has_errors() {
579 panic!(
580 "rlx-check: model `{name}` has {} error-level finding(s) — see report above \
581 (RLX_CHECK=strict)",
582 report.errors()
583 );
584 }
585}
586
587#[cfg(test)]
588mod tests {
589 use super::*;
590 use rlx_ir::infer::GraphExt;
591 use rlx_ir::{DType, Shape};
592
593 fn f32s(d: &[usize]) -> Shape {
594 Shape::new(d, DType::F32)
595 }
596
597 #[test]
598 fn clean_mlp_is_cpu_ready() {
599 let mut g = Graph::new("mlp");
600 let x = g.input("x", f32s(&[4, 16]));
601 let w = g.param("w", f32s(&[16, 8]));
602 let h = g.mm(x, w);
603 let y = g.gelu(h);
604 g.set_outputs(vec![y]);
605
606 let r = check_graph(&g, &CheckOptions::default());
607 assert_eq!(r.errors(), 0, "unexpected errors: {:#?}", r.diagnostics);
608 let cpu = r.backends.iter().find(|b| b.backend == "cpu").unwrap();
609 let leg = cpu.legality.as_ref().expect("cpu legality");
610 assert!(leg.compile_ready);
611 assert_eq!(leg.unsupported_kinds, 0);
612 }
613
614 #[test]
615 fn self_check_hook_runs_without_panic() {
616 let mut g = Graph::new("hooked");
618 let x = g.input("x", f32s(&[2, 4]));
619 let w = g.param("w", f32s(&[4, 4]));
620 let y = g.mm(x, w);
621 g.set_outputs(vec![y]);
622 model_self_check("hooked", &g);
623 }
624}