use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Mutex, OnceLock};
#[derive(Clone, Debug, PartialEq)]
pub struct Histogram {
pub lo: f32,
pub hi: f32,
pub bins: Vec<u64>,
}
impl Histogram {
pub fn new(lo: f32, hi: f32, nbins: usize) -> Self {
Histogram {
lo,
hi,
bins: vec![0; nbins.max(1)],
}
}
pub fn of(data: &[f32], nbins: usize) -> Self {
let (mut lo, mut hi) = (f32::INFINITY, f32::NEG_INFINITY);
for &x in data {
if x.is_finite() {
lo = lo.min(x);
hi = hi.max(x);
}
}
if !lo.is_finite() || !hi.is_finite() {
lo = 0.0;
hi = 0.0;
}
if hi <= lo {
hi = lo + 1.0; }
let mut h = Histogram::new(lo, hi, nbins);
for &x in data {
h.add(x);
}
h
}
pub fn add(&mut self, x: f32) {
if !x.is_finite() {
return;
}
let n = self.bins.len();
let frac = ((x - self.lo) / (self.hi - self.lo)).clamp(0.0, 1.0);
let mut idx = (frac * n as f32) as usize;
if idx >= n {
idx = n - 1;
}
self.bins[idx] += 1;
}
pub fn total(&self) -> u64 {
self.bins.iter().sum()
}
pub fn sparkline(&self) -> String {
let blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
let maxln = self
.bins
.iter()
.map(|&c| ((c as f64) + 1.0).ln())
.fold(0.0f64, f64::max)
.max(1e-9);
self.bins
.iter()
.map(|&c| {
let frac = ((c as f64) + 1.0).ln() / maxln;
let bi =
((frac * (blocks.len() - 1) as f64).round() as usize).min(blocks.len() - 1);
blocks[bi]
})
.collect()
}
pub fn to_cell(&self) -> String {
let bins = self
.bins
.iter()
.map(|c| c.to_string())
.collect::<Vec<_>>()
.join(";");
format!("{}|{}|{}", self.lo, self.hi, bins)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct TensorStats {
pub name: String,
pub shape: Vec<usize>,
pub count: usize,
pub min: f32,
pub max: f32,
pub mean: f32,
pub std: f32,
pub absmax: f32,
pub nan: usize,
pub inf: usize,
pub hist: Histogram,
}
impl TensorStats {
pub fn compute(name: impl Into<String>, shape: &[usize], data: &[f32], nbins: usize) -> Self {
let (mut min, mut max, mut absmax) = (f32::INFINITY, f32::NEG_INFINITY, 0.0f32);
let (mut sum, mut n_finite, mut nan, mut inf) = (0.0f64, 0usize, 0usize, 0usize);
for &x in data {
if x.is_nan() {
nan += 1;
} else if x.is_infinite() {
inf += 1;
} else {
min = min.min(x);
max = max.max(x);
absmax = absmax.max(x.abs());
sum += x as f64;
n_finite += 1;
}
}
let mean = if n_finite > 0 {
(sum / n_finite as f64) as f32
} else {
0.0
};
let mut var = 0.0f64;
if n_finite > 0 {
for &x in data {
if x.is_finite() {
let d = x as f64 - mean as f64;
var += d * d;
}
}
var /= n_finite as f64;
}
if n_finite == 0 {
min = 0.0;
max = 0.0;
}
TensorStats {
name: name.into(),
shape: shape.to_vec(),
count: data.len(),
min,
max,
mean,
std: (var.sqrt()) as f32,
absmax,
nan,
inf,
hist: Histogram::of(data, nbins),
}
}
pub fn shape_str(&self) -> String {
if self.shape.is_empty() {
"scalar".to_string()
} else {
self.shape
.iter()
.map(|d| d.to_string())
.collect::<Vec<_>>()
.join("x")
}
}
pub fn line(&self) -> String {
let flags = if self.nan + self.inf > 0 {
format!(" !!{}nan/{}inf", self.nan, self.inf)
} else {
String::new()
};
format!(
"{:<28} [{:>12}] min {:+.4} max {:+.4} mean {:+.4} std {:.4} |max| {:.4} {}{}",
self.name,
self.shape_str(),
self.min,
self.max,
self.mean,
self.std,
self.absmax,
self.hist.sparkline(),
flags,
)
}
}
#[derive(Clone, Debug)]
pub struct InspectRecord {
pub step: usize,
pub stream: String,
pub stats: TensorStats,
}
#[derive(Clone, Debug, Default)]
pub struct InspectLog {
records: Vec<InspectRecord>,
edges: Vec<(String, String)>,
}
impl InspectLog {
pub fn new() -> Self {
Self::default()
}
pub fn record(&mut self, step: usize, stream: impl Into<String>, stats: TensorStats) {
self.records.push(InspectRecord {
step,
stream: stream.into(),
stats,
});
}
pub fn record_tensor(
&mut self,
step: usize,
stream: &str,
shape: &[usize],
data: &[f32],
nbins: usize,
) {
let stats = TensorStats::compute(stream, shape, data, nbins);
self.record(step, stream, stats);
}
pub fn edge(&mut self, from: impl Into<String>, to: impl Into<String>) {
let e = (from.into(), to.into());
if !self.edges.contains(&e) {
self.edges.push(e);
}
}
pub fn records(&self) -> &[InspectRecord] {
&self.records
}
pub fn edges(&self) -> &[(String, String)] {
&self.edges
}
pub fn len(&self) -> usize {
self.records.len()
}
pub fn is_empty(&self) -> bool {
self.records.is_empty()
}
pub fn to_csv(&self) -> String {
let mut s = String::from("step,stream,shape,count,min,max,mean,std,absmax,nan,inf\n");
for r in &self.records {
let t = &r.stats;
s.push_str(&format!(
"{},{},{},{},{:.6},{:.6},{:.6},{:.6},{:.6},{},{}\n",
r.step,
r.stream,
t.shape_str(),
t.count,
t.min,
t.max,
t.mean,
t.std,
t.absmax,
t.nan,
t.inf,
));
}
s
}
pub fn to_hist_csv(&self) -> String {
let mut s = String::from("step,stream,hist\n");
for r in &self.records {
s.push_str(&format!(
"{},{},{}\n",
r.step,
r.stream,
r.stats.hist.to_cell()
));
}
s
}
pub fn dataflow_dot(&self) -> String {
let mut s = String::from("digraph inspect {\n rankdir=LR;\n");
for (from, to) in &self.edges {
s.push_str(&format!(" {:?} -> {:?};\n", from, to));
}
s.push_str("}\n");
s
}
pub fn report(&self) -> String {
use std::collections::BTreeMap;
let mut latest: BTreeMap<&str, &TensorStats> = BTreeMap::new();
for r in &self.records {
latest.insert(r.stream.as_str(), &r.stats);
}
let mut s = format!(
"inspect: {} snapshots across {} streams\n",
self.records.len(),
latest.len()
);
for (_, t) in latest {
s.push_str(" ");
s.push_str(&t.line());
s.push('\n');
}
s
}
}
static OP_TAP: OnceLock<Mutex<InspectLog>> = OnceLock::new();
static OP_TAP_BINS: OnceLock<usize> = OnceLock::new();
static OP_TAP_PASS: AtomicUsize = AtomicUsize::new(0);
fn op_tap() -> &'static Mutex<InspectLog> {
OP_TAP.get_or_init(|| Mutex::new(InspectLog::new()))
}
pub fn op_tap_begin_pass() -> usize {
OP_TAP_PASS.fetch_add(1, Ordering::Relaxed)
}
pub fn op_tap_enabled() -> bool {
matches!(crate::env::var("RLX_INSPECT_OPS").as_deref(), Some(v) if !v.is_empty() && v != "0")
}
pub fn op_tap_bins() -> usize {
*OP_TAP_BINS.get_or_init(|| {
crate::env::var("RLX_INSPECT_BINS")
.and_then(|v| v.parse().ok())
.unwrap_or(24)
})
}
pub fn op_tap_record(
step: usize,
op_kind: &str,
node_id: usize,
shape: &[usize],
data: &[f32],
input_ids: &[usize],
) {
let name = format!("{op_kind}#{node_id}");
let stats = TensorStats::compute(name.clone(), shape, data, op_tap_bins());
if let Ok(mut log) = op_tap().lock() {
for &inp in input_ids {
log.edge(format!("#{inp}"), format!("#{node_id}"));
}
log.record(step, name, stats);
}
}
pub fn op_tap_take() -> InspectLog {
if let Ok(mut log) = op_tap().lock() {
std::mem::take(&mut *log)
} else {
InspectLog::new()
}
}
pub fn op_tap_len() -> usize {
op_tap().lock().map(|l| l.len()).unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn histogram_bins_and_clamps() {
let h = Histogram::of(&[0.0, 0.5, 1.0, 1.0, 2.0], 4);
assert_eq!(h.total(), 5);
assert_eq!(h.bins, vec![1, 1, 2, 1]);
}
#[test]
fn tensor_stats_flags_nan_inf_and_shape() {
let data = [1.0, 2.0, 3.0, f32::NAN, f32::INFINITY];
let t = TensorStats::compute("x", &[5], &data, 8);
assert_eq!(t.count, 5);
assert_eq!(t.nan, 1);
assert_eq!(t.inf, 1);
assert_eq!(t.min, 1.0);
assert_eq!(t.max, 3.0);
assert!((t.mean - 2.0).abs() < 1e-6);
assert_eq!(t.shape_str(), "5");
}
#[test]
fn inspect_log_csv_and_dataflow() {
let mut log = InspectLog::new();
log.record_tensor(0, "kv.k", &[2, 3], &[1.0, 2.0, 3.0, 4.0, 5.0, 6.0], 4);
log.record_tensor(1, "kv.k", &[2, 3], &[0.0; 6], 4);
log.edge("kv.k", "attn");
assert_eq!(log.len(), 2);
let csv = log.to_csv();
assert!(csv.lines().next().unwrap().starts_with("step,stream,shape"));
assert_eq!(csv.lines().count(), 3); assert!(log.to_hist_csv().contains("kv.k"));
assert!(log.dataflow_dot().contains("\"kv.k\" -> \"attn\""));
}
#[test]
fn op_tap_records_edges_and_drains() {
let _ = op_tap_take(); let pass = op_tap_begin_pass();
op_tap_record(pass, "Matmul", 7, &[2, 2], &[1.0, 2.0, 3.0, 4.0], &[3, 5]);
let log = op_tap_take();
assert!(log.records().iter().any(|r| r.stream == "Matmul#7"));
assert!(log.edges().contains(&("#3".to_string(), "#7".to_string())));
assert!(log.edges().contains(&("#5".to_string(), "#7".to_string())));
assert_eq!(op_tap_len(), 0);
}
}