use crate::op::{Activation, BinaryOp};
use crate::provenance::node_label;
use crate::{Graph, NodeId, Op};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BadValue {
Nan,
PosInf,
NegInf,
}
impl BadValue {
#[inline]
fn classify(v: f32) -> Option<BadValue> {
if v.is_nan() {
Some(BadValue::Nan)
} else if v.is_infinite() {
Some(if v > 0.0 {
BadValue::PosInf
} else {
BadValue::NegInf
})
} else {
None
}
}
pub fn as_str(self) -> &'static str {
match self {
BadValue::Nan => "NaN",
BadValue::PosInf => "+inf",
BadValue::NegInf => "-inf",
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct BadHit {
pub kind: BadValue,
pub index: usize,
}
#[inline]
pub fn first_bad(data: &[f32]) -> Option<BadHit> {
for (i, &v) in data.iter().enumerate() {
if let Some(kind) = BadValue::classify(v) {
return Some(BadHit { kind, index: i });
}
}
None
}
#[derive(Debug, Clone)]
pub struct NanReport {
pub node: NodeId,
pub label: String,
pub op: String,
pub kind: BadValue,
pub index: usize,
pub source_input: Option<NodeId>,
pub fix: Option<&'static str>,
}
impl std::fmt::Display for NanReport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} at index {} of {} {} \"{}\"",
self.kind.as_str(),
self.index,
self.node,
self.op,
self.label
)?;
match self.source_input {
Some(src) => write!(
f,
"\n → propagated: input {src} was already non-finite (look upstream)"
),
None => {
write!(f, "\n → inputs finite, this op produced it")?;
if let Some(fix) = self.fix {
write!(f, "\n fix: {fix}")?;
}
Ok(())
}
}
}
}
impl std::error::Error for NanReport {}
fn op_short(op: &Op) -> String {
match op {
Op::Activation(a) => format!("{a:?}"),
Op::Binary(b) => format!("{b:?}"),
other => format!("{:?}", other.kind()),
}
}
pub fn fix_hint(op: &Op) -> Option<&'static str> {
match op {
Op::Activation(Activation::Rsqrt | Activation::Sqrt) => Some(
"rsqrt/sqrt of a negative or zero — norm variance underflow; raise eps or clamp input ≥ 0",
),
Op::Activation(Activation::Log) => {
Some("log of ≤ 0 — clamp the input to a small positive floor (e.g. 1e-12) or add eps")
}
Op::Activation(Activation::Exp) => {
Some("exp overflow → +inf — subtract the row max before exp (unstable softmax?)")
}
Op::Binary(BinaryOp::Div) => Some(
"division by zero — guard the denominator with eps, or mask with where(denom != 0, …)",
),
Op::Binary(BinaryOp::Pow) => {
Some("pow of a negative base or huge exponent — check base sign / exponent magnitude")
}
Op::Softmax { .. } => {
Some("all-masked row or -inf logits — use a finite mask fill (-1e9), not f32 -inf")
}
Op::Constant { .. } => Some(
"a Constant already holds NaN/inf — likely baked by constant-folding; run with RLX_LINT_NUMERICS to name the source op",
),
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DebugMode {
Off,
Warn,
Abort,
}
#[derive(Debug, Clone, Copy)]
pub struct DebugScanner {
pub mode: DebugMode,
backend: &'static str,
}
impl DebugScanner {
pub fn with_mode(mode: DebugMode, backend: &'static str) -> Self {
Self { mode, backend }
}
pub fn from_env(backend: &'static str) -> Self {
let mode = match crate::env::var("RLX_DEBUG_NANS").as_deref() {
None => DebugMode::Off,
Some(v) if v.is_empty() || v == "0" => DebugMode::Off,
Some("abort") => DebugMode::Abort,
Some(_) => DebugMode::Warn,
};
Self { mode, backend }
}
#[inline]
pub fn enabled(&self) -> bool {
self.mode != DebugMode::Off
}
pub fn check_outputs(&self, graph: &Graph, outputs: &[Vec<f32>]) {
if self.mode == DebugMode::Off {
return;
}
for (buf, &id) in outputs.iter().zip(graph.outputs.iter()) {
self.check(graph, id, buf, &[]);
}
}
pub fn check(
&self,
graph: &Graph,
node: NodeId,
output: &[f32],
inputs: &[(NodeId, &[f32])],
) -> Option<NanReport> {
if self.mode == DebugMode::Off {
return None;
}
match check_node(graph, node, output, inputs) {
Ok(()) => None,
Err(report) => {
eprintln!("rlx nan-check [{}]: {report}", self.backend);
if self.mode == DebugMode::Abort {
panic!(
"rlx nan-check [{}]: NaN/Inf localized — aborting\n{report}",
self.backend
);
}
Some(report)
}
}
}
}
pub fn check_node(
graph: &Graph,
node: NodeId,
output: &[f32],
inputs: &[(NodeId, &[f32])],
) -> Result<(), NanReport> {
let Some(hit) = first_bad(output) else {
return Ok(());
};
let source_input = inputs
.iter()
.find(|(_, buf)| first_bad(buf).is_some())
.map(|(id, _)| *id);
let op = &graph.node(node).op;
Err(NanReport {
node,
label: node_label(graph, node),
op: op_short(op),
kind: hit.kind,
index: hit.index,
source_input,
fix: if source_input.is_none() {
fix_hint(op)
} else {
None
},
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::op::Activation;
use crate::{DType, Shape};
fn rsqrt_graph() -> (Graph, NodeId, NodeId) {
let mut g = Graph::new("t");
let x = g.input("x", Shape::new(&[2], DType::F32));
let r = g.activation(Activation::Rsqrt, x, Shape::new(&[2], DType::F32));
g.set_outputs(vec![r]);
(g, x, r)
}
#[test]
fn clean_output_passes() {
let (g, x, r) = rsqrt_graph();
let out = [1.0f32, 0.5];
let inp = [1.0f32, 4.0];
assert!(check_node(&g, r, &out, &[(x, &inp)]).is_ok());
}
#[test]
fn culprit_when_inputs_clean() {
let (g, x, r) = rsqrt_graph();
let out = [f32::NAN, 0.5];
let inp = [-1.0f32, 4.0];
let err = check_node(&g, r, &out, &[(x, &inp)]).unwrap_err();
assert_eq!(err.kind, BadValue::Nan);
assert_eq!(err.index, 0);
assert!(err.source_input.is_none(), "should be flagged as culprit");
assert!(err.fix.is_some(), "culprit should carry a fix hint");
assert!(err.op.contains("Rsqrt"));
}
#[test]
fn propagator_when_input_already_bad() {
let (g, x, r) = rsqrt_graph();
let out = [f32::NAN, 0.5];
let inp = [f32::NAN, 4.0];
let err = check_node(&g, r, &out, &[(x, &inp)]).unwrap_err();
assert_eq!(err.source_input, Some(x));
assert!(err.fix.is_none(), "propagator's fix lives upstream");
}
#[test]
fn scanner_modes_and_policy() {
let (g, x, r) = rsqrt_graph();
let off = DebugScanner::with_mode(DebugMode::Off, "test");
assert!(!off.enabled());
assert!(off.check(&g, r, &[f32::NAN], &[(x, &[1.0])]).is_none());
let warn = DebugScanner::with_mode(DebugMode::Warn, "test");
assert!(warn.enabled());
let rep = warn.check(&g, r, &[f32::NAN], &[(x, &[1.0])]);
assert!(rep.is_some());
assert!(warn.check(&g, r, &[0.5], &[(x, &[4.0])]).is_none());
}
#[test]
#[should_panic(expected = "aborting")]
fn scanner_abort_panics_on_bad() {
let (g, x, r) = rsqrt_graph();
let abort = DebugScanner::with_mode(DebugMode::Abort, "test");
abort.check(&g, r, &[f32::NAN], &[(x, &[1.0])]);
}
#[test]
fn detects_pos_inf() {
assert_eq!(
first_bad(&[f32::INFINITY, 0.0]).unwrap().kind,
BadValue::PosInf
);
assert_eq!(
first_bad(&[0.0, f32::NEG_INFINITY]).unwrap().kind,
BadValue::NegInf
);
assert!(first_bad(&[1.0, 2.0, -3.0]).is_none());
}
}