Skip to main content

fuel_ir/
dispatch.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Cross-crate dispatch table types — Phase 6b's empirical
3//! `(op, dtype, size_class) → (backend, device)` lookup.
4//!
5//! These types live here, not in `fuel-core`, because both
6//! `fuel-core` (which produces tables via the [`Judge`] in its
7//! `judge` module) and `fuel-graph-router` (which consumes them at
8//! op-dispatch time to pick between competing backends) need the
9//! same shapes. The producer-side `Judge` and the
10//! `populate_dispatch_table` / `cached` cache APIs live in
11//! `fuel-core` because they call into `Tensor`'s realize path.
12//! Only the data + the pure lookup helpers are here.
13//!
14//! [`Judge`]: https://github.com/ciresnave/fuel/blob/main/fuel-core/src/judge/mod.rs
15
16use crate::DType;
17use crate::Shape;
18use crate::probe::BackendId;
19use std::collections::HashMap;
20
21#[cfg(feature = "serde")]
22use serde::{Deserialize, Serialize};
23
24/// Schema version for persisted profile reports. Bump when the
25/// entry layout changes in a way that can't be covered by
26/// `#[serde(default)]`.
27///
28/// **v2 (2026-06-08)**: per-alternative measurement landed —
29/// [`ProfileEntry`] gains `kernel_source` so the AOCL/MKL/portable-cpu
30/// kernel siblings at one `(op, dtype, size_class, BackendId::Cpu)`
31/// cell each get their own measured entry. v1 reports lacked the
32/// field and were ambiguous about which CPU-substrate kernel they
33/// timed; old reports load as `Ok(None)` (cache miss → re-Judge).
34///
35/// **v3 (2026-07-04)**: dtype-axis coverage landed — the Judge's
36/// measurement matrix now iterates `{F32, F16, BF16}` for every
37/// profiled op/size (Judge Layer-2 coverage arc, dtype slice). The
38/// [`ProfileEntry`] *layout* is unchanged (`dtype` was always a
39/// field), but the *coverage* is: a v3 report carries f16/bf16 cells
40/// a v2 report never had. The bump is deliberate rather than
41/// serde-forced: without it, `populate_dispatch_table`'s idempotence
42/// guard would keep an upgraded install's f32-only v2 profile forever
43/// (the guard no-ops when a cached report exists), silently denying
44/// the ranker the new per-dtype latencies. Bumping makes v2 reports
45/// load as `Ok(None)` (cache miss → clean re-Judge of the full dtype
46/// matrix) — the same migration shape v1→v2 used. A v2 report is not
47/// *wrong*, just partial; the empty-oracle version gate in
48/// `ProfileJudgeOracle::from_report` and `ProfileReport::load` both
49/// reject it so no partial data leaks into the cost composer.
50///
51/// **v4 (2026-07-04)**: matmul [`SizeClass`] repr change — the
52/// aspect-blind `log2(total_elements)` key that keyed a matmul on
53/// `m·n` (output) is replaced, *for matmul only*, by an aspect key
54/// packing `(log2(m·n), log2(m), log2(k))` (see [`SizeClass`]). This
55/// fixes a latent producer/consumer key disagreement: the Judge keyed
56/// matmul cells on `m·n` while the `fuel-dispatch` ranker keyed its
57/// realize-time lookup on `m·k` (LHS input), so every *non-square*
58/// matmul lookup missed the profiled cell. Producer and consumer now
59/// derive the key from the operand shapes through the SAME
60/// [`SizeClass::matmul`] / [`SizeClass::for_op`] helper. The
61/// [`SizeClass`] field also widens `u8 → u32` to hold the packed key.
62/// v3 reports (scalar matmul keys) load as `Ok(None)` — cache miss →
63/// clean re-Judge — the established migration (v1→v2, v2→v3). The
64/// `ProfileReport::load` / `ProfileJudgeOracle::from_report` version
65/// gates reject the older schema so no scalar matmul key leaks into
66/// the aspect-keyed lookup path.
67///
68/// **v5 (2026-08-27)**: device + kernel IDENTITY landed — [`ProfileEntry`]
69/// gains `kernel_revision_hash` (variant-granular kernel identity, so the many
70/// tilings of one provider stop collapsing into one Judge cell) and `device`
71/// (the profiled hardware's [`EquivalenceKey`](crate::probe::EquivalenceKey),
72/// so a report is REFUSED on hardware it was not measured on rather than
73/// answering for a device it never saw — `device_index` was an ordinal that
74/// rebinds across boots and under `CUDA_VISIBLE_DEVICES`). The bump is
75/// REQUIRED, not merely serde-forced: a v4 report read by v5 code would
76/// `#[serde(default)]` `kernel_revision_hash` to `0`, landing on the untracked
77/// sentinel and collapsing every sibling; and every v4 report was keyed by an
78/// ordinal that may ALREADY be wrong on a multi-GPU or re-imaged box.
79/// Discarding them (load → `Ok(None)`) is the correct outcome — anything
80/// written pre-v5 is indistinguishable from a right report and may be
81/// mis-attributed. This is also the cheapest re-profile there will ever be
82/// (baseline, siblings ≈ 1); every later one, once candidate kernels exist,
83/// costs strictly more.
84pub const PROFILE_REPORT_VERSION: u32 = 5;
85
86/// Op kinds the Judge profiles. Adding a variant + a Judge match
87/// arm extends the profile matrix; existing reports parse forward
88/// thanks to `#[non_exhaustive]`.
89///
90/// Phase 7.5 storage unification — Phase C grows this enum one op
91/// family per migration step. Each variant is the dispatch key that
92/// pairs with [`crate::DType`] and
93/// [`crate::probe::BackendId`] to select a kernel. The naming
94/// convention is `<Op><Family>` (e.g. `ReluElementwise`,
95/// `MulElementwise`); families currently in use:
96///
97/// - elementwise binary: `Add/Sub/Mul/Div` + `Elementwise`
98/// - elementwise unary: `Relu/Neg/Sqr/Sqrt/Recip/Abs/Tanh/...`
99///   + `Elementwise`
100/// - dense linear algebra: `MatMul`
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
102#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
103#[non_exhaustive]
104pub enum OpKind {
105    /// Dense matrix multiply, `[M, K] @ [K, N] → [M, N]`.
106    MatMul,
107    /// Elementwise addition of two equally-shaped tensors.
108    AddElementwise,
109    /// Elementwise subtraction (`a - b`).
110    SubElementwise,
111    /// Elementwise multiplication.
112    MulElementwise,
113    /// Elementwise division (`a / b`).
114    DivElementwise,
115    /// Elementwise rectified linear unit (`max(0, x)`).
116    ReluElementwise,
117    /// Elementwise negation (`-x`).
118    NegElementwise,
119    /// Elementwise square (`x * x`).
120    SqrElementwise,
121    /// Elementwise square root.
122    SqrtElementwise,
123    /// Elementwise reciprocal (`1 / x`).
124    RecipElementwise,
125    /// Elementwise absolute value.
126    AbsElementwise,
127    /// Elementwise hyperbolic tangent.
128    TanhElementwise,
129    /// Elementwise exponential (`e^x`).
130    ExpElementwise,
131    /// Elementwise natural logarithm (`ln(x)`).
132    LogElementwise,
133    /// Elementwise sine.
134    SinElementwise,
135    /// Elementwise cosine.
136    CosElementwise,
137    /// Elementwise logistic sigmoid (`1 / (1 + e^-x)`).
138    SigmoidElementwise,
139    /// SiLU / Swish activation (`x * sigmoid(x)`).
140    SiluElementwise,
141    /// GELU activation, tanh approximation
142    /// (`0.5*x*(1 + tanh(√(2/π) * (x + 0.044715*x³)))`).
143    GeluTanhElementwise,
144    /// Heaviside step function (`1` where `x > 0`, `0` otherwise) —
145    /// the derivative of [`OpKind::ReluElementwise`].
146    StepElementwise,
147
148    /// Sum-reduce one or more dimensions of the input. The reduced
149    /// dims and the input shape live in
150    /// [`OpParams::Reduce`](super::dispatch::OpKind); the output is
151    /// the input with those dims dropped (or rank-0 when every dim
152    /// is reduced).
153    SumReduce,
154    /// Max-reduce — same shape contract as [`OpKind::SumReduce`].
155    MaxReduce,
156    /// Min-reduce — same shape contract as [`OpKind::SumReduce`].
157    MinReduce,
158    /// Arithmetic-mean reduce — same shape contract as
159    /// [`OpKind::SumReduce`]; divides the sum by the product of reduced
160    /// dim sizes.
161    MeanReduce,
162
163    /// Convert input bytes from one dtype to another. The target
164    /// dtype lives on the output Storage's `dtype` field (and on
165    /// the Node's dtype); the source dtype is read from the input
166    /// Storage at runtime by the wrapper, which dispatches to the
167    /// right typed conversion kernel internally. Binding-table
168    /// lookup is keyed on the *target* dtype (= the Node's dtype).
169    Cast,
170    /// 2D convolution forward pass. Shape + geometry flow through
171    /// [`OpParams::Conv2D`](super::dispatch::OpKind). Two-input
172    /// case: `(x, weight)`; three-input case adds `bias`. Output
173    /// shape `[N, Cout, Hout, Wout]`.
174    Conv2D,
175    /// 2D transposed convolution. Shape + geometry flow through
176    /// `OpParams::ConvTranspose2D`. Inputs and bias mirror Conv2D,
177    /// but weight has transposed channel order (`[Cin, Cout/groups,
178    /// Kh, Kw]`) and the variant carries `output_padding`.
179    ConvTranspose2D,
180    /// Sum-reduce a tensor to a smaller broadcast-compatible shape.
181    /// The output rank may be lower than the input's; in that case
182    /// the output shape is implicitly left-padded with 1s for axis
183    /// alignment. Carried by `OpParams::ReduceSumTo` with both the
184    /// input and target shapes.
185    ReduceSumTo,
186    /// Max-reduce a tensor to a smaller broadcast-compatible shape —
187    /// the max-symmetric counterpart of `ReduceSumTo`. Same axis
188    /// alignment rules; per-axis reduction is `max` instead of `+`.
189    /// Carried by `OpParams::ReduceMaxTo` with both shapes.
190    ReduceMaxTo,
191    /// Fused matmul + bias-add. Inputs `[a, b, bias]`, output
192    /// `[..., M, N] = a @ b + bias[None..., :]`. Reuses
193    /// `OpParams::Matmul` for shape (kernel inits accumulator with
194    /// the bias element instead of zero).
195    FusedLinear,
196    /// Multi-head scaled-dot-product attention (the math definition,
197    /// not a tiled FlashAttention-2 kernel — naive on CPU). Inputs
198    /// `[q, k, v, optional alibi_slopes]`. Geometry, softmax_scale,
199    /// causal, window, softcap all flow through `OpParams::FlashAttn`.
200    FlashAttn,
201    /// Backward of [`OpKind::FlashAttn`]: produces dQ from `(q, k, v, do, [alibi])`.
202    /// Reuses `OpParams::FlashAttn` (same shape/geometry/causal flags
203    /// as the forward — the recompute pass needs every forward parameter).
204    /// Output shape == q shape. The dK and dV gradients are emitted as
205    /// separate [`OpKind::FlashAttnBackwardK`] / [`OpKind::FlashAttnBackwardV`] nodes
206    /// against the same inputs; CPU backends recompute the softmax
207    /// state independently per call. A 3-output fused variant would
208    /// share the recompute but needs multi-output infrastructure that
209    /// doesn't exist yet.
210    FlashAttnBackwardQ,
211    /// Backward of [`OpKind::FlashAttn`]: produces dK. See [`OpKind::FlashAttnBackwardQ`].
212    /// Output shape == k shape.
213    FlashAttnBackwardK,
214    /// Backward of [`OpKind::FlashAttn`]: produces dV. See [`OpKind::FlashAttnBackwardQ`].
215    /// Output shape == v shape.
216    FlashAttnBackwardV,
217    /// Paged-cache scaled-dot-product attention. Inputs `[q, k_cache,
218    /// v_cache, block_table, context_lens, optional alibi_slopes]`.
219    /// Geometry + scale + softcap flow through `OpParams::PagedAttn`.
220    PagedAttn,
221    /// Affine transformation `y = mul * x + add` with scalar
222    /// coefficients. `Op::AddScalar(c)` maps as `mul=1, add=c`;
223    /// `Op::MulScalar(c)` maps as `mul=c, add=0`.
224    Affine,
225    /// Element-wise clamp: `y = clamp(x, min, max)`.
226    ClampElementwise,
227    /// Element-wise integer power: `y = x.powi(exp)`.
228    PowIElementwise,
229    /// Backward of [`OpKind::PowIElementwise`]: `(x, upstream) → grad_x = exp ·
230    /// x^(exp-1) · upstream`. Two inputs; carries the same `exp: i32`
231    /// in `OpParams::PowI` as the forward. Single-launch alternative
232    /// to the autograd primitive decomposition (PowI(exp-1) →
233    /// MulScalar → Mul, 3 nodes). Per-dtype.
234    PowIElementwiseBackward,
235    /// Element-wise tensor maximum: `y[i] = max(lhs[i], rhs[i])`.
236    MaximumElementwise,
237    /// Element-wise tensor minimum: `y[i] = min(lhs[i], rhs[i])`.
238    MinimumElementwise,
239    /// Element-wise equality `a == b`. Both inputs share dtype `T`;
240    /// output is a `U8` mask (`1` where equal, `0` otherwise). The
241    /// binding-table dtype list is keyed `[T, T, U8]`, mirroring the
242    /// ArgMax convention of carrying the output dtype explicitly when
243    /// it differs from the inputs.
244    EqualElementwise,
245    /// Element-wise inequality `a != b`. Same shape contract as
246    /// [`OpKind::EqualElementwise`]; output `U8` mask (`1` where unequal,
247    /// `0` otherwise). NaN follows IEEE-754: `NaN != NaN` is true,
248    /// so `ne` returns `1` on NaN-vs-NaN positions.
249    NotEqualElementwise,
250    /// Element-wise strictly-less `a < b`. Same shape contract as
251    /// [`OpKind::EqualElementwise`]; output `U8` mask. NaN-on-either-side is
252    /// always false (IEEE-754 unordered comparison).
253    LessElementwise,
254    /// Element-wise less-or-equal `a <= b`. Same shape contract.
255    /// NaN comparisons are unordered → `0`.
256    LessEqualElementwise,
257    /// Element-wise strictly-greater `a > b`. Same shape contract.
258    /// NaN comparisons are unordered → `0`.
259    GreaterElementwise,
260    /// Element-wise greater-or-equal `a >= b`. Same shape contract.
261    /// NaN comparisons are unordered → `0`.
262    GreaterEqualElementwise,
263    /// Ternary select: `out[i] = if cond[i] != 0 { a[i] } else { b[i] }`.
264    /// Inputs `(cond, a, b)` — `cond` is `U8`, `a` and `b` share dtype
265    /// `T` and shape with `cond`. Output is `T` shape `cond.shape()`.
266    /// Binding-table dtype list `[U8, T, T, T]`.
267    Where,
268    /// Element-wise floor (`⌊x⌋`). Same dtype as input. Backward is
269    /// the zero distribution almost everywhere; gradient through
270    /// rounding ops is dropped silently.
271    FloorElementwise,
272    /// Element-wise ceiling (`⌈x⌉`). Same dtype as input. Backward
273    /// drops gradient (mirrors Floor).
274    CeilElementwise,
275    /// Element-wise round-to-nearest with **banker's rounding**
276    /// (round-half-to-even, IEEE 754 roundeven). Backward drops
277    /// gradient (mirrors Floor/Ceil).
278    RoundElementwise,
279    /// Element-wise sign (`-1` if negative, `0` if zero, `1` if
280    /// positive). Same dtype as input. Backward drops gradient
281    /// (zero almost everywhere; the subgradient at 0 is taken as 0).
282    SignElementwise,
283    /// Element-wise Gauss error function (`erf(x)`). Same dtype as
284    /// input. Backward: `d/dx erf(x) = (2/√π) * exp(-x²)` —
285    /// expressed via `Sqr` + `Neg` + `Exp` + `MulScalar`.
286    ErfElementwise,
287    /// Element-wise GELU activation, **exact erf formulation**:
288    /// `0.5 * x * (1 + erf(x/√2))`. Distinct from
289    /// [`OpKind::GeluTanhElementwise`] (tanh approximation). Same dtype as input.
290    /// Backward decomposes into the standard-normal CDF + `x * φ(x)`
291    /// (PDF) chain via existing primitives.
292    GeluElementwise,
293    /// Element-wise binary power: `out[i] = pow(a[i], b[i])`. Both
294    /// inputs share dtype `T` and shape; output is `T` with the same
295    /// shape. Distinct from [`OpKind::PowIElementwise`] (scalar `i32`
296    /// exponent). NaN follows IEEE-754 (e.g. `pow(-2, 0.5) = NaN`).
297    PowElementwise,
298    /// Element-wise reciprocal square root: `out[i] = 1 / sqrt(x[i])`.
299    /// Single op (vs Sqrt + Recip — combining loses precision and
300    /// doubles kernel launches). Critical for RMSNorm (`x / sqrt(mean(x²)+eps)`
301    /// → `x * rsqrt(mean(x²)+eps)`).
302    RsqrtElementwise,
303    /// Element-wise remainder, **PyTorch convention**:
304    /// `out[i] = a[i] - floor(a[i] / b[i]) * b[i]`. The sign of the
305    /// result matches the sign of the divisor (differs from C99 `fmod`
306    /// which has the sign of the dividend; `f32::%` is also fmod-style).
307    /// Same dtype + shape on both inputs.
308    RemElementwise,
309    /// Reverse the order of elements along one dim. Dtype-agnostic
310    /// at the byte level. Materializing op (Layout strides are
311    /// unsigned; the negative-stride view path requires a wider
312    /// stride representation that's a separate scope).
313    Flip,
314    /// Cyclic shift along one dim by a signed `shift`. Wraps
315    /// (e.g. `roll([0,1,2], shift=1) = [2,0,1]`). Materializing op,
316    /// dtype-agnostic.
317    Roll,
318    /// Running cumulative sum along one dim:
319    /// `out[..., i, ...] = sum(in[..., 0..=i, ...])`. Same shape as
320    /// input. Per-dtype kernel (sum needs typed addition).
321    CumSum,
322    /// Pad along one dim with `before`/`after` extra slots. Modes:
323    /// Constant (fill with a value), Reflect (mirror edges),
324    /// Replicate (repeat edges). Only Constant is implemented in
325    /// the v1 cut; Reflect/Replicate fall through to a clean error.
326    Pad,
327    /// Upper-triangular mask along the last two dims. Materializing,
328    /// dtype-agnostic at the byte level (output is x or zero per
329    /// position; just selects bytes from src or the zero-init buffer).
330    Triu,
331    /// Lower-triangular mask along the last two dims (mirror of [`OpKind::Triu`]).
332    Tril,
333    /// Numerically-stable log-softmax along the last dim. Per-dtype
334    /// (uses log/exp). Output shape == input shape.
335    LogSoftmaxLastDim,
336    /// Backward of [`OpKind::LogSoftmaxLastDim`]: takes `(forward_output, upstream)`
337    /// and produces the input gradient. Per-dtype.
338    LogSoftmaxLastDimBackward,
339    /// MaskedFill: fill positions where mask is nonzero with a scalar.
340    /// Inputs `(x, mask)` where mask is U8. Per-dtype on x; output
341    /// shape == x shape.
342    MaskedFill,
343    /// Backward helper for `Pad`. Per-dtype since accumulation is
344    /// typed addition. Routes all 3 modes uniformly — the kernel
345    /// switches on `mode_tag`.
346    PadBackward,
347    /// Concatenate N inputs along one dim. Inputs must agree on
348    /// every dim except the concat dim; output's concat-dim size
349    /// is the sum of inputs' concat-dim sizes.
350    Concat,
351    /// Softmax along the last dim, numerically stable
352    /// (subtract per-row max, exp, divide by sum).
353    SoftmaxLastDim,
354    /// Backward of [`OpKind::SoftmaxLastDim`]: `(y, g) → y · (g - sum(y · g, last))`.
355    /// Per-dtype; output shape == y shape.
356    SoftmaxLastDimBackward,
357    /// RMS normalization along the last dim, no affine params:
358    /// `y = x / sqrt(mean(x², last) + eps)`.
359    RmsNormLastDim,
360    /// Backward of [`OpKind::RmsNormLastDim`]: `(x, g_y) → grad_x` per the
361    /// closed-form formula in
362    /// `fuel-reference-backend::ops::rms_norm_last_dim_backward`.
363    /// Per-dtype + eps; output shape == x shape.
364    RmsNormLastDimBackward,
365    /// Layer normalization along the last dim, no affine params:
366    /// `y = (x - mean(x)) / sqrt(var(x) + eps)`.
367    LayerNormLastDim,
368    /// Backward of [`OpKind::LayerNormLastDim`]: `(x, g) → grad_x` per the
369    /// canonical formula. Per-dtype + eps; output shape == x shape.
370    LayerNormLastDimBackward,
371    /// Backward of [`OpKind::ReduceMaxTo`]: `(x, upstream) → grad_x` of x's
372    /// shape. Routes upstream to argmax positions; ties split
373    /// equally. Per-dtype; carries shape pair via
374    /// `OpParams::ReduceMaxToBackward`.
375    ReduceMaxToBackward,
376    /// Pick slices from a source tensor along `dim` using a rank-1
377    /// U32 index tensor. Output's `dim` size = number of indices.
378    IndexSelect,
379    /// N-dimensional gather along `dim`. Source and indices have
380    /// the same rank; output shape equals indices.shape(). For
381    /// each output position, source is read at the same multi-index
382    /// except `dim`'s coord is read from the indices tensor.
383    Gather,
384    /// Fused rotary position embedding. Inputs `(x, cos, sin)`;
385    /// `cos`/`sin` broadcast across leading dims. Used by every
386    /// modern transformer's attention layer.
387    Rope,
388    /// Add `src` values into a copy of `base` at positions given
389    /// by a rank-1 U32 `indices` tensor along `dim`. Inputs:
390    /// `(base, indices, src)`. Output shape == base shape.
391    IndexAdd,
392    /// N-dimensional scatter-add: the functional inverse of
393    /// `Gather`. Inputs `(base, indices, src)`; indices and src
394    /// share the same shape; for each position `p`, the source
395    /// value is added into base at the multi-index where `dim`'s
396    /// coord is `indices[p]`.
397    ScatterAdd,
398    /// Argmax along one dim — produces a U32 index tensor with
399    /// `dim` removed from the output shape.
400    ArgMaxDim,
401    /// Argmin along one dim — same shape contract as
402    /// [`OpKind::ArgMaxDim`].
403    ArgMinDim,
404    /// Quantized matmul: `C = A @ dequant(W_Q)`. Activations are
405    /// f32 (or eventually bf16); weights are a U32-typed byte
406    /// stream of quantized blocks. The quant format is carried in
407    /// [`OpParams::QMatMul`](super::dispatch::OpKind).
408    QMatMul,
409    /// In-place rectangular scatter write: copies source's bytes
410    /// into a slab of destination defined per-axis by `ranges`.
411    /// Backs Phase E.3.2's persistent KV-cache writes
412    /// (`InferenceContext` + `KvCache`). See
413    /// `Op::WriteSlice` for the IR
414    /// contract and [`OpParams::WriteSlice`](super::dispatch::OpKind)
415    /// for the kernel-side params.
416    WriteSlice,
417    /// In-place ring-buffer scatter write: copies source's bytes into
418    /// destination at a slab that wraps modulo `modulus` on the
419    /// rotating axis. The write position comes from a dynamic input
420    /// (rank-0 U32). Backs sliding-window KV caches (Mistral /
421    /// Phi-3 sliding-window / sliding-window Qwen). See
422    /// `Op::WriteSliceRotating`
423    /// for the IR contract.
424    WriteSliceRotating,
425    /// In-place scatter write whose start on ONE axis is read from a
426    /// device-resident `i64` scalar input (rank-0) at kernel launch,
427    /// not host-baked into the range — the CUDA-graph-capturable
428    /// KV-cache append. Like [`WriteSlice`](Self::WriteSlice) but the
429    /// `ranges[axis].0` placeholder is overridden by `*offset` device-
430    /// side; no modulo wrap. Backs `DecodeSession` / CapturedRun. See
431    /// `Op::WriteSliceDoff` for the
432    /// IR contract.
433    WriteSliceDoff,
434    /// Cross-device copy: produce a fresh tensor on the target
435    /// device, copying bytes from the input's residency. Backs
436    /// `Op::Copy` for the bridge-retirement
437    /// trajectory's Phase 2 (D2H through the binding table).
438    ///
439    /// Binding-table key shape `[T, T]` (input dtype, output dtype —
440    /// always equal). The `BackendId` axis encodes the **source**
441    /// backend (where the kernel runs — the source owns its own
442    /// download path); the executor allocates the output Storage on
443    /// the `Op::Copy { target }` location field via a dedicated
444    /// `WorkItemKind::Copy` arm.
445    Copy,
446    // ---- In-place ops (Phase 3 of the in-place ops infrastructure;
447    // ---- see docs/session-prompts/in-place-ops-infrastructure.md) ----
448    //
449    // Each in-place OpKind mirrors a non-inplace functional cousin.
450    // The dispatch difference is structural rather than computational:
451    // the executor adopts the target's Storage Arc as the output slot
452    // instead of allocating fresh bytes. The kernel reads + writes the
453    // same buffer through a single write lock. Binding-table key
454    // shape `[T]` (the in-place op's single input dtype = output
455    // dtype). `KernelCaps::strided_input` semantics apply identically
456    // to the non-inplace cousin (same-pointer dispatch handles strided
457    // inputs as long as the stride pattern doesn't produce overlapping
458    // writes; the unary kernels write index `i` after reading index `i`
459    // so no aliasing issues).
460    /// In-place [`OpKind::ReluElementwise`] — `x = max(0, x)`.
461    ReluInplace,
462    /// In-place [`OpKind::SiluElementwise`] — `x = x · sigmoid(x)`.
463    SiluInplace,
464    /// In-place [`OpKind::GeluTanhElementwise`] (tanh approximation) —
465    /// `x = 0.5 · x · (1 + tanh(√(2/π) · (x + 0.044715·x³)))`.
466    GeluTanhInplace,
467    /// In-place [`OpKind::TanhElementwise`] — `x = tanh(x)`.
468    TanhInplace,
469    /// In-place [`OpKind::SigmoidElementwise`] — `x = 1 / (1 + exp(-x))`.
470    SigmoidInplace,
471    /// In-place [`OpKind::NegElementwise`] — `x = -x`.
472    NegInplace,
473    /// In-place [`OpKind::AbsElementwise`] — `x = |x|`.
474    AbsInplace,
475    /// In-place [`OpKind::SqrElementwise`] — `x = x²`.
476    SqrInplace,
477    /// In-place [`OpKind::SqrtElementwise`] — `x = √x`.
478    SqrtInplace,
479    /// In-place [`OpKind::RsqrtElementwise`] — `x = 1/√x`.
480    RsqrtInplace,
481    /// In-place [`OpKind::RecipElementwise`] — `x = 1/x`.
482    RecipInplace,
483    /// In-place [`OpKind::ExpElementwise`] — `x = exp(x)`.
484    ExpInplace,
485    /// In-place [`OpKind::LogElementwise`] — `x = ln(x)`.
486    LogInplace,
487    /// In-place [`OpKind::SinElementwise`] — `x = sin(x)`.
488    SinInplace,
489    /// In-place [`OpKind::CosElementwise`] — `x = cos(x)`.
490    CosInplace,
491    /// In-place [`OpKind::SignElementwise`] — `x = sign(x)`.
492    SignInplace,
493    /// In-place [`OpKind::FloorElementwise`] — `x = ⌊x⌋`.
494    FloorInplace,
495    /// In-place [`OpKind::CeilElementwise`] — `x = ⌈x⌉`.
496    CeilInplace,
497    /// In-place [`OpKind::RoundElementwise`] — `x = round(x)`.
498    RoundInplace,
499    /// In-place [`OpKind::ErfElementwise`] — `x = erf(x)`.
500    ErfInplace,
501    /// In-place [`OpKind::GeluElementwise`] — exact-GeLU
502    /// `x = 0.5 · x · (1 + erf(x/√2))`.
503    GeluInplace,
504    /// In-place [`OpKind::ClampElementwise`] — `x = clamp(x, min, max)`.
505    /// Scalar `(min, max)` flow through `OpParams::Clamp`.
506    ClampInplace,
507    /// In-place [`OpKind::PowIElementwise`] — `x = x.powi(exp)`.
508    /// Scalar `exp` flows through `OpParams::PowI`.
509    PowIInplace,
510    /// In-place [`OpKind::Affine`] — `x = mul · x + add`. The
511    /// `(mul, add)` coefficients flow through
512    /// `OpParams::Affine`; the kernel reads + writes the same
513    /// buffer. Single-input, single-output. Backs
514    /// `FusedOps::INPLACE_AFFINE`.
515    InplaceAffine,
516    /// Fused softmax + negative log-likelihood with integer class
517    /// targets — the standard PyTorch / Liger-Kernel training loss.
518    /// Inputs `[logits, targets]` where `logits: [n_rows, vocab]`
519    /// (F32, flattened from rank-N) and `targets: [n_rows]` (I64).
520    /// Output F32 — scalar for Mean/Sum reductions, `[n_rows]` for
521    /// None. Geometry + reduction + `ignore_index` flow through
522    /// `OpParams::FusedSoftmaxCrossEntropy`. Backs
523    /// `FusedOps::FUSED_SOFTMAX_CROSS_ENTROPY`.
524    FusedSoftmaxCrossEntropy,
525    /// Depthwise 1-D causal convolution + bias + optional fused SiLU.
526    /// Inputs `[x, weight, bias]` where
527    /// `x: [batch, channels, seq + kernel - 1]` (left-pre-padded by
528    /// caller), `weight: [channels, 1, kernel]`, `bias: [channels]`.
529    /// Output `[batch, channels, seq]`. Geometry + `use_silu` flow
530    /// through `OpParams::CausalConv1d`. Backs
531    /// `FusedOps::CAUSAL_CONV1D`
532    /// — the Mamba-1 / Mamba-2 prefill convolution fusion.
533    CausalConv1d,
534    /// Mamba-1's selective state-space scan (forward). Five inputs
535    /// `[u, delta, a, b, c]` — see
536    /// `FusedOps::SELECTIVE_SCAN`
537    /// for the full shape contract. Output `y: [batch, seqlen, dim]`.
538    /// Geometry + `delta_softplus` flow through
539    /// `OpParams::SelectiveScan`.
540    SelectiveScan,
541    /// Mamba-2's State-Space Duality chunked scan (forward). Five
542    /// inputs `[x, dt, a, b, c]` — see
543    /// `FusedOps::SSD_CHUNK_SCAN`
544    /// for the full shape contract. Output
545    /// `y: [batch, seqlen, heads, head_dim]`. Geometry + `chunk_size`
546    /// flow through `OpParams::SsdChunkScan`.
547    SsdChunkScan,
548    /// bitsandbytes-style 4-bit NormalFloat quantized matmul. Three
549    /// inputs `[activations, w_packed, absmax]` — see
550    /// `FusedOps::NF4_MATMUL`
551    /// for the full shape contract. Output `[..., M, N]` matches the
552    /// activations' dtype. Geometry + `block_size` flow through
553    /// `OpParams::Nf4Matmul`.
554    Nf4Matmul,
555    /// A **runtime-registered** (Tier-2, JIT-synthesized / import-time) fused
556    /// op. Carries no static kernel family: the concrete op is named by the
557    /// runtime `FusedOpId` on the graph node, and its kernel resolves from the
558    /// runtime sidecar (`fuel_dispatch::runtime_fused_kernels`), never the
559    /// `OpKind`-keyed binding table. Exists so a `CompiledNode` for such an op
560    /// has an honest `op` tag; no binding-table registration may use it.
561    RuntimeFused,
562    /// Data-determined nonzero-index extraction — the keystone primitive
563    /// for data-dependent dynamic shapes. One input `x`; one bundled
564    /// output `[indices [capacity] U32 ; count [1] U32]`. Geometry +
565    /// `count_sym` flow through `OpParams::NonZeroIndices`. See
566    /// `Op::NonZeroIndices`.
567    NonZeroIndices,
568}
569
570impl OpKind {
571    pub fn as_str(self) -> &'static str {
572        match self {
573            OpKind::MatMul => "matmul",
574            OpKind::AddElementwise => "add",
575            OpKind::SubElementwise => "sub",
576            OpKind::MulElementwise => "mul",
577            OpKind::DivElementwise => "div",
578            OpKind::ReluElementwise => "relu",
579            OpKind::NegElementwise => "neg",
580            OpKind::SqrElementwise => "sqr",
581            OpKind::SqrtElementwise => "sqrt",
582            OpKind::RecipElementwise => "recip",
583            OpKind::AbsElementwise => "abs",
584            OpKind::TanhElementwise => "tanh",
585            OpKind::ExpElementwise => "exp",
586            OpKind::LogElementwise => "log",
587            OpKind::SinElementwise => "sin",
588            OpKind::CosElementwise => "cos",
589            OpKind::SigmoidElementwise => "sigmoid",
590            OpKind::SiluElementwise => "silu",
591            OpKind::GeluTanhElementwise => "gelu_tanh",
592            OpKind::StepElementwise => "step",
593            OpKind::SumReduce => "sum_reduce",
594            OpKind::MaxReduce => "max_reduce",
595            OpKind::MinReduce => "min_reduce",
596            OpKind::MeanReduce => "mean_reduce",
597            OpKind::Cast => "cast",
598            OpKind::Conv2D => "conv2d",
599            OpKind::ConvTranspose2D => "conv_transpose2d",
600            OpKind::ReduceSumTo => "reduce_sum_to",
601            OpKind::ReduceMaxTo => "reduce_max_to",
602            OpKind::FusedLinear => "fused_linear",
603            OpKind::FlashAttn => "flash_attn",
604            OpKind::FlashAttnBackwardQ => "flash_attn_backward_q",
605            OpKind::FlashAttnBackwardK => "flash_attn_backward_k",
606            OpKind::FlashAttnBackwardV => "flash_attn_backward_v",
607            OpKind::PagedAttn => "paged_attn",
608            OpKind::Affine => "affine",
609            OpKind::ClampElementwise => "clamp",
610            OpKind::PowIElementwise => "powi",
611            OpKind::PowIElementwiseBackward => "powi_backward",
612            OpKind::MaximumElementwise => "maximum",
613            OpKind::MinimumElementwise => "minimum",
614            OpKind::EqualElementwise => "eq",
615            OpKind::NotEqualElementwise => "ne",
616            OpKind::LessElementwise => "lt",
617            OpKind::LessEqualElementwise => "le",
618            OpKind::GreaterElementwise => "gt",
619            OpKind::GreaterEqualElementwise => "ge",
620            OpKind::Where => "where",
621            OpKind::FloorElementwise => "floor",
622            OpKind::CeilElementwise => "ceil",
623            OpKind::RoundElementwise => "round",
624            OpKind::SignElementwise => "sign",
625            OpKind::ErfElementwise => "erf",
626            OpKind::GeluElementwise => "gelu",
627            OpKind::PowElementwise => "pow",
628            OpKind::RsqrtElementwise => "rsqrt",
629            OpKind::RemElementwise => "rem",
630            OpKind::Flip => "flip",
631            OpKind::Roll => "roll",
632            OpKind::CumSum => "cumsum",
633            OpKind::Pad => "pad",
634            OpKind::PadBackward => "pad_backward",
635            OpKind::Triu => "triu",
636            OpKind::Tril => "tril",
637            OpKind::LogSoftmaxLastDim => "log_softmax_last_dim",
638            OpKind::LogSoftmaxLastDimBackward => "log_softmax_last_dim_backward",
639            OpKind::MaskedFill => "masked_fill",
640            OpKind::Concat => "concat",
641            OpKind::SoftmaxLastDim => "softmax_last_dim",
642            OpKind::SoftmaxLastDimBackward => "softmax_last_dim_backward",
643            OpKind::RmsNormLastDim => "rms_norm_last_dim",
644            OpKind::RmsNormLastDimBackward => "rms_norm_last_dim_backward",
645            OpKind::LayerNormLastDim => "layer_norm_last_dim",
646            OpKind::LayerNormLastDimBackward => "layer_norm_last_dim_backward",
647            OpKind::ReduceMaxToBackward => "reduce_max_to_backward",
648            OpKind::IndexSelect => "index_select",
649            OpKind::Gather => "gather",
650            OpKind::Rope => "rope",
651            OpKind::IndexAdd => "index_add",
652            OpKind::ScatterAdd => "scatter_add",
653            OpKind::ArgMaxDim => "argmax_dim",
654            OpKind::ArgMinDim => "argmin_dim",
655            OpKind::QMatMul => "qmatmul",
656            OpKind::WriteSlice => "write_slice",
657            OpKind::WriteSliceRotating => "write_slice_rotating",
658            OpKind::WriteSliceDoff => "write_slice_doff",
659            OpKind::Copy => "copy",
660            OpKind::ReluInplace => "relu_inplace",
661            OpKind::SiluInplace => "silu_inplace",
662            OpKind::GeluTanhInplace => "gelu_tanh_inplace",
663            OpKind::TanhInplace => "tanh_inplace",
664            OpKind::SigmoidInplace => "sigmoid_inplace",
665            OpKind::NegInplace => "neg_inplace",
666            OpKind::AbsInplace => "abs_inplace",
667            OpKind::SqrInplace => "sqr_inplace",
668            OpKind::SqrtInplace => "sqrt_inplace",
669            OpKind::RsqrtInplace => "rsqrt_inplace",
670            OpKind::RecipInplace => "recip_inplace",
671            OpKind::ExpInplace => "exp_inplace",
672            OpKind::LogInplace => "log_inplace",
673            OpKind::SinInplace => "sin_inplace",
674            OpKind::CosInplace => "cos_inplace",
675            OpKind::SignInplace => "sign_inplace",
676            OpKind::FloorInplace => "floor_inplace",
677            OpKind::CeilInplace => "ceil_inplace",
678            OpKind::RoundInplace => "round_inplace",
679            OpKind::ErfInplace => "erf_inplace",
680            OpKind::GeluInplace => "gelu_inplace",
681            OpKind::ClampInplace => "clamp_inplace",
682            OpKind::PowIInplace => "powi_inplace",
683            OpKind::InplaceAffine => "inplace_affine",
684            OpKind::FusedSoftmaxCrossEntropy => "fused_softmax_cross_entropy",
685            OpKind::CausalConv1d => "causal_conv1d",
686            OpKind::SelectiveScan => "selective_scan",
687            OpKind::SsdChunkScan => "ssd_chunk_scan",
688            OpKind::Nf4Matmul => "nf4_matmul",
689            OpKind::RuntimeFused => "runtime_fused",
690            OpKind::NonZeroIndices => "nonzero_indices",
691        }
692    }
693}
694
695impl std::fmt::Display for OpKind {
696    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
697        f.write_str(self.as_str())
698    }
699}
700
701/// Dispatch size bucket. One `u32` field carries two representations,
702/// disambiguated by the [`OpKind`] that always sits beside it in the
703/// dispatch key (a matmul key is only ever compared to matmul keys):
704///
705/// - **Total-elements key** (every non-matmul op — elementwise,
706///   reduce, cast, …): the value is `log2(total_elements)`. A 256×256
707///   elementwise tensor (65,536 elements) → `16`; a 1024×1024
708///   (1,048,576) → `20`. Built by [`SizeClass::from_elem_count`].
709///   Two shapes that round to the same bucket share a profile entry.
710///
711/// - **MatMul aspect key**: matmul cost is aspect-dependent — a
712///   `[1,K]×[K,N]` GEMV is bandwidth-bound, a square `[S,S]×[S,S]`
713///   GEMM of the *same output size* is compute-bound — so
714///   `log2(total_elements)` alone can't tell them apart. A matmul
715///   packs all three dims: `(log2(m·n) << 16) | (log2(m) << 8) |
716///   log2(k)`, read from `lhs=[…,m,k]` / `rhs=[…,k,n]`. `log2(m·n)`
717///   occupies the high byte so [`DispatchTable::pick_nearest`]'s
718///   magnitude ordering still tracks output size, while the low bytes
719///   distinguish aspect (a GEMV and a same-output-size square never
720///   collide, because their `log2(m)` bytes differ). Built by
721///   [`SizeClass::matmul`].
722///
723/// **Producer/consumer single-sourcing.** The `fuel-core` Judge
724/// (producer) and every consumer (the `fuel-dispatch` ranker at
725/// realize time) derive the key through the SAME helpers —
726/// [`SizeClass::matmul`] from native `(m,n,k)` on the producer side,
727/// [`SizeClass::for_op`] from the operand shapes on the consumer side.
728/// A given matmul shape therefore maps to one identical key on both
729/// sides, so a cell the Judge profiles is found by the ranker at
730/// dispatch time (the bug this repr fixes: the old scalar key had the
731/// producer on `m·n` and the consumer on `m·k`, so non-square matmul
732/// lookups always missed).
733#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
734#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
735pub struct SizeClass(pub u32);
736
737impl SizeClass {
738    /// Bucket a raw element count as `log2(n)` — the total-elements key
739    /// used by every non-matmul op. `n == 0` clamps to `1` (→ `0`).
740    pub fn from_elem_count(n: usize) -> Self {
741        let n = n.max(1);
742        let log2 = 63 - (n as u64).leading_zeros();
743        SizeClass(log2)
744    }
745
746    /// Aspect-carrying dispatch key for a matmul `[m,k] × [k,n]`.
747    ///
748    /// THE single source of matmul-key derivation: both the Judge
749    /// (producer, from its native `(m,n,k)`) and the ranker (consumer,
750    /// via [`SizeClass::for_op`] from operand shapes) call this, so both
751    /// sides agree for every shape. Packs `(log2(m·n) << 16) |
752    /// (log2(m) << 8) | log2(k)`; each `log2` fits a byte (≤ 63 for any
753    /// `usize`). A `[1,K]×[K,N]` GEMV (`m = 1`) never collides with a
754    /// square GEMM of equal output size — the `log2(m)` byte differs.
755    pub fn matmul(m: usize, n: usize, k: usize) -> Self {
756        let mn = Self::from_elem_count(m.saturating_mul(n)).0 & 0xff;
757        let ml = Self::from_elem_count(m).0 & 0xff;
758        let kl = Self::from_elem_count(k).0 & 0xff;
759        SizeClass((mn << 16) | (ml << 8) | kl)
760    }
761
762    /// Aspect-carrying dispatch key for a decode attention op
763    /// ([`OpKind::FlashAttn`]): `q = [B, Hq, Sq, D]`, K/V attended
764    /// length `k_len`, head dim `d`.
765    ///
766    /// THE single source of attention-key derivation, mirroring
767    /// [`SizeClass::matmul`]: both the Judge (producer, from its native
768    /// `OpSize::Attention` `(hq, k_len, d)`) and the flash-vs-decomposed
769    /// bake (consumer, via [`SizeClass::for_op`] from the flash node's
770    /// operand shapes) call this, so both sides agree for every shape.
771    /// Packs `(log2(hq·k_len·d) << 16) | (log2(k_len) << 8) | log2(d)`:
772    /// the high 16 bits track total attention work (`O(Hq·k_len·D)` for
773    /// the QKᵀ + PV matmuls at seq_q=1), so [`DispatchTable::pick_nearest`]'s
774    /// magnitude ordering still follows problem size, while the low bytes
775    /// carry the `k_len` (context-length / bandwidth regime) and head-dim
776    /// aspects — so two decode shapes that differ only in `k_len` (short
777    /// vs capacity context) never collide onto one cell. Each `log2` fits
778    /// a byte (≤ 63 for any `usize`).
779    pub fn attention(hq: usize, k_len: usize, d: usize) -> Self {
780        let work = hq.saturating_mul(k_len).saturating_mul(d);
781        let mag = Self::from_elem_count(work).0 & 0xff;
782        let kl = Self::from_elem_count(k_len).0 & 0xff;
783        let dl = Self::from_elem_count(d).0 & 0xff;
784        SizeClass((mag << 16) | (kl << 8) | dl)
785    }
786
787    /// Derive the dispatch key for `op` from its input operand shapes —
788    /// the single entry point every consumer uses. A [`OpKind::MatMul`]
789    /// reads `(m,n,k)` from `lhs=[…,m,k]` / `rhs=[…,k,n]` (trailing two
790    /// dims; leading batch dims don't affect the key) and keys via
791    /// [`SizeClass::matmul`]; every other op keys on the first operand's
792    /// total element count via [`SizeClass::from_elem_count`]. Empty
793    /// shapes (nullary op) → `SizeClass(0)`.
794    pub fn for_op(op: OpKind, input_shapes: &[Shape]) -> Self {
795        if op == OpKind::MatMul
796            && let (Some(lhs), Some(rhs)) = (input_shapes.first(), input_shapes.get(1))
797        {
798            let (ld, rd) = (lhs.dims(), rhs.dims());
799            if ld.len() >= 2 && rd.len() >= 2 {
800                let m = ld[ld.len() - 2];
801                let k = ld[ld.len() - 1];
802                let n = rd[rd.len() - 1];
803                return Self::matmul(m, n, k);
804            }
805        }
806        if op == OpKind::FlashAttn {
807            // Operand layout: q = [B, Hq, Sq, D], k = [B, Hkv, k_len, D]
808            // (v matches k). Read `hq`/`d` from q and the attended length
809            // `k_len` from k's length axis — the leading B batch dim and
810            // k's Hkv don't affect the key (mirrors matmul's batch-blind
811            // trailing-dim read), so a 2D per-head profile and the batched
812            // op agree.
813            if let (Some(q), Some(k)) = (input_shapes.first(), input_shapes.get(1)) {
814                let (qd, kd) = (q.dims(), k.dims());
815                if qd.len() >= 4 && kd.len() >= 2 {
816                    let hq = qd[1];
817                    let d = qd[qd.len() - 1];
818                    let k_len = kd[kd.len() - 2];
819                    return Self::attention(hq, k_len, d);
820                }
821            }
822        }
823        match input_shapes.first() {
824            Some(s) => Self::from_elem_count(s.elem_count()),
825            None => SizeClass(0),
826        }
827    }
828}
829
830/// Single (op_kind, dtype, size_class) × (backend, device_index,
831/// kernel_source) datum produced by one measurement run.
832///
833/// **Per-alternative measurement (2026-06-08)**: `kernel_source`
834/// distinguishes kernels that register at the same
835/// `(op, dtypes, backend)` decision point in the binding table —
836/// AOCL vs MKL vs portable-cpu, cuBLAS vs CUTLASS, etc. Each
837/// sibling alternative produces its own [`ProfileEntry`]; the
838/// dispatch table picks the best entry per cell across both
839/// backends and kernel_sources. Default empty string preserves the
840/// pre-v2 shape for kernels that don't need to distinguish.
841#[derive(Debug, Clone, PartialEq)]
842#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
843pub struct ProfileEntry {
844    pub op: OpKind,
845    pub dtype: DType,
846    pub size_class: SizeClass,
847    pub backend: BackendId,
848    pub device_index: u32,
849    /// Median wall-clock time per invocation over `iterations`.
850    pub latency_ns: u64,
851    /// Number of timed iterations that produced `latency_ns`.
852    pub iterations: u32,
853    /// Max relative element-wise error vs the reference backend's
854    /// output on the same input.
855    pub max_rel_error: f32,
856    /// Diagnostic tag identifying which kernel sibling produced this
857    /// measurement when multiple alternatives register at the same
858    /// `(op, dtypes, backend)` binding-table key. Mirrors
859    /// `BindingEntry::kernel_source` (in `fuel-dispatch`). `""` is
860    /// the pre-v2 default for kernels that don't need to distinguish;
861    /// conventional values include `"portable-cpu"`, `"aocl"`,
862    /// `"mkl"`, `"cublas"`, `"cutlass"`.
863    ///
864    /// Owned `String` (not `&'static str`) so the field round-trips
865    /// through serde — deserialized reports can't carry borrowed
866    /// data with a `'static` lifetime. The producer-side `Judge`
867    /// converts from `BindingEntry::kernel_source: &'static str`
868    /// via `.to_string()` at measurement time.
869    #[cfg_attr(feature = "serde", serde(default))]
870    pub kernel_source: String,
871    /// Content-derived revision of the exact kernel build that produced
872    /// this measurement. Mirrors `BindingEntry::kernel_revision_hash`
873    /// (`fuel-dispatch`) and the FKC ledger key's `kernel_revision_hash`;
874    /// with `kernel_source` it completes Fuel's canonical kernel identity
875    /// `ImplId = (backend, op, dtypes, kernel_source, kernel_revision_hash)`.
876    ///
877    /// `kernel_source` is provider-granular (`"cublas"`, `"baracuda"`, …);
878    /// `kernel_revision_hash` is *variant*-granular — forty tilings of one
879    /// provider emit forty distinct source strings and forty distinct
880    /// content-derived hashes, so they no longer collapse into one Judge
881    /// cell. `0` means "untracked" (the pre-v5 default and the value test
882    /// fixtures use when the axis is irrelevant); the Judge keys on it, so
883    /// two DISTINCT non-zero revisions occupy two distinct cells.
884    #[cfg_attr(feature = "serde", serde(default))]
885    pub kernel_revision_hash: u64,
886    /// The hardware identity this measurement was taken on — the
887    /// [`EquivalenceKey`](crate::probe::EquivalenceKey) of the profiled
888    /// device: `(backend, vendor_id, device_id, compute_capability,
889    /// driver_version)`. A `ProfileReport` is one Judge run on ONE machine.
890    /// `device_index` above is a within-run ORDINAL that rebinds across boots
891    /// and under `CUDA_VISIBLE_DEVICES`, so it is NOT a portable identity —
892    /// worse than none, because it answers confidently for a different
893    /// physical device. The ordinal is retained for within-run indexing; this
894    /// field is what `ProfileReport::load` validates against the loading
895    /// machine's hardware, so a report written on a 4070 Laptop is REFUSED on
896    /// a Radeon VII instead of loading and mis-ranking. `backend` here always
897    /// equals the sibling `backend` field above (checked on ingest).
898    ///
899    /// `#[serde(default)]` supplies a sentinel identity ONLY so a pre-v5
900    /// report — which lacks this field — still deserializes and is then
901    /// rejected by the version gate; the sentinel matches no real device (see
902    /// `default_device_identity`).
903    #[cfg_attr(feature = "serde", serde(default = "default_device_identity"))]
904    pub device: crate::probe::EquivalenceKey,
905}
906
907/// Sentinel hardware identity for the `#[serde(default)]` on
908/// [`ProfileEntry::device`]. Reached ONLY on the pre-v5 deserialize path (a
909/// v4 report lacks the field → deserializes with this → the version gate then
910/// returns `Ok(None)` before it is ever consulted). Deliberately matches NO
911/// real device — an `driver_version` string no backend reports — so that if a
912/// future reordering ever let it past the version gate, it would refuse every
913/// load (loud) rather than silently match the wrong hardware. Applied by hand
914/// rather than a blanket `Default`, precisely so the value is a deliberate
915/// refuse-everything sentinel and not a zero that could coincidentally match.
916#[cfg(feature = "serde")]
917fn default_device_identity() -> crate::probe::EquivalenceKey {
918    crate::probe::EquivalenceKey {
919        backend: BackendId::Cpu,
920        vendor_id: 0,
921        device_id: 0,
922        compute_capability: None,
923        driver_version: "<unset: pre-v5 profile, rejected by the version gate>".to_string(),
924    }
925}
926
927/// A persistable table of every profile measurement the Judge
928/// produced in one run.
929#[derive(Debug, Clone, PartialEq)]
930#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
931pub struct ProfileReport {
932    pub version: u32,
933    pub entries: Vec<ProfileEntry>,
934}
935
936#[cfg(feature = "serde")]
937impl ProfileReport {
938    /// Atomic write to `path` as JSON (sibling `.tmp` + rename).
939    pub fn save(&self, path: &std::path::Path) -> crate::Result<()> {
940        let json = serde_json::to_vec_pretty(self)
941            .map_err(|e| crate::Error::Msg(format!("judge: JSON encode failed: {e}")))?;
942        let tmp = path.with_extension("tmp");
943        std::fs::write(&tmp, &json)
944            .map_err(|e| crate::Error::Msg(format!("judge: write {tmp:?} failed: {e}")))?;
945        std::fs::rename(&tmp, path).map_err(|e| {
946            crate::Error::Msg(format!("judge: rename {tmp:?} → {path:?} failed: {e}"))
947        })?;
948        Ok(())
949    }
950
951    /// Load a previously-persisted report, validated against the hardware of
952    /// the loading machine. `current` is that machine's device equivalence
953    /// classes (via `crate::probe::equivalence_classes`); requiring it in the
954    /// signature makes it impossible to load without declaring what hardware
955    /// you are on — the gate cannot be forgotten by a caller.
956    ///
957    /// Returns:
958    /// - `Ok(None)` on a missing file.
959    /// - `Ok(None)` on a schema-version mismatch **that still deserializes**
960    ///   (an additive change: the pre-v5 fields `#[serde(default)]` in, then
961    ///   this version gate rejects the report).
962    /// - `Ok(None)` on FOREIGN HARDWARE. A `ProfileReport` is the output of ONE
963    ///   Judge run on ONE machine, so if ANY entry's `device` identity is not
964    ///   among `current`, the run happened elsewhere and the WHOLE report is
965    ///   refused — the entries that do match are not a valid subset, they are a
966    ///   foreign report with coincidental overlap, and the `device_index` it
967    ///   was keyed by rebinds across machines.
968    /// - `Err` on a NON-ADDITIVE schema change that fails to PARSE. Such a
969    ///   change never reaches the version gate above, because deserialization
970    ///   fails first — which is exactly why every new field carries
971    ///   `#[serde(default)]`. A future "check the version first, it's cheaper"
972    ///   cleanup MUST preserve `Ok(None)` for a deserializable stale report;
973    ///   `v4_report_yields_no_oracle_entries` pins that invariant.
974    ///
975    /// All three `Ok(None)` cases are "cache miss, re-run the Judge" signals.
976    pub fn load(
977        path: &std::path::Path,
978        current: &[crate::probe::EquivalenceKey],
979    ) -> crate::Result<Option<Self>> {
980        let bytes = match std::fs::read(path) {
981            Ok(b) => b,
982            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
983            Err(e) => {
984                return Err(crate::Error::Msg(format!(
985                    "judge: read {path:?} failed: {e}"
986                )));
987            }
988        };
989        let report: Self = serde_json::from_slice(&bytes)
990            .map_err(|e| crate::Error::Msg(format!("judge: parse {path:?} failed: {e}")))?;
991        if report.version != PROFILE_REPORT_VERSION {
992            return Ok(None);
993        }
994        // Whole-report hardware validation: one run, one machine. Any entry
995        // measured on a device this machine does not have means the report is
996        // foreign — refuse it entirely rather than serve a coincidental subset.
997        if report.entries.iter().any(|e| !current.contains(&e.device)) {
998            return Ok(None);
999        }
1000        Ok(Some(report))
1001    }
1002}
1003
1004/// A selection criterion — what "best" means for a lookup.
1005#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1006#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1007pub enum Criterion {
1008    /// Lowest median latency.
1009    Fastest,
1010    /// Lowest max relative error vs the reference backend.
1011    MostAccurate,
1012    /// Weighted blend — lower is better.
1013    Balanced,
1014}
1015
1016impl Criterion {
1017    pub fn as_str(self) -> &'static str {
1018        match self {
1019            Criterion::Fastest => "fastest",
1020            Criterion::MostAccurate => "accurate",
1021            Criterion::Balanced => "balanced",
1022        }
1023    }
1024}
1025
1026impl std::fmt::Display for Criterion {
1027    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1028        f.write_str(self.as_str())
1029    }
1030}
1031
1032/// Default weight applied to `max_rel_error` in the Balanced
1033/// criterion's cost function.
1034pub const DEFAULT_ACCURACY_PENALTY: f64 = 100.0;
1035
1036/// Lookup key into [`DispatchTable`]. Combines the per-op axes
1037/// (what + on what data + how big) with the user's criterion.
1038#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1039struct DispatchKey {
1040    op: OpKind,
1041    dtype: DType,
1042    size_class: SizeClass,
1043    criterion: Criterion,
1044}
1045
1046/// Where the dispatch table decided an op should run, and which
1047/// kernel sibling won at that decision point.
1048///
1049/// **Per-alternative measurement (2026-06-08)**: `kernel_source`
1050/// surfaces the winning [`ProfileEntry::kernel_source`] so
1051/// consumers (Router, planner, telemetry) can name the actual
1052/// kernel — critical when multiple alternatives register at
1053/// `(BackendId::Cpu)` (e.g. AOCL vs MKL vs portable-cpu) and the
1054/// (backend, device) pair alone is ambiguous. `""` for legacy
1055/// reports / single-impl cells.
1056///
1057/// `Pick` stays `Copy` (and `&'static str` is `Copy`); the picker
1058/// preserves the string identity by reading it directly from the
1059/// winning `ProfileEntry` — its `String` value is matched against
1060/// the well-known interned set via [`kernel_source_intern`] so the
1061/// `&'static str` is the same pointer the binding-table entry
1062/// carries.
1063///
1064/// `Pick` is only ever constructed at runtime by `DispatchTable::rebuild_from`
1065/// from in-memory [`ProfileEntry`] data — never deserialized from JSON
1066/// or other owned input. We therefore intentionally do NOT derive
1067/// `Deserialize` (the `&'static str` field can't be deserialized from
1068/// owned input). `Serialize` is also dropped for symmetry; the public
1069/// serialization surface is [`ProfileReport`] / [`ProfileEntry`], and
1070/// `Pick` is regenerated by calling [`DispatchTable::build`].
1071#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1072pub struct Pick {
1073    pub backend: BackendId,
1074    pub device_index: u32,
1075    /// Diagnostic tag from the winning [`ProfileEntry::kernel_source`].
1076    /// `""` when no sibling distinguishes the cell.
1077    pub kernel_source: &'static str,
1078    /// Revision of the winning kernel build, from
1079    /// [`ProfileEntry::kernel_revision_hash`]. Completes the kernel
1080    /// identity that `kernel_source` alone (provider-granular) leaves
1081    /// ambiguous among the variant siblings of one provider. `0` when
1082    /// untracked. `u64` is `Copy`, so `Pick` stays `Copy`.
1083    pub kernel_revision_hash: u64,
1084}
1085
1086/// Intern a kernel-source string into a `&'static str`. Well-known conventional
1087/// tags return their static literal (the fast path); any OTHER non-empty tag is
1088/// interned through a process-static pool (leaked once per distinct string) so
1089/// it round-trips unchanged. The set mirrors the documented convention on
1090/// [`BindingEntry::kernel_source`](../../fuel_dispatch/struct.BindingEntry.html#structfield.kernel_source).
1091///
1092/// An unknown tag is NEVER collapsed to `""`. `""` is the pre-v2
1093/// "undifferentiated" marker, so returning it for a new provider's kernel makes
1094/// that kernel indistinguishable from an untagged one — a category error in a
1095/// field that keys persisted profiles (the router then cannot resolve
1096/// `(backend, device, "")` to the right same-backend sibling). A new provider's
1097/// tag is legitimate; preserve it. The bounded leak — one allocation per
1098/// distinct tag a process ever sees, and providers are few — is the right trade
1099/// for never losing kernel identity. (This reverses an earlier decision that
1100/// preferred dropping the tag over the leak; the collision cost was underpriced.)
1101///
1102/// SAFETY of the leak — an ASSUMPTION this code relies on but does not enforce:
1103/// it is bounded only because the tag SET is bounded, and the bound is a property
1104/// of the data path, not of the type. The input CAN be a deserialized `String`
1105/// (`ProfileEntry::kernel_source` comes from JSON), not only a compile-time
1106/// `&'static str`. Tags stay bounded because they derive from bindings registered
1107/// on THIS machine and `ProfileReport::load`'s hardware gate refuses foreign
1108/// reports, so a persisted profile cannot smuggle in arbitrary tags. What would
1109/// break it: a profile carrying unbounded distinct tags — an untrusted or
1110/// machine-generated one — would leak without bound, at which point this interner
1111/// needs a cap.
1112pub fn kernel_source_intern(s: &str) -> &'static str {
1113    match s {
1114        "" => "",
1115        "portable-cpu" => "portable-cpu",
1116        "aocl" => "aocl",
1117        "mkl" => "mkl",
1118        "cublas" => "cublas",
1119        "cutlass" => "cutlass",
1120        "baracuda" => "baracuda",
1121        "slang" => "slang",
1122        other => intern_unknown_kernel_source(other),
1123    }
1124}
1125
1126/// Intern an unfamiliar kernel-source tag into a stable `&'static str`, leaking
1127/// it exactly once. Bounded by the number of distinct tags a process ever sees
1128/// (a handful of providers), so the leak does not grow unboundedly. Preserving
1129/// the tag beats collapsing it to `""` — see [`kernel_source_intern`].
1130fn intern_unknown_kernel_source(s: &str) -> &'static str {
1131    use std::collections::HashSet;
1132    use std::sync::{Mutex, OnceLock};
1133    static POOL: OnceLock<Mutex<HashSet<&'static str>>> = OnceLock::new();
1134    let pool = POOL.get_or_init(|| Mutex::new(HashSet::new()));
1135    let mut set = pool.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
1136    if let Some(&existing) = set.get(s) {
1137        return existing;
1138    }
1139    let leaked: &'static str = Box::leak(s.to_owned().into_boxed_str());
1140    set.insert(leaked);
1141    leaked
1142}
1143
1144/// Options for building a [`DispatchTable`].
1145#[derive(Debug, Clone, Copy)]
1146pub struct DispatchOptions {
1147    pub accuracy_penalty: f64,
1148}
1149
1150impl Default for DispatchOptions {
1151    fn default() -> Self {
1152        Self {
1153            accuracy_penalty: DEFAULT_ACCURACY_PENALTY,
1154        }
1155    }
1156}
1157
1158impl DispatchOptions {
1159    pub fn with_balanced_penalty(mut self, k: f64) -> Self {
1160        self.accuracy_penalty = k;
1161        self
1162    }
1163}
1164
1165/// O(1) runtime dispatch table, constructed once from a
1166/// [`ProfileReport`] and then queried at realize time.
1167#[derive(Debug, Clone)]
1168pub struct DispatchTable {
1169    entries: HashMap<DispatchKey, Pick>,
1170    /// All size classes present for each `(op, dtype)` — sorted
1171    /// ascending so `pick_nearest` can do a linear scan for the
1172    /// closest profiled bucket.
1173    size_index: HashMap<(OpKind, DType), Vec<SizeClass>>,
1174    accuracy_penalty: f64,
1175}
1176
1177impl DispatchTable {
1178    /// Build a dispatch table from a profile report with default options.
1179    pub fn build(report: &ProfileReport) -> Self {
1180        Self::build_with(report, DispatchOptions::default())
1181    }
1182
1183    pub fn build_with(report: &ProfileReport, opts: DispatchOptions) -> Self {
1184        let mut tbl = Self {
1185            entries: HashMap::new(),
1186            size_index: HashMap::new(),
1187            accuracy_penalty: opts.accuracy_penalty,
1188        };
1189        tbl.rebuild_from(report);
1190        tbl
1191    }
1192
1193    fn rebuild_from(&mut self, report: &ProfileReport) {
1194        self.entries.clear();
1195        self.size_index.clear();
1196        let mut groups: HashMap<(OpKind, DType, SizeClass), Vec<&ProfileEntry>> = HashMap::new();
1197        for e in &report.entries {
1198            groups
1199                .entry((e.op, e.dtype, e.size_class))
1200                .or_default()
1201                .push(e);
1202        }
1203        for ((op, dtype, size_class), group) in &groups {
1204            for &criterion in &[
1205                Criterion::Fastest,
1206                Criterion::MostAccurate,
1207                Criterion::Balanced,
1208            ] {
1209                if let Some(winner) = self.pick_winner(group, criterion) {
1210                    let key = DispatchKey {
1211                        op: *op,
1212                        dtype: *dtype,
1213                        size_class: *size_class,
1214                        criterion,
1215                    };
1216                    self.entries.insert(
1217                        key,
1218                        Pick {
1219                            backend: winner.backend,
1220                            device_index: winner.device_index,
1221                            kernel_source: kernel_source_intern(&winner.kernel_source),
1222                            kernel_revision_hash: winner.kernel_revision_hash,
1223                        },
1224                    );
1225                }
1226            }
1227            self.size_index
1228                .entry((*op, *dtype))
1229                .or_default()
1230                .push(*size_class);
1231        }
1232        for classes in self.size_index.values_mut() {
1233            classes.sort_by_key(|s| s.0);
1234            classes.dedup();
1235        }
1236    }
1237
1238    fn pick_winner<'a>(
1239        &self,
1240        group: &[&'a ProfileEntry],
1241        crit: Criterion,
1242    ) -> Option<&'a ProfileEntry> {
1243        match crit {
1244            Criterion::Fastest => group.iter().copied().min_by_key(|e| e.latency_ns),
1245            Criterion::MostAccurate => group.iter().copied().min_by(|a, b| {
1246                a.max_rel_error
1247                    .total_cmp(&b.max_rel_error)
1248                    .then(a.latency_ns.cmp(&b.latency_ns))
1249            }),
1250            Criterion::Balanced => group.iter().copied().min_by(|a, b| {
1251                let sa =
1252                    a.latency_ns as f64 * (1.0 + self.accuracy_penalty * a.max_rel_error as f64);
1253                let sb =
1254                    b.latency_ns as f64 * (1.0 + self.accuracy_penalty * b.max_rel_error as f64);
1255                sa.total_cmp(&sb)
1256            }),
1257        }
1258    }
1259
1260    /// Exact lookup — returns `None` if the requested size class
1261    /// wasn't profiled.
1262    pub fn pick(
1263        &self,
1264        op: OpKind,
1265        dtype: DType,
1266        size_class: SizeClass,
1267        criterion: Criterion,
1268    ) -> Option<Pick> {
1269        self.entries
1270            .get(&DispatchKey {
1271                op,
1272                dtype,
1273                size_class,
1274                criterion,
1275            })
1276            .copied()
1277    }
1278
1279    /// Nearest-neighbour lookup. Ties go to the larger size class.
1280    pub fn pick_nearest(
1281        &self,
1282        op: OpKind,
1283        dtype: DType,
1284        size_class: SizeClass,
1285        criterion: Criterion,
1286    ) -> Option<Pick> {
1287        if let Some(p) = self.pick(op, dtype, size_class, criterion) {
1288            return Some(p);
1289        }
1290        let classes = self.size_index.get(&(op, dtype))?;
1291        if classes.is_empty() {
1292            return None;
1293        }
1294        let target = size_class.0 as i32;
1295        let nearest = classes.iter().min_by_key(|c| {
1296            let diff = (c.0 as i32 - target).abs();
1297            (diff, -(c.0 as i32))
1298        })?;
1299        self.pick(op, dtype, *nearest, criterion)
1300    }
1301
1302    pub fn len(&self) -> usize {
1303        self.entries.len()
1304    }
1305    pub fn is_empty(&self) -> bool {
1306        self.entries.is_empty()
1307    }
1308
1309    /// Every distinct `(op, dtype, size_class)` for which the table
1310    /// has at least one criterion entry. Sorted, stable across calls.
1311    pub fn keys(&self) -> Vec<(OpKind, DType, SizeClass)> {
1312        let mut seen: std::collections::HashSet<(OpKind, DType, SizeClass)> = Default::default();
1313        for k in self.entries.keys() {
1314            seen.insert((k.op, k.dtype, k.size_class));
1315        }
1316        let mut out: Vec<_> = seen.into_iter().collect();
1317        out.sort_by(|a, b| {
1318            a.0.as_str()
1319                .cmp(b.0.as_str())
1320                .then(format!("{:?}", a.1).cmp(&format!("{:?}", b.1)))
1321                .then(a.2.0.cmp(&b.2.0))
1322        });
1323        out
1324    }
1325}
1326
1327#[cfg(test)]
1328mod size_class_tests {
1329    use super::*;
1330
1331    /// The interner must NEVER collapse a non-empty unknown tag to `""` — `""`
1332    /// is the pre-v2 "undifferentiated" marker, so an unknown provider's tag
1333    /// coming back as `""` makes its kernel indistinguishable from an untagged
1334    /// one, in a field that keys persisted profiles. An ARBITRARY never-known
1335    /// tag must round-trip. Born-red: the pre-interner unknown arm `debug_assert`s
1336    /// and returns `""`, so this panics in a debug/test build.
1337    #[test]
1338    fn intern_preserves_an_arbitrary_unknown_tag() {
1339        // No provider registers this and it is not in the known set — the point
1340        // is that the floor holds for any FUTURE provider, not just a named one.
1341        let unknown = "some-future-provider-xyz";
1342        assert_eq!(
1343            kernel_source_intern(unknown),
1344            unknown,
1345            "an unknown tag must be preserved, never collapsed to \"\""
1346        );
1347        assert_ne!(kernel_source_intern(unknown), "");
1348        // Interning is idempotent: the same tag returns the same pointer.
1349        assert!(std::ptr::eq(
1350            kernel_source_intern(unknown),
1351            kernel_source_intern(unknown)
1352        ));
1353        // Known tags still fast-path to their literal; empty stays empty.
1354        assert_eq!(kernel_source_intern("slang"), "slang");
1355        assert_eq!(kernel_source_intern(""), "");
1356    }
1357
1358    /// The shared matmul key derivation gives producer == consumer for a
1359    /// NON-SQUARE matmul (SizeClass v4, slice 2.5).
1360    ///
1361    /// **Born-red**: before v4 `SizeClass` was `log2(total_elements)` and
1362    /// matmul keyed on a single scalar — the Judge (producer) on `m·n`,
1363    /// the ranker (consumer) on `m·k` (LHS elem count). For a non-square
1364    /// matmul those disagree, so the ranker never found the profiled
1365    /// cell. The `matmul` / `for_op` helpers now derive one identical
1366    /// key on both sides.
1367    #[test]
1368    fn matmul_key_producer_equals_consumer_non_square() {
1369        // FFN-width decode GEMV `[1,2048]×[2048,5632]`: m=1, k=2048, n=5632.
1370        let (m, n, k) = (1usize, 5632usize, 2048usize);
1371
1372        // Producer derives from its native (m, n, k).
1373        let producer = SizeClass::matmul(m, n, k);
1374        // Consumer derives from the operand shapes lhs=[m,k]/rhs=[k,n]
1375        // through the shared `for_op` entry point.
1376        let consumer = SizeClass::for_op(
1377            OpKind::MatMul,
1378            &[Shape::from_dims(&[m, k]), Shape::from_dims(&[k, n])],
1379        );
1380        assert_eq!(
1381            producer, consumer,
1382            "producer (native m,n,k) and consumer (operand shapes) must \
1383             derive one identical key",
1384        );
1385
1386        // Old-bug witness: the pre-v4 scalar keys the two sides used
1387        // (m·n vs m·k) disagree for this non-square shape.
1388        assert_ne!(
1389            SizeClass::from_elem_count(m * n), // old producer key
1390            SizeClass::from_elem_count(m * k), // old consumer key
1391            "the non-square shape is exactly where the old scalar keys \
1392             diverged",
1393        );
1394    }
1395
1396    /// Batched matmul keys the same as its per-head 2D shape — `for_op`
1397    /// reads only the trailing two dims, so a Judge that profiles the 2D
1398    /// per-head GEMV and a consumer costing the batched op agree.
1399    #[test]
1400    fn matmul_key_batched_ignores_leading_dims() {
1401        let per_head = SizeClass::for_op(
1402            OpKind::MatMul,
1403            &[Shape::from_dims(&[1, 64]), Shape::from_dims(&[64, 128])],
1404        );
1405        let batched = SizeClass::for_op(
1406            OpKind::MatMul,
1407            &[
1408                Shape::from_dims(&[32, 1, 64]),
1409                Shape::from_dims(&[32, 64, 128]),
1410            ],
1411        );
1412        assert_eq!(per_head, batched);
1413        assert_eq!(per_head, SizeClass::matmul(1, 128, 64));
1414    }
1415
1416    /// A bandwidth-bound GEMV and a compute-bound square GEMM of the SAME
1417    /// output size key to DISTINCT buckets — the aspect distinction.
1418    /// Under the old scalar `log2(m·n)` key they collided.
1419    #[test]
1420    fn gemv_and_same_output_square_are_distinct() {
1421        // GEMV `[1,2048]×[2048,4096]` → output m·n = 4096.
1422        let gemv = SizeClass::matmul(1, 4096, 2048);
1423        // Square `[64,64]×[64,64]` → output m·n = 4096 — SAME total.
1424        let square = SizeClass::matmul(64, 64, 64);
1425
1426        // Old scalar key: identical (both log2(4096) = 12) → collision.
1427        // `1 * 4096` is the GEMV output 1·4096, written to mirror `64 * 64`
1428        // (square output 64·64) so the two colliding shapes read in parallel —
1429        // the `1 *` is the GEMV's `m` dim, not a no-op. Stripping it would
1430        // destroy the shape structure the test documents, so identity_op is
1431        // wrong here.
1432        #[allow(clippy::identity_op)] // `1 *` is the GEMV `m` dim, kept for parallel with `64 * 64`
1433        let gemv_elems = 1 * 4096; // [1, 4096]
1434        let square_elems = 64 * 64; // [64, 64]
1435        assert_eq!(
1436            SizeClass::from_elem_count(gemv_elems),
1437            SizeClass::from_elem_count(square_elems),
1438            "sanity: equal output size → the OLD scalar keys collided",
1439        );
1440        // v4 aspect key: distinct (the log2(m) byte differs, 0 vs 6).
1441        assert_ne!(
1442            gemv, square,
1443            "a bandwidth-bound GEMV must not share a dispatch cell with a \
1444             compute-bound square of equal output size",
1445        );
1446    }
1447
1448    /// Non-matmul ops are untouched: `for_op` keys on the first
1449    /// operand's total element count, identical to `from_elem_count`.
1450    #[test]
1451    fn non_matmul_keeps_total_elements_key() {
1452        let sc = SizeClass::for_op(OpKind::AddElementwise, &[Shape::from_dims(&[1024])]);
1453        assert_eq!(sc, SizeClass::from_elem_count(1024));
1454        assert_eq!(sc.0, 10); // log2(1024)
1455    }
1456
1457    /// FlashAttn: the shared `attention` key derivation gives
1458    /// producer == consumer (Judge Layer-2 coverage arc, slice 3).
1459    ///
1460    /// The Judge (producer) keys a decode-attention cell via
1461    /// `SizeClass::attention(hq, k_len, d)` from its native
1462    /// `OpSize::Attention`; slice-4's bake (consumer) keys via
1463    /// `SizeClass::for_op(FlashAttn, [q_shape, k_shape])` from the flash
1464    /// node's operand shapes. Both must derive one identical key so the
1465    /// bake's lookup HITS the profiled cell.
1466    #[test]
1467    fn attention_key_producer_equals_consumer() {
1468        // TinyLlama-ish decode: Hq=32, Hkv=4, D=64, k_len=128, seq_q=1.
1469        let (b, hq, hkv, d, k_len) = (1usize, 32usize, 4usize, 64usize, 128usize);
1470
1471        // Producer derives from its native (hq, k_len, d).
1472        let producer = SizeClass::attention(hq, k_len, d);
1473        // Consumer derives from the flash node's operand shapes
1474        // q=[B,Hq,Sq,D] / k=[B,Hkv,k_len,D] through the shared `for_op`.
1475        let consumer = SizeClass::for_op(
1476            OpKind::FlashAttn,
1477            &[
1478                Shape::from_dims(&[b, hq, 1, d]),
1479                Shape::from_dims(&[b, hkv, k_len, d]),
1480                Shape::from_dims(&[b, hkv, k_len, d]),
1481            ],
1482        );
1483        assert_eq!(
1484            producer, consumer,
1485            "producer (native hq,k_len,d) and consumer (operand shapes) must \
1486             derive one identical attention key",
1487        );
1488    }
1489
1490    /// Two decode attention shapes that differ only in `k_len` (short
1491    /// vs capacity context) must key to DISTINCT buckets — the oracle
1492    /// keeps the MIN latency across a colliding key, which would let the
1493    /// short-context cell poison the capacity cell's cost.
1494    #[test]
1495    fn attention_decode_shapes_distinct_by_klen() {
1496        let small = SizeClass::attention(32, 128, 64);
1497        let cap = SizeClass::attention(32, 4096, 64);
1498        assert_ne!(
1499            small, cap,
1500            "short-context and capacity-context decode attention must not \
1501             share a dispatch cell",
1502        );
1503    }
1504
1505    /// `for_op(FlashAttn)` reads `hq`/`d` from q and `k_len` from k's
1506    /// length axis; the leading batch dim and k's Hkv (GQA) don't affect
1507    /// the key — so a per-head 2D-ish profile and the batched op agree,
1508    /// exactly like the matmul batch-blind trailing-dim read.
1509    #[test]
1510    fn attention_key_ignores_batch_and_hkv() {
1511        let base = SizeClass::for_op(
1512            OpKind::FlashAttn,
1513            &[
1514                Shape::from_dims(&[1, 32, 1, 64]),
1515                Shape::from_dims(&[1, 4, 128, 64]),
1516            ],
1517        );
1518        // Different B and different Hkv, same Hq/k_len/D → same key.
1519        let other = SizeClass::for_op(
1520            OpKind::FlashAttn,
1521            &[
1522                Shape::from_dims(&[8, 32, 1, 64]),
1523                Shape::from_dims(&[8, 8, 128, 64]),
1524            ],
1525        );
1526        assert_eq!(base, other);
1527        assert_eq!(base, SizeClass::attention(32, 128, 64));
1528    }
1529}