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    /// AWQ (mit-han-lab) asymmetric int4 weight-only matmul. Four inputs
556    /// `[activations, weight, zeros, scales]` in baracuda's W4A16 kernel layout
557    /// — see `FusedOps::AWQ_MATMUL` for the full shape contract. Output
558    /// `[..., M, N]` matches the activations' dtype. Geometry, `group_size` and
559    /// `split_k_iters` flow through `OpParams::AwqMatmul`.
560    AwqMatmul,
561    /// A **runtime-registered** (Tier-2, JIT-synthesized / import-time) fused
562    /// op. Carries no static kernel family: the concrete op is named by the
563    /// runtime `FusedOpId` on the graph node, and its kernel resolves from the
564    /// runtime sidecar (`fuel_dispatch::runtime_fused_kernels`), never the
565    /// `OpKind`-keyed binding table. Exists so a `CompiledNode` for such an op
566    /// has an honest `op` tag; no binding-table registration may use it.
567    RuntimeFused,
568    /// Data-determined nonzero-index extraction — the keystone primitive
569    /// for data-dependent dynamic shapes. One input `x`; one bundled
570    /// output `[indices [capacity] U32 ; count [1] U32]`. Geometry +
571    /// `count_sym` flow through `OpParams::NonZeroIndices`. See
572    /// `Op::NonZeroIndices`.
573    NonZeroIndices,
574}
575
576impl OpKind {
577    pub fn as_str(self) -> &'static str {
578        match self {
579            OpKind::MatMul => "matmul",
580            OpKind::AddElementwise => "add",
581            OpKind::SubElementwise => "sub",
582            OpKind::MulElementwise => "mul",
583            OpKind::DivElementwise => "div",
584            OpKind::ReluElementwise => "relu",
585            OpKind::NegElementwise => "neg",
586            OpKind::SqrElementwise => "sqr",
587            OpKind::SqrtElementwise => "sqrt",
588            OpKind::RecipElementwise => "recip",
589            OpKind::AbsElementwise => "abs",
590            OpKind::TanhElementwise => "tanh",
591            OpKind::ExpElementwise => "exp",
592            OpKind::LogElementwise => "log",
593            OpKind::SinElementwise => "sin",
594            OpKind::CosElementwise => "cos",
595            OpKind::SigmoidElementwise => "sigmoid",
596            OpKind::SiluElementwise => "silu",
597            OpKind::GeluTanhElementwise => "gelu_tanh",
598            OpKind::StepElementwise => "step",
599            OpKind::SumReduce => "sum_reduce",
600            OpKind::MaxReduce => "max_reduce",
601            OpKind::MinReduce => "min_reduce",
602            OpKind::MeanReduce => "mean_reduce",
603            OpKind::Cast => "cast",
604            OpKind::Conv2D => "conv2d",
605            OpKind::ConvTranspose2D => "conv_transpose2d",
606            OpKind::ReduceSumTo => "reduce_sum_to",
607            OpKind::ReduceMaxTo => "reduce_max_to",
608            OpKind::FusedLinear => "fused_linear",
609            OpKind::FlashAttn => "flash_attn",
610            OpKind::FlashAttnBackwardQ => "flash_attn_backward_q",
611            OpKind::FlashAttnBackwardK => "flash_attn_backward_k",
612            OpKind::FlashAttnBackwardV => "flash_attn_backward_v",
613            OpKind::PagedAttn => "paged_attn",
614            OpKind::Affine => "affine",
615            OpKind::ClampElementwise => "clamp",
616            OpKind::PowIElementwise => "powi",
617            OpKind::PowIElementwiseBackward => "powi_backward",
618            OpKind::MaximumElementwise => "maximum",
619            OpKind::MinimumElementwise => "minimum",
620            OpKind::EqualElementwise => "eq",
621            OpKind::NotEqualElementwise => "ne",
622            OpKind::LessElementwise => "lt",
623            OpKind::LessEqualElementwise => "le",
624            OpKind::GreaterElementwise => "gt",
625            OpKind::GreaterEqualElementwise => "ge",
626            OpKind::Where => "where",
627            OpKind::FloorElementwise => "floor",
628            OpKind::CeilElementwise => "ceil",
629            OpKind::RoundElementwise => "round",
630            OpKind::SignElementwise => "sign",
631            OpKind::ErfElementwise => "erf",
632            OpKind::GeluElementwise => "gelu",
633            OpKind::PowElementwise => "pow",
634            OpKind::RsqrtElementwise => "rsqrt",
635            OpKind::RemElementwise => "rem",
636            OpKind::Flip => "flip",
637            OpKind::Roll => "roll",
638            OpKind::CumSum => "cumsum",
639            OpKind::Pad => "pad",
640            OpKind::PadBackward => "pad_backward",
641            OpKind::Triu => "triu",
642            OpKind::Tril => "tril",
643            OpKind::LogSoftmaxLastDim => "log_softmax_last_dim",
644            OpKind::LogSoftmaxLastDimBackward => "log_softmax_last_dim_backward",
645            OpKind::MaskedFill => "masked_fill",
646            OpKind::Concat => "concat",
647            OpKind::SoftmaxLastDim => "softmax_last_dim",
648            OpKind::SoftmaxLastDimBackward => "softmax_last_dim_backward",
649            OpKind::RmsNormLastDim => "rms_norm_last_dim",
650            OpKind::RmsNormLastDimBackward => "rms_norm_last_dim_backward",
651            OpKind::LayerNormLastDim => "layer_norm_last_dim",
652            OpKind::LayerNormLastDimBackward => "layer_norm_last_dim_backward",
653            OpKind::ReduceMaxToBackward => "reduce_max_to_backward",
654            OpKind::IndexSelect => "index_select",
655            OpKind::Gather => "gather",
656            OpKind::Rope => "rope",
657            OpKind::IndexAdd => "index_add",
658            OpKind::ScatterAdd => "scatter_add",
659            OpKind::ArgMaxDim => "argmax_dim",
660            OpKind::ArgMinDim => "argmin_dim",
661            OpKind::QMatMul => "qmatmul",
662            OpKind::WriteSlice => "write_slice",
663            OpKind::WriteSliceRotating => "write_slice_rotating",
664            OpKind::WriteSliceDoff => "write_slice_doff",
665            OpKind::Copy => "copy",
666            OpKind::ReluInplace => "relu_inplace",
667            OpKind::SiluInplace => "silu_inplace",
668            OpKind::GeluTanhInplace => "gelu_tanh_inplace",
669            OpKind::TanhInplace => "tanh_inplace",
670            OpKind::SigmoidInplace => "sigmoid_inplace",
671            OpKind::NegInplace => "neg_inplace",
672            OpKind::AbsInplace => "abs_inplace",
673            OpKind::SqrInplace => "sqr_inplace",
674            OpKind::SqrtInplace => "sqrt_inplace",
675            OpKind::RsqrtInplace => "rsqrt_inplace",
676            OpKind::RecipInplace => "recip_inplace",
677            OpKind::ExpInplace => "exp_inplace",
678            OpKind::LogInplace => "log_inplace",
679            OpKind::SinInplace => "sin_inplace",
680            OpKind::CosInplace => "cos_inplace",
681            OpKind::SignInplace => "sign_inplace",
682            OpKind::FloorInplace => "floor_inplace",
683            OpKind::CeilInplace => "ceil_inplace",
684            OpKind::RoundInplace => "round_inplace",
685            OpKind::ErfInplace => "erf_inplace",
686            OpKind::GeluInplace => "gelu_inplace",
687            OpKind::ClampInplace => "clamp_inplace",
688            OpKind::PowIInplace => "powi_inplace",
689            OpKind::InplaceAffine => "inplace_affine",
690            OpKind::FusedSoftmaxCrossEntropy => "fused_softmax_cross_entropy",
691            OpKind::CausalConv1d => "causal_conv1d",
692            OpKind::SelectiveScan => "selective_scan",
693            OpKind::SsdChunkScan => "ssd_chunk_scan",
694            OpKind::Nf4Matmul => "nf4_matmul",
695            OpKind::AwqMatmul => "awq_matmul",
696            OpKind::RuntimeFused => "runtime_fused",
697            OpKind::NonZeroIndices => "nonzero_indices",
698        }
699    }
700}
701
702impl std::fmt::Display for OpKind {
703    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
704        f.write_str(self.as_str())
705    }
706}
707
708/// Dispatch size bucket. One `u32` field carries two representations,
709/// disambiguated by the [`OpKind`] that always sits beside it in the
710/// dispatch key (a matmul key is only ever compared to matmul keys):
711///
712/// - **Total-elements key** (every non-matmul op — elementwise,
713///   reduce, cast, …): the value is `log2(total_elements)`. A 256×256
714///   elementwise tensor (65,536 elements) → `16`; a 1024×1024
715///   (1,048,576) → `20`. Built by [`SizeClass::from_elem_count`].
716///   Two shapes that round to the same bucket share a profile entry.
717///
718/// - **MatMul aspect key**: matmul cost is aspect-dependent — a
719///   `[1,K]×[K,N]` GEMV is bandwidth-bound, a square `[S,S]×[S,S]`
720///   GEMM of the *same output size* is compute-bound — so
721///   `log2(total_elements)` alone can't tell them apart. A matmul
722///   packs all three dims: `(log2(m·n) << 16) | (log2(m) << 8) |
723///   log2(k)`, read from `lhs=[…,m,k]` / `rhs=[…,k,n]`. `log2(m·n)`
724///   occupies the high byte so [`DispatchTable::pick_nearest`]'s
725///   magnitude ordering still tracks output size, while the low bytes
726///   distinguish aspect (a GEMV and a same-output-size square never
727///   collide, because their `log2(m)` bytes differ). Built by
728///   [`SizeClass::matmul`].
729///
730/// **Producer/consumer single-sourcing.** The `fuel-core` Judge
731/// (producer) and every consumer (the `fuel-dispatch` ranker at
732/// realize time) derive the key through the SAME helpers —
733/// [`SizeClass::matmul`] from native `(m,n,k)` on the producer side,
734/// [`SizeClass::for_op`] from the operand shapes on the consumer side.
735/// A given matmul shape therefore maps to one identical key on both
736/// sides, so a cell the Judge profiles is found by the ranker at
737/// dispatch time (the bug this repr fixes: the old scalar key had the
738/// producer on `m·n` and the consumer on `m·k`, so non-square matmul
739/// lookups always missed).
740#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
741#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
742pub struct SizeClass(pub u32);
743
744impl SizeClass {
745    /// Bucket a raw element count as `log2(n)` — the total-elements key
746    /// used by every non-matmul op. `n == 0` clamps to `1` (→ `0`).
747    pub fn from_elem_count(n: usize) -> Self {
748        let n = n.max(1);
749        let log2 = 63 - (n as u64).leading_zeros();
750        SizeClass(log2)
751    }
752
753    /// Aspect-carrying dispatch key for a matmul `[m,k] × [k,n]`.
754    ///
755    /// THE single source of matmul-key derivation: both the Judge
756    /// (producer, from its native `(m,n,k)`) and the ranker (consumer,
757    /// via [`SizeClass::for_op`] from operand shapes) call this, so both
758    /// sides agree for every shape. Packs `(log2(m·n) << 16) |
759    /// (log2(m) << 8) | log2(k)`; each `log2` fits a byte (≤ 63 for any
760    /// `usize`). A `[1,K]×[K,N]` GEMV (`m = 1`) never collides with a
761    /// square GEMM of equal output size — the `log2(m)` byte differs.
762    pub fn matmul(m: usize, n: usize, k: usize) -> Self {
763        let mn = Self::from_elem_count(m.saturating_mul(n)).0 & 0xff;
764        let ml = Self::from_elem_count(m).0 & 0xff;
765        let kl = Self::from_elem_count(k).0 & 0xff;
766        SizeClass((mn << 16) | (ml << 8) | kl)
767    }
768
769    /// Aspect-carrying dispatch key for a decode attention op
770    /// ([`OpKind::FlashAttn`]): `q = [B, Hq, Sq, D]`, K/V attended
771    /// length `k_len`, head dim `d`.
772    ///
773    /// THE single source of attention-key derivation, mirroring
774    /// [`SizeClass::matmul`]: both the Judge (producer, from its native
775    /// `OpSize::Attention` `(hq, k_len, d)`) and the flash-vs-decomposed
776    /// bake (consumer, via [`SizeClass::for_op`] from the flash node's
777    /// operand shapes) call this, so both sides agree for every shape.
778    /// Packs `(log2(hq·k_len·d) << 16) | (log2(k_len) << 8) | log2(d)`:
779    /// the high 16 bits track total attention work (`O(Hq·k_len·D)` for
780    /// the QKᵀ + PV matmuls at seq_q=1), so [`DispatchTable::pick_nearest`]'s
781    /// magnitude ordering still follows problem size, while the low bytes
782    /// carry the `k_len` (context-length / bandwidth regime) and head-dim
783    /// aspects — so two decode shapes that differ only in `k_len` (short
784    /// vs capacity context) never collide onto one cell. Each `log2` fits
785    /// a byte (≤ 63 for any `usize`).
786    pub fn attention(hq: usize, k_len: usize, d: usize) -> Self {
787        let work = hq.saturating_mul(k_len).saturating_mul(d);
788        let mag = Self::from_elem_count(work).0 & 0xff;
789        let kl = Self::from_elem_count(k_len).0 & 0xff;
790        let dl = Self::from_elem_count(d).0 & 0xff;
791        SizeClass((mag << 16) | (kl << 8) | dl)
792    }
793
794    /// Derive the dispatch key for `op` from its input operand shapes —
795    /// the single entry point every consumer uses. A [`OpKind::MatMul`]
796    /// reads `(m,n,k)` from `lhs=[…,m,k]` / `rhs=[…,k,n]` (trailing two
797    /// dims; leading batch dims don't affect the key) and keys via
798    /// [`SizeClass::matmul`]; every other op keys on the first operand's
799    /// total element count via [`SizeClass::from_elem_count`]. Empty
800    /// shapes (nullary op) → `SizeClass(0)`.
801    pub fn for_op(op: OpKind, input_shapes: &[Shape]) -> Self {
802        if op == OpKind::MatMul
803            && let (Some(lhs), Some(rhs)) = (input_shapes.first(), input_shapes.get(1))
804        {
805            let (ld, rd) = (lhs.dims(), rhs.dims());
806            if ld.len() >= 2 && rd.len() >= 2 {
807                let m = ld[ld.len() - 2];
808                let k = ld[ld.len() - 1];
809                let n = rd[rd.len() - 1];
810                return Self::matmul(m, n, k);
811            }
812        }
813        if op == OpKind::FlashAttn {
814            // Operand layout: q = [B, Hq, Sq, D], k = [B, Hkv, k_len, D]
815            // (v matches k). Read `hq`/`d` from q and the attended length
816            // `k_len` from k's length axis — the leading B batch dim and
817            // k's Hkv don't affect the key (mirrors matmul's batch-blind
818            // trailing-dim read), so a 2D per-head profile and the batched
819            // op agree.
820            if let (Some(q), Some(k)) = (input_shapes.first(), input_shapes.get(1)) {
821                let (qd, kd) = (q.dims(), k.dims());
822                if qd.len() >= 4 && kd.len() >= 2 {
823                    let hq = qd[1];
824                    let d = qd[qd.len() - 1];
825                    let k_len = kd[kd.len() - 2];
826                    return Self::attention(hq, k_len, d);
827                }
828            }
829        }
830        match input_shapes.first() {
831            Some(s) => Self::from_elem_count(s.elem_count()),
832            None => SizeClass(0),
833        }
834    }
835}
836
837/// Single (op_kind, dtype, size_class) × (backend, device_index,
838/// kernel_source) datum produced by one measurement run.
839///
840/// **Per-alternative measurement (2026-06-08)**: `kernel_source`
841/// distinguishes kernels that register at the same
842/// `(op, dtypes, backend)` decision point in the binding table —
843/// AOCL vs MKL vs portable-cpu, cuBLAS vs CUTLASS, etc. Each
844/// sibling alternative produces its own [`ProfileEntry`]; the
845/// dispatch table picks the best entry per cell across both
846/// backends and kernel_sources. Default empty string preserves the
847/// pre-v2 shape for kernels that don't need to distinguish.
848#[derive(Debug, Clone, PartialEq)]
849#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
850pub struct ProfileEntry {
851    pub op: OpKind,
852    pub dtype: DType,
853    pub size_class: SizeClass,
854    pub backend: BackendId,
855    pub device_index: u32,
856    /// Median wall-clock time per invocation over `iterations`.
857    pub latency_ns: u64,
858    /// Number of timed iterations that produced `latency_ns`.
859    pub iterations: u32,
860    /// Max relative element-wise error vs the reference backend's
861    /// output on the same input.
862    pub max_rel_error: f32,
863    /// Diagnostic tag identifying which kernel sibling produced this
864    /// measurement when multiple alternatives register at the same
865    /// `(op, dtypes, backend)` binding-table key. Mirrors
866    /// `BindingEntry::kernel_source` (in `fuel-dispatch`). `""` is
867    /// the pre-v2 default for kernels that don't need to distinguish;
868    /// conventional values include `"portable-cpu"`, `"aocl"`,
869    /// `"mkl"`, `"cublas"`, `"cutlass"`.
870    ///
871    /// Owned `String` (not `&'static str`) so the field round-trips
872    /// through serde — deserialized reports can't carry borrowed
873    /// data with a `'static` lifetime. The producer-side `Judge`
874    /// converts from `BindingEntry::kernel_source: &'static str`
875    /// via `.to_string()` at measurement time.
876    #[cfg_attr(feature = "serde", serde(default))]
877    pub kernel_source: String,
878    /// Content-derived revision of the exact kernel build that produced
879    /// this measurement. Mirrors `BindingEntry::kernel_revision_hash`
880    /// (`fuel-dispatch`) and the FKC ledger key's `kernel_revision_hash`;
881    /// with `kernel_source` it completes Fuel's canonical kernel identity
882    /// `ImplId = (backend, op, dtypes, kernel_source, kernel_revision_hash)`.
883    ///
884    /// `kernel_source` is provider-granular (`"cublas"`, `"baracuda"`, …);
885    /// `kernel_revision_hash` is *variant*-granular — forty tilings of one
886    /// provider emit forty distinct source strings and forty distinct
887    /// content-derived hashes, so they no longer collapse into one Judge
888    /// cell. `0` means "untracked" (the pre-v5 default and the value test
889    /// fixtures use when the axis is irrelevant); the Judge keys on it, so
890    /// two DISTINCT non-zero revisions occupy two distinct cells.
891    #[cfg_attr(feature = "serde", serde(default))]
892    pub kernel_revision_hash: u64,
893    /// The hardware identity this measurement was taken on — the
894    /// [`EquivalenceKey`](crate::probe::EquivalenceKey) of the profiled
895    /// device: `(backend, vendor_id, device_id, compute_capability,
896    /// driver_version)`. A `ProfileReport` is one Judge run on ONE machine.
897    /// `device_index` above is a within-run ORDINAL that rebinds across boots
898    /// and under `CUDA_VISIBLE_DEVICES`, so it is NOT a portable identity —
899    /// worse than none, because it answers confidently for a different
900    /// physical device. The ordinal is retained for within-run indexing; this
901    /// field is what `ProfileReport::load` validates against the loading
902    /// machine's hardware, so a report written on a 4070 Laptop is REFUSED on
903    /// a Radeon VII instead of loading and mis-ranking. `backend` here always
904    /// equals the sibling `backend` field above (checked on ingest).
905    ///
906    /// `#[serde(default)]` supplies a sentinel identity ONLY so a pre-v5
907    /// report — which lacks this field — still deserializes and is then
908    /// rejected by the version gate; the sentinel matches no real device (see
909    /// `default_device_identity`).
910    #[cfg_attr(feature = "serde", serde(default = "default_device_identity"))]
911    pub device: crate::probe::EquivalenceKey,
912}
913
914/// Sentinel hardware identity for the `#[serde(default)]` on
915/// [`ProfileEntry::device`]. Reached ONLY on the pre-v5 deserialize path (a
916/// v4 report lacks the field → deserializes with this → the version gate then
917/// returns `Ok(None)` before it is ever consulted). Deliberately matches NO
918/// real device — an `driver_version` string no backend reports — so that if a
919/// future reordering ever let it past the version gate, it would refuse every
920/// load (loud) rather than silently match the wrong hardware. Applied by hand
921/// rather than a blanket `Default`, precisely so the value is a deliberate
922/// refuse-everything sentinel and not a zero that could coincidentally match.
923#[cfg(feature = "serde")]
924fn default_device_identity() -> crate::probe::EquivalenceKey {
925    crate::probe::EquivalenceKey {
926        backend: BackendId::Cpu,
927        vendor_id: 0,
928        device_id: 0,
929        compute_capability: None,
930        driver_version: "<unset: pre-v5 profile, rejected by the version gate>".to_string(),
931    }
932}
933
934/// A persistable table of every profile measurement the Judge
935/// produced in one run.
936#[derive(Debug, Clone, PartialEq)]
937#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
938pub struct ProfileReport {
939    pub version: u32,
940    pub entries: Vec<ProfileEntry>,
941}
942
943#[cfg(feature = "serde")]
944impl ProfileReport {
945    /// Atomic write to `path` as JSON (sibling `.tmp` + rename).
946    pub fn save(&self, path: &std::path::Path) -> crate::Result<()> {
947        let json = serde_json::to_vec_pretty(self)
948            .map_err(|e| crate::Error::Msg(format!("judge: JSON encode failed: {e}")))?;
949        let tmp = path.with_extension("tmp");
950        std::fs::write(&tmp, &json)
951            .map_err(|e| crate::Error::Msg(format!("judge: write {tmp:?} failed: {e}")))?;
952        std::fs::rename(&tmp, path).map_err(|e| {
953            crate::Error::Msg(format!("judge: rename {tmp:?} → {path:?} failed: {e}"))
954        })?;
955        Ok(())
956    }
957
958    /// Load a previously-persisted report, validated against the hardware of
959    /// the loading machine. `current` is that machine's device equivalence
960    /// classes (via `crate::probe::equivalence_classes`); requiring it in the
961    /// signature makes it impossible to load without declaring what hardware
962    /// you are on — the gate cannot be forgotten by a caller.
963    ///
964    /// Returns:
965    /// - `Ok(None)` on a missing file.
966    /// - `Ok(None)` on a schema-version mismatch **that still deserializes**
967    ///   (an additive change: the pre-v5 fields `#[serde(default)]` in, then
968    ///   this version gate rejects the report).
969    /// - `Ok(None)` on FOREIGN HARDWARE. A `ProfileReport` is the output of ONE
970    ///   Judge run on ONE machine, so if ANY entry's `device` identity is not
971    ///   among `current`, the run happened elsewhere and the WHOLE report is
972    ///   refused — the entries that do match are not a valid subset, they are a
973    ///   foreign report with coincidental overlap, and the `device_index` it
974    ///   was keyed by rebinds across machines.
975    /// - `Err` on a NON-ADDITIVE schema change that fails to PARSE. Such a
976    ///   change never reaches the version gate above, because deserialization
977    ///   fails first — which is exactly why every new field carries
978    ///   `#[serde(default)]`. A future "check the version first, it's cheaper"
979    ///   cleanup MUST preserve `Ok(None)` for a deserializable stale report;
980    ///   `v4_report_yields_no_oracle_entries` pins that invariant.
981    ///
982    /// All three `Ok(None)` cases are "cache miss, re-run the Judge" signals.
983    pub fn load(
984        path: &std::path::Path,
985        current: &[crate::probe::EquivalenceKey],
986    ) -> crate::Result<Option<Self>> {
987        let bytes = match std::fs::read(path) {
988            Ok(b) => b,
989            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
990            Err(e) => {
991                return Err(crate::Error::Msg(format!(
992                    "judge: read {path:?} failed: {e}"
993                )));
994            }
995        };
996        let report: Self = serde_json::from_slice(&bytes)
997            .map_err(|e| crate::Error::Msg(format!("judge: parse {path:?} failed: {e}")))?;
998        if report.version != PROFILE_REPORT_VERSION {
999            return Ok(None);
1000        }
1001        // Whole-report hardware validation: one run, one machine. Any entry
1002        // measured on a device this machine does not have means the report is
1003        // foreign — refuse it entirely rather than serve a coincidental subset.
1004        if report.entries.iter().any(|e| !current.contains(&e.device)) {
1005            return Ok(None);
1006        }
1007        Ok(Some(report))
1008    }
1009}
1010
1011/// A selection criterion — what "best" means for a lookup.
1012#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1013#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1014pub enum Criterion {
1015    /// Lowest median latency.
1016    Fastest,
1017    /// Lowest max relative error vs the reference backend.
1018    MostAccurate,
1019    /// Weighted blend — lower is better.
1020    Balanced,
1021}
1022
1023impl Criterion {
1024    pub fn as_str(self) -> &'static str {
1025        match self {
1026            Criterion::Fastest => "fastest",
1027            Criterion::MostAccurate => "accurate",
1028            Criterion::Balanced => "balanced",
1029        }
1030    }
1031}
1032
1033impl std::fmt::Display for Criterion {
1034    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1035        f.write_str(self.as_str())
1036    }
1037}
1038
1039/// Default weight applied to `max_rel_error` in the Balanced
1040/// criterion's cost function.
1041pub const DEFAULT_ACCURACY_PENALTY: f64 = 100.0;
1042
1043/// Lookup key into [`DispatchTable`]. Combines the per-op axes
1044/// (what + on what data + how big) with the user's criterion.
1045#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1046struct DispatchKey {
1047    op: OpKind,
1048    dtype: DType,
1049    size_class: SizeClass,
1050    criterion: Criterion,
1051}
1052
1053/// Where the dispatch table decided an op should run, and which
1054/// kernel sibling won at that decision point.
1055///
1056/// **Per-alternative measurement (2026-06-08)**: `kernel_source`
1057/// surfaces the winning [`ProfileEntry::kernel_source`] so
1058/// consumers (Router, planner, telemetry) can name the actual
1059/// kernel — critical when multiple alternatives register at
1060/// `(BackendId::Cpu)` (e.g. AOCL vs MKL vs portable-cpu) and the
1061/// (backend, device) pair alone is ambiguous. `""` for legacy
1062/// reports / single-impl cells.
1063///
1064/// `Pick` stays `Copy` (and `&'static str` is `Copy`); the picker
1065/// preserves the string identity by reading it directly from the
1066/// winning `ProfileEntry` — its `String` value is matched against
1067/// the well-known interned set via [`kernel_source_intern`] so the
1068/// `&'static str` is the same pointer the binding-table entry
1069/// carries.
1070///
1071/// `Pick` is only ever constructed at runtime by `DispatchTable::rebuild_from`
1072/// from in-memory [`ProfileEntry`] data — never deserialized from JSON
1073/// or other owned input. We therefore intentionally do NOT derive
1074/// `Deserialize` (the `&'static str` field can't be deserialized from
1075/// owned input). `Serialize` is also dropped for symmetry; the public
1076/// serialization surface is [`ProfileReport`] / [`ProfileEntry`], and
1077/// `Pick` is regenerated by calling [`DispatchTable::build`].
1078#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1079pub struct Pick {
1080    pub backend: BackendId,
1081    pub device_index: u32,
1082    /// Diagnostic tag from the winning [`ProfileEntry::kernel_source`].
1083    /// `""` when no sibling distinguishes the cell.
1084    pub kernel_source: &'static str,
1085    /// Revision of the winning kernel build, from
1086    /// [`ProfileEntry::kernel_revision_hash`]. Completes the kernel
1087    /// identity that `kernel_source` alone (provider-granular) leaves
1088    /// ambiguous among the variant siblings of one provider. `0` when
1089    /// untracked. `u64` is `Copy`, so `Pick` stays `Copy`.
1090    pub kernel_revision_hash: u64,
1091}
1092
1093/// Intern a kernel-source string into a `&'static str`. Well-known conventional
1094/// tags return their static literal (the fast path); any OTHER non-empty tag is
1095/// interned through a process-static pool (leaked once per distinct string) so
1096/// it round-trips unchanged. The set mirrors the documented convention on
1097/// [`BindingEntry::kernel_source`](../../fuel_dispatch/struct.BindingEntry.html#structfield.kernel_source).
1098///
1099/// An unknown tag is NEVER collapsed to `""`. `""` is the pre-v2
1100/// "undifferentiated" marker, so returning it for a new provider's kernel makes
1101/// that kernel indistinguishable from an untagged one — a category error in a
1102/// field that keys persisted profiles (the router then cannot resolve
1103/// `(backend, device, "")` to the right same-backend sibling). A new provider's
1104/// tag is legitimate; preserve it. The bounded leak — one allocation per
1105/// distinct tag a process ever sees, and providers are few — is the right trade
1106/// for never losing kernel identity. (This reverses an earlier decision that
1107/// preferred dropping the tag over the leak; the collision cost was underpriced.)
1108///
1109/// SAFETY of the leak — an ASSUMPTION this code relies on but does not enforce:
1110/// it is bounded only because the tag SET is bounded, and the bound is a property
1111/// of the data path, not of the type. The input CAN be a deserialized `String`
1112/// (`ProfileEntry::kernel_source` comes from JSON), not only a compile-time
1113/// `&'static str`. Tags stay bounded because they derive from bindings registered
1114/// on THIS machine and `ProfileReport::load`'s hardware gate refuses foreign
1115/// reports, so a persisted profile cannot smuggle in arbitrary tags. What would
1116/// break it: a profile carrying unbounded distinct tags — an untrusted or
1117/// machine-generated one — would leak without bound, at which point this interner
1118/// needs a cap.
1119pub fn kernel_source_intern(s: &str) -> &'static str {
1120    match s {
1121        "" => "",
1122        "portable-cpu" => "portable-cpu",
1123        "aocl" => "aocl",
1124        "mkl" => "mkl",
1125        "cublas" => "cublas",
1126        "cutlass" => "cutlass",
1127        "baracuda" => "baracuda",
1128        "slang" => "slang",
1129        other => intern_unknown_kernel_source(other),
1130    }
1131}
1132
1133/// Intern an unfamiliar kernel-source tag into a stable `&'static str`, leaking
1134/// it exactly once. Bounded by the number of distinct tags a process ever sees
1135/// (a handful of providers), so the leak does not grow unboundedly. Preserving
1136/// the tag beats collapsing it to `""` — see [`kernel_source_intern`].
1137fn intern_unknown_kernel_source(s: &str) -> &'static str {
1138    use std::collections::HashSet;
1139    use std::sync::{Mutex, OnceLock};
1140    static POOL: OnceLock<Mutex<HashSet<&'static str>>> = OnceLock::new();
1141    let pool = POOL.get_or_init(|| Mutex::new(HashSet::new()));
1142    let mut set = pool.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
1143    if let Some(&existing) = set.get(s) {
1144        return existing;
1145    }
1146    let leaked: &'static str = Box::leak(s.to_owned().into_boxed_str());
1147    set.insert(leaked);
1148    leaked
1149}
1150
1151/// Options for building a [`DispatchTable`].
1152#[derive(Debug, Clone, Copy)]
1153pub struct DispatchOptions {
1154    pub accuracy_penalty: f64,
1155}
1156
1157impl Default for DispatchOptions {
1158    fn default() -> Self {
1159        Self {
1160            accuracy_penalty: DEFAULT_ACCURACY_PENALTY,
1161        }
1162    }
1163}
1164
1165impl DispatchOptions {
1166    pub fn with_balanced_penalty(mut self, k: f64) -> Self {
1167        self.accuracy_penalty = k;
1168        self
1169    }
1170}
1171
1172/// O(1) runtime dispatch table, constructed once from a
1173/// [`ProfileReport`] and then queried at realize time.
1174#[derive(Debug, Clone)]
1175pub struct DispatchTable {
1176    entries: HashMap<DispatchKey, Pick>,
1177    /// All size classes present for each `(op, dtype)` — sorted
1178    /// ascending so `pick_nearest` can do a linear scan for the
1179    /// closest profiled bucket.
1180    size_index: HashMap<(OpKind, DType), Vec<SizeClass>>,
1181    accuracy_penalty: f64,
1182}
1183
1184impl DispatchTable {
1185    /// Build a dispatch table from a profile report with default options.
1186    pub fn build(report: &ProfileReport) -> Self {
1187        Self::build_with(report, DispatchOptions::default())
1188    }
1189
1190    pub fn build_with(report: &ProfileReport, opts: DispatchOptions) -> Self {
1191        let mut tbl = Self {
1192            entries: HashMap::new(),
1193            size_index: HashMap::new(),
1194            accuracy_penalty: opts.accuracy_penalty,
1195        };
1196        tbl.rebuild_from(report);
1197        tbl
1198    }
1199
1200    fn rebuild_from(&mut self, report: &ProfileReport) {
1201        self.entries.clear();
1202        self.size_index.clear();
1203        let mut groups: HashMap<(OpKind, DType, SizeClass), Vec<&ProfileEntry>> = HashMap::new();
1204        for e in &report.entries {
1205            groups
1206                .entry((e.op, e.dtype, e.size_class))
1207                .or_default()
1208                .push(e);
1209        }
1210        for ((op, dtype, size_class), group) in &groups {
1211            for &criterion in &[
1212                Criterion::Fastest,
1213                Criterion::MostAccurate,
1214                Criterion::Balanced,
1215            ] {
1216                if let Some(winner) = self.pick_winner(group, criterion) {
1217                    let key = DispatchKey {
1218                        op: *op,
1219                        dtype: *dtype,
1220                        size_class: *size_class,
1221                        criterion,
1222                    };
1223                    self.entries.insert(
1224                        key,
1225                        Pick {
1226                            backend: winner.backend,
1227                            device_index: winner.device_index,
1228                            kernel_source: kernel_source_intern(&winner.kernel_source),
1229                            kernel_revision_hash: winner.kernel_revision_hash,
1230                        },
1231                    );
1232                }
1233            }
1234            self.size_index
1235                .entry((*op, *dtype))
1236                .or_default()
1237                .push(*size_class);
1238        }
1239        for classes in self.size_index.values_mut() {
1240            classes.sort_by_key(|s| s.0);
1241            classes.dedup();
1242        }
1243    }
1244
1245    fn pick_winner<'a>(
1246        &self,
1247        group: &[&'a ProfileEntry],
1248        crit: Criterion,
1249    ) -> Option<&'a ProfileEntry> {
1250        match crit {
1251            Criterion::Fastest => group.iter().copied().min_by_key(|e| e.latency_ns),
1252            Criterion::MostAccurate => group.iter().copied().min_by(|a, b| {
1253                a.max_rel_error
1254                    .total_cmp(&b.max_rel_error)
1255                    .then(a.latency_ns.cmp(&b.latency_ns))
1256            }),
1257            Criterion::Balanced => group.iter().copied().min_by(|a, b| {
1258                let sa =
1259                    a.latency_ns as f64 * (1.0 + self.accuracy_penalty * a.max_rel_error as f64);
1260                let sb =
1261                    b.latency_ns as f64 * (1.0 + self.accuracy_penalty * b.max_rel_error as f64);
1262                sa.total_cmp(&sb)
1263            }),
1264        }
1265    }
1266
1267    /// Exact lookup — returns `None` if the requested size class
1268    /// wasn't profiled.
1269    pub fn pick(
1270        &self,
1271        op: OpKind,
1272        dtype: DType,
1273        size_class: SizeClass,
1274        criterion: Criterion,
1275    ) -> Option<Pick> {
1276        self.entries
1277            .get(&DispatchKey {
1278                op,
1279                dtype,
1280                size_class,
1281                criterion,
1282            })
1283            .copied()
1284    }
1285
1286    /// Nearest-neighbour lookup. Ties go to the larger size class.
1287    pub fn pick_nearest(
1288        &self,
1289        op: OpKind,
1290        dtype: DType,
1291        size_class: SizeClass,
1292        criterion: Criterion,
1293    ) -> Option<Pick> {
1294        if let Some(p) = self.pick(op, dtype, size_class, criterion) {
1295            return Some(p);
1296        }
1297        let classes = self.size_index.get(&(op, dtype))?;
1298        if classes.is_empty() {
1299            return None;
1300        }
1301        let target = size_class.0 as i32;
1302        let nearest = classes.iter().min_by_key(|c| {
1303            let diff = (c.0 as i32 - target).abs();
1304            (diff, -(c.0 as i32))
1305        })?;
1306        self.pick(op, dtype, *nearest, criterion)
1307    }
1308
1309    pub fn len(&self) -> usize {
1310        self.entries.len()
1311    }
1312    pub fn is_empty(&self) -> bool {
1313        self.entries.is_empty()
1314    }
1315
1316    /// Every distinct `(op, dtype, size_class)` for which the table
1317    /// has at least one criterion entry. Sorted, stable across calls.
1318    pub fn keys(&self) -> Vec<(OpKind, DType, SizeClass)> {
1319        let mut seen: std::collections::HashSet<(OpKind, DType, SizeClass)> = Default::default();
1320        for k in self.entries.keys() {
1321            seen.insert((k.op, k.dtype, k.size_class));
1322        }
1323        let mut out: Vec<_> = seen.into_iter().collect();
1324        out.sort_by(|a, b| {
1325            a.0.as_str()
1326                .cmp(b.0.as_str())
1327                .then(format!("{:?}", a.1).cmp(&format!("{:?}", b.1)))
1328                .then(a.2.0.cmp(&b.2.0))
1329        });
1330        out
1331    }
1332}
1333
1334#[cfg(test)]
1335mod size_class_tests {
1336    use super::*;
1337
1338    /// The interner must NEVER collapse a non-empty unknown tag to `""` — `""`
1339    /// is the pre-v2 "undifferentiated" marker, so an unknown provider's tag
1340    /// coming back as `""` makes its kernel indistinguishable from an untagged
1341    /// one, in a field that keys persisted profiles. An ARBITRARY never-known
1342    /// tag must round-trip. Born-red: the pre-interner unknown arm `debug_assert`s
1343    /// and returns `""`, so this panics in a debug/test build.
1344    #[test]
1345    fn intern_preserves_an_arbitrary_unknown_tag() {
1346        // No provider registers this and it is not in the known set — the point
1347        // is that the floor holds for any FUTURE provider, not just a named one.
1348        let unknown = "some-future-provider-xyz";
1349        assert_eq!(
1350            kernel_source_intern(unknown),
1351            unknown,
1352            "an unknown tag must be preserved, never collapsed to \"\""
1353        );
1354        assert_ne!(kernel_source_intern(unknown), "");
1355        // Interning is idempotent: the same tag returns the same pointer.
1356        assert!(std::ptr::eq(
1357            kernel_source_intern(unknown),
1358            kernel_source_intern(unknown)
1359        ));
1360        // Known tags still fast-path to their literal; empty stays empty.
1361        assert_eq!(kernel_source_intern("slang"), "slang");
1362        assert_eq!(kernel_source_intern(""), "");
1363    }
1364
1365    /// The shared matmul key derivation gives producer == consumer for a
1366    /// NON-SQUARE matmul (SizeClass v4, slice 2.5).
1367    ///
1368    /// **Born-red**: before v4 `SizeClass` was `log2(total_elements)` and
1369    /// matmul keyed on a single scalar — the Judge (producer) on `m·n`,
1370    /// the ranker (consumer) on `m·k` (LHS elem count). For a non-square
1371    /// matmul those disagree, so the ranker never found the profiled
1372    /// cell. The `matmul` / `for_op` helpers now derive one identical
1373    /// key on both sides.
1374    #[test]
1375    fn matmul_key_producer_equals_consumer_non_square() {
1376        // FFN-width decode GEMV `[1,2048]×[2048,5632]`: m=1, k=2048, n=5632.
1377        let (m, n, k) = (1usize, 5632usize, 2048usize);
1378
1379        // Producer derives from its native (m, n, k).
1380        let producer = SizeClass::matmul(m, n, k);
1381        // Consumer derives from the operand shapes lhs=[m,k]/rhs=[k,n]
1382        // through the shared `for_op` entry point.
1383        let consumer = SizeClass::for_op(
1384            OpKind::MatMul,
1385            &[Shape::from_dims(&[m, k]), Shape::from_dims(&[k, n])],
1386        );
1387        assert_eq!(
1388            producer, consumer,
1389            "producer (native m,n,k) and consumer (operand shapes) must \
1390             derive one identical key",
1391        );
1392
1393        // Old-bug witness: the pre-v4 scalar keys the two sides used
1394        // (m·n vs m·k) disagree for this non-square shape.
1395        assert_ne!(
1396            SizeClass::from_elem_count(m * n), // old producer key
1397            SizeClass::from_elem_count(m * k), // old consumer key
1398            "the non-square shape is exactly where the old scalar keys \
1399             diverged",
1400        );
1401    }
1402
1403    /// Batched matmul keys the same as its per-head 2D shape — `for_op`
1404    /// reads only the trailing two dims, so a Judge that profiles the 2D
1405    /// per-head GEMV and a consumer costing the batched op agree.
1406    #[test]
1407    fn matmul_key_batched_ignores_leading_dims() {
1408        let per_head = SizeClass::for_op(
1409            OpKind::MatMul,
1410            &[Shape::from_dims(&[1, 64]), Shape::from_dims(&[64, 128])],
1411        );
1412        let batched = SizeClass::for_op(
1413            OpKind::MatMul,
1414            &[
1415                Shape::from_dims(&[32, 1, 64]),
1416                Shape::from_dims(&[32, 64, 128]),
1417            ],
1418        );
1419        assert_eq!(per_head, batched);
1420        assert_eq!(per_head, SizeClass::matmul(1, 128, 64));
1421    }
1422
1423    /// A bandwidth-bound GEMV and a compute-bound square GEMM of the SAME
1424    /// output size key to DISTINCT buckets — the aspect distinction.
1425    /// Under the old scalar `log2(m·n)` key they collided.
1426    #[test]
1427    fn gemv_and_same_output_square_are_distinct() {
1428        // GEMV `[1,2048]×[2048,4096]` → output m·n = 4096.
1429        let gemv = SizeClass::matmul(1, 4096, 2048);
1430        // Square `[64,64]×[64,64]` → output m·n = 4096 — SAME total.
1431        let square = SizeClass::matmul(64, 64, 64);
1432
1433        // Old scalar key: identical (both log2(4096) = 12) → collision.
1434        // `1 * 4096` is the GEMV output 1·4096, written to mirror `64 * 64`
1435        // (square output 64·64) so the two colliding shapes read in parallel —
1436        // the `1 *` is the GEMV's `m` dim, not a no-op. Stripping it would
1437        // destroy the shape structure the test documents, so identity_op is
1438        // wrong here.
1439        #[allow(clippy::identity_op)] // `1 *` is the GEMV `m` dim, kept for parallel with `64 * 64`
1440        let gemv_elems = 1 * 4096; // [1, 4096]
1441        let square_elems = 64 * 64; // [64, 64]
1442        assert_eq!(
1443            SizeClass::from_elem_count(gemv_elems),
1444            SizeClass::from_elem_count(square_elems),
1445            "sanity: equal output size → the OLD scalar keys collided",
1446        );
1447        // v4 aspect key: distinct (the log2(m) byte differs, 0 vs 6).
1448        assert_ne!(
1449            gemv, square,
1450            "a bandwidth-bound GEMV must not share a dispatch cell with a \
1451             compute-bound square of equal output size",
1452        );
1453    }
1454
1455    /// Non-matmul ops are untouched: `for_op` keys on the first
1456    /// operand's total element count, identical to `from_elem_count`.
1457    #[test]
1458    fn non_matmul_keeps_total_elements_key() {
1459        let sc = SizeClass::for_op(OpKind::AddElementwise, &[Shape::from_dims(&[1024])]);
1460        assert_eq!(sc, SizeClass::from_elem_count(1024));
1461        assert_eq!(sc.0, 10); // log2(1024)
1462    }
1463
1464    /// FlashAttn: the shared `attention` key derivation gives
1465    /// producer == consumer (Judge Layer-2 coverage arc, slice 3).
1466    ///
1467    /// The Judge (producer) keys a decode-attention cell via
1468    /// `SizeClass::attention(hq, k_len, d)` from its native
1469    /// `OpSize::Attention`; slice-4's bake (consumer) keys via
1470    /// `SizeClass::for_op(FlashAttn, [q_shape, k_shape])` from the flash
1471    /// node's operand shapes. Both must derive one identical key so the
1472    /// bake's lookup HITS the profiled cell.
1473    #[test]
1474    fn attention_key_producer_equals_consumer() {
1475        // TinyLlama-ish decode: Hq=32, Hkv=4, D=64, k_len=128, seq_q=1.
1476        let (b, hq, hkv, d, k_len) = (1usize, 32usize, 4usize, 64usize, 128usize);
1477
1478        // Producer derives from its native (hq, k_len, d).
1479        let producer = SizeClass::attention(hq, k_len, d);
1480        // Consumer derives from the flash node's operand shapes
1481        // q=[B,Hq,Sq,D] / k=[B,Hkv,k_len,D] through the shared `for_op`.
1482        let consumer = SizeClass::for_op(
1483            OpKind::FlashAttn,
1484            &[
1485                Shape::from_dims(&[b, hq, 1, d]),
1486                Shape::from_dims(&[b, hkv, k_len, d]),
1487                Shape::from_dims(&[b, hkv, k_len, d]),
1488            ],
1489        );
1490        assert_eq!(
1491            producer, consumer,
1492            "producer (native hq,k_len,d) and consumer (operand shapes) must \
1493             derive one identical attention key",
1494        );
1495    }
1496
1497    /// Two decode attention shapes that differ only in `k_len` (short
1498    /// vs capacity context) must key to DISTINCT buckets — the oracle
1499    /// keeps the MIN latency across a colliding key, which would let the
1500    /// short-context cell poison the capacity cell's cost.
1501    #[test]
1502    fn attention_decode_shapes_distinct_by_klen() {
1503        let small = SizeClass::attention(32, 128, 64);
1504        let cap = SizeClass::attention(32, 4096, 64);
1505        assert_ne!(
1506            small, cap,
1507            "short-context and capacity-context decode attention must not \
1508             share a dispatch cell",
1509        );
1510    }
1511
1512    /// `for_op(FlashAttn)` reads `hq`/`d` from q and `k_len` from k's
1513    /// length axis; the leading batch dim and k's Hkv (GQA) don't affect
1514    /// the key — so a per-head 2D-ish profile and the batched op agree,
1515    /// exactly like the matmul batch-blind trailing-dim read.
1516    #[test]
1517    fn attention_key_ignores_batch_and_hkv() {
1518        let base = SizeClass::for_op(
1519            OpKind::FlashAttn,
1520            &[
1521                Shape::from_dims(&[1, 32, 1, 64]),
1522                Shape::from_dims(&[1, 4, 128, 64]),
1523            ],
1524        );
1525        // Different B and different Hkv, same Hq/k_len/D → same key.
1526        let other = SizeClass::for_op(
1527            OpKind::FlashAttn,
1528            &[
1529                Shape::from_dims(&[8, 32, 1, 64]),
1530                Shape::from_dims(&[8, 8, 128, 64]),
1531            ],
1532        );
1533        assert_eq!(base, other);
1534        assert_eq!(base, SizeClass::attention(32, 128, 64));
1535    }
1536}