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