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}