fuel_tensor/judge/mod.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! The Judge — Phase 6b's empirical profiler.
3//!
4//! For every `(backend, device)` pair the probe report knows about,
5//! the Judge walks a matrix of `(op_kind, dtype, size_class)` and
6//! measures two things: wall-clock latency and numerical precision
7//! relative to the reference backend. Output is a persistable
8//! [`ProfileReport`] that the (future) ranked dispatch table indexes
9//! at realize time.
10//!
11//! ## Submodules
12//!
13//! - [`cache`] — process-wide [`DispatchTable`] cache. Hosts
14//! `cached()`, `populate_dispatch_table()`, `invalidate()`. Was
15//! `fuel_core::dispatch` before the 2026-05-31 dispatch-move; the
16//! name was a misnomer (it caches the Judge's output, not the
17//! binding-table dispatch path), so it now lives under the Judge
18//! module that owns its lifecycle. The cache surface is re-exported
19//! at this module's top level so callers can write
20//! `fuel_tensor::judge::cached()` directly.
21//!
22//! # Scope of this revision
23//!
24//! Ships the types, the runner skeleton, and per-family probe
25//! coverage spanning the inference op surface in F32:
26//!
27//! - **Ops** — the canonical list, currently:
28//! - dense linear algebra: [`OpKind::MatMul`]
29//! - elementwise binary: Add / Sub / Mul / Div / Maximum / Minimum
30//! - elementwise unary: Neg / Sqr / Sqrt / Exp / Log / Sin / Cos /
31//! Tanh / Sigmoid / Silu / Gelu / Relu / Step
32//! - per-axis reductions: SumReduce / MaxReduce / MinReduce /
33//! MeanReduce (probed as last-dim reduction over `[rows, 64]`)
34//! - reduce-to-broadcast-target: ReduceSumTo / ReduceMaxTo
35//! - parametric one-input: Affine / Clamp / PowI
36//! - 28 OpKind variants total, 84 (op, size) cells per backend
37//! class.
38//! - **Dtypes**: `{f32, f16, bf16}`. The
39//! dispatch key always carried a `dtype` axis
40//! ([`ProfileEntry::dtype`]); the 2026-07-04 dtype slice made the
41//! *runner* iterate it rather than hard-coding f32. Each profiled
42//! op/size is now measured once per dtype, so the report carries
43//! `3 ×` the cells it did in the f32-only era. Inputs are generated
44//! as deterministic f32 and down-converted to the cell's dtype at
45//! graph-build time; the realizer times the native-dtype kernel and
46//! reads the output back at its native width, converting to f32
47//! only for the cross-backend correctness verdict
48//! ([`crate::factories::Realizer::realize_capture_f32`]). A cell
49//! whose backend has no kernel for the requested dtype is skipped
50//! cleanly (logged), never fatal — CPU currently carries
51//! f16/bf16 kernels for the elementwise/matmul/reduction families
52//! (FKC `[F32,F64,BF16,F16]` fan-out). f64 stays OUT of the matrix:
53//! decode/inference is f16/bf16, and f64's only consumer is
54//! correctness reference paths that don't route through the ranked
55//! dispatch table.
56//! - **Backends**: every backend in the [`crate::factories`] registry.
57//! A backend that doesn't implement an op is skipped per cell with
58//! a stderr note (the realize call is wrapped in `catch_unwind`),
59//! not fatal to the run — what makes the Judge safe to expand
60//! ahead of every backend's coverage.
61//!
62//! Op kinds *not yet profiled* (build_input_graph would `panic!` if
63//! the size_plan returned a non-empty ladder for them; the size_plan's
64//! catch-all `_ => Vec::new()` arm silently skips):
65//!
66//! - composition / fused ops: SoftmaxLastDim / RmsNormLastDim /
67//! LayerNormLastDim / Rope / FlashAttn / PagedAttn / FusedLinear.
68//! These are slated for Phase 7.6 fused-op profiling (per ROADMAP),
69//! not the primitive-OpKind sweep this module covers.
70//! - shape-rearranging: Cast / IndexSelect / Gather / Concat /
71//! IndexAdd / ScatterAdd / ArgMaxDim / ArgMinDim.
72//! - convolutions: Conv2D / ConvTranspose2D.
73//! - quantized: QMatMul.
74//!
75//! Each of those is a separable add: extend `PROFILED_OPS`, add a
76//! `size_plan` arm with a representative size ladder, add a
77//! `build_input_graph` arm that constructs the input graph.
78//!
79//! # Decode-shaped ladders (Layer-2 coverage arc, slice 2)
80//!
81//! Alongside the square/general ladder, `Judge::size_plan` appends
82//! **decode-representative** cells on the decode-attention path so the
83//! Judge measures the ops at the SKINNY shapes autoregressive decode
84//! (seq_q=1) actually runs:
85//!
86//! - **MatMul** — a `[1,H]×[H,H]` hidden/attention projection GEMV
87//! (bandwidth-bound), distinct in cost regime from the square
88//! (compute-bound) cells.
89//! - **MaxReduce / SumReduce** — the row-max and denominator-sum of the
90//! decomposed softmax, reducing a wide `[Hq, k_len]` score row.
91//! - **Sub / Exp / Div** — the elementwise softmax components over the
92//! `[Hq, k_len]` score tensor.
93//!
94//! These feed slice-4's flash-vs-decomposed ranking: arm-0 (the
95//! decomposed attention region) is costed from the MEASURED decode
96//! latencies of its primitives, so the CUDA flash arm only wins on a
97//! real comparison.
98//!
99//! ## Decode ladder — SizeClass aspect key (reconciled, v4)
100//!
101//! For **non-matmul** ops [`SizeClass`] is still `log2(total_elements)`,
102//! so decode reduction/elementwise cells are placed in buckets that are
103//! FREE within their op family (reduce/elementwise sc12 + sc17) — a
104//! collision would let the oracle keep the MIN latency across the shared
105//! key and poison the cell.
106//!
107//! For **matmul** the aspect-blindness that once blocked the non-square
108//! decode cells is FIXED (SizeClass v4, 2026-07-04). The Judge keyed a
109//! matmul cell on `total_elements = m*n` (output) while the ranker's
110//! realize-time lookup ([`fuel_dispatch::ranker::compute_static_costs`])
111//! keyed on `shapes[0].elem_count() = m*k` (the LHS input) — so every
112//! *non-square* matmul lookup missed the profiled cell (square matmuls
113//! agreed by accident, `m*k == m*n`). Both sides now derive the key from
114//! the operand shapes through the shared [`SizeClass::matmul`] /
115//! [`SizeClass::for_op`] helper — packing `(log2(m·n), log2(m),
116//! log2(k))` — so the same shape maps to one identical key on producer
117//! and consumer, and a GEMV never collides with a same-output-size
118//! square (their `log2(m)` bytes differ). The previously DEFERRED decode
119//! matmuls — the FFN-width GEMV (`[1,2048]×[2048,5632]`), the QKᵀ score
120//! GEMV, the attention-output GEMV — are un-deferred into `size_plan`
121//! now that they key correctly. This bumped `PROFILE_REPORT_VERSION`
122//! v3 → v4 (fuel-ir + fuel-core + fuel-dispatch reconciliation).
123//!
124//! # Equivalence-class discipline
125//!
126//! The Judge profiles one device per equivalence class. A rig with
127//! four identical RTX 4090s runs one CUDA profile and the dispatch
128//! table shares the result across all four ordinals.
129//!
130//! # Why latency *and* precision
131//!
132//! Two ops with identical latency can produce different answers — a
133//! fast backend that drifts 1e-3 rel vs reference is fine for most
134//! ML workloads but catastrophic for a correctness-critical path
135//! like a cross-device equivalence test. Carrying both axes lets a
136//! dispatch table serve both "fastest" and "most accurate"
137//! criteria without re-profiling.
138
139pub mod cache;
140pub use cache::*;
141// `oracle` moved to `fuel_dispatch::ranker::profile_oracle` (fuel-core
142// dissolution, plan item 8) — it had zero coupling to this module's
143// `Tensor`/`Device`-constructing profiling code, only to `fuel-dispatch`'s
144// own `ranker::judge` types. Re-exported here so `crate::judge::
145// ProfileJudgeOracle` / `fuel_tensor::judge::ProfileJudgeOracle` callers
146// (this module's own tests, `cache.rs`) are unchanged.
147pub use fuel_dispatch::ranker::profile_oracle::ProfileJudgeOracle;
148
149use fuel_correctness_fixtures::{
150 CorrectnessDrift, CorrectnessFixture, FIXTURE_FILE_VERSION, FixtureFile,
151 validate_against_fixture,
152};
153use fuel_hardware::probe::ProbeReport;
154use fuel_ir::probe::{BackendId, DeviceDescriptor};
155use fuel_ir::{DType, Result, Shape};
156use std::collections::HashMap;
157use std::panic::AssertUnwindSafe;
158use std::path::Path;
159use std::sync::{Arc, RwLock};
160use std::time::Instant;
161
162// Re-export the dispatch types (moved to fuel-core-types so
163// `fuel-graph-router`'s Router can consume them without depending
164// on `fuel-core`). All existing callers `use fuel_tensor::judge::*`
165// keep working — `ProfileReport::save` / `ProfileReport::load` are
166// inherent methods on the moved type.
167pub use fuel_ir::dispatch::{
168 OpKind, PROFILE_REPORT_VERSION, ProfileEntry, ProfileReport, SizeClass,
169};
170
171// `PROFILE_REPORT_FILENAME` + `default_report_path` moved to
172// `fuel_dispatch::judge_report_path` (fuel-core dissolution, plan item 8) —
173// pure path-joining logic, zero coupling to this module's `Tensor`/`Device`
174// profiling code. Re-exported below so `crate::judge::PROFILE_REPORT_FILENAME`
175// / `crate::judge::default_report_path` callers (`cache.rs`, `scheduling.rs`,
176// and this module's own tests) are unchanged.
177pub use fuel_dispatch::judge_report_path::{PROFILE_REPORT_FILENAME, default_report_path};
178
179/// A synthetic device identity for fixtures, stamped with the given
180/// `backend` so `ProfileEntry::device.backend` agrees with the entry's
181/// own `backend` axis (the `from_report` `debug_assert` that keeps the
182/// co-encoding honest). Fixtures never load through the hardware gate,
183/// so the vendor/device ids are irrelevant here — only the backend is.
184#[cfg(test)]
185pub(crate) fn test_equiv_key(backend: BackendId) -> fuel_ir::probe::EquivalenceKey {
186 fuel_ir::probe::EquivalenceKey {
187 backend,
188 vendor_id: 0,
189 device_id: 0,
190 compute_capability: None,
191 driver_version: String::new(),
192 }
193}
194
195/// Op kinds the Judge currently profiles. Each entry must have a
196/// matching `size_plan` arm (returning a non-empty ladder) and a
197/// matching `build_input_graph` arm. New families land here as they
198/// ship; the order here is the order entries appear in the persisted
199/// report (deterministic for diff-friendly output).
200const PROFILED_OPS: &[OpKind] = &[
201 OpKind::MatMul,
202 OpKind::AddElementwise,
203 // --- elementwise unary fanout ---
204 OpKind::NegElementwise,
205 OpKind::SqrElementwise,
206 OpKind::SqrtElementwise,
207 OpKind::ExpElementwise,
208 OpKind::LogElementwise,
209 OpKind::SinElementwise,
210 OpKind::CosElementwise,
211 OpKind::TanhElementwise,
212 OpKind::SigmoidElementwise,
213 OpKind::SiluElementwise,
214 OpKind::GeluTanhElementwise,
215 OpKind::ReluElementwise,
216 OpKind::StepElementwise,
217 OpKind::RecipElementwise,
218 OpKind::AbsElementwise,
219 OpKind::FloorElementwise,
220 OpKind::CeilElementwise,
221 OpKind::RoundElementwise,
222 OpKind::SignElementwise,
223 OpKind::ErfElementwise,
224 OpKind::GeluElementwise,
225 OpKind::RsqrtElementwise,
226 // --- elementwise binary additions ---
227 OpKind::PowElementwise,
228 OpKind::RemElementwise,
229 // --- elementwise binary fanout ---
230 OpKind::SubElementwise,
231 OpKind::MulElementwise,
232 OpKind::DivElementwise,
233 OpKind::MaximumElementwise,
234 OpKind::MinimumElementwise,
235 // --- reductions along one dim ---
236 OpKind::SumReduce,
237 OpKind::MaxReduce,
238 OpKind::MinReduce,
239 OpKind::MeanReduce,
240 // --- reduce-to-broadcast-target ---
241 OpKind::ReduceSumTo,
242 OpKind::ReduceMaxTo,
243 // --- scalar / clamp / powi ---
244 OpKind::Affine,
245 OpKind::ClampElementwise,
246 OpKind::PowIElementwise,
247 // --- fused decode attention (arm-1 of slice-4's flash-vs-decomposed
248 // ranking; the decomposed arm-0 primitives are already covered at
249 // decode shapes by the elementwise/reduction/matmul families above) ---
250 OpKind::FlashAttn,
251];
252
253/// Dtypes the Judge profiles for every op in [`PROFILED_OPS`]. The
254/// dispatch key ([`ProfileEntry::dtype`]) always carried this axis;
255/// this list is what the *runner* iterates (2026-07-04 dtype slice).
256///
257/// - **F32** — the correctness/reference baseline and the widest
258/// kernel coverage.
259/// - **F16 / BF16** — the decode/inference dtypes. Flash-attention,
260/// skinny GQA matmuls, and the elementwise glue in a transformer
261/// forward all run in half precision, so the ranked dispatch table
262/// needs their measured per-dtype/per-backend latencies (the coarse
263/// Layer-1 model can't tell an f16 kernel from an f32 one).
264///
265/// **F64 is intentionally excluded**: it has no inference consumer and
266/// its only routing path is the correctness-reference backend, which
267/// the ranked table never picks. Adding it would triple-count cold
268/// cells for no ranking benefit. A cell whose backend lacks a kernel
269/// for one of these dtypes is skipped cleanly, so listing a dtype here
270/// is safe even on backends with partial coverage.
271const PROFILED_DTYPES: &[DType] = &[DType::F32, DType::F16, DType::BF16];
272
273// =============================================================
274// Decode-shaped size-ladder constants (Layer-2 coverage arc,
275// slice 2, 2026-07-04)
276// =============================================================
277//
278// The general ladders are square/large — a 1024×1024×1024 matmul,
279// a 2²⁰-element reduction. Decode (seq_q=1 autoregressive) runs
280// SKINNY shapes that live in a different cost regime: a
281// `[1,K]×[K,N]` GEMV is bandwidth-bound, not compute-bound, and the
282// decomposed decode-attention softmax reduces/maps over a `[Hq,
283// k_len]` score row. These constants shape the decode-representative
284// cells the Judge appends so it measures the ops at the shapes decode
285// actually runs (slice-4's flash-vs-decomposed comparison ranks on
286// arm-0's MEASURED decode primitives, not extrapolated square costs).
287//
288// **SizeClass discipline.** For non-matmul ops [`SizeClass`] is
289// `log2(total_elements)`, aspect-blind — so each decode reduction/
290// elementwise cell below lands in a bucket that is FREE within its op
291// family (no collision with an existing ladder cell — the oracle keeps
292// the MIN latency across a colliding key, which would poison the cell).
293// For matmul the v4 aspect key `matmul(m,n,k)` is derived identically
294// on the producer (Judge) and consumer (ranker) sides, so the non-square
295// FFN/QKᵀ/attn-output GEMVs are un-deferred (no longer square-consistency
296// constrained). See the module-level "Decode ladder — SizeClass aspect
297// key" note.
298
299/// TinyLlama-class decode attention head count (Hq). The decomposed
300/// decode softmax runs one score row per query head at seq_q=1, so the
301/// decode reduction/elementwise cells are shaped `[DECODE_HEADS,
302/// k_len]`.
303const DECODE_HEADS: usize = 32;
304
305/// Small decode key-length (early decode / short context). Wide enough
306/// (≥128) to be a genuine row-reduction, distinct from the cols=64
307/// general reduction ladder.
308const DECODE_KLEN_SMALL: usize = 128;
309
310/// Capacity-ish decode key-length (long context / near KV-cache cap) —
311/// the bandwidth-bound end of the decode reduction regime.
312const DECODE_KLEN_CAP: usize = 4096;
313
314/// Hidden dim of the decode hidden/attention projection GEMV
315/// (`[1,H]×[H,H]`). With the v4 aspect [`SizeClass`] this cell keys as
316/// `matmul(1, H, H)` — distinct from every square cell by the `log2(m)`
317/// byte (`m=1` vs `m=n`). (Pre-v4 this constant was pinned to keep the
318/// old scalar `m·n == m·k` keys in agreement; the aspect key removes
319/// that constraint.)
320const DECODE_HIDDEN: usize = 2048;
321
322/// TinyLlama-class attention head dim (D). The decode QKᵀ score GEMV
323/// contracts over D (`[1,D]×[D,k_len]`), the attention-output GEMV
324/// contracts over k_len back down to D (`[1,k_len]×[k_len,D]`).
325const DECODE_HEAD_DIM: usize = 64;
326
327/// TinyLlama-class FFN intermediate width. The decode up-projection is a
328/// wide GEMV `[1,H]×[H,FFN]` (H=2048 → FFN=5632). Under the *old* scalar
329/// key its output `m·n = 5632` bucketed to sc12 — colliding with the
330/// 64³ square (`m·n = 4096` → sc12) — which is exactly why slice 2
331/// DEFERRED it. The v4 aspect key `matmul(1, 5632, 2048)` no longer
332/// collides (the `log2(m)=0` byte separates it from the square's
333/// `log2(m)=6`), so it is un-deferred here.
334const DECODE_FFN: usize = 5632;
335
336/// Element count of the `[Hq, k_len]` softmax score tensor at the small
337/// decode key-length (`Hq * k_len` = 4096 → sc12, free in the
338/// elementwise family whose ladder occupies sc {10,16,20}).
339const DECODE_SCORE_SMALL_ELEMS: usize = DECODE_HEADS * DECODE_KLEN_SMALL;
340
341/// Element count of the `[Hq, k_len]` softmax score tensor at the
342/// capacity decode key-length (`Hq * k_len` = 131072 → sc17, free).
343const DECODE_SCORE_CAP_ELEMS: usize = DECODE_HEADS * DECODE_KLEN_CAP;
344
345/// TinyLlama-class decode KV head count (Hkv). GQA folds the 32 query
346/// heads onto 4 KV heads (`Hq / Hkv = 8`). Shapes the fused FlashAttn
347/// op's `k`/`v` operands `[b, DECODE_KV_HEADS, k_len, d]` — the arm-1
348/// half of slice-4's flash-vs-decomposed comparison (arm-0's decomposed
349/// QKᵀ/softmax/PV primitives are already covered by slices 2/2.5 at the
350/// same decode shapes).
351const DECODE_KV_HEADS: usize = 4;
352
353/// How many measurement iterations per (op, dtype, size, backend)
354/// cell. Median of this many runs is recorded.
355pub const DEFAULT_ITERATIONS: u32 = 7;
356
357/// How many warmup iterations before the timed run. Discards cold-
358/// cache and JIT-compile latency; matters most on GPU backends
359/// where the first launch includes kernel-cache population.
360pub const DEFAULT_WARMUP: u32 = 3;
361
362/// The runner. Consumes a [`ProbeReport`] (so it knows which devices
363/// exist and which equivalence classes to share across), builds the
364/// full measurement matrix, and produces a [`ProfileReport`].
365pub struct Judge {
366 pub iterations: u32,
367 pub warmup: u32,
368 /// Optional shrunk size ladder. `None` = use the full default
369 /// ladder: three square/general sizes per op (up to 1024×1024
370 /// matmul / 2²⁰ elementwise), PLUS decode-shaped cells on the
371 /// decode-attention path (seq_q=1 matmul GEMV; wide-`k_len`
372 /// MaxReduce/SumReduce; decode-score-row Sub/Exp/Div). Tests supply
373 /// a shrunk ladder to stay fast.
374 pub size_plan_override: Option<Vec<(OpKind, OpSize)>>,
375 /// Optional pre-validated correctness fixtures keyed by
376 /// `(op, dtype, size_class)`. When a fixture covers a profiling
377 /// cell, the Judge validates every CellRun's output against it
378 /// via [`validate_against_fixture`] and assigns each
379 /// `ProfileEntry::max_rel_error` from the verdict, bypassing the
380 /// inline pairwise-consensus path for that cell. Cells without a
381 /// matching fixture fall back to the consensus path (today's
382 /// default behavior on multi-backend systems).
383 ///
384 /// One key may map to multiple fixtures when capture used
385 /// different `input_seed`s; the Judge applies the first whose
386 /// fixture-shape and the cell's regenerated input agree. If no
387 /// fixture matches the cell's input seed (mismatch logged), the
388 /// fallback consensus path is used for the cell.
389 pub fixtures: Option<HashMap<(OpKind, DType, SizeClass), Vec<CorrectnessFixture>>>,
390}
391
392impl Default for Judge {
393 fn default() -> Self {
394 Self {
395 iterations: DEFAULT_ITERATIONS,
396 warmup: DEFAULT_WARMUP,
397 size_plan_override: None,
398 fixtures: None,
399 }
400 }
401}
402
403impl Judge {
404 /// Construct a Judge with the default measurement parameters and
405 /// the fixture set loaded from `path`. The path may point at a
406 /// single `*.json` file or a directory; in the directory case
407 /// every `*.json` under the root is loaded recursively (the
408 /// `fuel-correctness-fixtures` capture binary writes
409 /// `v1/<dtype>/<op>.json`, so a single root covers an entire
410 /// fixture distribution).
411 ///
412 /// Returns an `io::Error` wrapped as a `fuel-core` `Error` if the
413 /// path can't be read or any file fails to parse as a
414 /// [`FixtureFile`].
415 ///
416 /// **Use**: pass a fixture root once at Judge construction time;
417 /// every cell the Judge profiles thereafter validates against the
418 /// fixture (if one exists for that cell) instead of inline
419 /// consensus. Cells with no matching fixture fall back to the
420 /// consensus path.
421 pub fn with_fixtures_from(path: &Path) -> Result<Self> {
422 let fixtures = load_fixtures_recursive(path)?;
423 Ok(Self {
424 fixtures: Some(fixtures),
425 ..Self::default()
426 })
427 }
428
429 /// Look up a fixture matching `(op, dtype, size_class)` and the
430 /// expected element count of the cell's output. Returns `None`
431 /// when no fixture set is configured, when no fixture is keyed
432 /// for the cell, when no fixture matches the cell's output
433 /// shape, when the fixture's `input_seed` doesn't match the
434 /// Judge-derived per-cell seed, or when the fixture's
435 /// `input_hash` doesn't match the regenerated input bytes.
436 ///
437 /// **Triple-agreement contract (2026-06-08)**: a fixture is
438 /// accepted iff
439 ///
440 /// 1. `(op, dtype, size_class)` matches the bucket key, AND
441 /// 2. `output_element_count` matches the cell's output shape, AND
442 /// 3. `input_seed` matches
443 /// [`fuel_correctness_fixtures::capture::derive_seed`]'s
444 /// output for this cell, AND
445 /// 4. `input_hash` matches
446 /// [`fuel_correctness_fixtures::capture::hash_f32_input`]
447 /// applied to the regenerated input via
448 /// [`fuel_correctness_fixtures::capture::deterministic_f32_input`].
449 ///
450 /// Failure of any check is logged and the cell falls back to
451 /// inline pairwise consensus (the caller's
452 /// `compute_pairwise_consensus` path runs).
453 ///
454 /// `input_elem_count` is the number of f32 elements the capture
455 /// tool's `deterministic_f32_input` would emit for this cell —
456 /// the caller derives it from the live `OpSize` via
457 /// [`OpSize::input_elements`]. It is decoupled from
458 /// `output_element_count` because binary ops carry `[a, b]`
459 /// concatenated (input = 2 × N) while their output is a single
460 /// `[n]` buffer.
461 fn lookup_fixture(
462 &self,
463 op: OpKind,
464 dtype: DType,
465 size_class: SizeClass,
466 output_elem_count: usize,
467 input_elem_count: usize,
468 ) -> Option<&CorrectnessFixture> {
469 let map = self.fixtures.as_ref()?;
470 let bucket = map.get(&(op, dtype, size_class))?;
471
472 // Derive the per-cell seed once — capture's `derive_seed`
473 // is a pure function of (op, dtype, size_class).
474 let expected_seed = fuel_correctness_fixtures::capture::derive_seed(op, dtype, size_class);
475
476 // Lazily regenerate and hash the input only when a fixture's
477 // shape + seed agree (the hash is the expensive check).
478 let mut regenerated_hash: Option<u64> = None;
479 let mut shape_matches = false;
480 for f in bucket {
481 if f.output_element_count != output_elem_count {
482 continue;
483 }
484 shape_matches = true;
485 if f.input_seed != expected_seed {
486 eprintln!(
487 "judge: fixture for ({op:?}, {dtype:?}, size_class={}) seed mismatch \
488 (fixture seed={}, expected={}); falling back to consensus",
489 size_class.0, f.input_seed, expected_seed,
490 );
491 continue;
492 }
493 let actual_hash = *regenerated_hash.get_or_insert_with(|| {
494 let input = fuel_correctness_fixtures::capture::deterministic_f32_input(
495 op,
496 input_elem_count,
497 );
498 fuel_correctness_fixtures::capture::hash_f32_input(&input)
499 });
500 if f.input_hash != actual_hash {
501 eprintln!(
502 "judge: fixture input drifted for ({op:?}, {dtype:?}, \
503 size_class={}, seed={}); fixture hash={}, regenerated hash={}; \
504 falling back to consensus",
505 size_class.0, f.input_seed, f.input_hash, actual_hash,
506 );
507 continue;
508 }
509 return Some(f);
510 }
511 if !shape_matches {
512 // The bucket exists but no fixture matches the cell's shape.
513 // The capture used a different input convention; skip + fall
514 // back to consensus rather than emit a misleading rel_err.
515 eprintln!(
516 "judge: fixture bucket for ({op:?}, {dtype:?}, size_class={}) has no entry \
517 matching output_element_count={output_elem_count}; falling back to consensus",
518 size_class.0,
519 );
520 }
521 None
522 }
523
524 /// Representative size classes for the Judge's op matrix. Each
525 /// entry is (op, [input element counts]) — the Judge picks the
526 /// first entry whose op matches and iterates the sizes.
527 fn size_plan(&self, op: OpKind) -> Vec<OpSize> {
528 if let Some(over) = &self.size_plan_override {
529 return over
530 .iter()
531 .filter(|(k, _)| *k == op)
532 .map(|(_, s)| *s)
533 .collect();
534 }
535 match op {
536 OpKind::MatMul => vec![
537 // General square ladder (compute-bound regime).
538 OpSize::MatMul {
539 m: 64,
540 n: 64,
541 k: 64,
542 },
543 OpSize::MatMul {
544 m: 256,
545 n: 256,
546 k: 256,
547 },
548 OpSize::MatMul {
549 m: 1024,
550 n: 1024,
551 k: 1024,
552 },
553 // ---- Decode GEMVs (seq_q=1, bandwidth-bound). ----
554 // With the v4 aspect SizeClass every cell below keys as
555 // `matmul(m,n,k)` and the same operand dims yield the
556 // same key on the ranker's realize-time lookup, so these
557 // are found (no longer square-consistency-constrained).
558 //
559 // Hidden / attention projection `[1,H]×[H,H]`.
560 OpSize::MatMul {
561 m: 1,
562 n: DECODE_HIDDEN,
563 k: DECODE_HIDDEN,
564 },
565 // FFN up-projection `[1,H]×[H,FFN]` (wide GEMV). Un-
566 // deferred from slice 2: under the old scalar key its
567 // output `m·n=5632` collided with the 64³ square (both
568 // sc12); the aspect key `matmul(1,5632,2048)` separates
569 // them by the `log2(m)=0` byte.
570 OpSize::MatMul {
571 m: 1,
572 n: DECODE_FFN,
573 k: DECODE_HIDDEN,
574 },
575 // QKᵀ score GEMV, per query head `[1,D]×[D,k_len]` (the
576 // leading Hq batch dim doesn't affect the aspect key —
577 // `for_op` reads the trailing two dims). Contracts over
578 // the head dim D; output width is k_len.
579 OpSize::MatMul {
580 m: 1,
581 n: DECODE_KLEN_SMALL,
582 k: DECODE_HEAD_DIM,
583 },
584 // Attention-output GEMV, per head `[1,k_len]×[k_len,D]`.
585 // Contracts over k_len back down to D — the (n,k)-swapped
586 // mirror of QKᵀ, and the aspect key keeps them distinct.
587 OpSize::MatMul {
588 m: 1,
589 n: DECODE_HEAD_DIM,
590 k: DECODE_KLEN_SMALL,
591 },
592 ],
593 // Element-wise binary + unary share one ladder. Three sizes
594 // span the regime where launch overhead dominates (1 KiB),
595 // L2 still fits the working set (256 KiB), and DRAM
596 // bandwidth dominates (4 MiB).
597 OpKind::AddElementwise
598 | OpKind::SubElementwise
599 | OpKind::MulElementwise
600 | OpKind::DivElementwise
601 | OpKind::MaximumElementwise
602 | OpKind::MinimumElementwise
603 | OpKind::PowElementwise
604 | OpKind::RemElementwise
605 | OpKind::NegElementwise
606 | OpKind::SqrElementwise
607 | OpKind::SqrtElementwise
608 | OpKind::ExpElementwise
609 | OpKind::LogElementwise
610 | OpKind::SinElementwise
611 | OpKind::CosElementwise
612 | OpKind::TanhElementwise
613 | OpKind::SigmoidElementwise
614 | OpKind::SiluElementwise
615 | OpKind::GeluTanhElementwise
616 | OpKind::ReluElementwise
617 | OpKind::StepElementwise
618 | OpKind::RecipElementwise
619 | OpKind::AbsElementwise
620 | OpKind::FloorElementwise
621 | OpKind::CeilElementwise
622 | OpKind::RoundElementwise
623 | OpKind::SignElementwise
624 | OpKind::ErfElementwise
625 | OpKind::GeluElementwise
626 | OpKind::RsqrtElementwise
627 | OpKind::Affine
628 | OpKind::ClampElementwise
629 | OpKind::PowIElementwise => {
630 let mut ladder = vec![
631 OpSize::Elementwise(1 << 10),
632 OpSize::Elementwise(1 << 16),
633 OpSize::Elementwise(1 << 20),
634 ];
635 // Decode-score-row cells for the softmax elementwise
636 // components (sub/exp/div over the `[Hq, k_len]` score
637 // tensor). Only these three ops appear on the decomposed
638 // decode-attention softmax path — the rest of the
639 // fanout (sin/cos/tanh/gelu_tanh/…) keeps the general ladder.
640 // Element counts 4096 (sc12) / 131072 (sc17) are both
641 // free in this family's occupied buckets {10,16,20}.
642 if is_decode_softmax_elementwise(op) {
643 ladder.push(OpSize::Elementwise(DECODE_SCORE_SMALL_ELEMS));
644 ladder.push(OpSize::Elementwise(DECODE_SCORE_CAP_ELEMS));
645 }
646 ladder
647 }
648 // Per-axis reductions: probe last-dim reductions over a
649 // `[rows, cols]` shape with cols=64 (typical hidden-dim
650 // chunk). Total elements 1 KiB / 64 KiB / 1 MiB to align
651 // with the elementwise size ladder for cross-family
652 // size_class comparison in the dispatch table.
653 OpKind::SumReduce | OpKind::MaxReduce | OpKind::MinReduce | OpKind::MeanReduce => {
654 let mut ladder = vec![
655 OpSize::Reduce {
656 rows: 1 << 4,
657 cols: 64,
658 },
659 OpSize::Reduce {
660 rows: 1 << 10,
661 cols: 64,
662 },
663 OpSize::Reduce {
664 rows: 1 << 14,
665 cols: 64,
666 },
667 ];
668 // Decode-row cells for the softmax reductions: MaxReduce
669 // (row max) + SumReduce (denominator). The reduced dim
670 // is the last dim (cols = k_len), so these reduce a WIDE
671 // `[Hq, k_len]` row — a different regime from the
672 // narrow cols=64 general ladder. Total elements
673 // 32*128=4096 (sc12) / 32*4096=131072 (sc17) are both
674 // free in this family's occupied buckets {10,16,20}.
675 // Min/Mean are NOT on the softmax path — no decode cell.
676 if is_decode_softmax_reduction(op) {
677 ladder.push(OpSize::Reduce {
678 rows: DECODE_HEADS,
679 cols: DECODE_KLEN_SMALL,
680 });
681 ladder.push(OpSize::Reduce {
682 rows: DECODE_HEADS,
683 cols: DECODE_KLEN_CAP,
684 });
685 }
686 ladder
687 }
688 // Reduce-to-broadcast-target — reduces a `[rows, cols]`
689 // input to a `[1, cols]` output (sum/max along leading
690 // dim). The canonical autograd-backward shape; broader
691 // patterns (multi-axis, mid-rank insertion) follow once
692 // the dispatch tables can carry them.
693 OpKind::ReduceSumTo | OpKind::ReduceMaxTo => vec![
694 OpSize::ReduceTo {
695 rows: 1 << 4,
696 cols: 64,
697 },
698 OpSize::ReduceTo {
699 rows: 1 << 10,
700 cols: 64,
701 },
702 OpSize::ReduceTo {
703 rows: 1 << 14,
704 cols: 64,
705 },
706 ],
707 // Fused decode attention (arm-1 of slice-4's flash-vs-
708 // decomposed ranking). Two decode-representative GQA cells at
709 // seq_q=1: a short-context and a capacity-ish k_len. Both share
710 // TinyLlama geometry (Hq=32, Hkv=4, D=64) and key to DISTINCT
711 // `SizeClass::attention` buckets (the k_len byte differs), so
712 // the oracle costs the short-context and long-context flash
713 // kernel separately. Measurement-bounded: at k_len=4096 the
714 // naive CPU SDPA is ~Hq·k_len·D ≈ 8.4M MACs per QKᵀ/PV pass.
715 OpKind::FlashAttn => vec![
716 OpSize::Attention {
717 b: 1,
718 hq: DECODE_HEADS,
719 hkv: DECODE_KV_HEADS,
720 d: DECODE_HEAD_DIM,
721 k_len: DECODE_KLEN_SMALL,
722 },
723 OpSize::Attention {
724 b: 1,
725 hq: DECODE_HEADS,
726 hkv: DECODE_KV_HEADS,
727 d: DECODE_HEAD_DIM,
728 k_len: DECODE_KLEN_CAP,
729 },
730 ],
731 // OpKind is `#[non_exhaustive]` — future variants land
732 // here until the Judge gets a measurement strategy for
733 // them. Empty plan = "Judge skips this op silently."
734 _ => Vec::new(),
735 }
736 }
737
738 /// Run the full profile matrix for every equivalence class in
739 /// `probe`. Skips backends that aren't compiled in; logs (stderr)
740 /// any backend that's present but hasn't been wired into the
741 /// Judge yet (e.g. Vulkan in this revision).
742 ///
743 /// **Reference-retirement note (2026-06-07)**: the loop order is
744 /// inverted relative to the pre-retirement shape — outer over
745 /// `(op, size)`, inner over equivalence classes. This lets Judge
746 /// collect all backends' outputs at a given cell, compute pairwise
747 /// consensus across them, and assign each entry's `max_rel_error`
748 /// relative to the consensus cluster (no privileged "reference
749 /// output" required). When only one backend is available at a
750 /// cell, consensus is trivial and `max_rel_error = 0.0` (no peers
751 /// to compare against — the caller treats the lone backend as
752 /// authoritative).
753 ///
754 /// **Per-alternative measurement (2026-06-08)**: for each
755 /// equivalence-class representative the Judge now walks
756 /// `KernelBindingTable::lookup_alternatives(op_kind, dtypes,
757 /// backend)` and emits one `CellRun` per registered alternative.
758 /// One alternative is timed via the standard realizer path; its
759 /// CellRun is attributed to the sibling the picker ACTUALLY
760 /// dispatched (the bridge's post-realize report — Session 3
761 /// rider, 2026-06-11 — with first-registered fallback when the
762 /// plan had no entry for the measured root). The remaining
763 /// alternatives at the same `(op, dtypes, backend)` key are
764 /// timed via a direct kernel-pointer call so AOCL/MKL siblings
765 /// get distinct latency numbers.
766 pub fn run(&self, probe: &ProbeReport) -> ProfileReport {
767 let mut entries = Vec::new();
768 let classes = probe.equivalence_classes();
769 // Stable iteration order over equivalence classes — sort
770 // class keys so the profile report is deterministic across
771 // runs (HashMap iteration order is not guaranteed across
772 // process invocations).
773 let mut class_keys: Vec<_> = classes.keys().collect();
774 class_keys.sort_by_key(|k| (k.backend as u32, k.device_id, k.vendor_id));
775
776 for &op in PROFILED_OPS {
777 for sz in self.size_plan(op) {
778 // MatMul keys on shape aspect `(m,n,k)` via the shared
779 // `SizeClass::matmul` helper the `fuel-dispatch` ranker
780 // also reaches (through `SizeClass::for_op`), so a
781 // non-square matmul this producer profiles is found by
782 // the consumer at realize time — the same operand dims
783 // map to one identical key on both sides. Every other op
784 // keys on total element count. (SizeClass v4 producer/
785 // consumer reconciliation, 2026-07-04.)
786 let size_class = match sz {
787 OpSize::MatMul { m, n, k } => SizeClass::matmul(m, n, k),
788 // FlashAttn keys on the attention aspect `(hq, k_len, d)`
789 // via the shared `SizeClass::attention` helper that
790 // slice-4's bake also reaches (through `SizeClass::for_op`
791 // from the flash node's operand shapes), so a decode
792 // attention cell this producer profiles is found by the
793 // consumer at bake time. Every other op keys on total
794 // element count.
795 OpSize::Attention { hq, k_len, d, .. } => SizeClass::attention(hq, k_len, d),
796 _ => SizeClass::from_elem_count(sz.total_elements()),
797 };
798
799 // Dtype axis (2026-07-04): measure each (op, size) cell
800 // once per profiled dtype. The size_class is dtype-
801 // independent (element count, not byte count), so it's
802 // computed once outside this loop. Consensus is per
803 // (op, dtype, size) — f16 outputs cluster against f16
804 // peers, never against the f32 measurement of the same
805 // op — because `cell_runs` is rebuilt inside the loop.
806 for &dtype in PROFILED_DTYPES {
807 // First pass: per equivalence-class representative,
808 // measure latency + capture the kernel's output for
809 // consensus comparison. The realizer path measures
810 // whichever alternative the picker dispatches; its
811 // CellRun already carries that sibling's
812 // `kernel_source` (the bridge's post-realize report,
813 // filled in by `time_op_capturing` — Session 3 rider).
814 let mut cell_runs: Vec<CellRun> = Vec::with_capacity(class_keys.len());
815 for key in &class_keys {
816 let devs = &classes[key];
817 let rep = devs[0];
818 if let Some(run) = self.measure_on_device_capturing(op, dtype, &sz, rep) {
819 let dispatched_source = run.kernel_source.clone();
820 // The dispatched kernel's FULL identity — the tag
821 // alone no longer picks a sibling out (see
822 // `direct_call_alternatives`). Captured before the
823 // move below.
824 let dispatched_revision = run.kernel_revision_hash;
825 cell_runs.push(run);
826
827 // Per-alternative measurement: walk the
828 // REMAINING alternatives at the same
829 // `(op_kind, dtypes, backend)` binding-table
830 // cell and time each via a direct
831 // kernel-pointer call. The dispatched
832 // alternative is already covered by the
833 // realizer measurement above.
834 if let Some(extra_runs) = self.measure_extra_alternatives(
835 op,
836 dtype,
837 &sz,
838 rep,
839 &dispatched_source,
840 dispatched_revision,
841 ) {
842 for extra in extra_runs {
843 cell_runs.push(extra);
844 }
845 }
846 }
847 }
848
849 // Second pass: pick a correctness verdict for each
850 // CellRun. The fixture fast-path is preferred when a
851 // fixture exists for the cell — that's the
852 // pre-validated multi-backend output captured on a
853 // reference rig, so single-backend systems get the
854 // same correctness signal without needing peers
855 // locally. When no fixture is present, fall back to
856 // pairwise consensus across the CellRuns at this
857 // cell.
858 let expected_elem_count =
859 cell_runs.first().map(|r| r.output.len()).unwrap_or(0);
860 let input_elem_count = sz.input_elements(op);
861 let fixture = self.lookup_fixture(
862 op,
863 dtype,
864 size_class,
865 expected_elem_count,
866 input_elem_count,
867 );
868 let consensus = if fixture.is_none() {
869 compute_pairwise_consensus(&cell_runs)
870 } else {
871 Vec::new() // unused on the fixture path
872 };
873
874 // Third pass: emit one ProfileEntry per (run, device).
875 // The first alternative's CellRun replicates across
876 // each equivalence-class device; per-alternative
877 // CellRuns from the binding-table direct path
878 // record on the representative's device_index only
879 // (the cross-device sharing convention applies to
880 // backend-level equivalence, not within-cell kernel
881 // sibling differentiation — siblings are
882 // hardware-agnostic at the binding-table layer).
883 for (i, run) in cell_runs.iter().enumerate() {
884 let rel_err = if let Some(f) = fixture {
885 max_rel_err_vs_fixture(&run.output, f)
886 } else {
887 max_rel_err_vs_consensus(&cell_runs, &consensus, i)
888 };
889 // Find the equivalence class this run belongs to
890 // (its representative's backend matches `run.backend`).
891 let class_devs = classes.iter().find_map(|(k, devs)| {
892 if k.backend == run.backend && devs[0].device_index == run.device_index
893 {
894 Some(devs)
895 } else {
896 None
897 }
898 });
899 if let Some(devs) = class_devs {
900 for d in devs {
901 entries.push(ProfileEntry {
902 op,
903 dtype,
904 size_class,
905 backend: run.backend,
906 device_index: d.device_index,
907 latency_ns: run.latency_ns,
908 iterations: run.iterations,
909 max_rel_error: rel_err,
910 kernel_source: run.kernel_source.clone(),
911 kernel_revision_hash: run.kernel_revision_hash,
912 // The device this row was measured on. `d`
913 // is a member of the equivalence class whose
914 // key `k.backend == run.backend`, so this
915 // identity's backend agrees with `backend`
916 // above by construction (from_report asserts
917 // it). The load gate refuses the whole report
918 // on any box that lacks this device.
919 device: d.equivalence_key(),
920 });
921 }
922 }
923 }
924 }
925 }
926 }
927
928 ProfileReport {
929 version: PROFILE_REPORT_VERSION,
930 entries,
931 }
932 }
933
934 /// Walk binding-table alternatives at `(op_kind, dtypes, backend)`
935 /// beyond the one the realizer already timed (the picker's
936 /// dispatched sibling, `dispatched_kernel_source`) and run a
937 /// direct kernel-pointer measurement on each. Returns `None` when
938 /// only one alternative is registered (no extra work) or when the
939 /// op family is not yet wired into the direct-call path.
940 ///
941 /// **Why direct call instead of realizer**: the realizer dispatches
942 /// through the picker, which selects ONE alternative per realize.
943 /// To measure the siblings the picker didn't choose (e.g. AOCL
944 /// when MKL won the rank), we have to bypass the picker entirely
945 /// and invoke each specific `BindingEntry::kernel` function
946 /// pointer with hand-built inputs.
947 ///
948 /// **Scope**: v1 supports the subset of [`PROFILED_OPS`] where
949 /// input/output Storage + Layout + OpParams can be built without
950 /// going through the lazy-tensor graph. Today: matmul, elementwise
951 /// unary/binary, reductions, reduce-to, affine/clamp/powi. Other
952 /// op families return `None` and only the realizer-measured
953 /// dispatched alternative is recorded for the cell.
954 fn measure_extra_alternatives(
955 &self,
956 op: OpKind,
957 dtype: DType,
958 size: &OpSize,
959 device: &DeviceDescriptor,
960 dispatched_kernel_source: &str,
961 dispatched_kernel_revision: u64,
962 ) -> Option<Vec<CellRun>> {
963 // Direct kernel-pointer calls are only meaningful on the CPU
964 // backend today — CUDA / Vulkan storage handles are backend-
965 // specific and the realizer-internal allocator hierarchy
966 // doesn't expose a stand-alone "build CUDA storage from f32
967 // slice" entry point at the binding-table layer.
968 if device.backend != BackendId::Cpu {
969 return None;
970 }
971
972 // Dtype axis (2026-07-04): the direct-call input builder
973 // (`prepare_direct_call_inputs`) materializes f32 CPU storage
974 // only, and `read_output_f32` reads it back as f32. For F16/
975 // BF16 cells the realizer-measured primary alternative is the
976 // sole entry until the direct-call path grows a typed input
977 // builder. This is a coverage gap, not a correctness hazard:
978 // AOCL/MKL half-precision siblings (the direct-call path's
979 // reason to exist) are feature-gated off in the born-red CPU
980 // build anyway, so today no sibling is silently dropped.
981 if dtype != DType::F32 {
982 return None;
983 }
984
985 let alternatives =
986 direct_call_alternatives(op, dispatched_kernel_source, dispatched_kernel_revision);
987 if alternatives.is_empty() {
988 return None;
989 }
990
991 // Build the inputs once — every alternative consumes the same
992 // input data, so this is shared across the per-alt timing loop.
993 let prepared = prepare_direct_call_inputs(op, size)?;
994
995 let mut extra: Vec<CellRun> = Vec::with_capacity(alternatives.len());
996 for alt in alternatives {
997 if let Some(run) = self.time_alternative_direct(op, size, device, &prepared, &alt) {
998 extra.push(run);
999 }
1000 }
1001 if extra.is_empty() { None } else { Some(extra) }
1002 }
1003
1004 /// Direct-call timing path. Bypasses the realizer; calls the
1005 /// supplied `BindingEntry::kernel` function pointer directly with
1006 /// the prepared inputs. `iterations` median + `warmup` discard
1007 /// applied identically to the realizer path.
1008 fn time_alternative_direct(
1009 &self,
1010 op: OpKind,
1011 size: &OpSize,
1012 device: &DeviceDescriptor,
1013 prepared: &PreparedDirectCall,
1014 alt: &DirectCallAlternative,
1015 ) -> Option<CellRun> {
1016 // Warmup — discard timings. A backend whose kernel panics
1017 // mid-warmup is treated as if the alternative didn't exist
1018 // (skip this CellRun); the primary realizer-measured run
1019 // still represents the cell.
1020 let kernel = alt.kernel;
1021 for _ in 0..self.warmup {
1022 let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
1023 let mut outs = vec![Arc::clone(&prepared.output)];
1024 kernel(
1025 &prepared.inputs,
1026 &mut outs,
1027 &prepared.layouts,
1028 &prepared.op_params,
1029 )
1030 }));
1031 if r.is_err() {
1032 eprintln!(
1033 "judge: skipping {op}@{size:?} on {}:{} kernel_source={:?} — kernel panicked in warmup",
1034 device.backend, device.device_index, alt.kernel_source,
1035 );
1036 return None;
1037 }
1038 // Match `time_op_capturing`'s call-twice tolerance for
1039 // panic-vs-return-Err: an alternative that returns Err is
1040 // a real signal (the kernel rejected the inputs) — skip
1041 // rather than abort.
1042 if let Ok(Err(_)) = r {
1043 eprintln!(
1044 "judge: skipping {op}@{size:?} on {}:{} kernel_source={:?} — kernel returned Err in warmup",
1045 device.backend, device.device_index, alt.kernel_source,
1046 );
1047 return None;
1048 }
1049 }
1050
1051 let mut timings_ns = Vec::with_capacity(self.iterations as usize);
1052 let mut captured_output: Option<Vec<f32>> = None;
1053 for _ in 0..self.iterations {
1054 let t0 = Instant::now();
1055 let res = std::panic::catch_unwind(AssertUnwindSafe(|| {
1056 let mut outs = vec![Arc::clone(&prepared.output)];
1057 kernel(
1058 &prepared.inputs,
1059 &mut outs,
1060 &prepared.layouts,
1061 &prepared.op_params,
1062 )
1063 }));
1064 let elapsed_ns = t0.elapsed().as_nanos() as u64;
1065 match res {
1066 Ok(Ok(())) => {}
1067 _ => {
1068 eprintln!(
1069 "judge: skipping {op}@{size:?} on {}:{} kernel_source={:?} — direct call failed mid-run",
1070 device.backend, device.device_index, alt.kernel_source,
1071 );
1072 return None;
1073 }
1074 }
1075 timings_ns.push(elapsed_ns);
1076 if captured_output.is_none() {
1077 captured_output = Some(read_output_f32(&prepared.output));
1078 }
1079 }
1080 timings_ns.sort_unstable();
1081 let median = timings_ns[timings_ns.len() / 2];
1082 let output = captured_output?;
1083
1084 Some(CellRun {
1085 backend: device.backend,
1086 device_index: device.device_index,
1087 output,
1088 latency_ns: median,
1089 iterations: self.iterations,
1090 kernel_source: alt.kernel_source.to_string(),
1091 // Direct-call sibling: a real, attributed revision from the
1092 // alternative's own binding — never a guess.
1093 kernel_revision_hash: alt.kernel_revision_hash,
1094 })
1095 }
1096
1097 /// Measure one (op, dtype, size) cell on one representative
1098 /// device, capturing the kernel's output alongside the timing
1099 /// data so the caller can compute pairwise consensus across
1100 /// backends. Returns `None` if the backend isn't compiled in,
1101 /// its constructor errors out, or the realize itself fails.
1102 ///
1103 /// **Reference-retirement note (2026-06-07)**: replaces the
1104 /// pre-retirement `measure_on_device` which compared against
1105 /// `tensor.realize_f32_reference()` per-cell. The new flow
1106 /// defers correctness comparison to [`Self::run`]'s consensus
1107 /// pass.
1108 fn measure_on_device_capturing(
1109 &self,
1110 op: OpKind,
1111 dtype: DType,
1112 size: &OpSize,
1113 device: &DeviceDescriptor,
1114 ) -> Option<CellRun> {
1115 // Native-implementation gate (executor-unification Session 2):
1116 // the bridge realizer dispatches through the picker, whose
1117 // off-device fallback (picker-arc step 4b) would silently run
1118 // a missing GPU op on the CPU — and this cell would record a
1119 // CPU+transfer latency mislabeled as `device.backend`. The
1120 // legacy executor skipped such cells (its trait method
1121 // panicked); preserve that contract by requiring a binding-
1122 // table alternative at the cell before measuring. CPU is
1123 // exempt: it's the fallback root (nowhere to fall back to —
1124 // a genuinely missing CPU kernel surfaces as a realize Err
1125 // below and the cell is skipped through that path).
1126 //
1127 // NB: existence-checked via `lookup_alternatives`, NOT via
1128 // `primary_kernel_source() == ""` — `kernel_source` is a
1129 // diagnostic tag that legitimately defaults to `""` (the
1130 // baracuda CUDA registrations are untagged), so an empty tag
1131 // does not mean an empty cell.
1132 if device.backend != BackendId::Cpu && !has_binding_alternative(op, dtype, device.backend) {
1133 eprintln!(
1134 "judge: skipping {op}@{size:?} ({dtype:?}) on {}:{} — no binding-table \
1135 alternative registered for this backend/dtype",
1136 device.backend, device.device_index,
1137 );
1138 return None;
1139 }
1140
1141 // Walk the factory registry instead of naming each backend.
1142 // A backend that isn't compiled in simply doesn't appear in
1143 // the registry; one whose constructor fails is logged and
1144 // skipped per-device.
1145 let factory = match crate::factories::factory_for(device.backend) {
1146 Some(f) => f,
1147 None => {
1148 eprintln!(
1149 "judge: skipping {}:{} — backend not compiled in",
1150 device.backend, device.device_index,
1151 );
1152 return None;
1153 }
1154 };
1155 let mut realizer = match factory.try_make_realizer(device.device_index) {
1156 Ok(r) => r,
1157 Err(e) => {
1158 eprintln!(
1159 "judge: skipping {}:{} — factory failed: {e}",
1160 device.backend, device.device_index,
1161 );
1162 return None;
1163 }
1164 };
1165
1166 let cell = self.time_op_capturing(op, dtype, size, device, realizer.as_mut());
1167 drop(realizer);
1168 cell
1169 }
1170
1171 /// Build the op's input graph, realize it for warmup + timed
1172 /// runs, measure latency, capture the first iteration's output.
1173 /// Returns the captured output alongside the timing data; the
1174 /// caller computes correctness via consensus across backends
1175 /// (see [`Self::run`]).
1176 ///
1177 /// All realize calls are wrapped in `catch_unwind` — a kernel
1178 /// that panics on a corner-case input is logged and skipped, not
1179 /// fatal to the run. Post-Session-2 the realizer also returns
1180 /// typed `Err`s (the bridge's no-panics contract); an erroring
1181 /// backend is skipped through the same per-cell path. This is
1182 /// what makes the Judge safe to expand across the full op
1183 /// surface ahead of every backend's coverage.
1184 fn time_op_capturing(
1185 &self,
1186 op: OpKind,
1187 dtype: DType,
1188 size: &OpSize,
1189 device: &DeviceDescriptor,
1190 realizer: &mut dyn crate::factories::Realizer,
1191 ) -> Option<CellRun> {
1192 let tensor =
1193 match std::panic::catch_unwind(AssertUnwindSafe(|| build_input_graph(op, dtype, size)))
1194 {
1195 Ok(t) => t,
1196 Err(_) => {
1197 eprintln!("judge: skipping {op}@{size:?} — build_input_graph panicked",);
1198 return None;
1199 }
1200 };
1201
1202 // Warmup — discard timings; stabilize kernel caches, warm
1203 // up any BLAS internal state, fault-in heap, upload Consts
1204 // into the realizer's persistent cache. If any warmup call
1205 // panics or errors (e.g. backend doesn't support this op),
1206 // skip the entire (op, backend) cell.
1207 for _ in 0..self.warmup {
1208 match std::panic::catch_unwind(AssertUnwindSafe(|| {
1209 realizer.realize_capture_f32(&tensor)
1210 })) {
1211 Ok(Ok(_)) => {}
1212 Ok(Err(e)) => {
1213 eprintln!(
1214 "judge: skipping {op}@{size:?} on {}:{} — backend realize errored: {e}",
1215 device.backend, device.device_index,
1216 );
1217 return None;
1218 }
1219 Err(_) => {
1220 eprintln!(
1221 "judge: skipping {op}@{size:?} on {}:{} — backend realize panicked",
1222 device.backend, device.device_index,
1223 );
1224 return None;
1225 }
1226 }
1227 }
1228
1229 // Timed iterations — record each and take the median so
1230 // one pre-empted scheduler tick doesn't skew the number.
1231 // Capture the first iteration's output for consensus
1232 // comparison; subsequent iterations discard the output
1233 // (correctness is deterministic for a given backend + input).
1234 let mut timings_ns = Vec::with_capacity(self.iterations as usize);
1235 let mut captured_output: Option<Vec<f32>> = None;
1236 for _ in 0..self.iterations {
1237 let t0 = Instant::now();
1238 let out = match std::panic::catch_unwind(AssertUnwindSafe(|| {
1239 realizer.realize_capture_f32(&tensor)
1240 })) {
1241 Ok(Ok(v)) => v,
1242 Ok(Err(e)) => {
1243 eprintln!(
1244 "judge: skipping {op}@{size:?} on {}:{} — backend realize errored mid-run: {e}",
1245 device.backend, device.device_index,
1246 );
1247 return None;
1248 }
1249 Err(_) => {
1250 eprintln!(
1251 "judge: skipping {op}@{size:?} on {}:{} — backend realize panicked mid-run",
1252 device.backend, device.device_index,
1253 );
1254 return None;
1255 }
1256 };
1257 let elapsed_ns = t0.elapsed().as_nanos() as u64;
1258 timings_ns.push(elapsed_ns);
1259 if captured_output.is_none() {
1260 captured_output = Some(out);
1261 }
1262 }
1263 timings_ns.sort_unstable();
1264 let median = timings_ns[timings_ns.len() / 2];
1265 let output = captured_output?;
1266
1267 // Attribution (executor-unification Session 3 rider): the
1268 // bridge realizer reports the `kernel_source` of the
1269 // alternative the picker actually dispatched for the measured
1270 // root. At multi-sibling cells (portable/AOCL/MKL at one CPU
1271 // key) the dispatched sibling can differ from the
1272 // first-registered one, so the realizer's report is the only
1273 // truthful tag. `None` (no plan entry for the root, or a
1274 // realizer impl without a picker) falls back to the
1275 // first-registered tag — exactly the alternative the
1276 // executor's `compile_node` fallback dispatches in that case.
1277 // The tag AND its revision come from the SAME picker result
1278 // (`last_kernel_source` / `last_kernel_revision`, set together in the
1279 // bridge realizer), so they are both `Some` or both `None`.
1280 // Conservative cut (Judge item 7, Option A): when the picker did not
1281 // attribute this dispatch — no plan `AlternativeSet` for the root, or
1282 // a best-effort lock/lookup miss — write NO row. `alts.first()`'s
1283 // revision would be a plausible GUESS about a real sibling, filed
1284 // against a measurement we could not attribute; a `0` would claim
1285 // "untracked" about a kernel that actually ran. Both are rows that
1286 // LIE about kernel identity — the identity `kernel_revision_hash`
1287 // exists to make trustworthy — so an absent row is the honest outcome
1288 // (an absent cell just misses in the oracle: Layer-1 static costs
1289 // stand). The dropped-row count is reported in the re-profile commit.
1290 let (kernel_source, kernel_revision_hash) = match (
1291 realizer.last_kernel_source(),
1292 realizer.last_kernel_revision(),
1293 ) {
1294 (Some(src), Some(rev)) => (src.to_string(), rev),
1295 _ => return None,
1296 };
1297
1298 Some(CellRun {
1299 backend: device.backend,
1300 device_index: device.device_index,
1301 output,
1302 latency_ns: median,
1303 iterations: self.iterations,
1304 kernel_source,
1305 kernel_revision_hash,
1306 })
1307 }
1308}
1309
1310// =============================================================
1311// Direct-kernel-call infrastructure for per-alternative timing
1312// (Phase 1.1 of the backend-extensions refactor, 2026-06-08)
1313// =============================================================
1314
1315/// One alternative the direct-call path will time. Holds the
1316/// kernel function pointer and the `kernel_source` tag the resulting
1317/// CellRun carries.
1318struct DirectCallAlternative {
1319 kernel: fuel_dispatch::kernel::KernelRef,
1320 kernel_source: &'static str,
1321 /// Revision of this alternative's binding, from the same `BindingEntry`
1322 /// as `kernel_source` (so tag and revision agree by construction).
1323 kernel_revision_hash: u64,
1324}
1325
1326/// Shared inputs/outputs/layouts/op_params for one (op, size) cell.
1327/// Built once per cell and reused across every alternative's
1328/// direct-call measurement so the kernels compare on identical
1329/// inputs (a prerequisite for cross-alternative consensus to mean
1330/// anything).
1331struct PreparedDirectCall {
1332 inputs: Vec<Arc<RwLock<fuel_memory::Storage>>>,
1333 output: Arc<RwLock<fuel_memory::Storage>>,
1334 layouts: Vec<fuel_ir::Layout>,
1335 op_params: fuel_dispatch::kernel::OpParams,
1336}
1337
1338/// Does the binding table hold ANY alternative at the cell's
1339/// canonical F32 `(op, dtypes, backend)` key? Existence check for
1340/// the Judge's native-implementation gate.
1341///
1342/// Judge item 7 removed the first-registered `kernel_source` fallback
1343/// that used to attribute a dispatched measurement when the realizer
1344/// reported no picker sibling: the tag alone was already lossy (an empty
1345/// tag conflated "no alternative" with an untagged registration — every
1346/// baracuda CUDA binding is untagged), and it carries no revision at all.
1347/// The dispatched-path attribution now comes solely from the realizer's
1348/// picker report (`last_kernel_source` + `last_kernel_revision`), so a
1349/// cell with no attribution produces no measurement row rather than an
1350/// untagged, zero-revision guess.
1351///
1352/// Ops without a canonical dtype mapping return `false` — the gate
1353/// then skips the (non-CPU) cell rather than risk recording an
1354/// off-device-fallback measurement under the wrong backend label.
1355/// Unreachable for today's [`PROFILED_OPS`] (all are mapped).
1356fn has_binding_alternative(op: OpKind, dtype: DType, backend: BackendId) -> bool {
1357 let dtypes = match canonical_binding_dtypes_for(op, dtype) {
1358 Some(d) => d,
1359 None => return false,
1360 };
1361 let table = fuel_dispatch::dispatch::global_bindings();
1362 !table.lookup_alternatives(op, &dtypes, backend).is_empty()
1363}
1364
1365/// Whether `alt` (identified by its `kernel_source` tag and
1366/// `kernel_revision_hash`) IS the dispatched kernel. Matched on the FULL
1367/// identity — BOTH fields — because variant siblings of one provider share a
1368/// tag and differ only by revision, so the tag alone no longer picks a sibling
1369/// out. Keying the skip on the tag would skip the wrong sibling; this is the
1370/// one place that identity is compared, so it cannot drift from the key
1371/// `ProfileEntry`/`Candidate`/`Pick` carry.
1372fn is_dispatched_alt(
1373 alt_source: &str,
1374 alt_revision: u64,
1375 dispatched_source: &str,
1376 dispatched_revision: u64,
1377) -> bool {
1378 alt_source == dispatched_source && alt_revision == dispatched_revision
1379}
1380
1381/// Collect every binding-table alternative at the cell EXCEPT the
1382/// dispatched one (already timed via the realizer). Returns a fresh
1383/// `Vec` of [`DirectCallAlternative`] — empty when the cell has only
1384/// one alternative or the op isn't direct-call-eligible yet.
1385fn direct_call_alternatives(
1386 op: OpKind,
1387 dispatched_kernel_source: &str,
1388 dispatched_kernel_revision: u64,
1389) -> Vec<DirectCallAlternative> {
1390 // Direct-call path is F32-only (see `measure_extra_alternatives`'s
1391 // dtype gate + `prepare_direct_call_inputs`), so the binding key is
1392 // always the F32 tuple here.
1393 let dtypes = match canonical_binding_dtypes_for(op, DType::F32) {
1394 Some(d) => d,
1395 None => return Vec::new(),
1396 };
1397 let table = fuel_dispatch::dispatch::global_bindings();
1398 let alts = table.lookup_alternatives(op, &dtypes, BackendId::Cpu);
1399 if alts.len() < 2 {
1400 return Vec::new();
1401 }
1402 // Skip the alternative the realizer already measured. Its identity is
1403 // (kernel_source, kernel_revision_hash) — the tag ALONE no longer picks a
1404 // sibling out, now that variant siblings of one provider share a tag and
1405 // differ only by revision, so keying the skip on the tag could skip the
1406 // WRONG sibling (leaving the dispatched one to be re-measured as an extra
1407 // AND dropping a genuine sibling). Skip the FIRST entry matching BOTH
1408 // fields, wherever it sits in registration order (the picker is free to
1409 // dispatch a non-first sibling). If nothing matches — off-cell dispatch, or
1410 // a revision the current binding table no longer holds — nothing is skipped:
1411 // every CPU sibling at the cell is then unmeasured work. That is a redundant
1412 // measurement (the oracle min-collapses the duplicate at the identical
1413 // (source, revision) key), never a wrong one.
1414 let mut extras = Vec::with_capacity(alts.len().saturating_sub(1));
1415 let mut skipped_dispatched = false;
1416 for alt in alts.iter() {
1417 if !skipped_dispatched
1418 && is_dispatched_alt(
1419 alt.kernel_source,
1420 alt.kernel_revision_hash,
1421 dispatched_kernel_source,
1422 dispatched_kernel_revision,
1423 )
1424 {
1425 skipped_dispatched = true;
1426 continue;
1427 }
1428 extras.push(DirectCallAlternative {
1429 kernel: alt.kernel,
1430 kernel_source: alt.kernel_source,
1431 kernel_revision_hash: alt.kernel_revision_hash,
1432 });
1433 }
1434 extras
1435}
1436
1437/// Map an `OpKind` to its binding-table dtype list (inputs + outputs)
1438/// at the profiled `dtype`. Every profiled family is uniform-dtype, so
1439/// the list is just `dtype` repeated per operand slot. Returns `None`
1440/// for op families the native-implementation gate / direct-call path
1441/// don't yet support — caller falls back to first-alternative-only
1442/// measurement at the cell (or skips the non-CPU cell).
1443fn canonical_binding_dtypes_for(op: OpKind, dtype: DType) -> Option<Vec<DType>> {
1444 // Most elementwise ops follow `[input..., output]`. Reductions
1445 // and reduce-to follow `[input, output]`. MatMul is 3 inputs
1446 // (no — 2 inputs + 1 output): `[lhs, rhs, out] = [T, T, T]`.
1447 //
1448 // Dtype axis (2026-07-04): every profiled family in this map is
1449 // uniform-dtype (all operands share `dtype`), so substituting the
1450 // profiled dtype `T` for the former hard-coded F32 gives the right
1451 // binding-table key for F16/BF16 cells too. The mixed-dtype ops
1452 // (Cast, QMatMul, comparisons with a U8 mask) are not in
1453 // PROFILED_OPS and fall through to the `None` arm.
1454 let t = dtype;
1455 Some(match op {
1456 OpKind::MatMul => vec![t, t, t],
1457 // Binary elementwise: 2 inputs + 1 output.
1458 OpKind::AddElementwise
1459 | OpKind::SubElementwise
1460 | OpKind::MulElementwise
1461 | OpKind::DivElementwise
1462 | OpKind::MaximumElementwise
1463 | OpKind::MinimumElementwise
1464 | OpKind::PowElementwise
1465 | OpKind::RemElementwise => vec![t, t, t],
1466 // Unary elementwise: 1 input + 1 output.
1467 OpKind::NegElementwise
1468 | OpKind::SqrElementwise
1469 | OpKind::SqrtElementwise
1470 | OpKind::ExpElementwise
1471 | OpKind::LogElementwise
1472 | OpKind::SinElementwise
1473 | OpKind::CosElementwise
1474 | OpKind::TanhElementwise
1475 | OpKind::SigmoidElementwise
1476 | OpKind::SiluElementwise
1477 | OpKind::GeluTanhElementwise
1478 | OpKind::ReluElementwise
1479 | OpKind::StepElementwise
1480 | OpKind::RecipElementwise
1481 | OpKind::AbsElementwise
1482 | OpKind::FloorElementwise
1483 | OpKind::CeilElementwise
1484 | OpKind::RoundElementwise
1485 | OpKind::SignElementwise
1486 | OpKind::ErfElementwise
1487 | OpKind::GeluElementwise
1488 | OpKind::RsqrtElementwise
1489 | OpKind::Affine
1490 | OpKind::ClampElementwise
1491 | OpKind::PowIElementwise => vec![t, t],
1492 // Reductions: 1 input + 1 output.
1493 OpKind::SumReduce
1494 | OpKind::MaxReduce
1495 | OpKind::MinReduce
1496 | OpKind::MeanReduce
1497 | OpKind::ReduceSumTo
1498 | OpKind::ReduceMaxTo => vec![t, t],
1499 // Fused decode attention, NO-alibi key `[q, k, v, out]` (the
1500 // Judge builds q/k/v without alibi_slopes). This drives the
1501 // non-CPU native-implementation gate ([`has_binding_alternative`])
1502 // so a live CUDA/Vulkan run finds the flash binding and populates
1503 // the GPU arm-1 rows (CUDA flash is f16/bf16-only, so its f32
1504 // cell has no binding and is skipped cleanly). The direct-call
1505 // path stays disabled for FlashAttn — `prepare_direct_call_inputs`
1506 // has no FlashAttn arm, so `measure_extra_alternatives` returns
1507 // None regardless; this mapping only powers the existence gate +
1508 // the first-registered `kernel_source` fallback.
1509 OpKind::FlashAttn => vec![t, t, t, t],
1510 // All others: not yet wired into the direct-call path. The
1511 // realizer-measured first alternative is the only entry for
1512 // the cell until this list expands.
1513 _ => return None,
1514 })
1515}
1516
1517/// Build the inputs/outputs/layouts/op_params for direct-call timing
1518/// of one (op, size) cell. Returns `None` for ops not yet supported.
1519/// Mirrors the input domains used by `build_input_graph` so the
1520/// direct-call CellRuns produce numerically-comparable output to the
1521/// realizer-measured primary alternative (consensus needs aligned
1522/// inputs across CellRuns).
1523fn prepare_direct_call_inputs(op: OpKind, size: &OpSize) -> Option<PreparedDirectCall> {
1524 use fuel_cpu_backend::CpuStorageBytes;
1525 use fuel_dispatch::kernel::OpParams;
1526 use fuel_ir::Layout;
1527 use fuel_memory::{BackendStorage, Storage};
1528
1529 let make_storage = |bytes: CpuStorageBytes| {
1530 Arc::new(RwLock::new(Storage::new(
1531 BackendStorage::Cpu(bytes),
1532 DType::F32,
1533 )))
1534 };
1535
1536 match (op, *size) {
1537 (OpKind::MatMul, OpSize::MatMul { m, n, k }) => {
1538 let a_data: Vec<f32> = (0..(m * k)).map(|i| ((i as f32) * 1.3e-3).sin()).collect();
1539 let b_data: Vec<f32> = (0..(k * n)).map(|i| ((i as f32) * 1.7e-3).cos()).collect();
1540 let lhs = make_storage(CpuStorageBytes::from_slice(&a_data));
1541 let rhs = make_storage(CpuStorageBytes::from_slice(&b_data));
1542 let out = make_storage(CpuStorageBytes::from_zero_bytes(
1543 m * n * std::mem::size_of::<f32>(),
1544 ));
1545 let layouts = vec![
1546 Layout::contiguous(Shape::from_dims(&[m, k])),
1547 Layout::contiguous(Shape::from_dims(&[k, n])),
1548 Layout::contiguous(Shape::from_dims(&[m, n])),
1549 ];
1550 let op_params = OpParams::Matmul {
1551 lhs_batch_dims: Vec::new(),
1552 rhs_batch_dims: Vec::new(),
1553 m,
1554 n,
1555 k,
1556 // Judge profiles dense static matmuls.
1557 m_compute: fuel_dispatch::kernel::MatmulM::All,
1558 };
1559 Some(PreparedDirectCall {
1560 inputs: vec![lhs, rhs],
1561 output: out,
1562 layouts,
1563 op_params,
1564 })
1565 }
1566 (op, OpSize::Elementwise(n)) if is_binary_elementwise(op) => {
1567 let (a_data, b_data) = binary_inputs(op, n);
1568 let lhs = make_storage(CpuStorageBytes::from_slice(&a_data));
1569 let rhs = make_storage(CpuStorageBytes::from_slice(&b_data));
1570 let out = make_storage(CpuStorageBytes::from_zero_bytes(
1571 n * std::mem::size_of::<f32>(),
1572 ));
1573 let shape = Shape::from_dims(&[n]);
1574 let layouts = vec![
1575 Layout::contiguous(shape.clone()),
1576 Layout::contiguous(shape.clone()),
1577 Layout::contiguous(shape),
1578 ];
1579 Some(PreparedDirectCall {
1580 inputs: vec![lhs, rhs],
1581 output: out,
1582 layouts,
1583 op_params: OpParams::None,
1584 })
1585 }
1586 (op, OpSize::Elementwise(n)) if is_unary_elementwise(op) => {
1587 let raw = unary_input(n);
1588 let needs_nonzero = matches!(
1589 op,
1590 OpKind::SqrtElementwise
1591 | OpKind::LogElementwise
1592 | OpKind::RecipElementwise
1593 | OpKind::RsqrtElementwise,
1594 );
1595 let data: Vec<f32> = if needs_nonzero {
1596 raw.into_iter().map(|x| x + 1.5).collect()
1597 } else {
1598 raw
1599 };
1600 let inp = make_storage(CpuStorageBytes::from_slice(&data));
1601 let out = make_storage(CpuStorageBytes::from_zero_bytes(
1602 n * std::mem::size_of::<f32>(),
1603 ));
1604 let shape = Shape::from_dims(&[n]);
1605 let layouts = vec![Layout::contiguous(shape.clone()), Layout::contiguous(shape)];
1606 Some(PreparedDirectCall {
1607 inputs: vec![inp],
1608 output: out,
1609 layouts,
1610 op_params: OpParams::None,
1611 })
1612 }
1613 (op, OpSize::Elementwise(n)) if is_scalar_op(op) => {
1614 let data = unary_input(n);
1615 let inp = make_storage(CpuStorageBytes::from_slice(&data));
1616 let out = make_storage(CpuStorageBytes::from_zero_bytes(
1617 n * std::mem::size_of::<f32>(),
1618 ));
1619 let shape = Shape::from_dims(&[n]);
1620 let layouts = vec![Layout::contiguous(shape.clone()), Layout::contiguous(shape)];
1621 let op_params = match op {
1622 OpKind::Affine => OpParams::Affine { mul: 2.0, add: 0.0 },
1623 OpKind::ClampElementwise => OpParams::Clamp {
1624 min: -0.5,
1625 max: 0.5,
1626 },
1627 OpKind::PowIElementwise => OpParams::PowI { exp: 3 },
1628 _ => unreachable!(),
1629 };
1630 Some(PreparedDirectCall {
1631 inputs: vec![inp],
1632 output: out,
1633 layouts,
1634 op_params,
1635 })
1636 }
1637 // Reductions + ReduceTo not yet wired into the direct-call
1638 // path: the OpParams shape needs the input layout, and the
1639 // output shape is op-dependent. Falls back to first-alt-only
1640 // measurement until the binding-table key conventions are
1641 // double-checked end-to-end here.
1642 _ => None,
1643 }
1644}
1645
1646/// Read an output `Storage`'s F32 bytes back out as a `Vec<f32>` for
1647/// consensus comparison.
1648fn read_output_f32(out: &Arc<RwLock<fuel_memory::Storage>>) -> Vec<f32> {
1649 use fuel_memory::BackendStorage;
1650 let g = out.read().expect("output storage lock");
1651 match &g.inner {
1652 BackendStorage::Cpu(c) => c
1653 .as_slice::<f32>()
1654 .expect("cpu storage as f32 slice")
1655 .to_vec(),
1656 // Direct-call path is CPU-only today; other backends are
1657 // guarded by `measure_extra_alternatives` returning None
1658 // before we reach this point. A non-CPU storage here is a
1659 // bug, but we'd rather skip the entry than panic in the
1660 // Judge.
1661 #[allow(unreachable_patterns)]
1662 _ => Vec::new(),
1663 }
1664}
1665
1666/// Per-op size descriptor. Exposed (not private) so tests can supply
1667/// a shrunk `size_plan_override`; not in the persisted profile
1668/// report shape — the report uses the bucketed [`SizeClass`] instead.
1669#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1670pub enum OpSize {
1671 /// Dense matmul `[m, k] @ [k, n] -> [m, n]`.
1672 MatMul { m: usize, n: usize, k: usize },
1673 /// Flat 1-D shape used by unary, binary, scalar/affine, clamp, powi
1674 /// kernels. Probe builds inputs of shape `[n]`.
1675 Elementwise(usize),
1676 /// Reduction along the last dim of a 2-D shape `[rows, cols]`.
1677 /// Total element count = rows × cols. Used for `SumReduce` /
1678 /// `MaxReduce` / `MinReduce` / `MeanReduce`.
1679 Reduce { rows: usize, cols: usize },
1680 /// Reduce-to-broadcast-target from `[rows, cols]` → `[1, cols]`
1681 /// (sum-reduce-along-rows). Used for `ReduceSumTo` / `ReduceMaxTo`.
1682 ReduceTo { rows: usize, cols: usize },
1683 /// Decode-shaped GQA attention for [`OpKind::FlashAttn`]. seq_q is
1684 /// fixed at 1 (autoregressive decode — the regime slice-4's
1685 /// flash-vs-decomposed ranking cares about). Builds
1686 /// `q = [b, hq, 1, d]`, `k = v = [b, hkv, k_len, d]` (`hq % hkv == 0`,
1687 /// GQA), attending the full `k_len` prefix. Keyed via
1688 /// [`SizeClass::attention`]`(hq, k_len, d)`.
1689 Attention {
1690 b: usize,
1691 hq: usize,
1692 hkv: usize,
1693 d: usize,
1694 k_len: usize,
1695 },
1696}
1697
1698impl OpSize {
1699 fn total_elements(&self) -> usize {
1700 match *self {
1701 OpSize::MatMul { m, n, k: _ } => m * n,
1702 OpSize::Elementwise(n) => n,
1703 OpSize::Reduce { rows, cols } => rows * cols,
1704 OpSize::ReduceTo { rows, cols } => rows * cols,
1705 // Output element count of decode attention `[b, hq, 1, d]`.
1706 // Attention keys via `SizeClass::attention`, not this — this
1707 // is only the Total-key fallback for a non-attention consumer.
1708 OpSize::Attention { b, hq, d, .. } => b * hq * d,
1709 }
1710 }
1711
1712 /// Element count of the input buffer the capture tool's
1713 /// [`fuel_correctness_fixtures::capture::deterministic_f32_input`]
1714 /// would emit for an op of this size. Used by the Judge's
1715 /// fixture-lookup path to regenerate-and-hash the input.
1716 ///
1717 /// Per-op shape (mirrors capture's `deterministic_f32_input`):
1718 /// - MatMul: `m*k + k*n` — `a_data` followed by `b_data`.
1719 /// - Elementwise (binary): `2 * n` — `[a, b]` concatenated.
1720 /// - Elementwise (unary / scalar / clamp / powi): `n`.
1721 /// - Reduce / ReduceTo: `rows * cols`.
1722 ///
1723 /// `op` is needed to disambiguate binary vs unary on the
1724 /// `Elementwise(n)` shape.
1725 fn input_elements(&self, op: OpKind) -> usize {
1726 match *self {
1727 OpSize::MatMul { m, n, k } => m * k + k * n,
1728 OpSize::Elementwise(n) => {
1729 if is_binary_elementwise(op) {
1730 2 * n
1731 } else {
1732 n
1733 }
1734 }
1735 OpSize::Reduce { rows, cols } => rows * cols,
1736 OpSize::ReduceTo { rows, cols } => rows * cols,
1737 // q + k + v elements. FlashAttn has no capture-tool fixture
1738 // (not in the fixture distribution), so this feeds no live
1739 // fixture-hash path today; kept consistent for completeness.
1740 OpSize::Attention {
1741 b,
1742 hq,
1743 hkv,
1744 d,
1745 k_len,
1746 } => b * hq * 1 * d + 2 * (b * hkv * k_len * d),
1747 }
1748 }
1749}
1750
1751/// Build a leaf [`Tensor`] at `dtype` from f32 source `data`. F16/
1752/// BF16 down-convert the deterministic f32 domain element-wise
1753/// (`half::f16/bf16::from_f32`) so the same input distribution is
1754/// exercised at every precision — the cross-dtype comparison stays
1755/// apples-to-apples modulo the dtype's own rounding. Panics only on a
1756/// dtype outside the profiled `{F32, F16, BF16}` set (never reached:
1757/// [`PROFILED_DTYPES`] is the sole caller of `build_input_graph`).
1758fn make_leaf(dtype: DType, data: Vec<f32>, shape: Shape) -> crate::lazy::Tensor {
1759 use crate::lazy::Tensor;
1760 let dev = crate::Device::cpu();
1761 // ⚠️ GAP-003, and this is NEITHER of the two usual dispositions.
1762 //
1763 // `make_leaf` cannot prove its operands agree -- `data` and `shape` are BOTH
1764 // parameters, so there is no local proof and an `.expect` claiming one would
1765 // be false. But `Result` is wrong here too, and for a reason that is a
1766 // property of the CALLER, not of this function: the sole caller wraps
1767 // `build_input_graph` in `std::panic::catch_unwind` (see `Judge::run`), so
1768 // this path ALREADY has a working error channel and a panic here is
1769 // CAUGHT and reported as a profile failure, not an abort.
1770 //
1771 // Adding `Result` would give the judge two error channels for one failure
1772 // and force every caller to handle both. So the message states the DESIGN
1773 // BOUNDARY rather than a proof it cannot make -- which is the honest third
1774 // form when a proof exists neither locally nor at all.
1775 let leaf = |t: std::result::Result<Tensor, fuel_ir::Error>| {
1776 t.expect(
1777 "make_leaf: data length must match shape element count. Both are caller-supplied, so this function cannot prove it -- the panic is the judge's error channel and is caught by the catch_unwind in Judge::run",
1778 )
1779 };
1780 match dtype {
1781 DType::F32 => leaf(Tensor::from_f32(data, shape, &dev)),
1782 DType::F16 => leaf(Tensor::from_f16(
1783 data.iter()
1784 .map(|&x| half::f16::from_f32(x))
1785 .collect::<Vec<_>>(),
1786 shape,
1787 &dev,
1788 )),
1789 DType::BF16 => leaf(Tensor::from_bf16(
1790 data.iter()
1791 .map(|&x| half::bf16::from_f32(x))
1792 .collect::<Vec<_>>(),
1793 shape,
1794 &dev,
1795 )),
1796 other => panic!("build_input_graph: unsupported profiled dtype {other:?}"),
1797 }
1798}
1799
1800/// Build a same-graph const sibling of `a` at `dtype` from f32 source
1801/// `data`. Mirrors [`make_leaf`] but through the `const_*_like`
1802/// constructors so the second operand shares `a`'s graph.
1803fn make_const_like(
1804 a: &crate::lazy::Tensor,
1805 dtype: DType,
1806 data: Vec<f32>,
1807 shape: Shape,
1808) -> crate::lazy::Tensor {
1809 // GAP-003, same DESIGN-BOUNDARY form as `make_leaf` directly above: `data`
1810 // and `shape` are BOTH parameters, so there is no local proof -- but the sole
1811 // caller wraps `build_input_graph` in `std::panic::catch_unwind`, so this
1812 // path already HAS an error channel and a `Result` would be a second one.
1813 let cl = |t: std::result::Result<crate::lazy::Tensor, fuel_ir::Error>| {
1814 t.expect(
1815 "make_const_like: data length must match shape element count. Both are caller-supplied, so this function cannot prove it -- the panic is the judge's error channel and is caught by the catch_unwind in Judge::run",
1816 )
1817 };
1818 match dtype {
1819 DType::F32 => cl(a.const_f32_like(data, shape)),
1820 DType::F16 => cl(a.const_f16_like(
1821 data.iter()
1822 .map(|&x| half::f16::from_f32(x))
1823 .collect::<Vec<_>>(),
1824 shape,
1825 )),
1826 DType::BF16 => cl(a.const_bf16_like(
1827 data.iter()
1828 .map(|&x| half::bf16::from_f32(x))
1829 .collect::<Vec<_>>(),
1830 shape,
1831 )),
1832 other => panic!("build_input_graph: unsupported profiled dtype {other:?}"),
1833 }
1834}
1835
1836/// Build a 1-node graph for the given (op, dtype, size) that takes
1837/// constant inputs. The inputs are deterministic (generated in f32 and
1838/// down-converted to `dtype`) so precision comparisons across backends
1839/// are meaningful.
1840fn build_input_graph(op: OpKind, dtype: DType, size: &OpSize) -> crate::lazy::Tensor {
1841 match (op, *size) {
1842 (OpKind::MatMul, OpSize::MatMul { m, n, k }) => {
1843 let a_data: Vec<f32> = (0..(m * k)).map(|i| ((i as f32) * 1.3e-3).sin()).collect();
1844 let b_data: Vec<f32> = (0..(k * n)).map(|i| ((i as f32) * 1.7e-3).cos()).collect();
1845 let a = make_leaf(dtype, a_data, Shape::from_dims(&[m, k]));
1846 let b = make_const_like(&a, dtype, b_data, Shape::from_dims(&[k, n]));
1847 a.matmul(&b).unwrap()
1848 }
1849 (op, OpSize::Elementwise(n)) if is_binary_elementwise(op) => {
1850 let (a_data, b_data) = binary_inputs(op, n);
1851 let a = make_leaf(dtype, a_data, Shape::from_dims(&[n]));
1852 let b = make_const_like(&a, dtype, b_data, Shape::from_dims(&[n]));
1853 apply_binary(op, &a, &b)
1854 }
1855 // -------- elementwise unary fanout --------
1856 //
1857 // Domain notes:
1858 // - sqrt/log require strictly positive inputs; inputs are in
1859 // `[0.5, 2.5]` after the +1.5 offset.
1860 // - exp/sigmoid saturate quickly; inputs are bounded
1861 // `[-1, 1]` (sin output) so exp stays in `[0.36, 2.72]` and
1862 // sigmoid stays away from saturation (more measurable
1863 // precision differences across backends).
1864 // - silu/gelu_tanh/relu/tanh/neg/sqr/step/sin/cos all accept any f32.
1865 // -------- per-axis reductions --------
1866 //
1867 // Reduce along the last dim of `[rows, cols]`, producing
1868 // `[rows]`. Bucketing on total element count means the
1869 // dispatch table groups `Reduce(rows=1024, cols=64)` and
1870 // `Elementwise(65536)` in the same size class — fine, the
1871 // size_class is the per-op axis, not a cross-op identity.
1872 (op, OpSize::Reduce { rows, cols }) if is_reduction(op) => {
1873 let n = rows * cols;
1874 let data: Vec<f32> = (0..n).map(|i| ((i as f32) * 1.7e-3).sin()).collect();
1875 let a = make_leaf(dtype, data, Shape::from_dims(&[rows, cols]));
1876 apply_reduction(op, &a)
1877 }
1878 // -------- reduce-to-broadcast-target --------
1879 //
1880 // Reduce `[rows, cols]` to `[1, cols]`.
1881 (op, OpSize::ReduceTo { rows, cols }) if is_reduce_to(op) => {
1882 let n = rows * cols;
1883 let data: Vec<f32> = (0..n).map(|i| ((i as f32) * 1.7e-3).sin()).collect();
1884 let a = make_leaf(dtype, data, Shape::from_dims(&[rows, cols]));
1885 let target = Shape::from_dims(&[1, cols]);
1886 match op {
1887 OpKind::ReduceSumTo => a.reduce_sum_to(target),
1888 OpKind::ReduceMaxTo => a.reduce_max_to(target),
1889 _ => unreachable!(),
1890 }
1891 }
1892 // -------- scalar / clamp / powi (one-input non-unary) --------
1893 //
1894 // Affine here is the canonical MulScalar form (mul=2.0); the
1895 // dispatch key OpKind::Affine covers AddScalar too. Clamp uses
1896 // bounds [-0.5, 0.5] so roughly half the [-1, 1] sin input
1897 // gets clipped, exercising both branches of the kernel. PowI
1898 // uses exp=3 so the kernel iterates rather than degenerating
1899 // to sqr/identity.
1900 (op, OpSize::Elementwise(n)) if is_scalar_op(op) => {
1901 let data: Vec<f32> = unary_input(n);
1902 let a = make_leaf(dtype, data, Shape::from_dims(&[n]));
1903 match op {
1904 OpKind::Affine => a.mul_scalar(2.0),
1905 OpKind::ClampElementwise => a.clamp(-0.5, 0.5),
1906 OpKind::PowIElementwise => a.powi(3),
1907 _ => unreachable!(),
1908 }
1909 }
1910 (op, OpSize::Elementwise(n)) if is_unary_elementwise(op) => {
1911 let raw = unary_input(n);
1912 // Sqrt/Log require strictly positive inputs; Recip can't be
1913 // measured against zero (1/0 = inf saturates `max_rel_err`).
1914 // The +1.5 shift puts unary_input's [-1, 1] range into
1915 // [0.5, 2.5] — safe for all three.
1916 let needs_nonzero = matches!(
1917 op,
1918 OpKind::SqrtElementwise
1919 | OpKind::LogElementwise
1920 | OpKind::RecipElementwise
1921 | OpKind::RsqrtElementwise,
1922 );
1923 let data: Vec<f32> = if needs_nonzero {
1924 raw.into_iter().map(|x| x + 1.5).collect()
1925 } else {
1926 raw
1927 };
1928 let a = make_leaf(dtype, data, Shape::from_dims(&[n]));
1929 apply_unary(op, &a)
1930 }
1931 // -------- fused decode attention (FlashAttn) --------
1932 //
1933 // Builds the GQA decode operands q=[b,hq,1,d], k=v=[b,hkv,k_len,d]
1934 // and emits `Op::Fused(FLASH_ATTN, FlashAttn{..})` via the
1935 // Tensor `flash_attn` builder (k_len == the K length axis, so
1936 // the whole prefix is attended — the concrete-k_len decode).
1937 // seq_q=1 with `causal=true` is a no-op mask (the single query at
1938 // the last position attends every key), matching decode. On CPU
1939 // the realize path dispatches this to the registered fused CPU
1940 // FlashAttn kernel (naive SDPA, `flash_attn_{f32,f16,bf16}` in
1941 // fuel-cpu-backend::byte_kernels); f16/bf16 inputs down-convert
1942 // the deterministic f32 domain, mirroring the other families.
1943 (
1944 OpKind::FlashAttn,
1945 OpSize::Attention {
1946 b,
1947 hq,
1948 hkv,
1949 d,
1950 k_len,
1951 },
1952 ) => {
1953 let scale = 1.0 / (d as f32).sqrt();
1954 let q_elems = b * hq * 1 * d;
1955 let kv_elems = b * hkv * k_len * d;
1956 let q_data: Vec<f32> = (0..q_elems).map(|i| ((i as f32) * 1.3e-3).sin()).collect();
1957 let k_data: Vec<f32> = (0..kv_elems).map(|i| ((i as f32) * 1.7e-3).cos()).collect();
1958 let v_data: Vec<f32> = (0..kv_elems)
1959 .map(|i| ((i as f32) * 0.9e-3).sin() + 1.0)
1960 .collect();
1961 let q = make_leaf(dtype, q_data, Shape::from_dims(&[b, hq, 1, d]));
1962 let k = make_const_like(&q, dtype, k_data, Shape::from_dims(&[b, hkv, k_len, d]));
1963 let v = make_const_like(&q, dtype, v_data, Shape::from_dims(&[b, hkv, k_len, d]));
1964 q.flash_attn(&k, &v, None, scale, /*causal=*/ true, None, None, None)
1965 .expect("judge: flash_attn shape/GQA invariant")
1966 }
1967 (op, sz) => panic!("build_input_graph: op {op:?} size {sz:?} not implemented"),
1968 }
1969}
1970
1971/// Whether `op` is a one-input scalar / clamp / powi op the Judge
1972/// profiles using the `Elementwise(n)` size ladder. These differ
1973/// from the unary ops only in carrying static parameters on the op
1974/// itself (scalar coefficient, clamp bounds, integer exponent).
1975fn is_scalar_op(op: OpKind) -> bool {
1976 matches!(
1977 op,
1978 OpKind::Affine | OpKind::ClampElementwise | OpKind::PowIElementwise,
1979 )
1980}
1981
1982/// Whether `op` is a reduce-to-broadcast-target op the Judge profiles
1983/// using the `ReduceTo { rows, cols }` size ladder.
1984fn is_reduce_to(op: OpKind) -> bool {
1985 matches!(op, OpKind::ReduceSumTo | OpKind::ReduceMaxTo)
1986}
1987
1988/// Elementwise ops on the decomposed decode-attention softmax path
1989/// (`exp(x - max) / sum`): Sub, Exp, Div. These get decode-score-row
1990/// [`OpSize::Elementwise`] cells appended to their base ladder so the
1991/// Judge costs the decomposed softmax at the element counts decode
1992/// runs (slice-4's arm-0 flash-vs-decomposed comparison needs the
1993/// decomposed primitives' MEASURED decode latencies). Every other
1994/// elementwise op is off the decode softmax path and keeps only its
1995/// general ladder.
1996fn is_decode_softmax_elementwise(op: OpKind) -> bool {
1997 matches!(
1998 op,
1999 OpKind::SubElementwise | OpKind::ExpElementwise | OpKind::DivElementwise,
2000 )
2001}
2002
2003/// Reductions on the decomposed decode-attention softmax path:
2004/// MaxReduce (row max) and SumReduce (denominator). Min/Mean don't
2005/// appear there and keep only their general ladder.
2006fn is_decode_softmax_reduction(op: OpKind) -> bool {
2007 matches!(op, OpKind::MaxReduce | OpKind::SumReduce)
2008}
2009
2010/// Whether `op` is a per-axis reduction the Judge profiles using the
2011/// `Reduce { rows, cols }` size ladder.
2012fn is_reduction(op: OpKind) -> bool {
2013 matches!(
2014 op,
2015 OpKind::SumReduce | OpKind::MaxReduce | OpKind::MinReduce | OpKind::MeanReduce,
2016 )
2017}
2018
2019/// Dispatch one per-axis reduction along the last dim. The reduced
2020/// dim is removed from the output shape.
2021fn apply_reduction(op: OpKind, a: &crate::lazy::Tensor) -> crate::lazy::Tensor {
2022 // Reduce the last dim. Input is rank-2 `[rows, cols]`; output is
2023 // rank-1 `[rows]`.
2024 let last_dim = a.rank() - 1;
2025 match op {
2026 OpKind::SumReduce => a.sum_dim(last_dim).unwrap(),
2027 OpKind::MaxReduce => a.max_dim(last_dim).unwrap(),
2028 OpKind::MinReduce => a.min_dim(last_dim).unwrap(),
2029 OpKind::MeanReduce => a.mean_dim(last_dim).unwrap(),
2030 _ => unreachable!("apply_reduction called on non-reduction OpKind {op:?}"),
2031 }
2032}
2033
2034/// Whether `op` is one of the elementwise binary ops the Judge profiles
2035/// using the `Elementwise(n)` size ladder.
2036fn is_binary_elementwise(op: OpKind) -> bool {
2037 matches!(
2038 op,
2039 OpKind::AddElementwise
2040 | OpKind::SubElementwise
2041 | OpKind::MulElementwise
2042 | OpKind::DivElementwise
2043 | OpKind::MaximumElementwise
2044 | OpKind::MinimumElementwise
2045 | OpKind::PowElementwise
2046 | OpKind::RemElementwise,
2047 )
2048}
2049
2050/// Deterministic two-input data for binary ops. `a` is `sin(i*2.1e-3)`
2051/// and `b` is `cos(i*1.9e-3)` for most ops; for Div, `b` is shifted
2052/// away from zero (`+ 1.5`, range `[0.5, 2.5]`) to avoid division by
2053/// near-zero values that would saturate `max_rel_err` to infinity on
2054/// any tiny precision difference.
2055fn binary_inputs(op: OpKind, n: usize) -> (Vec<f32>, Vec<f32>) {
2056 let mut a: Vec<f32> = (0..n).map(|i| ((i as f32) * 2.1e-3).sin()).collect();
2057 let mut b: Vec<f32> = (0..n).map(|i| ((i as f32) * 1.9e-3).cos()).collect();
2058 if matches!(op, OpKind::DivElementwise) {
2059 for x in &mut b {
2060 *x += 1.5;
2061 }
2062 }
2063 if matches!(op, OpKind::PowElementwise) {
2064 // Both inputs must be positive: pow(neg, non-int) = NaN under
2065 // IEEE-754 and saturates max_rel_err. Shift both to [0.5, 2.5].
2066 for x in &mut a {
2067 *x += 1.5;
2068 }
2069 for x in &mut b {
2070 *x += 1.5;
2071 }
2072 }
2073 if matches!(op, OpKind::RemElementwise) {
2074 // Divisor must be away from zero (`a / b` blows up otherwise).
2075 for x in &mut b {
2076 *x += 1.5;
2077 }
2078 }
2079 (a, b)
2080}
2081
2082/// Dispatch one elementwise binary op against `(a, b)`.
2083fn apply_binary(
2084 op: OpKind,
2085 a: &crate::lazy::Tensor,
2086 b: &crate::lazy::Tensor,
2087) -> crate::lazy::Tensor {
2088 match op {
2089 OpKind::AddElementwise => a.add(b).unwrap(),
2090 OpKind::SubElementwise => a.sub(b).unwrap(),
2091 OpKind::MulElementwise => a.mul(b).unwrap(),
2092 OpKind::DivElementwise => a.div(b).unwrap(),
2093 OpKind::MaximumElementwise => a.maximum(b).unwrap(),
2094 OpKind::MinimumElementwise => a.minimum(b).unwrap(),
2095 // Pow/Rem return Result; expect() in Judge's measurement
2096 // path is fine — inputs are constructed locally here, so a
2097 // shape/dtype mismatch is a programming bug in the test
2098 // harness, not a runtime user input.
2099 OpKind::PowElementwise => a.pow(b).expect("judge: pow shape/dtype invariant"),
2100 OpKind::RemElementwise => a.rem(b).expect("judge: rem shape/dtype invariant"),
2101 _ => unreachable!("apply_binary called on non-binary OpKind {op:?}"),
2102 }
2103}
2104
2105/// Whether `op` is one of the elementwise unary ops the Judge profiles
2106/// using the `Elementwise(n)` size ladder.
2107fn is_unary_elementwise(op: OpKind) -> bool {
2108 matches!(
2109 op,
2110 OpKind::NegElementwise
2111 | OpKind::SqrElementwise
2112 | OpKind::SqrtElementwise
2113 | OpKind::ExpElementwise
2114 | OpKind::LogElementwise
2115 | OpKind::SinElementwise
2116 | OpKind::CosElementwise
2117 | OpKind::TanhElementwise
2118 | OpKind::SigmoidElementwise
2119 | OpKind::SiluElementwise
2120 | OpKind::GeluTanhElementwise
2121 | OpKind::ReluElementwise
2122 | OpKind::StepElementwise
2123 | OpKind::RecipElementwise
2124 | OpKind::AbsElementwise
2125 | OpKind::FloorElementwise
2126 | OpKind::CeilElementwise
2127 | OpKind::RoundElementwise
2128 | OpKind::SignElementwise
2129 | OpKind::ErfElementwise
2130 | OpKind::GeluElementwise
2131 | OpKind::RsqrtElementwise,
2132 )
2133}
2134
2135/// Deterministic unary-op input. `sin(i * 2.1e-3)` produces values in
2136/// `[-1, 1]`, varied enough that argmax/argmin/relu/step all see a
2137/// realistic mix of positive and negative values.
2138fn unary_input(n: usize) -> Vec<f32> {
2139 (0..n).map(|i| ((i as f32) * 2.1e-3).sin()).collect()
2140}
2141
2142/// Dispatch one elementwise unary op against `a`.
2143fn apply_unary(op: OpKind, a: &crate::lazy::Tensor) -> crate::lazy::Tensor {
2144 match op {
2145 OpKind::NegElementwise => a.neg(),
2146 OpKind::SqrElementwise => a.sqr(),
2147 OpKind::SqrtElementwise => a.sqrt(),
2148 OpKind::ExpElementwise => a.exp(),
2149 OpKind::LogElementwise => a.log(),
2150 OpKind::TanhElementwise => a.tanh(),
2151 OpKind::SigmoidElementwise => a.sigmoid(),
2152 OpKind::SiluElementwise => a.silu(),
2153 OpKind::GeluTanhElementwise => a.gelu_tanh(),
2154 OpKind::ReluElementwise => a.relu(),
2155 OpKind::SinElementwise => a.sin(),
2156 OpKind::CosElementwise => a.cos(),
2157 OpKind::StepElementwise => a.step(),
2158 OpKind::RecipElementwise => a.recip(),
2159 OpKind::AbsElementwise => a.abs(),
2160 OpKind::FloorElementwise => a.floor(),
2161 OpKind::CeilElementwise => a.ceil(),
2162 OpKind::RoundElementwise => a.round(),
2163 OpKind::SignElementwise => a.sign(),
2164 OpKind::ErfElementwise => a.erf(),
2165 OpKind::GeluElementwise => a.gelu(),
2166 OpKind::RsqrtElementwise => a.rsqrt(),
2167 _ => unreachable!("apply_unary called on non-unary OpKind {op:?}"),
2168 }
2169}
2170
2171/// Max element-wise relative error between two same-length f32
2172/// vectors. Denominator guards against divide-by-zero on mutually-
2173/// zero pairs. Returns 0.0 when the lengths don't match (caller is
2174/// responsible for length sanity — this function is in the hot
2175/// path and avoids panics).
2176fn max_rel_err(a: &[f32], b: &[f32]) -> f32 {
2177 if a.len() != b.len() {
2178 return f32::INFINITY;
2179 }
2180 let mut worst = 0.0_f32;
2181 for (&x, &y) in a.iter().zip(b.iter()) {
2182 if !x.is_finite() || !y.is_finite() {
2183 return f32::INFINITY;
2184 }
2185 let denom = x.abs().max(y.abs()).max(f32::MIN_POSITIVE);
2186 let rel = (x - y).abs() / denom;
2187 if rel > worst {
2188 worst = rel;
2189 }
2190 }
2191 worst
2192}
2193
2194// =============================================================
2195// Pairwise consensus — Reference-retirement replacement (2026-06-07)
2196// =============================================================
2197//
2198// Pre-retirement, Judge compared every backend's output against
2199// `tensor.realize_f32_reference()` to populate `ProfileEntry::max_rel_error`.
2200// That privileged the `fuel-reference-backend` crate as THE oracle —
2201// the architectural decision in v0.2 of `05-backend-contract.md`
2202// demoted Reference but the code lagged.
2203//
2204// Post-retirement, Judge runs every available backend's kernel for
2205// each (op, dtype, size) cell, clusters the outputs by mutual
2206// `rel_err < CONSENSUS_EPSILON`, and computes each backend's
2207// `max_rel_error` against the largest cluster (the "consensus
2208// group"). A backend in the consensus group reports its max
2209// within-cluster rel_err (typically near 0); a backend outside
2210// the consensus reports its rel_err against the cluster — that's
2211// the outlier signal callers want.
2212
2213/// Generous epsilon for cross-backend consensus clustering.
2214/// Different backends produce numerically-different bits for the
2215/// same logical operation (IEEE rounding rules + accumulation
2216/// order differ across BLAS impls, GPU shader cores, etc.); the
2217/// cluster definition has to accommodate this. `1e-3` is loose
2218/// enough that floating-point accumulation reordering won't push
2219/// honest implementations out of consensus while still catching
2220/// kernels that are silently wrong.
2221///
2222/// Per-op tightening can land later via per-op
2223/// `PrecisionGuarantee::max_relative` if a particular op family
2224/// warrants tighter clustering. The 1e-3 default matches the
2225/// `assert!(e.max_rel_error < 5e-3)` thresholds used in Judge's
2226/// own tests below — both numbers reflect "this is the noise
2227/// floor for honest cross-backend implementations of f32 math."
2228const CONSENSUS_EPSILON: f32 = 1e-3;
2229
2230/// One backend's measurement for one (op, dtype, size, kernel_source)
2231/// cell — kernel output captured alongside latency for downstream
2232/// consensus comparison.
2233///
2234/// **Per-alternative measurement (2026-06-08)**: `kernel_source`
2235/// distinguishes sibling kernels at one `(op, dtypes, backend)`
2236/// binding-table key (e.g. AOCL vs MKL vs portable-cpu at
2237/// `(MatMul, [F32×3], BackendId::Cpu)`). Each alternative produces
2238/// its own CellRun; the pairwise consensus then sees the full
2239/// cross-kernel-source population, treating AOCL/MKL/portable as
2240/// peers alongside CUDA/Vulkan.
2241#[derive(Debug)]
2242struct CellRun {
2243 backend: BackendId,
2244 device_index: u32,
2245 output: Vec<f32>,
2246 latency_ns: u64,
2247 iterations: u32,
2248 /// Diagnostic tag identifying the kernel sibling at the
2249 /// binding-table cell. `""` for cells with a single alternative
2250 /// or when the kernel-source tag is unknown.
2251 kernel_source: String,
2252 /// Content-derived revision of the kernel that produced this run,
2253 /// paired with `kernel_source` from a single attribution so the two
2254 /// never describe different kernels. Always a real, attributed value:
2255 /// a run whose revision could not be attributed is not recorded (see
2256 /// the dispatched-path conservative cut) rather than stamped `0`.
2257 kernel_revision_hash: u64,
2258}
2259
2260/// Compute the largest mutually-close cluster across `runs`. Returns
2261/// indices into `runs` (sorted ascending) of the consensus members.
2262///
2263/// Algorithm: for each run i, build the set of runs that pairwise
2264/// agree with i (and with each other). The largest such set is the
2265/// consensus. Ties broken by lowest first-index — deterministic.
2266///
2267/// Edge cases:
2268/// - Empty `runs`: empty consensus.
2269/// - Single run: trivial consensus of [0]. The lone backend is
2270/// authoritative by absence of peers.
2271/// - Two runs agreeing within `CONSENSUS_EPSILON`: consensus is
2272/// both; each reports its rel_err against the other (small).
2273/// - Two runs disagreeing: consensus is [0] (first wins ties by
2274/// index); the other gets the disagreement as its rel_err.
2275/// Callers should treat 2-backend cells with disagreement as
2276/// "human review needed" — neither answer is independently
2277/// validated.
2278/// - Three runs with one outlier: consensus is the two that agree;
2279/// the outlier reports its rel_err against the cluster.
2280// needless_range_loop here: the index is used as a value
2281// (assert/message/push/compare), not only to index.
2282#[allow(clippy::needless_range_loop)]
2283fn compute_pairwise_consensus(runs: &[CellRun]) -> Vec<usize> {
2284 let n = runs.len();
2285 if n == 0 {
2286 return Vec::new();
2287 }
2288 if n == 1 {
2289 return vec![0];
2290 }
2291
2292 // Pairwise rel_err matrix: agree[i][j] = (max_rel_err < CONSENSUS_EPSILON).
2293 let mut agree = vec![vec![false; n]; n];
2294 for i in 0..n {
2295 agree[i][i] = true;
2296 for j in (i + 1)..n {
2297 let close = max_rel_err(&runs[i].output, &runs[j].output) < CONSENSUS_EPSILON;
2298 agree[i][j] = close;
2299 agree[j][i] = close;
2300 }
2301 }
2302
2303 // For each i, expand a cluster greedily: start with {i}, add
2304 // each j ∈ neighbors that pairwise-agrees with all current
2305 // members. Greedy — but for small N (typical: 2-4 backends),
2306 // exhaustive search would also be cheap. Greedy is correct
2307 // when the threshold defines a true equivalence-like relation;
2308 // for f32 cross-backend agreement that's a reasonable assumption.
2309 let mut best: Vec<usize> = vec![0];
2310 for i in 0..n {
2311 let mut cluster = vec![i];
2312 for j in 0..n {
2313 if j == i {
2314 continue;
2315 }
2316 // j joins iff agrees with every existing member.
2317 if cluster.iter().all(|&k| agree[j][k]) {
2318 cluster.push(j);
2319 }
2320 }
2321 cluster.sort_unstable();
2322 // Larger cluster wins; ties broken by lower starting index.
2323 if cluster.len() > best.len() {
2324 best = cluster;
2325 }
2326 }
2327 best
2328}
2329
2330/// Reinterpret an f32 vector as the little-endian bytes that
2331/// [`validate_against_fixture`] expects, run validation, and lift
2332/// the verdict into a single rel_err number for the ProfileEntry.
2333///
2334/// - `Ok(())` → `0.0` (output matches fixture within tolerance).
2335/// - `Err(OutOfTolerance { rel_err, .. })` → that `rel_err`.
2336/// - `Err(LengthMismatch | NonFinite | UnvalidatableDType)` →
2337/// `f32::INFINITY` — these are categorical failures (wrong shape,
2338/// NaN/Inf where finite was expected, or a dtype this crate has no
2339/// element-wise validator for). The dispatch picker will rank such
2340/// an alternative behind any finite-rel_err peer; the kernel-source
2341/// tag on the ProfileEntry tells us which alternative blew up.
2342/// `UnvalidatableDType` must NOT map to `0.0`: that would report an
2343/// unvalidated kernel as *perfectly* correct, out-ranking every
2344/// validated peer while the validator certified nothing (GAP-075).
2345fn max_rel_err_vs_fixture(out: &[f32], fixture: &CorrectnessFixture) -> f32 {
2346 let bytes: Vec<u8> = out.iter().flat_map(|x| x.to_le_bytes()).collect();
2347 match validate_against_fixture(fixture, &bytes) {
2348 Ok(()) => 0.0,
2349 Err(CorrectnessDrift::OutOfTolerance { rel_err, .. }) => rel_err as f32,
2350 Err(CorrectnessDrift::LengthMismatch { .. }) => f32::INFINITY,
2351 Err(CorrectnessDrift::NonFinite { .. }) => f32::INFINITY,
2352 // Correctness UNKNOWN (no element-wise validator for this dtype)
2353 // ranks as a categorical failure, never as 0.0 "perfect" (GAP-075).
2354 Err(CorrectnessDrift::UnvalidatableDType { .. }) => f32::INFINITY,
2355 }
2356}
2357
2358/// Load every `*.json` file under `root` and flatten the contained
2359/// [`FixtureFile`] entries into a map keyed by
2360/// `(op, dtype, size_class)`. `root` may itself be a file (one
2361/// fixture file loaded) or a directory (recursive walk).
2362///
2363/// Errors propagate the first I/O failure or deserialization error
2364/// encountered. A directory with no `*.json` files yields an empty
2365/// map (the Judge then behaves as if no fixtures were configured).
2366///
2367/// Files whose `FixtureFile.version` does not match the current
2368/// [`FIXTURE_FILE_VERSION`] are skipped with a stderr warning. The
2369/// load itself is non-fatal — a single stale file in the fixture
2370/// distribution shouldn't strand the rest of the run.
2371fn load_fixtures_recursive(
2372 root: &Path,
2373) -> Result<HashMap<(OpKind, DType, SizeClass), Vec<CorrectnessFixture>>> {
2374 let mut map: HashMap<(OpKind, DType, SizeClass), Vec<CorrectnessFixture>> = HashMap::new();
2375 let mut visit = |p: &Path| -> Result<()> {
2376 let raw = std::fs::read_to_string(p).map_err(|e| {
2377 fuel_ir::error::Error::Msg(format!(
2378 "judge: failed to read fixture file {}: {e}",
2379 p.display(),
2380 ))
2381 })?;
2382 let file: FixtureFile = serde_json::from_str(&raw).map_err(|e| {
2383 fuel_ir::error::Error::Msg(format!(
2384 "judge: failed to parse fixture file {} as FixtureFile: {e}",
2385 p.display(),
2386 ))
2387 })?;
2388 if file.version != FIXTURE_FILE_VERSION {
2389 eprintln!(
2390 "judge: skipping fixture file {} — version {} does not match \
2391 current FIXTURE_FILE_VERSION ({}); regenerate via fuel-capture-fixtures",
2392 p.display(),
2393 file.version,
2394 FIXTURE_FILE_VERSION,
2395 );
2396 return Ok(());
2397 }
2398 for f in file.fixtures {
2399 let key = (f.op, f.dtype, f.size_class);
2400 map.entry(key).or_default().push(f);
2401 }
2402 Ok(())
2403 };
2404
2405 fn walk(dir: &Path, visit: &mut dyn FnMut(&Path) -> Result<()>) -> Result<()> {
2406 let entries = std::fs::read_dir(dir).map_err(|e| {
2407 fuel_ir::error::Error::Msg(format!(
2408 "judge: failed to read fixture dir {}: {e}",
2409 dir.display(),
2410 ))
2411 })?;
2412 for entry in entries {
2413 let entry = entry.map_err(|e| {
2414 fuel_ir::error::Error::Msg(format!(
2415 "judge: failed reading dir entry in {}: {e}",
2416 dir.display(),
2417 ))
2418 })?;
2419 let p = entry.path();
2420 if p.is_dir() {
2421 walk(&p, visit)?;
2422 } else if p.extension().and_then(|s| s.to_str()) == Some("json") {
2423 visit(&p)?;
2424 }
2425 }
2426 Ok(())
2427 }
2428
2429 if root.is_file() {
2430 visit(root)?;
2431 } else if root.is_dir() {
2432 walk(root, &mut visit)?;
2433 } else {
2434 return Err(fuel_ir::error::Error::Msg(format!(
2435 "judge: fixture path {} does not exist",
2436 root.display(),
2437 )));
2438 }
2439 Ok(map)
2440}
2441
2442/// Compute `runs[self_idx]`'s max relative error against the
2443/// consensus cluster. When the consensus contains only `self_idx`
2444/// (single backend or every backend disagreed), returns `0.0` —
2445/// there's no peer to measure drift against.
2446fn max_rel_err_vs_consensus(runs: &[CellRun], consensus: &[usize], self_idx: usize) -> f32 {
2447 let peers: Vec<usize> = consensus
2448 .iter()
2449 .copied()
2450 .filter(|&i| i != self_idx)
2451 .collect();
2452 if peers.is_empty() {
2453 return 0.0;
2454 }
2455 let self_out = &runs[self_idx].output;
2456 let mut worst = 0.0_f32;
2457 for i in peers {
2458 let e = max_rel_err(self_out, &runs[i].output);
2459 if e > worst {
2460 worst = e;
2461 }
2462 }
2463 worst
2464}
2465
2466#[cfg(test)]
2467mod tests {
2468 use super::*;
2469
2470 #[test]
2471 fn size_class_matches_log2_floor() {
2472 assert_eq!(SizeClass::from_elem_count(1).0, 0);
2473 assert_eq!(SizeClass::from_elem_count(2).0, 1);
2474 assert_eq!(SizeClass::from_elem_count(3).0, 1);
2475 assert_eq!(SizeClass::from_elem_count(4).0, 2);
2476 assert_eq!(SizeClass::from_elem_count(1 << 16).0, 16);
2477 assert_eq!(SizeClass::from_elem_count(65_537).0, 16);
2478 }
2479
2480 #[test]
2481 fn size_class_zero_input_clamps_to_one() {
2482 assert_eq!(SizeClass::from_elem_count(0).0, 0);
2483 }
2484
2485 #[test]
2486 fn op_kind_display_is_stable() {
2487 assert_eq!(OpKind::MatMul.to_string(), "matmul");
2488 assert_eq!(OpKind::AddElementwise.to_string(), "add");
2489 }
2490
2491 /// GAP-075: a fixture whose dtype has no element-wise validator
2492 /// wired must rank as a categorical FAILURE (`INFINITY`), never as
2493 /// `0.0` ("perfect"). The old `_ => Ok(())` in
2494 /// `validate_against_fixture` mapped here to `0.0`, so an
2495 /// unvalidated kernel out-ranked every validated peer while the
2496 /// validator had certified nothing. This locks the verdict at the
2497 /// Judge boundary, where the false-green would otherwise surface as
2498 /// a green test suite with no traceable symptom.
2499 #[test]
2500 fn unvalidatable_dtype_fixture_ranks_as_failure_not_perfect() {
2501 use fuel_correctness_fixtures::ToleranceBand;
2502 // Byte-lengths must match so we reach the dtype-decline arm, not
2503 // LengthMismatch: `out` is f32 (4 B each) → 2 elems = 8 B; the
2504 // F16 fixture (2 B each) with 4 elems = 8 B. Equal ⇒ past the
2505 // length gate, into the unwired-dtype decline.
2506 let out = vec![0.0f32, 0.0f32];
2507 let fixture = CorrectnessFixture {
2508 op: OpKind::AddElementwise,
2509 dtype: DType::F16,
2510 size_class: SizeClass(2),
2511 input_seed: 1,
2512 input_hash: 0,
2513 expected_output: vec![0u8; 8],
2514 output_element_count: 4,
2515 tolerance: ToleranceBand::F32_DEFAULT,
2516 };
2517 let verdict = max_rel_err_vs_fixture(&out, &fixture);
2518 assert_eq!(
2519 verdict,
2520 f32::INFINITY,
2521 "unvalidatable-dtype fixture must rank as failure (INFINITY), \
2522 not 0.0 — a false 'perfect' certifies an unchecked kernel",
2523 );
2524 }
2525
2526 /// Session 3 rider + Judge item 7 (device+kernel identity): the
2527 /// realizer-measured CellRun records the `(kernel_source,
2528 /// kernel_revision_hash)` the realizer REPORTS — the picker's dispatched
2529 /// sibling — both fields from ONE attribution so they cannot disagree
2530 /// about which kernel ran.
2531 ///
2532 /// When the realizer has NO attribution (`last_kernel_source` /
2533 /// `last_kernel_revision` both `None` — the plan had no entry for the
2534 /// root, or a best-effort lock/lookup miss), NO CellRun is produced. This
2535 /// is the conservative cut: `alts.first()`'s revision would be a plausible
2536 /// GUESS about a real sibling, and `0` would claim "untracked" about a
2537 /// kernel that actually ran — both are rows that LIE about the identity
2538 /// `kernel_revision_hash` exists to make trustworthy. An absent row is the
2539 /// honest outcome (the cell then simply misses in the oracle; Layer-1
2540 /// static costs stand). Born-red: before the cut, the `None` case wrote a
2541 /// row stamped with the first-registered fallback tag and a `0` revision.
2542 #[test]
2543 fn cell_run_carries_attribution_or_is_dropped() {
2544 struct StubRealizer {
2545 src: Option<&'static str>,
2546 rev: Option<u64>,
2547 }
2548 impl crate::factories::Realizer for StubRealizer {
2549 fn realize_f32(&mut self, _tensor: &crate::lazy::Tensor) -> Result<Vec<f32>> {
2550 Ok(vec![0.0; 8])
2551 }
2552 fn last_kernel_source(&self) -> Option<&'static str> {
2553 self.src
2554 }
2555 fn last_kernel_revision(&self) -> Option<u64> {
2556 self.rev
2557 }
2558 }
2559
2560 let judge = Judge {
2561 iterations: 1,
2562 warmup: 0,
2563 size_plan_override: None,
2564 fixtures: None,
2565 };
2566 let device = DeviceDescriptor {
2567 backend: BackendId::Cpu,
2568 device_index: 0,
2569 hardware_sku: "test-cpu".into(),
2570 vendor_id: 0,
2571 device_id: 0,
2572 compute_capability: None,
2573 subgroup_width: None,
2574 driver_version: String::new(),
2575 total_memory_bytes: 0,
2576 location: fuel_ir::DeviceLocation::Cpu,
2577 };
2578 let op = OpKind::AddElementwise;
2579 let size = OpSize::Elementwise(8);
2580
2581 // Attributed: source AND revision from one picker result → the CellRun
2582 // carries both verbatim, regardless of registration order at the cell.
2583 let mut reporting = StubRealizer {
2584 src: Some("stub-sibling"),
2585 rev: Some(0x00AB_CDEF),
2586 };
2587 let run = judge
2588 .time_op_capturing(op, DType::F32, &size, &device, &mut reporting)
2589 .expect("attributed dispatch produces a CellRun");
2590 assert_eq!(run.kernel_source, "stub-sibling");
2591 assert_eq!(run.kernel_revision_hash, 0x00AB_CDEF);
2592
2593 // No attribution (both None) → NO CellRun. The conservative cut writes
2594 // nothing rather than a guessed or fabricated revision.
2595 let mut silent = StubRealizer {
2596 src: None,
2597 rev: None,
2598 };
2599 assert!(
2600 judge
2601 .time_op_capturing(op, DType::F32, &size, &device, &mut silent)
2602 .is_none(),
2603 "unattributed dispatch must produce NO row, not a guessed/fabricated one",
2604 );
2605 }
2606
2607 #[test]
2608 fn judge_profiles_cpu_on_small_matmul() {
2609 // Post-Reference-retirement (2026-06-07): with Reference
2610 // gone, the local single-backend (CPU) is alone in its
2611 // (op, dtype, size) cell. Consensus is trivially `[0]`;
2612 // `max_rel_error` is `0.0` (no peers to measure drift
2613 // against). The test verifies the Judge still produces
2614 // entries with sane timing data when only one backend is
2615 // available — the single-backend edge case in the
2616 // consensus algorithm.
2617 let probe = ProbeReport::probe_all();
2618 let judge = Judge {
2619 iterations: 3,
2620 warmup: 1,
2621 size_plan_override: Some(vec![
2622 (
2623 OpKind::MatMul,
2624 OpSize::MatMul {
2625 m: 32,
2626 n: 32,
2627 k: 32,
2628 },
2629 ),
2630 (OpKind::AddElementwise, OpSize::Elementwise(1 << 10)),
2631 ]),
2632 fixtures: None,
2633 };
2634 let report = judge.run(&probe);
2635 assert_eq!(report.version, PROFILE_REPORT_VERSION);
2636 assert!(report.entries.iter().any(|e| e.backend == BackendId::Cpu));
2637 // Post-v2 (per-alternative measurement): one CPU cell may
2638 // produce multiple entries (one per kernel_source — AOCL,
2639 // MKL, portable-cpu). When peers exist the rel_err can be
2640 // non-zero but should stay below the bit-stable floor;
2641 // when only one alternative is registered rel_err is 0.0
2642 // (no peers → no drift reference).
2643 for e in report
2644 .entries
2645 .iter()
2646 .filter(|e| e.backend == BackendId::Cpu)
2647 {
2648 assert!(
2649 e.max_rel_error < 1e-3,
2650 "cpu cell rel_err should stay tight ({e:?})"
2651 );
2652 assert!(e.latency_ns > 0);
2653 }
2654 }
2655
2656 #[test]
2657 fn judge_profiles_all_unary_elementwise_ops() {
2658 // Exercise every elementwise-unary OpKind on cpu + reference at
2659 // a tiny size. Confirms the size_plan / build_input_graph /
2660 // apply_unary wiring is complete and that no unary kernel
2661 // diverges wildly from reference on the cpu backend.
2662 let probe = ProbeReport::probe_all();
2663 let unary = [
2664 OpKind::NegElementwise,
2665 OpKind::SqrElementwise,
2666 OpKind::SqrtElementwise,
2667 OpKind::ExpElementwise,
2668 OpKind::LogElementwise,
2669 OpKind::SinElementwise,
2670 OpKind::CosElementwise,
2671 OpKind::TanhElementwise,
2672 OpKind::SigmoidElementwise,
2673 OpKind::SiluElementwise,
2674 OpKind::GeluTanhElementwise,
2675 OpKind::ReluElementwise,
2676 OpKind::StepElementwise,
2677 OpKind::RecipElementwise,
2678 OpKind::AbsElementwise,
2679 OpKind::FloorElementwise,
2680 OpKind::CeilElementwise,
2681 OpKind::RoundElementwise,
2682 OpKind::SignElementwise,
2683 OpKind::ErfElementwise,
2684 OpKind::GeluElementwise,
2685 OpKind::RsqrtElementwise,
2686 ];
2687 let plan: Vec<_> = unary
2688 .iter()
2689 .map(|&op| (op, OpSize::Elementwise(1 << 8)))
2690 .collect();
2691 let judge = Judge {
2692 iterations: 3,
2693 warmup: 1,
2694 size_plan_override: Some(plan),
2695 fixtures: None,
2696 };
2697 let report = judge.run(&probe);
2698 for &op in &unary {
2699 let cpu_entries: Vec<_> = report
2700 .entries
2701 .iter()
2702 .filter(|e| e.op == op && e.backend == BackendId::Cpu)
2703 .collect();
2704 // Post-v2 (per-alternative measurement): ≥1 — when only the
2705 // portable CPU kernel is registered the count is 1; with
2706 // AOCL/MKL feature-gated alternatives it grows. Pick the
2707 // first entry (the realizer-primary measurement) for the
2708 // numerical-divergence check.
2709 assert!(
2710 !cpu_entries.is_empty(),
2711 "expected ≥1 cpu entry for {op}, got 0"
2712 );
2713 let e = cpu_entries[0];
2714 // Elementwise unary ops are bit-stable on cpu vs reference
2715 // (no accumulation order to worry about). Allow a
2716 // generous bound so transcendental approximations
2717 // (tanh/sigmoid/silu/gelu_tanh) on different math libs still
2718 // pass — but a runaway divergence (>1e-3) flags a bug.
2719 assert!(
2720 e.max_rel_error < 1e-3,
2721 "cpu vs reference disagreement on {op}: rel_err={}",
2722 e.max_rel_error
2723 );
2724 assert!(e.latency_ns > 0);
2725 }
2726 }
2727
2728 #[test]
2729 fn judge_profiles_all_binary_elementwise_ops() {
2730 let probe = ProbeReport::probe_all();
2731 let binary = [
2732 OpKind::AddElementwise,
2733 OpKind::SubElementwise,
2734 OpKind::MulElementwise,
2735 OpKind::DivElementwise,
2736 OpKind::MaximumElementwise,
2737 OpKind::MinimumElementwise,
2738 OpKind::PowElementwise,
2739 OpKind::RemElementwise,
2740 ];
2741 let plan: Vec<_> = binary
2742 .iter()
2743 .map(|&op| (op, OpSize::Elementwise(1 << 8)))
2744 .collect();
2745 let judge = Judge {
2746 iterations: 3,
2747 warmup: 1,
2748 size_plan_override: Some(plan),
2749 fixtures: None,
2750 };
2751 let report = judge.run(&probe);
2752 for &op in &binary {
2753 let cpu_entries: Vec<_> = report
2754 .entries
2755 .iter()
2756 .filter(|e| e.op == op && e.backend == BackendId::Cpu)
2757 .collect();
2758 assert!(
2759 !cpu_entries.is_empty(),
2760 "expected >=1 cpu entry for {op}, got 0"
2761 );
2762 let e = cpu_entries[0];
2763 assert!(
2764 e.max_rel_error < 1e-3,
2765 "cpu vs reference disagreement on {op}: rel_err={}",
2766 e.max_rel_error
2767 );
2768 assert!(e.latency_ns > 0);
2769 }
2770 }
2771
2772 #[test]
2773 fn judge_profiles_all_reductions() {
2774 let probe = ProbeReport::probe_all();
2775 let reduce = [
2776 OpKind::SumReduce,
2777 OpKind::MaxReduce,
2778 OpKind::MinReduce,
2779 OpKind::MeanReduce,
2780 ];
2781 let plan: Vec<_> = reduce
2782 .iter()
2783 .map(|&op| (op, OpSize::Reduce { rows: 16, cols: 16 }))
2784 .collect();
2785 let judge = Judge {
2786 iterations: 3,
2787 warmup: 1,
2788 size_plan_override: Some(plan),
2789 fixtures: None,
2790 };
2791 let report = judge.run(&probe);
2792 for &op in &reduce {
2793 let cpu_entries: Vec<_> = report
2794 .entries
2795 .iter()
2796 .filter(|e| e.op == op && e.backend == BackendId::Cpu)
2797 .collect();
2798 assert!(
2799 !cpu_entries.is_empty(),
2800 "expected >=1 cpu entry for {op}, got 0"
2801 );
2802 let e = cpu_entries[0];
2803 // Sum-style reductions accumulate in different order on
2804 // backend vs reference; allow up to 5e-3.
2805 assert!(
2806 e.max_rel_error < 5e-3,
2807 "cpu vs reference disagreement on {op}: rel_err={}",
2808 e.max_rel_error
2809 );
2810 assert!(e.latency_ns > 0);
2811 }
2812 }
2813
2814 #[test]
2815 fn judge_profiles_all_reduce_to() {
2816 let probe = ProbeReport::probe_all();
2817 let reduce_to = [OpKind::ReduceSumTo, OpKind::ReduceMaxTo];
2818 let plan: Vec<_> = reduce_to
2819 .iter()
2820 .map(|&op| (op, OpSize::ReduceTo { rows: 16, cols: 16 }))
2821 .collect();
2822 let judge = Judge {
2823 iterations: 3,
2824 warmup: 1,
2825 size_plan_override: Some(plan),
2826 fixtures: None,
2827 };
2828 let report = judge.run(&probe);
2829 for &op in &reduce_to {
2830 let cpu_entries: Vec<_> = report
2831 .entries
2832 .iter()
2833 .filter(|e| e.op == op && e.backend == BackendId::Cpu)
2834 .collect();
2835 assert!(
2836 !cpu_entries.is_empty(),
2837 "expected >=1 cpu entry for {op}, got 0"
2838 );
2839 let e = cpu_entries[0];
2840 assert!(
2841 e.max_rel_error < 5e-3,
2842 "cpu vs reference disagreement on {op}: rel_err={}",
2843 e.max_rel_error
2844 );
2845 assert!(e.latency_ns > 0);
2846 }
2847 }
2848
2849 #[test]
2850 fn judge_profiles_all_scalar_ops() {
2851 let probe = ProbeReport::probe_all();
2852 let scalar = [
2853 OpKind::Affine,
2854 OpKind::ClampElementwise,
2855 OpKind::PowIElementwise,
2856 ];
2857 let plan: Vec<_> = scalar
2858 .iter()
2859 .map(|&op| (op, OpSize::Elementwise(1 << 8)))
2860 .collect();
2861 let judge = Judge {
2862 iterations: 3,
2863 warmup: 1,
2864 size_plan_override: Some(plan),
2865 fixtures: None,
2866 };
2867 let report = judge.run(&probe);
2868 for &op in &scalar {
2869 let cpu_entries: Vec<_> = report
2870 .entries
2871 .iter()
2872 .filter(|e| e.op == op && e.backend == BackendId::Cpu)
2873 .collect();
2874 assert!(
2875 !cpu_entries.is_empty(),
2876 "expected >=1 cpu entry for {op}, got 0"
2877 );
2878 let e = cpu_entries[0];
2879 assert!(
2880 e.max_rel_error < 1e-3,
2881 "cpu vs reference disagreement on {op}: rel_err={}",
2882 e.max_rel_error
2883 );
2884 assert!(e.latency_ns > 0);
2885 }
2886 }
2887
2888 /// Decode-shaped-ladder slice (Judge Layer-2 coverage arc,
2889 /// 2026-07-04, slice 2).
2890 ///
2891 /// **Born-red**: before this slice the `size_plan` ladders were all
2892 /// SQUARE/large — no seq_q=1 matmul, no wide-`k_len` reduction, no
2893 /// decode-score-row elementwise cell. The decomposed decode-
2894 /// attention softmax (`exp(x-max)/sum`) and the projection/score
2895 /// GEMVs run at SKINNY shapes that behave nothing like the square
2896 /// ladder (bandwidth-bound vs compute-bound). This asserts the real
2897 /// default `size_plan` now carries the decode cells and that the
2898 /// decode matmul cell keys to a DISTINCT [`SizeClass`] from every
2899 /// square cell (slice-4's arm-0 flash-vs-decomposed comparison
2900 /// needs the decomposed primitives measured at decode shapes, keyed
2901 /// distinctly so the oracle doesn't collapse them onto a square
2902 /// cell). Exercises the production `size_plan`, not an override.
2903 #[test]
2904 fn size_plan_carries_decode_shaped_cells() {
2905 let judge = Judge::default(); // no override → real size_plan
2906
2907 // -- MatMul: a seq_q=1 (m==1) decode GEMV must be present, and
2908 // its aspect SizeClass must differ from every square cell's.
2909 // Keys are derived via `SizeClass::matmul(m,n,k)` — the SAME
2910 // helper the producer (`run`) and the ranker consumer reach
2911 // (through `for_op`), so this asserts the real dispatch key. --
2912 let mm = judge.size_plan(OpKind::MatMul);
2913 let sc_of = |s: &OpSize| match *s {
2914 OpSize::MatMul { m, n, k } => SizeClass::matmul(m, n, k),
2915 _ => unreachable!("size_plan(MatMul) yields only MatMul sizes"),
2916 };
2917 let square_scs: Vec<SizeClass> = mm
2918 .iter()
2919 .filter(|s| matches!(s, OpSize::MatMul { m, n, .. } if m == n))
2920 .map(sc_of)
2921 .collect();
2922 assert!(
2923 !square_scs.is_empty(),
2924 "the general square matmul ladder must be preserved",
2925 );
2926 let decode_mm: Vec<&OpSize> = mm
2927 .iter()
2928 .filter(|s| matches!(s, OpSize::MatMul { m, .. } if *m == 1))
2929 .collect();
2930 assert!(
2931 !decode_mm.is_empty(),
2932 "size_plan(MatMul) must include a seq_q=1 decode GEMV cell",
2933 );
2934 for d in &decode_mm {
2935 let sc = sc_of(d);
2936 assert!(
2937 !square_scs.contains(&sc),
2938 "decode matmul cell {d:?} SizeClass {sc:?} collides with a \
2939 square cell — arm-0 costing would read the square latency",
2940 );
2941 }
2942
2943 // -- Decode-row reductions (softmax row-max + denominator-sum):
2944 // a WIDE reduced dim (cols >= 128 = k_len), distinct from the
2945 // cols=64 general ladder. Min/Mean are NOT on the softmax
2946 // path and must keep only their general ladder. --
2947 for op in [OpKind::MaxReduce, OpKind::SumReduce] {
2948 let plan = judge.size_plan(op);
2949 assert!(
2950 plan.iter()
2951 .any(|s| matches!(s, OpSize::Reduce { cols, .. } if *cols >= 128)),
2952 "size_plan({op}) must include a decode-row (wide-cols) reduction cell",
2953 );
2954 }
2955 for op in [OpKind::MinReduce, OpKind::MeanReduce] {
2956 let plan = judge.size_plan(op);
2957 assert!(
2958 plan.iter()
2959 .all(|s| matches!(s, OpSize::Reduce { cols, .. } if *cols == 64)),
2960 "size_plan({op}) is off the decode softmax path — no decode cell expected",
2961 );
2962 }
2963
2964 // -- Decode-score-row elementwise (softmax sub/exp/div over the
2965 // [Hq, k_len] score tensor). These three get decode cells
2966 // appended; an off-path elementwise op (e.g. Sin) does not. --
2967 for op in [
2968 OpKind::SubElementwise,
2969 OpKind::ExpElementwise,
2970 OpKind::DivElementwise,
2971 ] {
2972 let plan = judge.size_plan(op);
2973 assert!(
2974 plan.len() > 3,
2975 "size_plan({op}) must append decode cells beyond the 3-size base ladder",
2976 );
2977 }
2978 assert_eq!(
2979 judge.size_plan(OpKind::SinElementwise).len(),
2980 3,
2981 "an off-softmax-path elementwise op must keep only its 3-size base ladder",
2982 );
2983 }
2984
2985 /// Slice-2 born-red companion: a fast CPU run over a square + a
2986 /// decode matmul cell produces `ProfileEntry`s at TWO distinct
2987 /// [`SizeClass`] keys, each with a positive latency. This is the
2988 /// end-to-end proof that the decode GEMV is measurable AND
2989 /// distinguishable from the square regime through the full run
2990 /// path (deliverable 3's "distinguishable from the square cell").
2991 #[test]
2992 fn judge_measures_decode_matmul_distinct_from_square() {
2993 let probe = ProbeReport::probe_all();
2994 let judge = Judge {
2995 iterations: 3,
2996 warmup: 1,
2997 size_plan_override: Some(vec![
2998 // Square (compute-bound) — small enough to stay fast.
2999 (
3000 OpKind::MatMul,
3001 OpSize::MatMul {
3002 m: 256,
3003 n: 256,
3004 k: 256,
3005 },
3006 ),
3007 // Decode GEMV (seq_q=1, bandwidth-bound).
3008 (
3009 OpKind::MatMul,
3010 OpSize::MatMul {
3011 m: 1,
3012 n: 2048,
3013 k: 2048,
3014 },
3015 ),
3016 ]),
3017 fixtures: None,
3018 };
3019 let report = judge.run(&probe);
3020 // v4 aspect keys — the same `matmul(m,n,k)` the producer's
3021 // `run` stamps on each entry.
3022 let square_sc = SizeClass::matmul(256, 256, 256);
3023 let decode_sc = SizeClass::matmul(1, 2048, 2048);
3024 assert_ne!(
3025 square_sc, decode_sc,
3026 "decode + square matmul must key to different SizeClass",
3027 );
3028 let measured = |sc: SizeClass| {
3029 report.entries.iter().any(|e| {
3030 e.op == OpKind::MatMul
3031 && e.backend == BackendId::Cpu
3032 && e.size_class == sc
3033 && e.latency_ns > 0
3034 })
3035 };
3036 assert!(measured(square_sc), "square matmul cell must be measured");
3037 assert!(
3038 measured(decode_sc),
3039 "decode matmul cell must be measured with latency > 0 at its own SizeClass",
3040 );
3041 }
3042
3043 /// SizeClass v4 producer/consumer round-trip (Judge Layer-2 coverage
3044 /// arc, slice 2.5, 2026-07-04).
3045 ///
3046 /// **Born-red**: this is the round-trip the aspect-blind key broke.
3047 /// The Judge (producer) profiled the FFN-width decode GEMV
3048 /// `[1,2048]×[2048,5632]` keyed on `m·n = 5632`; the `fuel-dispatch`
3049 /// ranker (consumer) keyed its realize-time lookup on
3050 /// `shapes[0].elem_count() = m·k = 2048` — a DIFFERENT bucket — so
3051 /// the lookup never found the produced cell. This drives the real
3052 /// producer path (`run`) to emit the cell, then performs the exact
3053 /// consumer key derivation (`SizeClass::for_op` from the operand
3054 /// shapes, as `compute_static_costs` does) and asserts the produced
3055 /// entry is HIT through the actual `ProfileJudgeOracle`. Pre-v4 this
3056 /// asserted-`is_some` lookup returned `None` (miss).
3057 #[test]
3058 fn ranker_lookup_hits_producer_decode_gemv_cell() {
3059 use fuel_dispatch::ranker::JudgeOracle;
3060
3061 let probe = ProbeReport::probe_all();
3062 let judge = Judge {
3063 iterations: 3,
3064 warmup: 1,
3065 // The genuinely non-square FFN-width decode GEMV — the cell
3066 // slice 2 had to DEFER because its old m·n key collided with
3067 // the 64³ square.
3068 size_plan_override: Some(vec![(
3069 OpKind::MatMul,
3070 OpSize::MatMul {
3071 m: 1,
3072 n: 5632,
3073 k: 2048,
3074 },
3075 )]),
3076 fixtures: None,
3077 };
3078 let report = judge.run(&probe);
3079
3080 // Consumer-side key: derived from the operand shapes
3081 // lhs=[m,k]/rhs=[k,n] exactly the way the ranker's
3082 // `compute_static_costs` does (`SizeClass::for_op`).
3083 let lhs = Shape::from_dims(&[1, 2048]);
3084 let rhs = Shape::from_dims(&[2048, 5632]);
3085 let ranker_key = SizeClass::for_op(OpKind::MatMul, &[lhs, rhs]);
3086
3087 // Old-bug witness: for this non-square shape the pre-v4 scalar
3088 // keys the two sides used (consumer m·k vs producer m·n) landed
3089 // in different buckets — the disagreement this slice fixes.
3090 assert_ne!(
3091 SizeClass::from_elem_count(1 * 2048), // old consumer key (m·k)
3092 SizeClass::from_elem_count(1 * 5632), // old producer key (m·n)
3093 "sanity: non-square shape → the OLD scalar keys disagreed",
3094 );
3095
3096 // The produced entry the ranker must now find.
3097 let cpu_entry = report
3098 .entries
3099 .iter()
3100 .find(|e| {
3101 e.op == OpKind::MatMul && e.backend == BackendId::Cpu && e.dtype == DType::F32
3102 })
3103 .expect("Judge produced a CPU f32 matmul entry for the GEMV");
3104 assert_eq!(
3105 cpu_entry.size_class, ranker_key,
3106 "producer key must EQUAL the consumer's for_op key for the \
3107 non-square GEMV (the reconciliation)",
3108 );
3109
3110 // And the real consumer oracle resolves it — the round trip.
3111 let oracle = ProfileJudgeOracle::from_report(&report);
3112 let hit = oracle.measured_latency_ns(
3113 OpKind::MatMul,
3114 DType::F32,
3115 ranker_key,
3116 BackendId::Cpu,
3117 cpu_entry.kernel_source.as_str(),
3118 // The round trip must key on the SAME revision the producer wrote,
3119 // not 0 — the produced entry now carries a real attributed
3120 // revision, so a 0 query would miss.
3121 cpu_entry.kernel_revision_hash,
3122 );
3123 assert!(
3124 hit.is_some(),
3125 "synthetic ranker lookup at the GEMV shape must HIT the \
3126 produced cell (pre-v4 this missed)",
3127 );
3128 assert!(hit.unwrap() > 0, "measured latency must be positive");
3129 }
3130
3131 /// Dtype-axis slice (Judge Layer-2 coverage arc, 2026-07-04).
3132 ///
3133 /// **Born-red history**: before this slice the Judge hard-coded
3134 /// `DType::F32` in every measurement call, so `run()` emitted ONLY
3135 /// f32 `ProfileEntry`s. This test asserts the measurement matrix
3136 /// now covers f16 AND bf16 for the profiled families, that each
3137 /// entry is keyed by the dtype it was measured at, and that the
3138 /// measured latencies are positive. CPU-only shrunk ladder — no
3139 /// live GPU needed (CPU carries f16/bf16 kernels for the
3140 /// elementwise + matmul families via the FKC `[F32,F64,BF16,F16]`
3141 /// fan-out). A live CUDA/Vulkan profile would ADD the per-dtype
3142 /// GPU rows the ranker ultimately compares flash-vs-decomposed
3143 /// against, but the axis mechanics are fully exercised on CPU.
3144 #[test]
3145 fn judge_profiles_f16_and_bf16_cells() {
3146 let probe = ProbeReport::probe_all();
3147 let judge = Judge {
3148 iterations: 3,
3149 warmup: 1,
3150 size_plan_override: Some(vec![
3151 (OpKind::AddElementwise, OpSize::Elementwise(1 << 8)),
3152 (OpKind::MulElementwise, OpSize::Elementwise(1 << 8)),
3153 (
3154 OpKind::MatMul,
3155 OpSize::MatMul {
3156 m: 16,
3157 n: 16,
3158 k: 16,
3159 },
3160 ),
3161 ]),
3162 fixtures: None,
3163 };
3164 let report = judge.run(&probe);
3165
3166 // The half-precision dtypes must each produce ≥1 CPU entry per
3167 // profiled op, keyed by that dtype, with a positive latency.
3168 for dtype in [DType::F16, DType::BF16] {
3169 for op in [
3170 OpKind::AddElementwise,
3171 OpKind::MulElementwise,
3172 OpKind::MatMul,
3173 ] {
3174 let cells: Vec<_> = report
3175 .entries
3176 .iter()
3177 .filter(|e| e.op == op && e.dtype == dtype && e.backend == BackendId::Cpu)
3178 .collect();
3179 assert!(
3180 !cells.is_empty(),
3181 "dtype-axis: expected ≥1 CPU {dtype:?} entry for {op}, got 0 \
3182 (is the {dtype:?} kernel registered for this op?)",
3183 );
3184 for e in &cells {
3185 assert_eq!(
3186 e.dtype, dtype,
3187 "entry must be keyed by the dtype it was measured at",
3188 );
3189 assert!(
3190 e.latency_ns > 0,
3191 "{op} {dtype:?} latency must be > 0, got {e:?}",
3192 );
3193 // Single-backend CPU cell → trivial consensus → the
3194 // rel_err is 0.0 (no peers). Half-precision rounding
3195 // shows up only against a differing peer, which the
3196 // CPU-only born-red doesn't have.
3197 assert!(
3198 e.max_rel_error.is_finite(),
3199 "{op} {dtype:?} rel_err must be finite, got {}",
3200 e.max_rel_error,
3201 );
3202 }
3203 }
3204 }
3205
3206 // Regression guard: the f32 rows the pre-slice Judge produced
3207 // are still present — the dtype loop ADDS f16/bf16, it doesn't
3208 // displace f32.
3209 assert!(
3210 report.entries.iter().any(|e| e.dtype == DType::F32
3211 && e.op == OpKind::AddElementwise
3212 && e.backend == BackendId::Cpu),
3213 "dtype-axis: f32 coverage regressed — expected f32 AddElementwise entry",
3214 );
3215
3216 // Cross-check: exactly the three profiled dtypes appear, none
3217 // else (no f64 or stray dtype leaked into the matrix).
3218 let mut dtypes: Vec<DType> = report.entries.iter().map(|e| e.dtype).collect();
3219 dtypes.sort_by_key(|d| format!("{d:?}"));
3220 dtypes.dedup();
3221 assert_eq!(
3222 dtypes.len(),
3223 PROFILED_DTYPES.len(),
3224 "expected exactly the profiled dtype set {PROFILED_DTYPES:?}, saw {dtypes:?}",
3225 );
3226 for d in PROFILED_DTYPES {
3227 assert!(
3228 dtypes.contains(d),
3229 "profiled dtype {d:?} missing from the report",
3230 );
3231 }
3232 }
3233
3234 /// FlashAttn-as-profiled-op slice (Judge Layer-2 coverage arc,
3235 /// slice 3, 2026-07-04).
3236 ///
3237 /// **Born-red**: before this slice `OpKind::FlashAttn` was absent from
3238 /// [`PROFILED_OPS`], so `run()`'s outer `for &op in PROFILED_OPS` loop
3239 /// never processed it — a `size_plan_override` carrying a FlashAttn
3240 /// cell was filtered out and the report held ZERO FlashAttn entries.
3241 /// This asserts the fused decode-attention op is now MEASURED on CPU:
3242 /// the Judge builds q=[b,hq,1,d] / k=v=[b,hkv,k_len,d], emits the
3243 /// `Op::Fused(FLASH_ATTN)` node, realizes it (the registered fused CPU
3244 /// SDPA kernel), and produces `ProfileEntry`s keyed by the flash
3245 /// node's `SizeClass::attention` — arm-1 of slice-4's flash-vs-
3246 /// decomposed comparison, whose latency is what THIS slice measures.
3247 ///
3248 /// Two decode GQA cells (short + capacity k_len) must each land at a
3249 /// DISTINCT attention SizeClass with a positive latency; the same
3250 /// `attention(hq, k_len, d)` key slice-4's bake derives via `for_op`.
3251 /// CPU-only born-red; f32 is asserted (the reference-width kernel),
3252 /// f16/bf16 are checked opportunistically (the half kernels exist,
3253 /// but a missing dtype cell is skipped cleanly, never fatal).
3254 #[test]
3255 fn judge_profiles_flash_attn_at_decode_shapes() {
3256 let probe = ProbeReport::probe_all();
3257 // TinyLlama-ish decode GQA: Hq=32, Hkv=4, D=64. Two k_len cells
3258 // (short + capacity) — kept small enough to stay fast.
3259 let small = OpSize::Attention {
3260 b: 1,
3261 hq: 32,
3262 hkv: 4,
3263 d: 64,
3264 k_len: 128,
3265 };
3266 let cap = OpSize::Attention {
3267 b: 1,
3268 hq: 32,
3269 hkv: 4,
3270 d: 64,
3271 k_len: 512,
3272 };
3273 let judge = Judge {
3274 iterations: 3,
3275 warmup: 1,
3276 size_plan_override: Some(vec![(OpKind::FlashAttn, small), (OpKind::FlashAttn, cap)]),
3277 fixtures: None,
3278 };
3279 let report = judge.run(&probe);
3280
3281 // The two decode cells key to DISTINCT attention SizeClasses —
3282 // the same helper the producer stamps and slice-4's `for_op`
3283 // consumer derives.
3284 let sc_small = SizeClass::attention(32, 128, 64);
3285 let sc_cap = SizeClass::attention(32, 512, 64);
3286 assert_ne!(
3287 sc_small, sc_cap,
3288 "short + capacity decode attention must key to different SizeClass",
3289 );
3290
3291 // f32 (reference-width) MUST be measured at BOTH decode cells.
3292 for (label, sc) in [("short", sc_small), ("capacity", sc_cap)] {
3293 let hit = report.entries.iter().any(|e| {
3294 e.op == OpKind::FlashAttn
3295 && e.backend == BackendId::Cpu
3296 && e.dtype == DType::F32
3297 && e.size_class == sc
3298 && e.latency_ns > 0
3299 });
3300 assert!(
3301 hit,
3302 "FlashAttn f32 {label} decode cell (SizeClass {sc:?}) must be \
3303 measured with latency > 0 — arm-1 of the flash-vs-decomposed \
3304 comparison",
3305 );
3306 }
3307
3308 // Consumer round-trip: the key slice-4's bake derives from the
3309 // flash node's operand shapes via `for_op` must EQUAL the
3310 // producer's stamped key, so the bake's lookup HITS the cell.
3311 let consumer_key = SizeClass::for_op(
3312 OpKind::FlashAttn,
3313 &[
3314 Shape::from_dims(&[1, 32, 1, 64]),
3315 Shape::from_dims(&[1, 4, 128, 64]),
3316 Shape::from_dims(&[1, 4, 128, 64]),
3317 ],
3318 );
3319 assert_eq!(
3320 consumer_key, sc_small,
3321 "the flash node's for_op key must equal the producer's attention key",
3322 );
3323
3324 // Half-precision cells exist opportunistically (the CPU flash
3325 // kernel carries f16/bf16 variants). Not fatal if a backend
3326 // skips one, but on this CPU build they should all measure.
3327 for dtype in [DType::F16, DType::BF16] {
3328 let hit = report.entries.iter().any(|e| {
3329 e.op == OpKind::FlashAttn
3330 && e.backend == BackendId::Cpu
3331 && e.dtype == dtype
3332 && e.size_class == sc_small
3333 && e.latency_ns > 0
3334 });
3335 assert!(
3336 hit,
3337 "FlashAttn {dtype:?} short decode cell must be measured (the CPU \
3338 flash kernel has an {dtype:?} variant)",
3339 );
3340 }
3341 }
3342
3343 #[test]
3344 fn dispatch_table_built_from_expanded_report_serves_multiple_kinds() {
3345 // Confirms the DispatchTable's O(1) lookup path handles the
3346 // expanded OpKind coverage. The route picker can now pick
3347 // among many more (op, size_class) cells than the original
3348 // matmul + add report supported.
3349 use fuel_ir::dispatch::{Criterion, DispatchTable};
3350
3351 let probe = ProbeReport::probe_all();
3352 let judge = Judge {
3353 iterations: 3,
3354 warmup: 1,
3355 size_plan_override: Some(vec![
3356 (
3357 OpKind::MatMul,
3358 OpSize::MatMul {
3359 m: 32,
3360 n: 32,
3361 k: 32,
3362 },
3363 ),
3364 (OpKind::AddElementwise, OpSize::Elementwise(1 << 8)),
3365 (OpKind::ReluElementwise, OpSize::Elementwise(1 << 8)),
3366 (OpKind::SumReduce, OpSize::Reduce { rows: 16, cols: 16 }),
3367 (OpKind::ReduceSumTo, OpSize::ReduceTo { rows: 16, cols: 16 }),
3368 (OpKind::Affine, OpSize::Elementwise(1 << 8)),
3369 ]),
3370 fixtures: None,
3371 };
3372 let report = judge.run(&probe);
3373 let table = DispatchTable::build(&report);
3374
3375 // Every kind in the plan should yield a Pick at every
3376 // criterion. SizeClass(8) covers the elementwise plan
3377 // (256 elems = 2^8) and the reductions (16*16 = 2^8); the
3378 // matmul cell keys on its v4 aspect key `matmul(32,32,32)`
3379 // (the producer no longer keys it on the scalar 32*32=2^10).
3380 let elementwise_kinds = [
3381 OpKind::AddElementwise,
3382 OpKind::ReluElementwise,
3383 OpKind::SumReduce,
3384 OpKind::ReduceSumTo,
3385 OpKind::Affine,
3386 ];
3387 for &op in &elementwise_kinds {
3388 for &crit in &[
3389 Criterion::Fastest,
3390 Criterion::MostAccurate,
3391 Criterion::Balanced,
3392 ] {
3393 let pick = table.pick(op, DType::F32, SizeClass(8), crit);
3394 assert!(
3395 pick.is_some(),
3396 "DispatchTable missing pick for {op}@2^8 / {crit:?}"
3397 );
3398 }
3399 }
3400 let matmul_pick = table.pick(
3401 OpKind::MatMul,
3402 DType::F32,
3403 SizeClass::matmul(32, 32, 32),
3404 Criterion::Fastest,
3405 );
3406 assert!(matmul_pick.is_some(), "DispatchTable missing matmul pick");
3407
3408 // The table's keys() should include every (op, dtype,
3409 // size_class) tuple exercised by the plan.
3410 let keys = table.keys();
3411 assert!(
3412 keys.iter()
3413 .any(|(op, _, sc)| *op == OpKind::ReluElementwise && sc.0 == 8)
3414 );
3415 assert!(
3416 keys.iter()
3417 .any(|(op, _, sc)| *op == OpKind::SumReduce && sc.0 == 8)
3418 );
3419 assert!(
3420 keys.iter()
3421 .any(|(op, _, sc)| *op == OpKind::ReduceSumTo && sc.0 == 8)
3422 );
3423 assert!(
3424 keys.iter()
3425 .any(|(op, _, sc)| *op == OpKind::Affine && sc.0 == 8)
3426 );
3427 }
3428
3429 #[test]
3430 fn profile_report_save_load_roundtrip() {
3431 let report = ProfileReport {
3432 version: PROFILE_REPORT_VERSION,
3433 entries: vec![ProfileEntry {
3434 op: OpKind::MatMul,
3435 dtype: DType::F32,
3436 size_class: SizeClass(8),
3437 backend: BackendId::Cpu,
3438 device_index: 0,
3439 latency_ns: 12_345,
3440 iterations: 7,
3441 max_rel_error: 1e-7,
3442 kernel_source: String::new(),
3443 kernel_revision_hash: 0,
3444 device: test_equiv_key(BackendId::Cpu),
3445 }],
3446 };
3447 let tmp = std::env::temp_dir().join(format!("fuel-judge-test-{}.json", std::process::id()));
3448 report.save(&tmp).expect("save");
3449 // Positive control: the loading machine declares it HAS the CPU device
3450 // the report was stamped with, so the hardware gate admits it.
3451 let loaded = ProfileReport::load(&tmp, &[test_equiv_key(BackendId::Cpu)])
3452 .expect("load")
3453 .expect("file exists");
3454 assert_eq!(loaded, report);
3455 let _ = std::fs::remove_file(&tmp);
3456 }
3457
3458 /// (a) device-identity gate. A `ProfileReport` is the output of ONE Judge
3459 /// run on ONE machine, so a report stamped with device X must be REFUSED
3460 /// WHOLE when loaded on a machine whose hardware is Y — the `device_index`
3461 /// ordinal it was keyed by rebinds across machines, so a coincidental
3462 /// overlap is a foreign report, not a valid subset. The paired positive
3463 /// control (`current = [X]`) proves the `None` is the hardware gate firing,
3464 /// not `load()` failing for a parse/version reason that would `None` either
3465 /// way. X and Y share a backend and differ only in vendor/device id, so the
3466 /// gate must discriminate on the FULL key, not merely the backend axis.
3467 #[test]
3468 fn load_refuses_a_report_measured_on_foreign_hardware() {
3469 use fuel_ir::probe::EquivalenceKey;
3470 let device_x = EquivalenceKey {
3471 backend: BackendId::Cuda,
3472 vendor_id: 0x10de, // NVIDIA
3473 device_id: 0x2820,
3474 compute_capability: Some((8, 9)),
3475 driver_version: "560.0".to_string(),
3476 };
3477 let device_y = EquivalenceKey {
3478 backend: BackendId::Cuda,
3479 vendor_id: 0x1002, // AMD
3480 device_id: 0x1234,
3481 compute_capability: None,
3482 driver_version: "24.0".to_string(),
3483 };
3484 assert_ne!(device_x, device_y);
3485
3486 let report = ProfileReport {
3487 version: PROFILE_REPORT_VERSION,
3488 entries: vec![ProfileEntry {
3489 op: OpKind::MatMul,
3490 dtype: DType::F32,
3491 size_class: SizeClass(8),
3492 backend: BackendId::Cuda,
3493 device_index: 0,
3494 latency_ns: 5_000,
3495 iterations: 7,
3496 max_rel_error: 1e-7,
3497 kernel_source: String::new(),
3498 kernel_revision_hash: 0,
3499 device: device_x.clone(),
3500 }],
3501 };
3502 let tmp =
3503 std::env::temp_dir().join(format!("fuel-judge-foreign-{}.json", std::process::id()));
3504 report.save(&tmp).expect("save");
3505
3506 // Loaded on a machine that has ONLY device Y: the report is foreign, so
3507 // the whole thing is refused -> Ok(None).
3508 let refused = ProfileReport::load(&tmp, std::slice::from_ref(&device_y)).expect("load ok");
3509 assert!(
3510 refused.is_none(),
3511 "a report stamped on device X must be refused on a Y-only machine"
3512 );
3513
3514 // Positive control: loaded on a machine that HAS device X, the SAME
3515 // bytes load as Some — so the None above is the gate, not a parse or
3516 // version failure that would fail regardless of hardware.
3517 let admitted = ProfileReport::load(&tmp, std::slice::from_ref(&device_x)).expect("load ok");
3518 assert_eq!(
3519 admitted.expect("positive control: device X is present, report must load"),
3520 report
3521 );
3522
3523 let _ = std::fs::remove_file(&tmp);
3524 }
3525
3526 /// `load()` deserializes BEFORE it checks the version, so a report from an
3527 /// OLDER schema (missing today's fields) must still DESERIALIZE — otherwise
3528 /// a stale report `Err`s where it should be a clean cache miss. This pins
3529 /// that every field added since v4 carries `#[serde(default)]`: a v4-shaped
3530 /// JSON (no `device`, no `kernel_revision_hash`) loads as `Ok(None)` (the
3531 /// version gate), never `Err` (a parse failure). If a future field drops
3532 /// its default, this goes red.
3533 #[test]
3534 fn v4_report_yields_no_oracle_entries() {
3535 let report = ProfileReport {
3536 version: PROFILE_REPORT_VERSION,
3537 entries: vec![ProfileEntry {
3538 op: OpKind::MatMul,
3539 dtype: DType::F32,
3540 size_class: SizeClass(8),
3541 backend: BackendId::Cpu,
3542 device_index: 0,
3543 latency_ns: 12_345,
3544 iterations: 7,
3545 max_rel_error: 1e-7,
3546 kernel_source: String::new(),
3547 kernel_revision_hash: 0,
3548 device: test_equiv_key(BackendId::Cpu),
3549 }],
3550 };
3551 // Strip the current report back to the v4 wire shape: version 4, and
3552 // drop the two fields v4 never had.
3553 let mut v: serde_json::Value = serde_json::to_value(&report).expect("to_value");
3554 v["version"] = serde_json::json!(4);
3555 for e in v["entries"].as_array_mut().unwrap() {
3556 let obj = e.as_object_mut().unwrap();
3557 obj.remove("device");
3558 obj.remove("kernel_revision_hash");
3559 }
3560 let tmp = std::env::temp_dir().join(format!("fuel-judge-v4-{}.json", std::process::id()));
3561 std::fs::write(&tmp, serde_json::to_vec(&v).unwrap()).expect("write");
3562
3563 // Ok(None): the missing fields default in (deserialization succeeds —
3564 // proving the `#[serde(default)]`s), then the version gate rejects the
3565 // stale schema. An `Err` here would mean a new field lost its default
3566 // and a stale report now crashes the load path instead of missing.
3567 let loaded = ProfileReport::load(&tmp, &[test_equiv_key(BackendId::Cpu)]);
3568 assert!(
3569 matches!(loaded, Ok(None)),
3570 "a v4-shaped report must load as Ok(None) (version gate), got {loaded:?}"
3571 );
3572 let _ = std::fs::remove_file(&tmp);
3573 }
3574
3575 /// F2 born-red: the dispatched-alternative skip keys on the FULL identity
3576 /// (kernel_source AND kernel_revision_hash), not the tag alone. Now that
3577 /// variant siblings of one provider share a tag and differ only by revision,
3578 /// a tag-only match would skip the WRONG sibling — leaving the dispatched one
3579 /// re-measured and dropping a real sibling. This pins `is_dispatched_alt`,
3580 /// the single place the skip identity is compared. Sabotage: drop the
3581 /// revision check (tag-only) and the first assertion flips true → red.
3582 #[test]
3583 fn dispatched_alt_is_matched_on_source_and_revision() {
3584 // A sibling sharing the tag but a DIFFERENT revision is NOT dispatched.
3585 assert!(!is_dispatched_alt(
3586 "portable-cpu",
3587 0xAAAA,
3588 "portable-cpu",
3589 0xBBBB
3590 ));
3591 // The exact (source, revision) IS the dispatched kernel.
3592 assert!(is_dispatched_alt(
3593 "portable-cpu",
3594 0xBBBB,
3595 "portable-cpu",
3596 0xBBBB
3597 ));
3598 // A different provider at the same revision is NOT dispatched.
3599 assert!(!is_dispatched_alt("aocl", 0xBBBB, "portable-cpu", 0xBBBB));
3600 // Untracked revision (0) still requires the tag to agree.
3601 assert!(is_dispatched_alt("", 0, "", 0));
3602 assert!(!is_dispatched_alt("mkl", 0, "", 0));
3603 }
3604
3605 #[test]
3606 fn max_rel_err_matches_known_values() {
3607 assert_eq!(max_rel_err(&[1.0, 2.0], &[1.0, 2.0]), 0.0);
3608 // f32 rounding in both `2.001` literals and the subtraction
3609 // means the exact numeric answer is not `0.001 / 2.001`. Pin
3610 // the answer loosely: should be within a few ULP of that
3611 // ratio and close to 5e-4.
3612 let got = max_rel_err(&[1.0, 2.0], &[1.0, 2.001]);
3613 assert!((got - 5.0e-4).abs() < 1e-6, "expected ~0.0005, got {got}",);
3614 // Length mismatch → infinity.
3615 assert!(max_rel_err(&[1.0], &[1.0, 1.0]).is_infinite());
3616 // NaN in either operand → infinity.
3617 assert!(max_rel_err(&[f32::NAN], &[0.0]).is_infinite());
3618 }
3619
3620 /// Fixture-fast-path integration: when a fixture exists for the
3621 /// cell, the Judge derives `max_rel_error` from
3622 /// `validate_against_fixture` instead of running inline pairwise
3623 /// consensus. This test constructs an intentionally-divergent
3624 /// fixture for the AddElementwise cell and asserts the divergence
3625 /// shows up as the entries' `max_rel_error`.
3626 ///
3627 /// We deliberately *don't* use a fixture matching the actual cpu
3628 /// kernel's output here — the assertion is on the wire-up, not on
3629 /// any specific numerical value the cpu produces. Real fixtures
3630 /// land via the capture pipeline; this test verifies the path.
3631 #[test]
3632 fn judge_uses_fixture_when_provided() {
3633 use fuel_correctness_fixtures::{CorrectnessFixture, ToleranceBand};
3634
3635 let probe = ProbeReport::probe_all();
3636 let elem_count = 1 << 10;
3637 let sc = SizeClass::from_elem_count(elem_count);
3638 // Plant an obviously-wrong fixture: all zeros expected. The
3639 // cpu add kernel produces sin+cos = non-zero, so
3640 // validate_against_fixture's OutOfTolerance.rel_err lands in
3641 // every entry's max_rel_error (well above any plausible
3642 // cross-backend drift floor).
3643 //
3644 // The fixture must satisfy the triple-agreement contract
3645 // (seed + hash + shape) for `lookup_fixture` to return it.
3646 // We use the same `derive_seed` / `deterministic_f32_input` /
3647 // `hash_f32_input` the capture tool uses, so the lookup path
3648 // accepts the (deliberately-wrong-expected-output) fixture.
3649 let zeros: Vec<u8> = vec![0u8; elem_count * 4];
3650 let seed =
3651 fuel_correctness_fixtures::capture::derive_seed(OpKind::AddElementwise, DType::F32, sc);
3652 // AddElementwise is binary: input is the concatenated `[a, b]`
3653 // buffer of length `2 * n`.
3654 let input = fuel_correctness_fixtures::capture::deterministic_f32_input(
3655 OpKind::AddElementwise,
3656 2 * elem_count,
3657 );
3658 let input_hash = fuel_correctness_fixtures::capture::hash_f32_input(&input);
3659 let bogus_fixture = CorrectnessFixture {
3660 op: OpKind::AddElementwise,
3661 dtype: DType::F32,
3662 size_class: sc,
3663 input_seed: seed,
3664 input_hash,
3665 expected_output: zeros,
3666 output_element_count: elem_count,
3667 // Tight tolerance — guarantees the all-zeros fixture
3668 // disagrees with any honest add(sin, cos) output.
3669 tolerance: ToleranceBand {
3670 max_relative: 1e-9,
3671 max_absolute: 1e-12,
3672 },
3673 };
3674 let mut map: HashMap<(OpKind, DType, SizeClass), Vec<CorrectnessFixture>> = HashMap::new();
3675 map.insert(
3676 (
3677 OpKind::AddElementwise,
3678 DType::F32,
3679 SizeClass::from_elem_count(elem_count),
3680 ),
3681 vec![bogus_fixture],
3682 );
3683
3684 let judge = Judge {
3685 iterations: 3,
3686 warmup: 1,
3687 size_plan_override: Some(vec![(
3688 OpKind::AddElementwise,
3689 OpSize::Elementwise(elem_count),
3690 )]),
3691 fixtures: Some(map),
3692 };
3693 let report = judge.run(&probe);
3694 // Dtype-axis note (2026-07-04): the planted fixture is keyed
3695 // `(AddElementwise, F32, sc)`, so only the F32 cells hit the
3696 // fixture fast-path. The f16/bf16 cells the dtype loop now also
3697 // emits have NO fixture → they take the consensus path → lone
3698 // CPU backend → rel_err 0.0. Scope this assertion to the F32
3699 // entries the fixture actually governs.
3700 let cpu_entries: Vec<_> = report
3701 .entries
3702 .iter()
3703 .filter(|e| {
3704 e.op == OpKind::AddElementwise
3705 && e.dtype == DType::F32
3706 && e.backend == BackendId::Cpu
3707 })
3708 .collect();
3709 assert!(
3710 !cpu_entries.is_empty(),
3711 "expected ≥1 cpu f32 entry for AddElementwise, got 0"
3712 );
3713 // The bogus all-zeros fixture is wildly wrong vs the honest
3714 // sin+cos output, so every cpu entry's max_rel_error should
3715 // be very large (≥ 1.0 — relative error vs ~zero expected is
3716 // unbounded; floor at 1.0 is conservative). Without the
3717 // fixture fast-path this value would be ~0.0 (lone CPU
3718 // backend → trivial consensus → no peers → 0.0).
3719 for e in &cpu_entries {
3720 assert!(
3721 e.max_rel_error >= 1.0,
3722 "expected fixture-derived rel_err ≥1.0 (vs all-zeros fixture), got {} for entry {e:?}",
3723 e.max_rel_error
3724 );
3725 }
3726 }
3727
3728 /// Loader: `with_fixtures_from(file)` reads a JSON fixture file
3729 /// and produces a Judge whose `fixtures` map has the entry keyed
3730 /// correctly.
3731 #[test]
3732 fn judge_with_fixtures_from_loads_json_file() {
3733 use fuel_correctness_fixtures::{
3734 CorrectnessFixture, FIXTURE_FILE_VERSION, FixtureFile, ToleranceBand,
3735 };
3736
3737 let fixture = CorrectnessFixture {
3738 op: OpKind::MatMul,
3739 dtype: DType::F32,
3740 size_class: SizeClass(10),
3741 input_seed: 42,
3742 input_hash: 0xdeadbeef,
3743 expected_output: vec![0u8; 16],
3744 output_element_count: 4,
3745 tolerance: ToleranceBand::F32_DEFAULT,
3746 };
3747 let file = FixtureFile {
3748 version: FIXTURE_FILE_VERSION,
3749 fixtures: vec![fixture],
3750 };
3751 let dir =
3752 std::env::temp_dir().join(format!("fuel-judge-fixture-load-{}", std::process::id(),));
3753 let _ = std::fs::create_dir_all(&dir);
3754 let path = dir.join("matmul_f32.json");
3755 std::fs::write(&path, serde_json::to_string(&file).unwrap()).unwrap();
3756
3757 let judge = Judge::with_fixtures_from(&dir).expect("load");
3758 let map = judge.fixtures.as_ref().expect("fixtures populated");
3759 let key = (OpKind::MatMul, DType::F32, SizeClass(10));
3760 let bucket = map.get(&key).expect("matmul fixture in bucket");
3761 assert_eq!(bucket.len(), 1);
3762 assert_eq!(bucket[0].input_seed, 42);
3763
3764 // Loading from a single file path works too.
3765 let judge2 = Judge::with_fixtures_from(&path).expect("load file");
3766 let map2 = judge2.fixtures.as_ref().expect("fixtures populated");
3767 assert!(map2.contains_key(&key));
3768
3769 let _ = std::fs::remove_dir_all(&dir);
3770 }
3771
3772 /// Loader: fixture files whose `version` doesn't match the
3773 /// current `FIXTURE_FILE_VERSION` are skipped with a stderr
3774 /// warning. The load itself is non-fatal — a single stale file
3775 /// in the distribution shouldn't strand the rest of the run.
3776 #[test]
3777 fn judge_load_skips_mismatched_version() {
3778 use fuel_correctness_fixtures::{CorrectnessFixture, FixtureFile, ToleranceBand};
3779
3780 let good_fixture = CorrectnessFixture {
3781 op: OpKind::MatMul,
3782 dtype: DType::F32,
3783 size_class: SizeClass(10),
3784 input_seed: 1,
3785 input_hash: 2,
3786 expected_output: vec![0u8; 16],
3787 output_element_count: 4,
3788 tolerance: ToleranceBand::F32_DEFAULT,
3789 };
3790 let bad_version_file = FixtureFile {
3791 version: FIXTURE_FILE_VERSION + 999,
3792 fixtures: vec![good_fixture.clone()],
3793 };
3794 let good_version_file = FixtureFile {
3795 version: FIXTURE_FILE_VERSION,
3796 fixtures: vec![CorrectnessFixture {
3797 op: OpKind::AddElementwise,
3798 ..good_fixture
3799 }],
3800 };
3801 let dir = std::env::temp_dir()
3802 .join(format!("fuel-judge-fixture-version-{}", std::process::id(),));
3803 let _ = std::fs::create_dir_all(&dir);
3804 let bad_path = dir.join("matmul_f32.json");
3805 let good_path = dir.join("add_f32.json");
3806 std::fs::write(&bad_path, serde_json::to_string(&bad_version_file).unwrap()).unwrap();
3807 std::fs::write(
3808 &good_path,
3809 serde_json::to_string(&good_version_file).unwrap(),
3810 )
3811 .unwrap();
3812
3813 let judge = Judge::with_fixtures_from(&dir).expect("load");
3814 let map = judge.fixtures.as_ref().expect("fixtures populated");
3815 // The bad-version file is silently dropped; the good-version
3816 // file's bucket is still present.
3817 assert!(
3818 !map.contains_key(&(OpKind::MatMul, DType::F32, SizeClass(10))),
3819 "expected mismatched-version file to be skipped",
3820 );
3821 assert!(
3822 map.contains_key(&(OpKind::AddElementwise, DType::F32, SizeClass(10))),
3823 "expected good-version file to be loaded",
3824 );
3825
3826 let _ = std::fs::remove_dir_all(&dir);
3827 }
3828
3829 /// `lookup_fixture` returns `None` for a fixture whose `input_seed`
3830 /// doesn't match the Judge-derived per-cell seed. The caller (run)
3831 /// then falls back to inline consensus.
3832 #[test]
3833 fn lookup_fixture_skips_seed_mismatch() {
3834 use fuel_correctness_fixtures::{CorrectnessFixture, ToleranceBand};
3835
3836 let op = OpKind::AddElementwise;
3837 let dtype = DType::F32;
3838 let elem_count = 1 << 10;
3839 let sc = SizeClass::from_elem_count(elem_count);
3840 let input = fuel_correctness_fixtures::capture::deterministic_f32_input(op, 2 * elem_count);
3841 let input_hash = fuel_correctness_fixtures::capture::hash_f32_input(&input);
3842 // Plant the right hash but the wrong seed — the lookup must
3843 // skip the fixture even though the hash would have matched.
3844 let real_seed = fuel_correctness_fixtures::capture::derive_seed(op, dtype, sc);
3845 let wrong_seed = real_seed.wrapping_add(1);
3846 let fixture = CorrectnessFixture {
3847 op,
3848 dtype,
3849 size_class: sc,
3850 input_seed: wrong_seed,
3851 input_hash,
3852 expected_output: vec![0u8; elem_count * 4],
3853 output_element_count: elem_count,
3854 tolerance: ToleranceBand::F32_DEFAULT,
3855 };
3856 let mut map: HashMap<(OpKind, DType, SizeClass), Vec<CorrectnessFixture>> = HashMap::new();
3857 map.insert((op, dtype, sc), vec![fixture]);
3858 let judge = Judge {
3859 fixtures: Some(map),
3860 ..Judge::default()
3861 };
3862 let got = judge.lookup_fixture(op, dtype, sc, elem_count, 2 * elem_count);
3863 assert!(
3864 got.is_none(),
3865 "fixture with mismatched seed should be skipped (got {got:?})",
3866 );
3867 }
3868
3869 /// `lookup_fixture` returns `None` for a fixture whose `input_hash`
3870 /// doesn't match the regenerated input. The caller falls back to
3871 /// inline consensus.
3872 #[test]
3873 fn lookup_fixture_skips_hash_mismatch() {
3874 use fuel_correctness_fixtures::{CorrectnessFixture, ToleranceBand};
3875
3876 let op = OpKind::AddElementwise;
3877 let dtype = DType::F32;
3878 let elem_count = 1 << 10;
3879 let sc = SizeClass::from_elem_count(elem_count);
3880 let real_seed = fuel_correctness_fixtures::capture::derive_seed(op, dtype, sc);
3881 let input = fuel_correctness_fixtures::capture::deterministic_f32_input(op, 2 * elem_count);
3882 let real_hash = fuel_correctness_fixtures::capture::hash_f32_input(&input);
3883 // Plant the right seed but a wrong hash. The lookup must skip.
3884 let wrong_hash = real_hash.wrapping_add(0xfeed_face);
3885 let fixture = CorrectnessFixture {
3886 op,
3887 dtype,
3888 size_class: sc,
3889 input_seed: real_seed,
3890 input_hash: wrong_hash,
3891 expected_output: vec![0u8; elem_count * 4],
3892 output_element_count: elem_count,
3893 tolerance: ToleranceBand::F32_DEFAULT,
3894 };
3895 let mut map: HashMap<(OpKind, DType, SizeClass), Vec<CorrectnessFixture>> = HashMap::new();
3896 map.insert((op, dtype, sc), vec![fixture]);
3897 let judge = Judge {
3898 fixtures: Some(map),
3899 ..Judge::default()
3900 };
3901 let got = judge.lookup_fixture(op, dtype, sc, elem_count, 2 * elem_count);
3902 assert!(
3903 got.is_none(),
3904 "fixture with drifted input_hash should be skipped (got {got:?})",
3905 );
3906 }
3907
3908 /// `lookup_fixture` accepts a fixture when (op, dtype, size_class) +
3909 /// output shape + seed + input_hash all agree. Triple-agreement
3910 /// contract.
3911 #[test]
3912 fn lookup_fixture_accepts_triple_agreement() {
3913 use fuel_correctness_fixtures::{CorrectnessFixture, ToleranceBand};
3914
3915 let op = OpKind::AddElementwise;
3916 let dtype = DType::F32;
3917 let elem_count = 1 << 10;
3918 let sc = SizeClass::from_elem_count(elem_count);
3919 let seed = fuel_correctness_fixtures::capture::derive_seed(op, dtype, sc);
3920 let input = fuel_correctness_fixtures::capture::deterministic_f32_input(op, 2 * elem_count);
3921 let hash = fuel_correctness_fixtures::capture::hash_f32_input(&input);
3922 let fixture = CorrectnessFixture {
3923 op,
3924 dtype,
3925 size_class: sc,
3926 input_seed: seed,
3927 input_hash: hash,
3928 expected_output: vec![0u8; elem_count * 4],
3929 output_element_count: elem_count,
3930 tolerance: ToleranceBand::F32_DEFAULT,
3931 };
3932 let mut map: HashMap<(OpKind, DType, SizeClass), Vec<CorrectnessFixture>> = HashMap::new();
3933 map.insert((op, dtype, sc), vec![fixture]);
3934 let judge = Judge {
3935 fixtures: Some(map),
3936 ..Judge::default()
3937 };
3938 let got = judge.lookup_fixture(op, dtype, sc, elem_count, 2 * elem_count);
3939 assert!(got.is_some(), "triple-agreement fixture should be returned");
3940 }
3941}