rlx_ir/numeric_check.rs
1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! Runtime NaN/Inf localization — the compiler-level debug epilogue.
17//!
18//! When a downstream model or developer produces a NaN/Inf, a raw bad
19//! float is useless on its own — it says *what* but not *where* or *why*.
20//! [`check_node`] turns it into a localized, actionable diagnostic:
21//!
22//! * **which** op first produced it (callers scan in topological order,
23//! so the first trip is the origin — not the hundreds of downstream
24//! ops that merely inherited the NaN),
25//! * **culprit vs propagator** — were the op's inputs already non-finite
26//! (it propagated one) or were they clean and the output went bad (this
27//! op *created* it)? This single bit is the most useful localization
28//! signal: it tells the developer whether to look here or upstream,
29//! * **provenance** back to the user's source via [`node_label`], and
30//! * a one-line **fix hint** keyed off the op kind.
31//!
32//! It is deliberately backend-agnostic: it operates on already-materialized
33//! `f32` host slices plus the [`Graph`] for provenance, so every backend can
34//! call it from its run loop by handing over the input+output buffers it
35//! already has. Detection is env-gated by callers (`RLX_DEBUG_NANS`) so it
36//! costs nothing in production — see the CPU executor for the reference wiring.
37
38use crate::op::{Activation, BinaryOp};
39use crate::provenance::node_label;
40use crate::{Graph, NodeId, Op};
41
42/// What was found in a buffer that fails the finiteness check.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum BadValue {
45 Nan,
46 PosInf,
47 NegInf,
48}
49
50impl BadValue {
51 /// Classify a single value, or `None` if it is finite.
52 #[inline]
53 fn classify(v: f32) -> Option<BadValue> {
54 if v.is_nan() {
55 Some(BadValue::Nan)
56 } else if v.is_infinite() {
57 Some(if v > 0.0 {
58 BadValue::PosInf
59 } else {
60 BadValue::NegInf
61 })
62 } else {
63 None
64 }
65 }
66
67 pub fn as_str(self) -> &'static str {
68 match self {
69 BadValue::Nan => "NaN",
70 BadValue::PosInf => "+inf",
71 BadValue::NegInf => "-inf",
72 }
73 }
74}
75
76/// The first non-finite value in a buffer: what and where.
77#[derive(Debug, Clone, Copy)]
78pub struct BadHit {
79 pub kind: BadValue,
80 pub index: usize,
81}
82
83/// Scan a slice for the first non-finite value. `None` ⇒ all finite.
84///
85/// Returns on the first hit — O(n) worst case but usually much less, and
86/// only ever called when a caller opts into debug checking.
87#[inline]
88pub fn first_bad(data: &[f32]) -> Option<BadHit> {
89 for (i, &v) in data.iter().enumerate() {
90 if let Some(kind) = BadValue::classify(v) {
91 return Some(BadHit { kind, index: i });
92 }
93 }
94 None
95}
96
97/// A localized NaN/Inf diagnostic tied to a specific graph node.
98#[derive(Debug, Clone)]
99pub struct NanReport {
100 /// The node whose output tripped the check.
101 pub node: NodeId,
102 /// Best-effort provenance label (origin label, node name, or id).
103 pub label: String,
104 /// Short op description (`Rsqrt`, `Div`, …).
105 pub op: String,
106 pub kind: BadValue,
107 /// Flat index of the first bad element in the output buffer.
108 pub index: usize,
109 /// The input node that was *already* non-finite, if this op merely
110 /// propagated a bad value. `None` ⇒ inputs were clean ⇒ this op is the
111 /// culprit that produced the NaN/Inf.
112 pub source_input: Option<NodeId>,
113 /// A remedy hint, present only for culprit ops (a propagator's fix lives
114 /// upstream, at whichever op is eventually flagged as the culprit).
115 pub fix: Option<&'static str>,
116}
117
118impl std::fmt::Display for NanReport {
119 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120 write!(
121 f,
122 "{} at index {} of {} {} \"{}\"",
123 self.kind.as_str(),
124 self.index,
125 self.node,
126 self.op,
127 self.label
128 )?;
129 match self.source_input {
130 Some(src) => write!(
131 f,
132 "\n → propagated: input {src} was already non-finite (look upstream)"
133 ),
134 None => {
135 write!(f, "\n → inputs finite, this op produced it")?;
136 if let Some(fix) = self.fix {
137 write!(f, "\n fix: {fix}")?;
138 }
139 Ok(())
140 }
141 }
142 }
143}
144
145impl std::error::Error for NanReport {}
146
147/// Short, human-readable op tag for diagnostics — keeps the specific
148/// activation/binary (`Rsqrt`, not just `Activation`) that matters for the
149/// fix hint, without dumping a `Constant`'s full byte payload.
150fn op_short(op: &Op) -> String {
151 match op {
152 Op::Activation(a) => format!("{a:?}"),
153 Op::Binary(b) => format!("{b:?}"),
154 other => format!("{:?}", other.kind()),
155 }
156}
157
158/// Map an op that *produced* a NaN/Inf to a concrete remedy. Deliberately
159/// covers the common downstream mistakes; returns `None` for ops with no
160/// single obvious fix (the location + culprit flag still localize it).
161pub fn fix_hint(op: &Op) -> Option<&'static str> {
162 match op {
163 Op::Activation(Activation::Rsqrt | Activation::Sqrt) => Some(
164 "rsqrt/sqrt of a negative or zero — norm variance underflow; raise eps or clamp input ≥ 0",
165 ),
166 Op::Activation(Activation::Log) => {
167 Some("log of ≤ 0 — clamp the input to a small positive floor (e.g. 1e-12) or add eps")
168 }
169 Op::Activation(Activation::Exp) => {
170 Some("exp overflow → +inf — subtract the row max before exp (unstable softmax?)")
171 }
172 Op::Binary(BinaryOp::Div) => Some(
173 "division by zero — guard the denominator with eps, or mask with where(denom != 0, …)",
174 ),
175 Op::Binary(BinaryOp::Pow) => {
176 Some("pow of a negative base or huge exponent — check base sign / exponent magnitude")
177 }
178 Op::Softmax { .. } => {
179 Some("all-masked row or -inf logits — use a finite mask fill (-1e9), not f32 -inf")
180 }
181 Op::Constant { .. } => Some(
182 "a Constant already holds NaN/inf — likely baked by constant-folding; run with RLX_LINT_NUMERICS to name the source op",
183 ),
184 _ => None,
185 }
186}
187
188/// How a backend run loop should react to a localized NaN/Inf.
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub enum DebugMode {
191 /// Scanning off — the whole epilogue is skipped.
192 Off,
193 /// Print the first bad value's diagnostic to stderr and keep running.
194 Warn,
195 /// Print and then panic on the first bad value (fail-fast, like JAX
196 /// `jax_debug_nans`), so a backtrace points at the call site.
197 Abort,
198}
199
200/// Shared NaN/Inf debug epilogue for every backend run loop.
201///
202/// Centralizes the `RLX_DEBUG_NANS` policy so each backend adds only a few
203/// lines: build one with [`DebugScanner::from_env`] before the loop, gate on
204/// [`enabled`](Self::enabled), and hand each computed node's output + operand
205/// slices to [`check`](Self::check). The env var is read once (constructing
206/// the scanner), never per-node.
207///
208/// `RLX_DEBUG_NANS` values: unset / `0` / empty ⇒ [`Off`](DebugMode::Off);
209/// `abort` ⇒ [`Abort`](DebugMode::Abort); anything else truthy ⇒
210/// [`Warn`](DebugMode::Warn).
211#[derive(Debug, Clone, Copy)]
212pub struct DebugScanner {
213 pub mode: DebugMode,
214 /// Short backend tag for the message prefix (e.g. "cpu", "metal").
215 backend: &'static str,
216}
217
218impl DebugScanner {
219 /// Construct with an explicit mode (bypassing the env), tagging messages
220 /// with `backend`. Useful in tests and when a caller drives the mode.
221 pub fn with_mode(mode: DebugMode, backend: &'static str) -> Self {
222 Self { mode, backend }
223 }
224
225 /// Build from `RLX_DEBUG_NANS`, tagging messages with `backend`.
226 pub fn from_env(backend: &'static str) -> Self {
227 let mode = match crate::env::var("RLX_DEBUG_NANS").as_deref() {
228 None => DebugMode::Off,
229 Some(v) if v.is_empty() || v == "0" => DebugMode::Off,
230 Some("abort") => DebugMode::Abort,
231 Some(_) => DebugMode::Warn,
232 };
233 Self { mode, backend }
234 }
235
236 /// True when scanning is active — gate the (possibly expensive) readback
237 /// and gather on this so production pays nothing.
238 #[inline]
239 pub fn enabled(&self) -> bool {
240 self.mode != DebugMode::Off
241 }
242
243 /// Scan a whole-graph run's outputs — the universal fallback for backends
244 /// that execute opaquely (MPSGraph, MLX, CoreML MIL, PJRT/HLO) and can't
245 /// hook per-op. Reports which graph output first went non-finite, with its
246 /// provenance. `outputs` is zipped against `graph.outputs` positionally, as
247 /// every backend returns them in that order. No-op when scanning is off.
248 ///
249 /// For *internal* localization on those backends, run the identical graph
250 /// on the CPU backend with `RLX_DEBUG_NANS` — provenance is backend-neutral.
251 pub fn check_outputs(&self, graph: &Graph, outputs: &[Vec<f32>]) {
252 if self.mode == DebugMode::Off {
253 return;
254 }
255 for (buf, &id) in outputs.iter().zip(graph.outputs.iter()) {
256 self.check(graph, id, buf, &[]);
257 }
258 }
259
260 /// Scan one node's output; on the first bad value, print a localized
261 /// diagnostic and (in [`Abort`](DebugMode::Abort) mode) panic. Returns the
262 /// report so callers may also collect/stop. No-op when the output is clean
263 /// or scanning is off.
264 pub fn check(
265 &self,
266 graph: &Graph,
267 node: NodeId,
268 output: &[f32],
269 inputs: &[(NodeId, &[f32])],
270 ) -> Option<NanReport> {
271 if self.mode == DebugMode::Off {
272 return None;
273 }
274 match check_node(graph, node, output, inputs) {
275 Ok(()) => None,
276 Err(report) => {
277 eprintln!("rlx nan-check [{}]: {report}", self.backend);
278 if self.mode == DebugMode::Abort {
279 panic!(
280 "rlx nan-check [{}]: NaN/Inf localized — aborting\n{report}",
281 self.backend
282 );
283 }
284 Some(report)
285 }
286 }
287 }
288}
289
290/// Localize a NaN/Inf to a graph node.
291///
292/// `output` is the node's freshly-computed output buffer; `inputs` are the
293/// `(id, buffer)` pairs the caller already has for the operands (any subset
294/// is fine — e.g. non-`f32` operands may be omitted). Returns `Ok(())` when
295/// the output is clean, or a [`NanReport`] pinpointing the first bad element
296/// and classifying the node as culprit or propagator.
297pub fn check_node(
298 graph: &Graph,
299 node: NodeId,
300 output: &[f32],
301 inputs: &[(NodeId, &[f32])],
302) -> Result<(), NanReport> {
303 let Some(hit) = first_bad(output) else {
304 return Ok(());
305 };
306 // Culprit vs propagator: was any operand already non-finite? If so this
307 // op just carried a NaN forward; if not, it manufactured one here.
308 let source_input = inputs
309 .iter()
310 .find(|(_, buf)| first_bad(buf).is_some())
311 .map(|(id, _)| *id);
312 let op = &graph.node(node).op;
313 Err(NanReport {
314 node,
315 label: node_label(graph, node),
316 op: op_short(op),
317 kind: hit.kind,
318 index: hit.index,
319 source_input,
320 fix: if source_input.is_none() {
321 fix_hint(op)
322 } else {
323 None
324 },
325 })
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331 use crate::op::Activation;
332 use crate::{DType, Shape};
333
334 fn rsqrt_graph() -> (Graph, NodeId, NodeId) {
335 let mut g = Graph::new("t");
336 let x = g.input("x", Shape::new(&[2], DType::F32));
337 let r = g.activation(Activation::Rsqrt, x, Shape::new(&[2], DType::F32));
338 g.set_outputs(vec![r]);
339 (g, x, r)
340 }
341
342 #[test]
343 fn clean_output_passes() {
344 let (g, x, r) = rsqrt_graph();
345 let out = [1.0f32, 0.5];
346 let inp = [1.0f32, 4.0];
347 assert!(check_node(&g, r, &out, &[(x, &inp)]).is_ok());
348 }
349
350 #[test]
351 fn culprit_when_inputs_clean() {
352 let (g, x, r) = rsqrt_graph();
353 // Finite (but negative) input → NaN output ⇒ this op is the culprit.
354 let out = [f32::NAN, 0.5];
355 let inp = [-1.0f32, 4.0];
356 let err = check_node(&g, r, &out, &[(x, &inp)]).unwrap_err();
357 assert_eq!(err.kind, BadValue::Nan);
358 assert_eq!(err.index, 0);
359 assert!(err.source_input.is_none(), "should be flagged as culprit");
360 assert!(err.fix.is_some(), "culprit should carry a fix hint");
361 assert!(err.op.contains("Rsqrt"));
362 }
363
364 #[test]
365 fn propagator_when_input_already_bad() {
366 let (g, x, r) = rsqrt_graph();
367 // Input already NaN ⇒ this op merely propagated; no local fix.
368 let out = [f32::NAN, 0.5];
369 let inp = [f32::NAN, 4.0];
370 let err = check_node(&g, r, &out, &[(x, &inp)]).unwrap_err();
371 assert_eq!(err.source_input, Some(x));
372 assert!(err.fix.is_none(), "propagator's fix lives upstream");
373 }
374
375 #[test]
376 fn scanner_modes_and_policy() {
377 let (g, x, r) = rsqrt_graph();
378 // Off: never reports, even on a bad output.
379 let off = DebugScanner::with_mode(DebugMode::Off, "test");
380 assert!(!off.enabled());
381 assert!(off.check(&g, r, &[f32::NAN], &[(x, &[1.0])]).is_none());
382 // Warn: reports (and prints) but does not panic.
383 let warn = DebugScanner::with_mode(DebugMode::Warn, "test");
384 assert!(warn.enabled());
385 let rep = warn.check(&g, r, &[f32::NAN], &[(x, &[1.0])]);
386 assert!(rep.is_some());
387 // Clean output → no report in any mode.
388 assert!(warn.check(&g, r, &[0.5], &[(x, &[4.0])]).is_none());
389 }
390
391 #[test]
392 #[should_panic(expected = "aborting")]
393 fn scanner_abort_panics_on_bad() {
394 let (g, x, r) = rsqrt_graph();
395 let abort = DebugScanner::with_mode(DebugMode::Abort, "test");
396 abort.check(&g, r, &[f32::NAN], &[(x, &[1.0])]);
397 }
398
399 #[test]
400 fn detects_pos_inf() {
401 assert_eq!(
402 first_bad(&[f32::INFINITY, 0.0]).unwrap().kind,
403 BadValue::PosInf
404 );
405 assert_eq!(
406 first_bad(&[0.0, f32::NEG_INFINITY]).unwrap().kind,
407 BadValue::NegInf
408 );
409 assert!(first_bad(&[1.0, 2.0, -3.0]).is_none());
410 }
411}