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}