Skip to main content

logicaffeine_compile/spatial/
equiv.rs

1//! `equiv` — the differential that decides whether two spatial programs MEAN the same thing.
2//!
3//! A pass, a legalization, an imported placement, a hand-written lowering: each hands us a second
4//! [`SpatialProgram`] together with a claim that it computes what the first one computes.
5//! [`equivalent_under`] is how that claim is discharged — evaluate both under the executable token
6//! semantics of [`super::eval`], on the SAME inputs, over a corpus of them, and name the first place the
7//! two part company.
8//!
9//! ★ WHY THIS EXISTS WHEN `certify_external_spatial` ALREADY HAS THE SHAPE. That function
10//! (`spatial/certificate.rs:759`) looks like the same idea — same tensors, evaluate both, compare — and it
11//! cannot validate a rewrite, for two independent reasons, either of which alone is fatal:
12//!
13//! 1. **It is a ONE-POINT differential.** It builds `dry_inputs(source)`: the recorded fills, and *zeros*
14//!    wherever the data is external. One evaluation, at one point, and that point is the origin. A rewrite
15//!    that is wrong everywhere except there passes it, and zero is exactly the argument at which dropped
16//!    terms, swapped operands and lost scale factors are all invisible.
17//! 2. **It gates on STRUCTURAL stage-kind equality.** Before evaluating anything it compares the two
18//!    programs' sorted, deduplicated multisets of `discriminant(StageKind)` — and, for compute stages,
19//!    `discriminant(ComputeOp)` — returning `None` when they differ. Every legalization worth the name
20//!    violates that BY CONSTRUCTION: expanding one fused `ComputeOp::RowReduce` into the
21//!    `Elementwise → ReduceRows → RowXform → RowRescale` chain changes those discriminants, and changing
22//!    them IS the rewrite. A validator that refuses precisely the programs it exists to validate can never
23//!    be called on one. (It has, today, no callers at all.)
24//!
25//! This module is the generalization: a CORPUS rather than a point, and no structural precondition
26//! whatsoever — only the tensors that come out the other end. The two programs need not share a stage, a
27//! buffer, a placement or a schedule. They need only agree on what they compute.
28//!
29//! Equality here is [`Outputs`]'s own equality, applied element by element so the answer can say WHERE:
30//! `Value::Int` compares exactly, `Value::Float` compares with `==` (so a rewrite that turns a number into
31//! `NaN` on both sides is reported as a divergence rather than waved through — fail-closed). `steps` is
32//! deliberately NOT compared: how many rounds a schedule takes is an artifact of the arrangement, not of
33//! the meaning, and a rewrite that changes it has changed nothing this module is asked about.
34
35use std::fmt;
36
37use super::eval::{eval_choreography, ChoreoFault, Inputs, Outputs, Value};
38use super::ir::{SpatialProgram, TensorDecl, TensorId, TensorRole};
39use super::target::precision_is_int;
40
41/// Which of the two programs a divergence is being reported against.
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub enum Side {
44    A,
45    B,
46}
47
48impl Side {
49    pub fn name(self) -> &'static str {
50        match self {
51            Side::A => "a",
52            Side::B => "b",
53        }
54    }
55}
56
57/// One element, in whichever numeric tower its tensor lives in — enough to read a divergence without
58/// carrying two whole tensors through a panic message.
59#[derive(Clone, Copy, Debug, PartialEq)]
60pub enum Elem {
61    Int(i64),
62    Float(f64),
63}
64
65impl fmt::Display for Elem {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        match self {
68            Elem::Int(v) => write!(f, "{v}"),
69            Elem::Float(v) => write!(f, "{v}"),
70        }
71    }
72}
73
74/// Why two programs are not the same program.
75///
76/// ★ NEVER A BARE `bool`. "Not equivalent" is unactionable; a failed differential has to say which input
77/// of the corpus broke it, which tensor, which element, and what the two sides put there — otherwise the
78/// next person re-derives all of that by hand before they can start.
79#[derive(Clone, Debug, PartialEq)]
80pub enum Divergence {
81    /// The caller passed no inputs. ★ Fail-closed: a validator handed nothing to check must never answer
82    /// "equivalent", because a vacuous pass reads exactly like a proof.
83    EmptyCorpus,
84    /// One side could not be evaluated at all — a choreography fault, not an arithmetic disagreement.
85    Faulted {
86        side: Side,
87        input: usize,
88        fault: ChoreoFault,
89    },
90    /// One side produced an output tensor the other never produced.
91    MissingTensor {
92        side: Side,
93        input: usize,
94        tensor: TensorId,
95    },
96    /// The same tensor came back in two different representations (int vs float, or two lengths).
97    Representation {
98        input: usize,
99        tensor: TensorId,
100        a: &'static str,
101        b: &'static str,
102        a_len: usize,
103        b_len: usize,
104    },
105    /// The two sides computed different numbers. `elem` is the FIRST differing flat index (row-major over
106    /// the tensor's element shape); `differing` of `total` says how much of the tensor moved, which is what
107    /// separates "one rounding" from "the whole rewrite is wrong".
108    Element {
109        input: usize,
110        tensor: TensorId,
111        elem: usize,
112        a: Elem,
113        b: Elem,
114        differing: usize,
115        total: usize,
116    },
117}
118
119impl fmt::Display for Divergence {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        match self {
122            Divergence::EmptyCorpus => write!(
123                f,
124                "an empty corpus proves nothing: pass at least one Inputs (see seeded_corpus)"
125            ),
126            Divergence::Faulted { side, input, fault } => {
127                write!(
128                    f,
129                    "program `{}` faulted on corpus input {input}: {fault:?}",
130                    side.name()
131                )
132            }
133            Divergence::MissingTensor {
134                side,
135                input,
136                tensor,
137            } => {
138                write!(
139                    f,
140                    "on corpus input {input}, program `{}` never produced output tensor {}",
141                    side.name(),
142                    tensor.idx()
143                )
144            }
145            Divergence::Representation {
146                input,
147                tensor,
148                a,
149                b,
150                a_len,
151                b_len,
152            } => {
153                write!(f, "on corpus input {input}, output tensor {} came back as {a}[{a_len}] from `a` but {b}[{b_len}] from `b`", tensor.idx())
154            }
155            Divergence::Element {
156                input,
157                tensor,
158                elem,
159                a,
160                b,
161                differing,
162                total,
163            } => {
164                write!(
165                    f,
166                    "on corpus input {input}, output tensor {} differs at element {elem}: a = {a}, b = {b} ({differing} of {total} elements differ)",
167                    tensor.idx()
168                )
169            }
170        }
171    }
172}
173
174/// Do `a` and `b` compute the same outputs on every input of the corpus?
175///
176/// `Ok(())` is the equivalence claim discharged over exactly those points — no more, and the corpus is the
177/// caller's argument for "no less". `Err` names the first divergence found, scanning inputs in order and
178/// tensors in id order, so the report is deterministic and diffable across runs.
179///
180/// Both programs are evaluated under [`super::eval::Scheduler::RoundRobin`]; scheduler independence is a
181/// separate property with its own oracle (`eval_choreography_confluent`) and conflating the two would make
182/// a nondeterministic program look like an inequivalent one.
183///
184/// ★ WHAT THIS IS NOT. Both sides run under the SAME evaluator, so this validates a REWRITE against the
185/// IR's semantics — never the semantics against the mathematics. A pass that preserves an operator the
186/// compiler has always computed wrongly passes here and should: that error belongs to
187/// `spatial_algebra_semantics_lock`, which checks `eval_choreography` against references written out
188/// longhand from each operator's definition. The two oracles are complementary and neither substitutes.
189pub fn equivalent_under(
190    a: &SpatialProgram,
191    b: &SpatialProgram,
192    inputs: &[Inputs],
193) -> Result<(), Divergence> {
194    if inputs.is_empty() {
195        return Err(Divergence::EmptyCorpus);
196    }
197    for (i, input) in inputs.iter().enumerate() {
198        let oa = eval_choreography(a, input).map_err(|fault| Divergence::Faulted {
199            side: Side::A,
200            input: i,
201            fault,
202        })?;
203        let ob = eval_choreography(b, input).map_err(|fault| Divergence::Faulted {
204            side: Side::B,
205            input: i,
206            fault,
207        })?;
208        compare_outputs(i, &oa, &ob)?;
209    }
210    Ok(())
211}
212
213/// ★ THE UNION OF THE KEYS, NOT ONE SIDE'S. A rewrite that quietly stops producing an output would pass a
214/// comparison that only walks `a`'s tensors, which is the one failure mode a validator must never have.
215///
216/// The union is never EMPTY, and that is an invariant of the IR rather than of this function:
217/// `SpatialProgram::validate` refuses a program without an output tensor, and `Eval::finish` emits one
218/// entry per output-role tensor or faults with `OutputTileNeverStored`. So there is no vacuous
219/// "both sides produced nothing, therefore they agree" path to guard here — if that ever becomes
220/// reachable it is `validate` that regressed, and this comment is the place the assumption is recorded.
221fn compare_outputs(input: usize, a: &Outputs, b: &Outputs) -> Result<(), Divergence> {
222    let mut ids: Vec<TensorId> = a.tensors.keys().copied().collect();
223    for id in b.tensors.keys() {
224        if !a.tensors.contains_key(id) {
225            ids.push(*id);
226        }
227    }
228    ids.sort();
229    for id in ids {
230        match (a.tensors.get(&id), b.tensors.get(&id)) {
231            (Some(va), Some(vb)) => compare_value(input, id, va, vb)?,
232            (None, Some(_)) => {
233                return Err(Divergence::MissingTensor {
234                    side: Side::A,
235                    input,
236                    tensor: id,
237                })
238            }
239            (Some(_), None) => {
240                return Err(Divergence::MissingTensor {
241                    side: Side::B,
242                    input,
243                    tensor: id,
244                })
245            }
246            (None, None) => unreachable!("an id came from one of the two maps"),
247        }
248    }
249    Ok(())
250}
251
252fn kind_of(v: &Value) -> &'static str {
253    match v {
254        Value::Int(_) => "Int",
255        Value::Float(_) => "Float",
256    }
257}
258
259fn len_of(v: &Value) -> usize {
260    match v {
261        Value::Int(x) => x.len(),
262        Value::Float(x) => x.len(),
263    }
264}
265
266/// The relative tolerance a `NumericsClaim::Approx` program's rewrite is discharged against.
267///
268/// ★ WHY EXACTNESS IS THE WRONG QUESTION FOR SOME REWRITES. Fusing two stages removes a round trip:
269/// the inner result stays in the destination registers instead of being packed to a buffer and
270/// unpacked again. That is a REAL change to the arithmetic — it rounds LESS — so a fused kernel and
271/// its unfused source differ in the last bits BY CONSTRUCTION. Demanding `==` would forbid the
272/// optimization; waving it through unchecked would let a genuinely wrong rewrite hide behind "floating
273/// point". So the program's own declared claim decides which question is asked: `Exact` uses
274/// [`equivalent_under`], `Approx` uses [`equivalent_within`].
275///
276/// This bound is ~1e-6 relative — about eight fp32 ulps, and four orders of magnitude tighter than
277/// the bf16 ulp (~4e-3) a wrong answer would have to hide inside.
278pub const APPROX_REL_TOL: f64 = 1e-6;
279
280/// [`equivalent_under`], but comparing floats within `rel` RELATIVE tolerance (falling back to `rel`
281/// as an absolute bound near zero, so a divergence cannot pass merely because both sides are small).
282/// Integers are still compared exactly — an approximate claim is about a float datapath, never about
283/// an integer one.
284pub fn equivalent_within(
285    a: &SpatialProgram,
286    b: &SpatialProgram,
287    inputs: &[Inputs],
288    rel: f64,
289) -> Result<(), Divergence> {
290    APPROX.with(|t| t.set(Some(rel)));
291    let r = equivalent_under(a, b, inputs);
292    APPROX.with(|t| t.set(None));
293    r
294}
295
296thread_local! {
297    /// Set only for the duration of an [`equivalent_within`] call. A thread-local keeps the tolerance
298    /// out of the exact path's signature entirely, so `equivalent_under` cannot accidentally acquire
299    /// a tolerance it was never asked for.
300    static APPROX: std::cell::Cell<Option<f64>> = const { std::cell::Cell::new(None) };
301}
302
303/// Whether two floats agree under the tolerance currently in force (exact when none is).
304fn floats_agree(p: f64, q: f64) -> bool {
305    match APPROX.with(|t| t.get()) {
306        None => p == q,
307        Some(rel) => (p - q).abs() <= rel * p.abs().max(q.abs()).max(1.0),
308    }
309}
310
311fn compare_value(input: usize, tensor: TensorId, a: &Value, b: &Value) -> Result<(), Divergence> {
312    let mismatch = || Divergence::Representation {
313        input,
314        tensor,
315        a: kind_of(a),
316        b: kind_of(b),
317        a_len: len_of(a),
318        b_len: len_of(b),
319    };
320    match (a, b) {
321        (Value::Int(x), Value::Int(y)) => {
322            if x.len() != y.len() {
323                return Err(mismatch());
324            }
325            match x.iter().zip(y.iter()).position(|(p, q)| p != q) {
326                None => Ok(()),
327                Some(elem) => Err(Divergence::Element {
328                    input,
329                    tensor,
330                    elem,
331                    a: Elem::Int(x[elem]),
332                    b: Elem::Int(y[elem]),
333                    differing: x.iter().zip(y.iter()).filter(|(p, q)| p != q).count(),
334                    total: x.len(),
335                }),
336            }
337        }
338        (Value::Float(x), Value::Float(y)) => {
339            if x.len() != y.len() {
340                return Err(mismatch());
341            }
342            // Exact unless an `equivalent_within` call is in force — see `floats_agree`.
343            match x
344                .iter()
345                .zip(y.iter())
346                .position(|(p, q)| !floats_agree(*p, *q))
347            {
348                None => Ok(()),
349                Some(elem) => Err(Divergence::Element {
350                    input,
351                    tensor,
352                    elem,
353                    a: Elem::Float(x[elem]),
354                    b: Elem::Float(y[elem]),
355                    differing: x
356                        .iter()
357                        .zip(y.iter())
358                        .filter(|(p, q)| !floats_agree(**p, **q))
359                        .count(),
360                    total: x.len(),
361                }),
362            }
363        }
364        _ => Err(mismatch()),
365    }
366}
367
368// ── A seeded corpus ────────────────────────────────────────────────────────────────────────────────
369
370/// The magnitude a corpus element is capped at.
371///
372/// ★ 128 BECAUSE BF16 IS THE NARROWEST OPERAND PRECISION THE TARGETS USE, and bf16 holds every integer up
373/// to 256 exactly. A corpus inside that window can never manufacture an operand rounding difference the
374/// workload did not already have, so a divergence it finds is a divergence of the REWRITE rather than of
375/// the datapath. The caller keeps the other half of that bargain by not asking for a reduction so long
376/// that the fp32 accumulator itself starts rounding (`K · 128²  <  2²⁴`).
377pub const CORPUS_MAGNITUDE_CAP: i64 = 128;
378
379/// The window used for a tensor that declares neither a range nor a bounded fill.
380pub const DEFAULT_ABS_BOUND: i64 = 8;
381
382/// The repo's deterministic hash PRNG — the same mixing `crates/logicaffeine_tests/tests/common/spatial.rs`
383/// uses, restated here because a corpus builder that lives in the compiler cannot reach into the test tree.
384/// Same seed, same index, same window ⇒ same value, on every machine and every run.
385///
386/// ★ TOTAL, AND THE WINDOW IS ORDERED BEFORE IT IS USED. A caller who hands the ends crossed gets a value
387/// from the window those ends span, never one outside it: the whole force of a corpus is the promise that
388/// every element it produces is inside the range the program declared and inside
389/// [`CORPUS_MAGNITUDE_CAP`], and a builder that can quietly break that promise turns "the rewrite
390/// diverged" into "the datapath rounded" with nothing in the report to tell them apart.
391pub fn corpus_prng(seed: u64, i: usize, lo: i64, hi: i64) -> i64 {
392    let (lo, hi) = (lo.min(hi), lo.max(hi));
393    if lo == hi {
394        return lo;
395    }
396    let mut x = seed
397        .wrapping_mul(6364136223846793005)
398        .wrapping_add(i as u64 + 1);
399    x ^= x >> 33;
400    x = x.wrapping_mul(0xff51afd7ed558ccd);
401    x ^= x >> 33;
402    let span = (hi as i128 - lo as i128 + 1) as u128;
403    (lo as i128 + (x as u128 % span) as i128) as i64
404}
405
406/// The window a tensor's corpus data is drawn from: its DECLARED range where it has one (that declaration
407/// is what the numerics claim was derived from, so leaving it is leaving the program's premises), else the
408/// bound its recorded fill implies, else [`DEFAULT_ABS_BOUND`] — capped either way.
409///
410/// ★ THE CAP WINS, AND IT CLAMPS THE TWO ENDS TOGETHER. A declared range lying wholly outside
411/// ±[`CORPUS_MAGNITUDE_CAP`] (weights in `[500, 1000]`, say) must still come back as an ORDERED window:
412/// clamping each end on its own returns `lo > hi`, and a "window" whose ends have crossed is exactly how a
413/// corpus builder emits the value it promised it never would. Clamping and then re-ordering keeps the
414/// guarantee the cap exists for — every element exactly representable in the narrowest operand precision —
415/// at the price of a window narrower than the declaration asked for. That is the trade
416/// [`CORPUS_MAGNITUDE_CAP`] makes on purpose: a corpus outside the exactness window cannot tell a diverged
417/// rewrite from a rounded datapath, so it is worth less than a narrow one.
418fn corpus_range(t: &TensorDecl) -> (i64, i64) {
419    let (lo, hi) = match t.range {
420        Some((lo, hi)) if hi > lo => (lo, hi),
421        _ => match t.init.abs_bound() {
422            Some(b) if b > 0 => (-b, b),
423            _ => (-DEFAULT_ABS_BOUND, DEFAULT_ABS_BOUND),
424        },
425    };
426    let (lo, hi) = (
427        lo.clamp(-CORPUS_MAGNITUDE_CAP, CORPUS_MAGNITUDE_CAP),
428        hi.clamp(-CORPUS_MAGNITUDE_CAP, CORPUS_MAGNITUDE_CAP),
429    );
430    (lo.min(hi), lo.max(hi))
431}
432
433/// A `Param` the compiler PLANTED, materialized from its own recorded fill — `Some` exactly when every
434/// element of that fill reproduces.
435///
436/// ★ A `Param` WHOSE FILL REPRODUCES IS PART OF THE PROGRAM, NOT ITS DATA. The rmsnorm chain's
437/// `rms_scaler` is literally `1/cols`, a constant the compiler planted so the reduction yields a mean;
438/// overwriting it with corpus noise would compare two programs on data neither of them was built for, and
439/// would silently break every rewrite that reads it. A `Param` the compiler left `External` has no
440/// recorded value, so it is data like any other and must be supplied.
441///
442/// ★ THE TEST AND THE VALUE ARE ONE FUNCTION ON PURPOSE. Asking "does this fill reproduce?" in one place
443/// and then rebuilding it in another invites the two to drift, and the only way to paper over the drift is
444/// an `unwrap_or` — a corpus element silently replaced by zero, which reads downstream as a rewrite that
445/// dropped a term. Returning the value IS the predicate, so there is nothing to keep in step.
446fn planted_constant(t: &TensorDecl) -> Option<Value> {
447    if t.role != TensorRole::Param {
448        return None;
449    }
450    let n = t.elems.rows * t.elems.cols;
451    if t.init.is_float() {
452        (0..n)
453            .map(|i| t.init.element_f64(i))
454            .collect::<Option<Vec<f64>>>()
455            .map(Value::Float)
456    } else {
457        (0..n)
458            .map(|i| t.init.element(i))
459            .collect::<Option<Vec<i64>>>()
460            .map(Value::Int)
461    }
462}
463
464/// One corpus point for `p`: every tensor the evaluator needs, keyed by [`TensorId`].
465///
466/// ★ KEYED BY ID, WHICH IS WHAT MAKES IT A DIFFERENTIAL. Two programs over the same workload agree on
467/// tensor ids, so one corpus feeds both and each side genuinely sees the same numbers. A tensor one side
468/// does not have (the chain's scaler, say) is simply never looked up.
469///
470/// Values are always INTEGRAL — `Value::Int` on an integer datapath, whole numbers as `Value::Float` on a
471/// float one. That is deliberate: see [`CORPUS_MAGNITUDE_CAP`].
472///
473/// ★ WHAT THIS CORPUS DELIBERATELY HOLDS STILL, stated so a caller is never surprised by it: a `Param`
474/// whose recorded fill reproduces is reproduced EXACTLY, identically at every seed (see
475/// `planted_constant`). For a compiler-planted constant that is the only correct thing to do; for a
476/// weight tensor the recognizer happened to record a fill for, it means the corpus explores the activations
477/// and not the weights. The differential stays SOUND either way — both sides still see the same numbers —
478/// but it is narrower, and a caller who wants that tensor varied must build the `Inputs` itself and supply
479/// it rather than expecting this function to guess which `Param`s are data.
480pub fn seeded_inputs(p: &SpatialProgram, seed: u64) -> Inputs {
481    let mut inputs = Inputs::default();
482    for t in &p.tensors {
483        if t.role == TensorRole::Output {
484            continue;
485        }
486        let n = t.elems.rows * t.elems.cols;
487        if let Some(planted) = planted_constant(t) {
488            inputs.tensors.insert(t.id, planted);
489            continue;
490        }
491        let (lo, hi) = corpus_range(t);
492        // Mixing the tensor id into the seed keeps two operands of one program from being handed the same
493        // stream (a matmul against its own transpose is a poor test), while keeping the SAME tensor of two
494        // programs on the same stream, which is the whole point.
495        let s = seed ^ (t.id.0 as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15);
496        if precision_is_int(t.dtype) {
497            inputs.tensors.insert(
498                t.id,
499                Value::Int((0..n).map(|i| corpus_prng(s, i, lo, hi)).collect()),
500            );
501        } else {
502            inputs.tensors.insert(
503                t.id,
504                Value::Float((0..n).map(|i| corpus_prng(s, i, lo, hi) as f64).collect()),
505            );
506        }
507    }
508    inputs
509}
510
511/// A corpus: one [`seeded_inputs`] per seed, in the order given.
512pub fn seeded_corpus(p: &SpatialProgram, seeds: &[u64]) -> Vec<Inputs> {
513    seeds.iter().map(|s| seeded_inputs(p, *s)).collect()
514}