Skip to main content

rlx_opscope/
lib.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! **rlx-opscope** — a data-pattern recording harness.
6//!
7//! The idea: run existing op graphs with varied synthetic (and, later, real)
8//! data and record cheap *sketches* of every tensor flowing through the ops we
9//! care about — matmuls first. Offline we mine those sketches for exploitable
10//! structure (sparsity, per-channel outliers, quantization headroom, sequence
11//! structure) that justifies a specialized kernel.
12//!
13//! The load-bearing trick (see the crate README) is that we do **not** tap the
14//! executor. We *rewrite the graph*: for each op-site we append reduction /
15//! histogram nodes on its inputs and output and mark them as extra graph
16//! outputs. The stats are then computed by the backend's own kernels — so this
17//! works identically on CPU, Metal, CUDA, … and costs nothing when not applied.
18//!
19//! This module provides:
20//! - [`inject_matmul_stats`] — the stat-injection graph pass (matmul MVP).
21//! - [`StatConfig`] / [`StatSpec`] — what to record and how to label it.
22//! - [`Dist`] / `gen` — synthetic distribution generators.
23//! - [`Recorder`] — a dependency-free tidy-CSV sink.
24
25use rlx_ir::infer::GraphExt;
26use rlx_ir::op::{Activation, BinaryOp, CmpOp, ReduceOp};
27use rlx_ir::{DType, Graph, NodeId, Op, Philox4x32, Shape};
28use std::collections::HashMap;
29use std::io::{self, Write};
30
31pub mod bytes;
32pub mod dataflow;
33pub mod demo;
34pub mod guard;
35// Decomposition/actuation kernels + the per-layer allocator are opt-in — they're
36// heavy, experimental optimization surface, kept out of the default build so the
37// base harness stays lean (see the `decompose`/`allocate` features).
38#[cfg(feature = "decompose")]
39pub mod kernels;
40#[cfg(feature = "allocate")]
41pub mod layers;
42pub mod live;
43pub mod motifs;
44pub mod online;
45pub mod optimize;
46pub mod parity;
47#[cfg(feature = "parquet")]
48pub mod parquet_sink;
49pub mod probe;
50pub mod shapes;
51#[cfg(feature = "decompose")]
52pub mod svd;
53pub mod timing;
54
55// ─────────────────────────── stat injection ───────────────────────────
56
57/// Which sketches to append per tapped tensor.
58#[derive(Clone, Copy, Debug)]
59pub struct StatConfig {
60    /// Value-histogram bin count.
61    pub hist_bins: usize,
62    /// Histogram lower / upper edge, used only when `hist_normalize` is off.
63    pub hist_min: f32,
64    pub hist_max: f32,
65    /// Scale-normalize the histogram: bin `x / (max|x| + eps)` over `[-1, 1]`
66    /// so the *shape* is comparable regardless of tensor scale. Fixes fixed-range
67    /// over-reporting "spiky" on tightly-scaled weights. One extra pass in-graph.
68    pub hist_normalize: bool,
69    /// Per-last-axis `max|x|` — the per-channel outlier signal (quant headroom).
70    pub per_channel: bool,
71    /// Per-row (reduce the last axis) sum-of-squares — the sequence/position
72    /// energy profile (sinks, decay, per-token sparsity).
73    pub per_position: bool,
74    /// Adjacent-row diff energy along axis 0 — within-call sequence coherence
75    /// (adjacent rows/tokens similar → delta-compute exploit). 2-D tensors only.
76    pub adjacency: bool,
77}
78
79impl Default for StatConfig {
80    fn default() -> Self {
81        Self {
82            hist_bins: 32,
83            hist_min: -6.0,
84            hist_max: 6.0,
85            hist_normalize: true,
86            per_channel: true,
87            per_position: true,
88            adjacency: true,
89        }
90    }
91}
92
93/// One appended output: where it is in `graph.outputs`, and what it means.
94#[derive(Clone, Debug)]
95pub struct StatSpec {
96    /// Index into the compiled graph's output list (== index into `run()`'s
97    /// returned `Vec<Vec<f32>>`).
98    pub out_idx: usize,
99    /// Op-site label (node name, or `matmul#<id>`).
100    pub site: String,
101    /// Tensor role at the site: `"lhs"`, `"rhs"`, or `"out"`.
102    pub role: &'static str,
103    /// Sketch kind: `min`/`max`/`mean`/`l1`/`sumsq`/`nnz`/`hist`/
104    /// `chan_maxabs`/`pos_sumsq`.
105    pub stat: &'static str,
106    /// Element count of this sketch (1 for scalars, `bins`/`C`/`rows` for
107    /// vectors).
108    pub len: usize,
109    /// Element count of the *source* tensor (for density / normalization).
110    pub numel: usize,
111    /// FLOPs of the op at this site (`2·M·K·N` for a matmul; 0 for non-matmul
112    /// taps). Lets the opportunity miner weight recurrence by cost.
113    pub flops: u64,
114}
115
116/// Write a sidecar mapping each op-site to its FLOPs (deduplicated), consumed by
117/// `opscope-optimize` to weight temporal-recurrence savings by op cost.
118pub fn write_site_costs(path: &str, specs: &[StatSpec]) -> io::Result<()> {
119    let mut seen: HashMap<String, u64> = HashMap::new();
120    for s in specs {
121        seen.entry(s.site.clone()).or_insert(s.flops);
122    }
123    let mut f = io::BufWriter::new(std::fs::File::create(path)?);
124    writeln!(f, "site,flops")?;
125    for (site, flops) in seen {
126        writeln!(f, "{},{}", site.replace(',', "_"), flops)?;
127    }
128    f.flush()
129}
130
131/// Reduce every axis of `t` to a single f32 (`[1]`).
132fn reduce_scalar(g: &mut Graph, t: NodeId, op: ReduceOp) -> NodeId {
133    let rank = g.shape(t).rank();
134    g.reduce(
135        t,
136        op,
137        (0..rank).collect(),
138        false,
139        Shape::new(&[1], DType::F32),
140    )
141}
142
143/// Build the sketch nodes for one tensor `t`; returns `(node, stat_name)`.
144fn build_tensor_stats(g: &mut Graph, t: NodeId, cfg: &StatConfig) -> Vec<(NodeId, &'static str)> {
145    let tshape = g.shape(t).clone();
146    let rank = tshape.rank();
147    let mut out: Vec<(NodeId, &'static str)> = Vec::new();
148
149    out.push((reduce_scalar(g, t, ReduceOp::Min), "min"));
150    out.push((reduce_scalar(g, t, ReduceOp::Max), "max"));
151    out.push((reduce_scalar(g, t, ReduceOp::Mean), "mean"));
152
153    // L1 = Σ|x|
154    let abs = g.activation(Activation::Abs, t, tshape.clone());
155    out.push((reduce_scalar(g, abs, ReduceOp::Sum), "l1"));
156
157    // Σx² (→ L2 norm in the miner via sqrt)
158    let sq = g.add_node(Op::Binary(BinaryOp::Mul), vec![t, t], tshape.clone());
159    out.push((reduce_scalar(g, sq, ReduceOp::Sum), "sumsq"));
160
161    // nnz (→ density in the miner via / numel): count where x != 0
162    let zero = g.full(&[1], 0.0, DType::F32);
163    let ne = g.add_node(
164        Op::Compare(CmpOp::Ne),
165        vec![t, zero],
166        tshape.clone().with_dtype(DType::Bool),
167    );
168    let ne_f = g.add_node(Op::Cast { to: DType::F32 }, vec![ne], tshape.clone());
169    out.push((reduce_scalar(g, ne_f, ReduceOp::Sum), "nnz"));
170
171    // Kurtosis = E[(x-μ)⁴]/E[(x-μ)²]² — a heavy-tail / outlier proxy: high means
172    // the mass is concentrated in rare extreme values (gaussian ≈ 3), so the
173    // tensor is HARD to quantize (a few outliers dominate the range). The flowing
174    // data's real quant-difficulty signal, per op — beyond what static shapes show.
175    let one = Shape::new(&[1], DType::F32);
176    let mean1 = reduce_scalar(g, t, ReduceOp::Mean);
177    let cen = g.add_node(Op::Binary(BinaryOp::Sub), vec![t, mean1], tshape.clone()); // x-μ (bcast)
178    let c2 = g.add_node(Op::Binary(BinaryOp::Mul), vec![cen, cen], tshape.clone());
179    let m2 = reduce_scalar(g, c2, ReduceOp::Mean);
180    let c4 = g.add_node(Op::Binary(BinaryOp::Mul), vec![c2, c2], tshape.clone());
181    let m4 = reduce_scalar(g, c4, ReduceOp::Mean);
182    let m2sq = g.add_node(Op::Binary(BinaryOp::Mul), vec![m2, m2], one.clone());
183    let eps4 = g.full(&[1], 1e-20, DType::F32);
184    let kden = g.add_node(Op::Binary(BinaryOp::Add), vec![m2sq, eps4], one.clone());
185    let kurt = g.add_node(Op::Binary(BinaryOp::Div), vec![m4, kden], one.clone());
186    out.push((kurt, "kurtosis"));
187
188    // Value histogram. When normalized, bin `x / (max|x| + eps)` over [-1,1] so
189    // the distribution *shape* is scale-invariant (a tight gaussian and a wide
190    // one read the same; only genuinely spiky/quantized data reads spiky).
191    // `hist_bins == 0` skips the histogram entirely — it's the node-heaviest sketch
192    // (bins × Compare+Reduce), so a caller tapping MANY sites (whole-model inject)
193    // that doesn't need the distribution can drop it to keep the injected graph
194    // small enough to compile.
195    if cfg.hist_bins > 0 {
196        let hist_node = if cfg.hist_normalize {
197            let absn = g.activation(Activation::Abs, t, tshape.clone());
198            let scale = reduce_scalar(g, absn, ReduceOp::Max); // [1] = max|x|
199            let eps = g.full(&[1], 1e-12, DType::F32);
200            let denom = g.add_node(
201                Op::Binary(BinaryOp::Add),
202                vec![scale, eps],
203                Shape::new(&[1], DType::F32),
204            );
205            let normed = g.add_node(Op::Binary(BinaryOp::Div), vec![t, denom], tshape.clone());
206            g.histogram(normed, cfg.hist_bins, -1.0, 1.0)
207        } else {
208            g.histogram(t, cfg.hist_bins, cfg.hist_min, cfg.hist_max)
209        };
210        out.push((hist_node, "hist"));
211    }
212
213    // Element-wise fingerprint: gather `k` spread elements. Unlike the moment/
214    // histogram sketches (which capture the *distribution*), this captures the
215    // actual *values*, so the optimizer can tell an exact temporal repeat
216    // (memoize) from mere distributional stationarity, and measure real per-step
217    // element drift (delta-compute).
218    let numel = tshape.num_elements().unwrap_or(0);
219    let k = 16usize.min(numel);
220    if k > 0 {
221        let flat = g.reshape_(t, vec![numel as i64]);
222        let idx: Vec<i64> = (0..k).map(|j| (j * numel / k) as i64).collect();
223        let data: Vec<u8> = idx.iter().flat_map(|v| v.to_le_bytes()).collect();
224        let idx_node = g.add_node(Op::Constant { data }, vec![], Shape::new(&[k], DType::I64));
225        let sig = g.gather_(flat, idx_node, 0);
226        out.push((sig, "elemsig"));
227    }
228
229    if rank >= 2 && cfg.per_channel {
230        // max|x| per last-axis channel: reduce every axis but the last.
231        let absc = g.activation(Activation::Abs, t, tshape.clone());
232        let c = tshape.dim(rank - 1).unwrap_static();
233        let chan = g.reduce(
234            absc,
235            ReduceOp::Max,
236            (0..rank - 1).collect(),
237            false,
238            Shape::new(&[c], DType::F32),
239        );
240        // Outlier SEVERITY as a scalar: peak channel / mean channel (of max|·|).
241        // High ⇒ a few channels carry the mass — the runtime driver of quant loss
242        // (int4 clips them; per-token int8 lets them crush the rest → W8A8). This
243        // is exactly the AWQ/SmoothQuant saliency signal, recorded per op.
244        let cmax = reduce_scalar(g, chan, ReduceOp::Max);
245        let cmean = reduce_scalar(g, chan, ReduceOp::Mean);
246        let epsc = g.full(&[1], 1e-20, DType::F32);
247        let cden = g.add_node(
248            Op::Binary(BinaryOp::Add),
249            vec![cmean, epsc],
250            Shape::new(&[1], DType::F32),
251        );
252        let cout = g.add_node(
253            Op::Binary(BinaryOp::Div),
254            vec![cmax, cden],
255            Shape::new(&[1], DType::F32),
256        );
257        out.push((chan, "chan_maxabs"));
258        out.push((cout, "chan_outlier"));
259    }
260
261    if rank >= 2 && cfg.per_position {
262        // Σx² per row: reduce the last axis only → [rows].
263        let sqp = g.add_node(Op::Binary(BinaryOp::Mul), vec![t, t], tshape.clone());
264        let c = tshape.dim(rank - 1).unwrap_static();
265        let rows = tshape.num_elements().unwrap_or(c) / c;
266        let pos = g.reduce(
267            sqp,
268            ReduceOp::Sum,
269            vec![rank - 1],
270            false,
271            Shape::new(&[rows], DType::F32),
272        );
273        out.push((pos, "pos_sumsq"));
274    }
275
276    if rank == 2 && cfg.adjacency {
277        // Σ(row_t − row_{t-1})² per adjacent pair along axis 0. Small values
278        // (relative to per-row energy) mean adjacent rows cohere → the matmul
279        // could compute only the delta from the previous row.
280        let rows = tshape.dim(0).unwrap_static();
281        let d = tshape.dim(1).unwrap_static();
282        if rows >= 2 {
283            let pair = Shape::new(&[rows - 1, d], DType::F32);
284            let prev = g.narrow_(t, 0, 0, rows - 1);
285            let next = g.narrow_(t, 0, 1, rows - 1);
286            let diff = g.add_node(Op::Binary(BinaryOp::Sub), vec![next, prev], pair.clone());
287            let sq = g.add_node(Op::Binary(BinaryOp::Mul), vec![diff, diff], pair);
288            let adj = g.reduce(
289                sq,
290                ReduceOp::Sum,
291                vec![1],
292                false,
293                Shape::new(&[rows - 1], DType::F32),
294            );
295            out.push((adj, "adj_sumsq"));
296        }
297    }
298
299    out
300}
301
302/// Rewrite `graph` so that every `Op::MatMul` / `Op::DequantMatMul` site's `lhs`,
303/// `rhs`, `out` tensors get sketch outputs appended. The original outputs stay
304/// first (same indices), so downstream code that reads output 0 is unaffected.
305/// Returns the rewritten graph and the manifest describing each appended output.
306pub fn inject_matmul_stats(graph: &Graph, cfg: &StatConfig) -> (Graph, Vec<StatSpec>) {
307    inject_matmul_stats_filtered(graph, cfg, &|_lhs_numel, _out_numel| true)
308}
309
310/// [`inject_matmul_stats`] but only taps sites for which `keep(lhs_numel,
311/// out_numel)` is true. The injected graph's compile cost grows superlinearly with
312/// the number of tapped sites, so whole-model injection (100+ matmuls) can become
313/// intractable; a `keep` predicate lets a caller tap just the tensors it reports
314/// (e.g. residual stream + query by their element counts), keeping the graph small
315/// enough to compile at depth.
316pub fn inject_matmul_stats_filtered(
317    graph: &Graph,
318    cfg: &StatConfig,
319    keep: &dyn Fn(usize, usize) -> bool,
320) -> (Graph, Vec<StatSpec>) {
321    let mut g = Graph::new(&graph.name);
322    let mut id_map: HashMap<NodeId, NodeId> = HashMap::new();
323    // (site, lhs, rhs, out) in the *new* graph's ids.
324    let mut matmuls: Vec<(String, NodeId, NodeId, NodeId)> = Vec::new();
325    // DequantMatMul sites: (site, lhs=activation, out). The rhs is PACKED quant
326    // bytes (u8), not an f32 tensor, so we tap only the f32 activation + output —
327    // captures the activation dataflow of quantized projections (MXFP4/MXFP8/int*).
328    let mut dq_matmuls: Vec<(String, NodeId, NodeId)> = Vec::new();
329
330    for node in graph.nodes() {
331        let inputs: Vec<NodeId> = node.inputs.iter().map(|i| id_map[i]).collect();
332        let new_id = g.add_node(node.op.clone(), inputs.clone(), node.shape.clone());
333        id_map.insert(node.id, new_id);
334        if matches!(node.op, Op::MatMul) {
335            // Always suffix the node id — flow-lowered graphs give every matmul
336            // the same name ("mir"), which would otherwise collapse all sites.
337            let site = match &node.name {
338                Some(n) => format!("{n}#{}", node.id.0),
339                None => format!("matmul#{}", node.id.0),
340            };
341            matmuls.push((site, inputs[0], inputs[1], new_id));
342        }
343        if matches!(node.op, Op::DequantMatMul { .. }) {
344            let site = match &node.name {
345                Some(n) => format!("{n}#{}", node.id.0),
346                None => format!("dqmm#{}", node.id.0),
347            };
348            dq_matmuls.push((site, inputs[0], new_id));
349        }
350    }
351
352    let mut outputs: Vec<NodeId> = graph.outputs.iter().map(|i| id_map[i]).collect();
353    let mut specs: Vec<StatSpec> = Vec::new();
354
355    for (site, lhs, rhs, out_t) in matmuls {
356        // 2·M·K·N: lhs [M,K], out [.,N].
357        let ls = g.shape(lhs).clone();
358        let os = g.shape(out_t).clone();
359        if !keep(
360            ls.num_elements().unwrap_or(0),
361            os.num_elements().unwrap_or(0),
362        ) {
363            continue;
364        }
365        let m = ls.dim(0).unwrap_static();
366        let kk = ls.dim(ls.rank() - 1).unwrap_static();
367        let n = os.dim(os.rank() - 1).unwrap_static();
368        let flops = 2 * m as u64 * kk as u64 * n as u64;
369        for (role, t) in [("lhs", lhs), ("rhs", rhs), ("out", out_t)] {
370            let numel = g.shape(t).num_elements().unwrap_or(0);
371            for (node, stat) in build_tensor_stats(&mut g, t, cfg) {
372                let len = g.shape(node).num_elements().unwrap_or(1);
373                specs.push(StatSpec {
374                    out_idx: outputs.len(),
375                    site: site.clone(),
376                    role,
377                    stat,
378                    len,
379                    numel,
380                    flops,
381                });
382                outputs.push(node);
383            }
384        }
385    }
386
387    // DequantMatMul taps: activation (lhs) + output only (rhs is packed bytes).
388    for (site, lhs, out_t) in dq_matmuls {
389        let ls = g.shape(lhs).clone();
390        let os = g.shape(out_t).clone();
391        if !keep(
392            ls.num_elements().unwrap_or(0),
393            os.num_elements().unwrap_or(0),
394        ) {
395            continue;
396        }
397        let m = ls.dim(0).unwrap_static();
398        let kk = ls.dim(ls.rank() - 1).unwrap_static();
399        let n = os.dim(os.rank() - 1).unwrap_static();
400        let flops = 2 * m as u64 * kk as u64 * n as u64;
401        for (role, t) in [("lhs", lhs), ("out", out_t)] {
402            let numel = g.shape(t).num_elements().unwrap_or(0);
403            for (node, stat) in build_tensor_stats(&mut g, t, cfg) {
404                let len = g.shape(node).num_elements().unwrap_or(1);
405                specs.push(StatSpec {
406                    out_idx: outputs.len(),
407                    site: site.clone(),
408                    role,
409                    stat,
410                    len,
411                    numel,
412                    flops,
413                });
414                outputs.push(node);
415            }
416        }
417    }
418
419    g.set_outputs(outputs);
420    (g, specs)
421}
422
423/// Build a **symmetric int8 fake-quant** subgraph on activation `a` (`[…, k]`):
424/// `round(a·127/amax)·amax/127`, clamped to `[-127,127]`. `per_channel=false`
425/// scopes `amax` per-token (reduce the last/feature axis) — hardware-friendly but
426/// wrecked by a single outlier channel; `per_channel=true` scopes it per-channel
427/// (reduce the token axes) — each outlier channel gets its own scale, the ceiling
428/// SmoothQuant approximates. Returns the dequantized-back node (same shape as `a`).
429/// The activation half of W8A8 — inserted before a matmul so the forward pass sees
430/// quantized activations. `Op::Round` is the STE primitive rlx-ir notes for this.
431fn fakequant(g: &mut Graph, a: NodeId, per_channel: bool) -> NodeId {
432    let ashape = g.shape(a).clone();
433    let rank = ashape.rank();
434    if rank == 0 {
435        return a;
436    }
437    let last = rank - 1;
438    let dims: Vec<usize> = (0..rank).map(|i| ashape.dim(i).unwrap_static()).collect();
439    // per-token: keep the last (feature) axis, reduce it → scale per row.
440    // per-channel: reduce the token axes (all but last) → scale per feature channel.
441    let (axes, mut rdims): (Vec<usize>, Vec<usize>) = if per_channel {
442        let mut rd = dims.clone();
443        (0..last).for_each(|i| rd[i] = 1);
444        ((0..last).collect(), rd)
445    } else {
446        let mut rd = dims.clone();
447        rd[last] = 1;
448        (vec![last], rd)
449    };
450    if axes.is_empty() {
451        rdims = dims.clone();
452    }
453    let rshape = Shape::new(&rdims, DType::F32);
454    let scalar = |g: &mut Graph, v: f32| {
455        g.add_node(
456            Op::Constant {
457                data: v.to_le_bytes().to_vec(),
458            },
459            vec![],
460            Shape::new(&[1], DType::F32),
461        )
462    };
463
464    let abs = g.add_node(Op::Activation(Activation::Abs), vec![a], ashape.clone());
465    let amax = g.reduce(abs, ReduceOp::Max, axes, true, rshape.clone());
466    let inv = g.add_node(
467        Op::Activation(Activation::Recip),
468        vec![amax],
469        rshape.clone(),
470    ); // 1/amax
471    let norm = g.add_node(Op::Binary(BinaryOp::Mul), vec![a, inv], ashape.clone()); // a/amax ∈ [-1,1] (bcast)
472    let c127 = scalar(g, 127.0);
473    let up = g.add_node(Op::Binary(BinaryOp::Mul), vec![norm, c127], ashape.clone());
474    let r = g.add_node(Op::Activation(Activation::Round), vec![up], ashape.clone());
475    let rc = g.add_node(
476        Op::Clamp {
477            min: -127.0,
478            max: 127.0,
479        },
480        vec![r],
481        ashape.clone(),
482    );
483    let c_inv = scalar(g, 1.0 / 127.0);
484    let back = g.add_node(Op::Binary(BinaryOp::Mul), vec![rc, c_inv], ashape.clone()); // /127
485    g.add_node(Op::Binary(BinaryOp::Mul), vec![back, amax], ashape.clone()) // * amax (bcast)
486}
487
488/// Rewrite `graph` so every `Op::MatMul`'s **activation input** (`input[0]`) is
489/// per-token int8 fake-quantized — the missing half of an end-to-end **W8A8**
490/// simulation (combine with int8 weights in the WeightMap). In a fused-attention
491/// qwen graph the `MatMul` nodes are exactly the linear projections (Q/K/V/O/
492/// gate/up/down + lm_head) — attention's internal QK/AV live inside `Op::Attention`
493/// — so this quantizes precisely the linear-layer activations, as real W8A8 does.
494/// Outputs are preserved (same indices); compile with `skip_fusion` (the inserted
495/// nodes would otherwise trip SwiGLU fusion). `per_channel` picks the scale scope
496/// (see `fakequant`) — `false` = per-token (deployable W8A8), `true` = per-channel
497/// (the outlier-robust ceiling).
498pub fn inject_activation_fakequant(graph: &Graph, per_channel: bool) -> Graph {
499    let mut g = Graph::new(&graph.name);
500    let mut id_map: HashMap<NodeId, NodeId> = HashMap::new();
501    for node in graph.nodes() {
502        let inputs: Vec<NodeId> = node.inputs.iter().map(|i| id_map[i]).collect();
503        let new_id = if matches!(node.op, Op::MatMul) && !inputs.is_empty() {
504            let fq = fakequant(&mut g, inputs[0], per_channel);
505            let mut mm = inputs.clone();
506            mm[0] = fq;
507            g.add_node(Op::MatMul, mm, node.shape.clone())
508        } else {
509            g.add_node(node.op.clone(), inputs, node.shape.clone())
510        };
511        id_map.insert(node.id, new_id);
512    }
513    let outputs: Vec<NodeId> = graph.outputs.iter().map(|i| id_map[i]).collect();
514    g.set_outputs(outputs);
515    g
516}
517
518/// One residual-`Add` tap: the recorded Σx² of each input, plus the block index.
519pub struct ResidualSpec {
520    /// Index into the run outputs for `‖input_a‖²` and `‖input_b‖²`.
521    pub a_idx: usize,
522    pub b_idx: usize,
523    /// Execution order of this residual add (0,1 = layer0 attn/mlp, …).
524    pub order: usize,
525}
526
527/// Tap every residual `Add(residual, block_out)` — record Σx² of both inputs so
528/// the analysis can compute `‖block-delta‖/‖residual‖`, the **block-influence**
529/// signal (ShortGPT/Gromov): a sublayer whose delta ≈ 0 barely changes the
530/// residual stream ⇒ it's near-identity ⇒ a **layer-skip** candidate. Compile
531/// with `skip_fusion` so the residual adds aren't folded into FusedResidualRmsNorm
532/// before the tap sees them. Outputs are preserved (same indices).
533pub fn inject_residual_stats(graph: &Graph) -> (Graph, Vec<ResidualSpec>) {
534    let mut g = Graph::new(&graph.name);
535    let mut id_map: HashMap<NodeId, NodeId> = HashMap::new();
536    let mut adds: Vec<(NodeId, NodeId)> = Vec::new();
537    for node in graph.nodes() {
538        let inputs: Vec<NodeId> = node.inputs.iter().map(|i| id_map[i]).collect();
539        let new_id = g.add_node(node.op.clone(), inputs.clone(), node.shape.clone());
540        id_map.insert(node.id, new_id);
541        if matches!(node.op, Op::Binary(BinaryOp::Add)) && inputs.len() == 2 {
542            adds.push((inputs[0], inputs[1]));
543        }
544    }
545    let mut outputs: Vec<NodeId> = graph.outputs.iter().map(|i| id_map[i]).collect();
546    let mut specs = Vec::new();
547    for (order, (a, b)) in adds.into_iter().enumerate() {
548        let ash = g.shape(a).clone();
549        let bsh = g.shape(b).clone();
550        let sqa = g.add_node(Op::Binary(BinaryOp::Mul), vec![a, a], ash);
551        let ssa = reduce_scalar(&mut g, sqa, ReduceOp::Sum);
552        let sqb = g.add_node(Op::Binary(BinaryOp::Mul), vec![b, b], bsh);
553        let ssb = reduce_scalar(&mut g, sqb, ReduceOp::Sum);
554        specs.push(ResidualSpec {
555            a_idx: outputs.len(),
556            b_idx: outputs.len() + 1,
557            order,
558        });
559        outputs.push(ssa);
560        outputs.push(ssb);
561    }
562    g.set_outputs(outputs);
563    (g, specs)
564}
565
566/// Rewrite `graph` to **skip** the residual blocks at the given execution orders
567/// (same ordering as [`inject_residual_stats`]): each targeted `Add(residual,
568/// block_out)` is replaced by its residual input (`input[0]`), so the block's
569/// output is bypassed — and its whole subgraph becomes dead code the compiler
570/// drops, actually removing that layer's weight-stream + compute. The concrete
571/// "replace-like-fusing" action for a near-identity layer (layer minimization).
572pub fn skip_residual_blocks(graph: &Graph, skip_orders: &[usize]) -> Graph {
573    let mut g = Graph::new(&graph.name);
574    let mut id_map: HashMap<NodeId, NodeId> = HashMap::new();
575    let mut order = 0usize;
576    for node in graph.nodes() {
577        let inputs: Vec<NodeId> = node.inputs.iter().map(|i| id_map[i]).collect();
578        if matches!(node.op, Op::Binary(BinaryOp::Add)) && inputs.len() == 2 {
579            let this = order;
580            order += 1;
581            if skip_orders.contains(&this) {
582                // Bypass: the residual (input[0]) flows straight through; the
583                // block_out branch (input[1]) is left dangling → dead-code removed.
584                id_map.insert(node.id, inputs[0]);
585                continue;
586            }
587        }
588        let new_id = g.add_node(node.op.clone(), inputs, node.shape.clone());
589        id_map.insert(node.id, new_id);
590    }
591    let outputs: Vec<NodeId> = graph.outputs.iter().map(|i| id_map[i]).collect();
592    g.set_outputs(outputs);
593    g
594}
595
596#[cfg(test)]
597mod fakequant_tests {
598    use super::*;
599    use rlx_runtime::{CompileOptions, Device, Session};
600
601    #[test]
602    fn fakequant_subgraph_matches_per_row_int8() {
603        let (m, k) = (3usize, 8usize);
604        let mut g = Graph::new("fq");
605        let x = g.input("x", Shape::new(&[m, k], DType::F32));
606        let fq = fakequant(&mut g, x, false);
607        g.set_outputs(vec![fq]);
608        let mut o = CompileOptions::default();
609        o.fusion_opts.skip_fusion = true;
610        let mut c = Session::new(Device::Cpu).compile_with(g, &o);
611        let xd: Vec<f32> = (0..m * k).map(|i| i as f32 * 0.37 - 1.5).collect();
612        let got = c.run(&[("x", &xd)]).remove(0);
613        // reference: per-row symmetric int8 round-trip (matches quantize_row_i8).
614        let mut want = xd.clone();
615        for r in 0..m {
616            let row = &mut want[r * k..(r + 1) * k];
617            let amax = row.iter().fold(0f32, |a, &v| a.max(v.abs()));
618            let s = if amax < 1e-20 { 1.0 } else { amax / 127.0 };
619            for v in row.iter_mut() {
620                *v = (*v / s).round().clamp(-127.0, 127.0) * s;
621            }
622        }
623        let num: f32 = got
624            .iter()
625            .zip(&want)
626            .map(|(a, b)| (a - b).powi(2))
627            .sum::<f32>()
628            .sqrt();
629        let den: f32 = want.iter().map(|v| v * v).sum::<f32>().sqrt() + 1e-9;
630        assert!(
631            num / den < 0.01,
632            "fakequant vs per-row int8 rel-err {}",
633            num / den
634        );
635    }
636}
637
638// ─────────────────── Tier 1: inference-dynamics taps ───────────────────
639
640/// Reduce a single `axis` of `t`, giving explicit output dims.
641fn reduce_axis(g: &mut Graph, t: NodeId, axis: usize, op: ReduceOp) -> (NodeId, usize) {
642    let dims: Vec<usize> = {
643        let s = g.shape(t);
644        (0..s.rank()).map(|i| s.dim(i).unwrap_static()).collect()
645    };
646    let out_dims: Vec<usize> = dims
647        .iter()
648        .enumerate()
649        .filter(|(i, _)| *i != axis)
650        .map(|(_, &d)| d)
651        .collect();
652    let len: usize = out_dims.iter().product::<usize>().max(1);
653    let out_shape = Shape::new(&out_dims, DType::F32);
654    (g.reduce(t, op, vec![axis], false, out_shape), len)
655}
656
657/// Append attention (`Op::Softmax`) and MoE-routing (`Op::TopK`) sketches as
658/// extra graph outputs — the distinctly *inference* signals:
659/// - `attn_qmax`  : per-query peak attention mass (concentration → sparse/
660///   windowed attention, keep top-k keys).
661/// - `attn_krecv` : per-key received mass (sinks → KV eviction / sink tokens).
662/// - `route_load` : per-expert selection count from the top-k router (skew →
663///   drop cold experts, prefetch/merge hot ones).
664pub fn inject_infer_stats(graph: &Graph, _cfg: &StatConfig) -> (Graph, Vec<StatSpec>) {
665    let mut g = Graph::new(&graph.name);
666    let mut id_map: HashMap<NodeId, NodeId> = HashMap::new();
667    // (kind, site, node-in-new-graph, extra usize e.g. num_experts)
668    let mut taps: Vec<(&'static str, String, NodeId, usize)> = Vec::new();
669
670    for node in graph.nodes() {
671        let inputs: Vec<NodeId> = node.inputs.iter().map(|i| id_map[i]).collect();
672        let new_id = g.add_node(node.op.clone(), inputs.clone(), node.shape.clone());
673        id_map.insert(node.id, new_id);
674        match &node.op {
675            Op::Softmax { .. } => {
676                let site = match &node.name {
677                    Some(n) => format!("{n}#{}", node.id.0),
678                    None => format!("softmax#{}", node.id.0),
679                };
680                taps.push(("softmax", site, new_id, 0));
681            }
682            // Universal MoE routing tap: every expert-dispatch (TopK router OR a
683            // custom group_limited_gate) feeds a GroupedMatMul with expert ids in
684            // input 2 — histogram those → per-expert load. Covers DeepSeek/GLM4-MoE
685            // (custom gate, no Op::TopK) as well as Llama4/Qwen3.5.
686            Op::GroupedMatMul if node.inputs.len() >= 3 => {
687                let site = format!("gmm#{}", node.id.0);
688                let w = &graph.node(node.inputs[1]).shape; // [num_experts, K, N]
689                let experts = w.dim(0).unwrap_static();
690                taps.push(("topk", site, inputs[2], experts)); // tap the expert-idx input
691            }
692            Op::TopK { .. } => {
693                let site = match &node.name {
694                    Some(n) => format!("{n}#{}", node.id.0),
695                    None => format!("topk#{}", node.id.0),
696                };
697                // experts = last dim of the gate (the TopK input).
698                let e = &graph.node(node.inputs[0]).shape;
699                let experts = e.dim(e.rank() - 1).unwrap_static();
700                taps.push(("topk", site, new_id, experts));
701            }
702            _ => {}
703        }
704    }
705
706    let mut outputs: Vec<NodeId> = graph.outputs.iter().map(|i| id_map[i]).collect();
707    let mut specs: Vec<StatSpec> = Vec::new();
708    let mut emit = |g: &mut Graph,
709                    outs: &mut Vec<NodeId>,
710                    node,
711                    site: &str,
712                    role: &'static str,
713                    stat: &'static str,
714                    numel: usize| {
715        let len = g.shape(node).num_elements().unwrap_or(1);
716        specs.push(StatSpec {
717            out_idx: outs.len(),
718            site: site.into(),
719            role,
720            stat,
721            len,
722            numel,
723            flops: 0,
724        });
725        outs.push(node);
726    };
727
728    for (kind, site, node, experts) in taps {
729        let rank = g.shape(node).rank();
730        let numel = g.shape(node).num_elements().unwrap_or(0);
731        match kind {
732            "softmax" if rank >= 2 => {
733                let (qmax, _) = reduce_axis(&mut g, node, rank - 1, ReduceOp::Max);
734                emit(
735                    &mut g,
736                    &mut outputs,
737                    qmax,
738                    &site,
739                    "attn",
740                    "attn_qmax",
741                    numel,
742                );
743                let (krecv, _) = reduce_axis(&mut g, node, rank - 2, ReduceOp::Sum);
744                emit(
745                    &mut g,
746                    &mut outputs,
747                    krecv,
748                    &site,
749                    "attn",
750                    "attn_krecv",
751                    numel,
752                );
753            }
754            "topk" if experts > 0 => {
755                // Histogram of selected expert ids → per-expert load.
756                let load = g.histogram(node, experts, 0.0, experts as f32);
757                emit(
758                    &mut g,
759                    &mut outputs,
760                    load,
761                    &site,
762                    "route",
763                    "route_load",
764                    numel,
765                );
766            }
767            _ => {}
768        }
769    }
770
771    g.set_outputs(outputs);
772    (g, specs)
773}
774
775/// Tap **fused `Op::Attention`** nodes: decompose each to its softmax-weights
776/// node (via rlx's own `attention_softmax_weights`) and append per-query peak
777/// mass + per-key received mass as extra outputs. This is how attention
778/// concentration / sink keys are recovered from a *fused* attention op (which
779/// otherwise exposes no softmax to tap). Adds a shadow `Q·Kᵀ→softmax` per site.
780pub fn inject_attention_stats(graph: &Graph, _cfg: &StatConfig) -> (Graph, Vec<StatSpec>) {
781    use rlx_autodiff::decompose_backward_kernels::attention_softmax_weights;
782    use rlx_ir::op::MaskKind;
783
784    let mut g = Graph::new(&graph.name);
785    let mut id_map: HashMap<NodeId, NodeId> = HashMap::new();
786    // (site, q, k, v, mask, num_heads, head_dim, mask_kind)
787    let mut attns: Vec<(
788        String,
789        NodeId,
790        NodeId,
791        NodeId,
792        Option<NodeId>,
793        usize,
794        usize,
795        MaskKind,
796    )> = Vec::new();
797
798    for node in graph.nodes() {
799        let inputs: Vec<NodeId> = node.inputs.iter().map(|i| id_map[i]).collect();
800        let new_id = g.add_node(node.op.clone(), inputs.clone(), node.shape.clone());
801        id_map.insert(node.id, new_id);
802        if let Op::Attention {
803            num_heads,
804            head_dim,
805            mask_kind,
806            ..
807        } = &node.op
808        {
809            let site = match &node.name {
810                Some(n) => format!("{n}#{}", node.id.0),
811                None => format!("attn#{}", node.id.0),
812            };
813            let mask = if matches!(mask_kind, MaskKind::Custom | MaskKind::Bias) {
814                inputs.get(3).copied()
815            } else {
816                None
817            };
818            attns.push((
819                site, inputs[0], inputs[1], inputs[2], mask, *num_heads, *head_dim, *mask_kind,
820            ));
821        }
822    }
823
824    let mut outputs: Vec<NodeId> = graph.outputs.iter().map(|i| id_map[i]).collect();
825    let mut specs: Vec<StatSpec> = Vec::new();
826    for (site, q, k, v, mask, nh, hd, mk) in attns {
827        let mask_shape = mask.map(|m| g.shape(m).clone());
828        let w = attention_softmax_weights(&mut g, q, k, v, nh, hd, mk, mask, mask_shape.as_ref());
829        let rank = g.shape(w).rank(); // [B*H, S_q, S_k]
830        let numel = g.shape(w).num_elements().unwrap_or(0);
831        // Per-(head,query) peak attention mass.
832        let (qmax, _) = reduce_axis(&mut g, w, rank - 1, ReduceOp::Max);
833        specs.push(StatSpec {
834            out_idx: outputs.len(),
835            site: site.clone(),
836            role: "attn",
837            stat: "attn_qmax",
838            len: g.shape(qmax).num_elements().unwrap_or(1),
839            numel,
840            flops: 0,
841        });
842        outputs.push(qmax);
843        // Per-(head,key) received mass (sum over queries) — sinks.
844        let (krecv, _) = reduce_axis(&mut g, w, rank - 2, ReduceOp::Sum);
845        specs.push(StatSpec {
846            out_idx: outputs.len(),
847            site,
848            role: "attn",
849            stat: "attn_krecv",
850            len: g.shape(krecv).num_elements().unwrap_or(1),
851            numel,
852            flops: 0,
853        });
854        outputs.push(krecv);
855    }
856
857    g.set_outputs(outputs);
858    (g, specs)
859}
860
861// ─────────────────────────── synthetic data ───────────────────────────
862
863/// Synthetic input distributions. Each is chosen to make a *different* sketch
864/// pop, so the miner can be validated against ground truth.
865#[derive(Clone, Copy, Debug, PartialEq, Eq)]
866pub enum Dist {
867    /// N(0,1) — the null hypothesis (dense, bell-shaped, no structure).
868    Gaussian,
869    /// U(-1,1) — dense, flat histogram.
870    Uniform,
871    /// 90% exact zeros — unstructured sparsity → sparse-GEMM candidate.
872    Sparse90,
873    /// Rank-4 product — low effective rank → factored-matmul candidate.
874    LowRank,
875    /// Gaussian with ~1% ×30 spikes → per-channel outliers → SmoothQuant/AWQ.
876    Outlier,
877    /// Gaussian snapped to 0.5 steps → spiky histogram → quantize/LUT.
878    Quantized,
879    /// Row random-walk — adjacent rows cohere → within-call sequence structure
880    /// → delta-compute candidate (proves the `adj_sumsq` detector).
881    Coherent,
882}
883
884impl Dist {
885    pub const ALL: [Dist; 7] = [
886        Dist::Gaussian,
887        Dist::Uniform,
888        Dist::Sparse90,
889        Dist::LowRank,
890        Dist::Outlier,
891        Dist::Quantized,
892        Dist::Coherent,
893    ];
894
895    pub fn name(self) -> &'static str {
896        match self {
897            Dist::Gaussian => "gaussian",
898            Dist::Uniform => "uniform",
899            Dist::Sparse90 => "sparse90",
900            Dist::LowRank => "lowrank",
901            Dist::Outlier => "outlier",
902            Dist::Quantized => "quantized",
903            Dist::Coherent => "coherent",
904        }
905    }
906}
907
908#[inline]
909fn u01(rng: &mut Philox4x32) -> f32 {
910    rng.next_u32() as f32 / u32::MAX as f32
911}
912
913/// Generate a `rows × cols` matrix (row-major) drawn from `dist`, seeded for
914/// reproducibility.
915pub fn sample(dist: Dist, rows: usize, cols: usize, seed: u64) -> Vec<f32> {
916    let n = rows * cols;
917    let mut rng = Philox4x32::new(seed);
918    let mut base = vec![0f32; n];
919    rng.fill_normal(&mut base); // N(0,1) starting point for most dists
920    let mut mask = Philox4x32::new(seed ^ 0x9E37_79B9_7F4A_7C15);
921
922    match dist {
923        Dist::Gaussian => base,
924        Dist::Uniform => (0..n).map(|_| 2.0 * u01(&mut rng) - 1.0).collect(),
925        Dist::Sparse90 => base
926            .iter()
927            .map(|&x| {
928                if mask.next_u32().is_multiple_of(10) {
929                    x
930                } else {
931                    0.0
932                }
933            })
934            .collect(),
935        Dist::LowRank => {
936            let r = 4usize;
937            let mut u = vec![0f32; rows * r];
938            let mut v = vec![0f32; r * cols];
939            rng.fill_normal(&mut u);
940            mask.fill_normal(&mut v);
941            let scale = 1.0 / (r as f32).sqrt();
942            let mut out = vec![0f32; n];
943            for i in 0..rows {
944                for j in 0..cols {
945                    let mut acc = 0f32;
946                    for k in 0..r {
947                        acc += u[i * r + k] * v[k * cols + j];
948                    }
949                    out[i * cols + j] = acc * scale;
950                }
951            }
952            out
953        }
954        Dist::Outlier => base
955            .iter()
956            .map(|&x| {
957                if mask.next_u32().is_multiple_of(100) {
958                    x * 30.0
959                } else {
960                    x
961                }
962            })
963            .collect(),
964        Dist::Quantized => base.iter().map(|&x| (x * 2.0).round() / 2.0).collect(),
965        Dist::Coherent => {
966            // Row t = row t-1 + small step; adjacent rows stay close.
967            let mut out = vec![0f32; n];
968            let mut row = vec![0f32; cols];
969            rng.fill_normal(&mut row); // row 0
970            let mut step = vec![0f32; cols];
971            for i in 0..rows {
972                if i > 0 {
973                    mask.fill_normal(&mut step);
974                    for j in 0..cols {
975                        row[j] += 0.15 * step[j];
976                    }
977                }
978                out[i * cols..(i + 1) * cols].copy_from_slice(&row);
979            }
980            out
981        }
982    }
983}
984
985// ─────────────────────────── recorder ───────────────────────────
986
987/// A dependency-free tidy-CSV sink: one row per sketch element. Columns:
988/// `run_id,backend,dist,M,K,N,site,role,stat,idx,value`. Tidy/long form so
989/// scalars (idx=0), per-channel vectors (idx=channel), and histograms
990/// (idx=bin) all share one flat schema that pandas/polars can `groupby`.
991pub struct Recorder<W: Write> {
992    w: W,
993}
994
995impl Recorder<io::BufWriter<std::fs::File>> {
996    /// Create a recorder writing to `path`, emitting the header row.
997    pub fn create(path: &str) -> io::Result<Self> {
998        let f = std::fs::File::create(path)?;
999        Self::new(io::BufWriter::new(f))
1000    }
1001}
1002
1003impl<W: Write> Recorder<W> {
1004    pub fn new(mut w: W) -> io::Result<Self> {
1005        // `numel` is the tapped tensor's own element count — density/normalization
1006        // use it directly so multi-site graphs (different shapes per matmul) work
1007        // without decoding M/K/N per role.
1008        writeln!(
1009            w,
1010            "run_id,step,backend,dist,M,K,N,numel,site,role,stat,idx,value"
1011        )?;
1012        Ok(Self { w })
1013    }
1014
1015    /// Record one call's sketches. `run_id` groups calls into a sequence and
1016    /// `step` orders them within it (0 for independent runs); `outs` is
1017    /// `run()`'s output; `specs` the injection manifest.
1018    #[allow(clippy::too_many_arguments)]
1019    pub fn record(
1020        &mut self,
1021        run_id: u64,
1022        step: u64,
1023        backend: &str,
1024        dist: &str,
1025        m: usize,
1026        k: usize,
1027        n: usize,
1028        specs: &[StatSpec],
1029        outs: &[Vec<f32>],
1030    ) -> io::Result<()> {
1031        for spec in specs {
1032            let data = &outs[spec.out_idx];
1033            let site = spec.site.replace(',', "_");
1034            for (idx, &val) in data.iter().enumerate() {
1035                writeln!(
1036                    self.w,
1037                    "{run_id},{step},{backend},{dist},{m},{k},{n},{},{site},{},{},{idx},{val}",
1038                    spec.numel, spec.role, spec.stat,
1039                )?;
1040            }
1041        }
1042        Ok(())
1043    }
1044
1045    pub fn flush(&mut self) -> io::Result<()> {
1046        self.w.flush()
1047    }
1048}