Skip to main content

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}