fuel_dispatch/fused.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! FusedOpRegistry — kernel-side payload. Phase 7.6 step 1 (skeleton).
3//!
4//! Architecture v1.0 splits the fused-op registry across two crates:
5//! - graph-side metadata in `fuel-graph::registry` (id, name, family,
6//! pattern, decompose, backward, shape/dtype rules);
7//! - kernel-side payload here in `fuel-storage::fused` ([`BackendImpl`],
8//! [`CostEstimate`], [`PrecisionGuarantee`], [`KernelRevisionHash`]).
9//!
10//! The split exists because [`KernelRef`] lives in fuel-storage and
11//! fuel-graph cannot depend on fuel-storage (the dependency arrow goes
12//! the other way). Joining the two halves is by [`fuel_graph::registry::FusedOpId`]
13//! at runtime: the optimizer reads the metadata-side entry to reason
14//! about decomposition and shape, then asks the kernel-side
15//! [`FusedKernelRegistry`] for the per-backend [`BackendImpl`] when it
16//! needs to pre-resolve a `KernelRef`.
17//!
18//! ## Status (step 1)
19//!
20//! Types only. No callers; no behavior change. Subsequent steps:
21//! - Step 3: register the SoftmaxLastDim CPU `BackendImpl` and dispatch
22//! `Op::Fused(SOFTMAX_LAST_DIM, _)` through it from the executor.
23//! - Step 6-9: extend per-backend coverage, populate `PrecisionGuarantee`
24//! and `cost`, and migrate the binding-table lookup off the executor's
25//! hot path.
26
27use crate::kernel::{KernelCaps, KernelRef};
28use fuel_graph::registry::{FusedOpId, FusedOpParams};
29use fuel_ir::{DType, Shape, backend::BackendCapabilities, probe::BackendId};
30use smallvec::SmallVec;
31use std::collections::HashMap;
32
33/// Per-backend kernel implementation for one fused op. The optimizer
34/// reads this to (1) pre-resolve [`KernelRef`] for nodes it places on
35/// this backend, (2) score routes against [`CostEstimate`], and
36/// (3) admit candidates against the per-route tolerance budget via
37/// [`PrecisionGuarantee`].
38///
39/// Function-pointer composition (no trait-object indirection) — the
40/// registry stores [`BackendImpl`] values inline, the executor calls
41/// the function pointer directly.
42///
43/// A single `(FusedOpId, BackendId)` decision point may have multiple
44/// registered `BackendImpl`s — one per dtype tuple, and potentially
45/// multiple alternatives at the same dtype (e.g. cuBLAS bias-epilogue
46/// vs CUTLASS bias-epilogue for `(FUSED_LINEAR, [BF16,BF16,BF16,BF16],
47/// Cuda)`). The route picker filters by [`Self::dtypes`] and ranks the
48/// remaining alternatives by cost + precision.
49#[derive(Copy, Clone)]
50pub struct BackendImpl {
51 /// Dispatch wrapper for this backend's kernel for this fused op.
52 /// Same `KernelRef` signature as primitive-op kernels.
53 pub kernel: KernelRef,
54 /// Dtype tuple this kernel is registered for. Convention mirrors
55 /// the binding-table key shape: typically `[input1, input2, ...,
56 /// output]` per the existing `register(table, op, &[dtype; N],
57 /// backend, kernel)` call sites. Using `&'static [DType]` keeps
58 /// `BackendImpl` `Copy` and lets registrations declare dtypes
59 /// inline as `&[F32, F32, F32, F32]` literals.
60 pub dtypes: &'static [DType],
61 /// Cost-estimate function. Given the input shapes, the
62 /// per-instance fused-op params, and the backend's capabilities,
63 /// returns a [`CostEstimate`] used for placement and route ranking.
64 pub cost: fn(&[Shape], &FusedOpParams, &BackendCapabilities) -> CostEstimate,
65 /// Numerical precision properties of this kernel.
66 pub precision: PrecisionGuarantee,
67 /// Layout / capability flags (e.g. strided_input).
68 pub caps: KernelCaps,
69 /// Revision hash — opaque identifier for the source-version of this
70 /// kernel. Persisted optimization caches use this to detect kernel
71 /// drift and invalidate stale cache entries (see
72 /// `docs/architecture/11-persistence.md`).
73 pub revision: KernelRevisionHash,
74 /// Declared cost AST compiled from the contract's `cost:` field
75 /// (FKC gap-closure §2.3, Task 2.4). `None` when the contract
76 /// declared no cost expression, or for hand-written (non-FKC)
77 /// registrations. When present, [`crate::fused_cost::fused_layer1_cost`]
78 /// prefers it over the [`is_fused_cost_sentinel`](crate::fused_cost::is_fused_cost_sentinel)
79 /// decompose-derived fallback.
80 pub cost_expr: Option<&'static crate::fkc::CompiledCostExpr>,
81}
82
83/// Coarse cost model for one kernel invocation. Layer-1 of the
84/// architecture's cost-model tower (FLOP counts + bandwidth + launch
85/// overhead). Layer-2 — empirical refinement from per-deployment
86/// telemetry — composes on top of these without changing this shape.
87#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
88pub struct CostEstimate {
89 /// Compute pressure — number of floating-point operations.
90 pub flops: u64,
91 /// Bandwidth pressure — bytes moved through device memory hierarchy
92 /// for this kernel's inputs + outputs (excluding cache hits).
93 pub bytes_moved: u64,
94 /// Fixed launch overhead. CPU kernels measure this in tens of ns;
95 /// GPU launches in low microseconds.
96 pub kernel_overhead_ns: u32,
97}
98
99/// What this kernel guarantees about its numerical behavior.
100///
101/// Replaces the binary-flag OracleGrade concept that pre-architecture
102/// drafts used; per architecture v1.0 every kernel registration carries
103/// a structured precision statement so the optimizer can reason about
104/// tolerance budgets and pick comparators for calibration.
105///
106/// `bit_stable_on_same_hardware` is the strongest property. The
107/// always-built backend (fuel-cpu-backend by convention) commits to
108/// providing at least one `bit_stable_on_same_hardware: true` kernel
109/// per primitive op as the architecture v1.0 coverage commitment;
110/// step 7 enforces this via a CI lint.
111///
112/// The Optional fields encode the bound's flavor (ULP / relative /
113/// absolute). Multiple may be present; the optimizer takes the
114/// intersection (a budget is admissible only if every populated bound
115/// satisfies it). Absent bounds mean "this kernel makes no claim
116/// about that flavor."
117#[derive(Copy, Clone, Debug)]
118pub struct PrecisionGuarantee {
119 /// True iff this kernel produces bit-identical output for
120 /// bit-identical inputs on the same hardware (no nondeterminism
121 /// from kernel scheduling, atomic ordering, etc.).
122 pub bit_stable_on_same_hardware: bool,
123 /// Maximum unit-in-last-place error vs the IEEE-754 correctly-
124 /// rounded result. Tighter than max_relative for low-magnitude
125 /// values; many vendor math libraries quote ULPs.
126 pub max_ulp: Option<u32>,
127 /// Maximum relative error: `|out - ref| / max(|ref|, eps)`.
128 pub max_relative: Option<f64>,
129 /// Maximum absolute error: `|out - ref|`.
130 pub max_absolute: Option<f64>,
131 /// Free-text qualifier — for audited claims, this carries the
132 /// audit reason (vendor citation, scheduler-dependence note,
133 /// implementation hint). For the [`UNAUDITED`] sentinel, this
134 /// is a fixed placeholder string the coverage lint matches
135 /// against to distinguish unaudited placeholders from audited
136 /// claims that happen to have no static bound. **If you change
137 /// `UNAUDITED.notes`, the lint's detector picks up the new value
138 /// automatically — it reads `PrecisionGuarantee::UNAUDITED.notes`
139 /// at test time, not a hardcoded string.**
140 ///
141 /// [`UNAUDITED`]: PrecisionGuarantee::UNAUDITED
142 pub notes: &'static str,
143}
144
145impl PrecisionGuarantee {
146 /// "Reference" guarantee: bit-stable on same hardware, no error
147 /// claim above that. Used by reference-grade CPU kernels that
148 /// commit to deterministic IEEE-754 evaluation.
149 pub const REFERENCE: PrecisionGuarantee = PrecisionGuarantee {
150 bit_stable_on_same_hardware: true,
151 max_ulp: Some(0),
152 max_relative: Some(0.0),
153 max_absolute: Some(0.0),
154 notes: "Reference IEEE-754 evaluation; bit-identical re-run.",
155 };
156
157 /// Phase 7.6 step 7b: shared precision guarantee for the
158 /// always-built CPU backend's **primitive** kernels — elementwise
159 /// unary/binary, reductions, matmul, casts, comparisons, indexing,
160 /// scalar ops, transcendentals (Exp/Log/Sin/Cos/Tanh/Sigmoid/
161 /// Gelu/Silu), etc. All claim:
162 ///
163 /// - `bit_stable_on_same_hardware: true` (deterministic iteration
164 /// order; no atomic FP adds; sequential nested loops)
165 /// - F32 accumulator for BF16/F16 inputs, native F32 / F64 / U32
166 /// for matching dtypes
167 /// - No specific ULP / relative / absolute bound claimed at this
168 /// layer — the empirical calibration framework in step 8
169 /// populates per-op-per-shape bounds via reference comparisons
170 ///
171 /// This is the default value bulk-applied by
172 /// [`crate::kernel::KernelBindingTable::fill_unset_cpu_precision`]
173 /// at the end of `register_cpu_kernels`. Sites that need a
174 /// different claim must call
175 /// [`crate::kernel::KernelBindingTable::register_with_precision`]
176 /// explicitly with the weaker guarantee — those don't get
177 /// bulk-overwritten because the fill only touches UNAUDITED
178 /// entries.
179 pub const PRIMITIVE_DETERMINISTIC_CPU: PrecisionGuarantee = PrecisionGuarantee {
180 bit_stable_on_same_hardware: true,
181 max_ulp: None,
182 max_relative: None,
183 max_absolute: None,
184 notes: "fuel-cpu-backend primitive kernel family: deterministic \
185 nested-loop iteration order; F32 accumulator for \
186 half-precision inputs; bit-identical same-hardware \
187 re-run. Per-op-per-shape ULP / relative bounds land \
188 with the step-8 empirical calibration framework.",
189 };
190
191 /// Default placeholder for kernels whose precision hasn't been
192 /// audited yet. Identified by the coverage lint via notes-
193 /// equality against `PrecisionGuarantee::UNAUDITED.notes` —
194 /// the only way a registration ends up with this specific
195 /// notes string is by using the literal `UNAUDITED` const
196 /// (the default in unannotated `register(...)` / `register_with_caps(...)`
197 /// calls).
198 ///
199 /// **For kernels audited and concluded "no static bound
200 /// applies"** (e.g. Vulkan subgroup reductions where FADD
201 /// order is scheduler-determined per dispatch), use
202 /// [`none`] instead. It returns a structurally-similar
203 /// PrecisionGuarantee but with the audit reason in `notes`,
204 /// captured at the registration site. The lint accepts any
205 /// notes value other than UNAUDITED's specific text.
206 ///
207 /// [`none`]: PrecisionGuarantee::none
208 pub const UNAUDITED: PrecisionGuarantee = PrecisionGuarantee {
209 bit_stable_on_same_hardware: false,
210 max_ulp: None,
211 max_relative: None,
212 max_absolute: None,
213 notes: "PrecisionGuarantee::UNAUDITED — default placeholder for \
214 kernels whose precision hasn't been audited yet. Coverage \
215 lints flag these. Sites must either annotate via \
216 `register_with_precision` with a real claim or use \
217 `PrecisionGuarantee::none(reason)` if the audit \
218 concluded no static bound applies.",
219 };
220
221 /// Constructor for the "audited, no static guarantee could be
222 /// established" conclusion. Used by Vulkan subgroup reductions
223 /// (where FADD order is scheduler-determined per dispatch) and
224 /// similar kernels whose numerical character can't be
225 /// summarized by static ULP / relative / absolute bounds.
226 ///
227 /// The `reason` is captured in `notes`, so the audit reasoning
228 /// lives at the registration site — visible in code review,
229 /// not buried in a separate lint allowlist file. Any `reason`
230 /// other than `UNAUDITED.notes` makes the resulting value pass
231 /// the coverage lint.
232 ///
233 /// Distinguished from [`UNAUDITED`] by `notes` content alone
234 /// (the value-field shape is identical). The lint uses
235 /// `precision.notes == PrecisionGuarantee::UNAUDITED.notes` as
236 /// the unaudited detector — robust to UNAUDITED's notes text
237 /// drifting because it reads from the const itself.
238 ///
239 /// [`UNAUDITED`]: PrecisionGuarantee::UNAUDITED
240 pub const fn none(reason: &'static str) -> PrecisionGuarantee {
241 PrecisionGuarantee {
242 bit_stable_on_same_hardware: false,
243 max_ulp: None,
244 max_relative: None,
245 max_absolute: None,
246 notes: reason,
247 }
248 }
249
250 /// Does this guarantee assert nothing machine-checkable — no
251 /// bit-stability, no ULP / relative / absolute bound?
252 ///
253 /// True for BOTH [`UNAUDITED`] and [`none`]: they are field-identical,
254 /// because they make the same (empty) set of claims. What separates them
255 /// is *provenance*, not claim content — see [`Self::is_audited_no_bound`].
256 ///
257 /// [`UNAUDITED`]: PrecisionGuarantee::UNAUDITED
258 /// [`none`]: PrecisionGuarantee::none
259 pub fn makes_no_static_claim(&self) -> bool {
260 !self.bit_stable_on_same_hardware
261 && self.max_ulp.is_none()
262 && self.max_relative.is_none()
263 && self.max_absolute.is_none()
264 }
265
266 /// Is this an **audited conclusion that no static bound applies**
267 /// ([`none(reason)`]) rather than the never-audited placeholder
268 /// ([`UNAUDITED`])?
269 ///
270 /// # Why this is a method and not an open-coded check (GAP-095)
271 ///
272 /// These two states are **field-identical apart from `notes`**, so any
273 /// consumer that must tell them apart is forced to key on a string. Two
274 /// consumers previously invented their own predicate and *disagreed*: the
275 /// bit-stable coverage lint compared `notes` against `UNAUDITED.notes`,
276 /// while [`KernelBindingTable::fill_unset_cpu_precision`] looked only at
277 /// the four value fields — and therefore silently rewrote an audited
278 /// "no static bound applies" conclusion into a
279 /// `bit_stable_on_same_hardware: true` claim the audit had declined to
280 /// make.
281 ///
282 /// One definition, so the next consumer inherits the distinction instead
283 /// of re-deriving it. **This is a seam, not a cure:** a `&'static str`
284 /// remains the load-bearing discriminator, and only making the two states
285 /// structurally distinct would make conflation *unrepresentable*. That
286 /// change is priced at ~64 `PrecisionGuarantee` literals across three
287 /// crates and is tracked separately; when it lands, this method's body
288 /// changes and its callers do not.
289 ///
290 /// [`none(reason)`]: PrecisionGuarantee::none
291 /// [`UNAUDITED`]: PrecisionGuarantee::UNAUDITED
292 /// [`KernelBindingTable::fill_unset_cpu_precision`]: crate::kernel::KernelBindingTable::fill_unset_cpu_precision
293 pub fn is_audited_no_bound(&self) -> bool {
294 self.makes_no_static_claim()
295 && !self.notes.is_empty()
296 && self.notes != Self::UNAUDITED.notes
297 }
298}
299
300/// Opaque revision hash of a registered kernel. Persisted optimization
301/// caches read this to detect kernel drift between cache build and
302/// cache load (see `docs/architecture/11-persistence.md`). Computed
303/// from kernel source + version metadata at registration time; step 9
304/// fills in the actual hashing function alongside the binding-table
305/// planning-time refactor.
306#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
307pub struct KernelRevisionHash(pub u64);
308
309impl KernelRevisionHash {
310 /// Sentinel meaning "no revision tracked yet." Used by step-1-shipped
311 /// `BackendImpl` registrations until step 9 wires real hashing.
312 pub const UNTRACKED: KernelRevisionHash = KernelRevisionHash(0);
313}
314
315/// Inline capacity for per-fused-op backend lists. SmallVec at 4 fits
316/// CPU + CUDA + Vulkan + Metal without spilling to heap; the typical
317/// fused op has 1-3 backends with kernels.
318type BackendImplList = SmallVec<[(BackendId, BackendImpl); 4]>;
319
320/// Kernel-side registry: `FusedOpId` → list of per-backend
321/// [`BackendImpl`]s. Joined to `fuel-graph`'s metadata-side
322/// [`fuel_graph::registry::FusedOpRegistry`] by id at runtime.
323///
324/// Built at process startup, frozen thereafter (architecture v1.0:
325/// no runtime extensibility).
326#[derive(Default)]
327pub struct FusedKernelRegistry {
328 by_id: HashMap<FusedOpId, BackendImplList>,
329 /// Finding 5.4 (FKC side, Task 3.6) side-table: preserves a fused
330 /// contract's `return.bundle` per-slot NAMES, keyed by the same
331 /// `(FusedOpId, BackendId, dtypes)` tuple the kernel itself was
332 /// registered under. Before this table existed the names were parsed
333 /// (`return_check::bundle_slot_names`) then discarded — a later
334 /// consumer (e.g. FDX output-view labeling) had no artifact to read
335 /// them from. `Vec<DType>` (not `&'static [DType]`) because the key is
336 /// built from a caller-owned slice at lookup time.
337 bundle_slot_names: HashMap<(FusedOpId, BackendId, Vec<DType>), Vec<String>>,
338}
339
340impl FusedKernelRegistry {
341 pub fn new() -> Self {
342 Self::default()
343 }
344
345 /// Register a [`BackendImpl`] for this `(id, backend)` pair. Always
346 /// appends — multiple impls per `(id, backend)` are allowed (per-
347 /// dtype registrations, alternative algorithms at the same dtype,
348 /// etc.). The route picker filters by [`BackendImpl::dtypes`] and
349 /// ranks remaining alternatives at lookup time.
350 pub fn register(&mut self, id: FusedOpId, backend: BackendId, impl_: BackendImpl) {
351 self.by_id.entry(id).or_default().push((backend, impl_));
352 }
353
354 /// Look up the first [`BackendImpl`] registered for `(id, backend)`
355 /// regardless of dtypes. Convenience for callers that already know
356 /// a single impl exists for the pair. Prefer
357 /// [`Self::lookup_by_dtypes`] when multiple per-dtype impls may
358 /// share a backend.
359 pub fn lookup(&self, id: FusedOpId, backend: BackendId) -> Option<BackendImpl> {
360 self.by_id
361 .get(&id)
362 .and_then(|impls| impls.iter().find(|(b, _)| *b == backend))
363 .map(|(_, impl_)| *impl_)
364 }
365
366 /// Look up the [`BackendImpl`] registered for `(id, backend,
367 /// dtypes)`. Returns `None` when no kernel matches all three; the
368 /// optimizer's fallback in that case is to lower the fused op via
369 /// the metadata-side `decompose` and run it as primitives on the
370 /// backend.
371 ///
372 /// When multiple impls match (alternatives at the same decision
373 /// point — e.g. cuBLAS vs CUTLASS bf16 matmul), the first
374 /// registration wins. Step 9's route picker replaces this with a
375 /// cost-aware selector.
376 pub fn lookup_by_dtypes(
377 &self,
378 id: FusedOpId,
379 backend: BackendId,
380 dtypes: &[DType],
381 ) -> Option<BackendImpl> {
382 self.by_id
383 .get(&id)
384 .and_then(|impls| {
385 impls
386 .iter()
387 .find(|(b, i)| *b == backend && i.dtypes == dtypes)
388 })
389 .map(|(_, impl_)| *impl_)
390 }
391
392 /// All `(BackendId, BackendImpl)` pairs registered for this id.
393 /// The optimizer reads this when deciding placement: a fused op
394 /// with a CUDA-only kernel limits its placement candidates.
395 pub fn impls_for(&self, id: FusedOpId) -> &[(BackendId, BackendImpl)] {
396 self.by_id.get(&id).map(|v| v.as_slice()).unwrap_or(&[])
397 }
398
399 /// Number of distinct `FusedOpId`s with at least one registered
400 /// backend impl.
401 pub fn len(&self) -> usize {
402 self.by_id.len()
403 }
404
405 /// Whether the registry has any registered impls.
406 pub fn is_empty(&self) -> bool {
407 self.by_id.is_empty()
408 }
409
410 /// Finding 5.4 (FKC side, Task 3.6): record a fused contract's
411 /// `return.bundle` per-slot names against the `(id, backend, dtypes)`
412 /// key it was registered under. Overwrites any prior entry at the same
413 /// key (re-import is idempotent, mirroring `register`'s append
414 /// semantics at the registration level — the side-table itself has no
415 /// duplicate-detection gate).
416 pub fn record_bundle_slot_names(
417 &mut self,
418 id: FusedOpId,
419 backend: BackendId,
420 dtypes: &[DType],
421 names: &[String],
422 ) {
423 self.bundle_slot_names
424 .insert((id, backend, dtypes.to_vec()), names.to_vec());
425 }
426
427 /// Look up the per-slot names recorded by [`Self::record_bundle_slot_names`]
428 /// for `(id, backend, dtypes)`. `None` when no bundle names were ever
429 /// recorded for that exact key (including a single-output section,
430 /// which never calls `record_bundle_slot_names` in the first place).
431 pub fn bundle_slot_names(
432 &self,
433 id: FusedOpId,
434 backend: BackendId,
435 dtypes: &[DType],
436 ) -> Option<&[String]> {
437 self.bundle_slot_names
438 .get(&(id, backend, dtypes.to_vec()))
439 .map(|v| v.as_slice())
440 }
441}
442
443/// Register a [`BackendImpl`] into a [`FusedKernelRegistry`] via the
444/// step-6 macro shape. Hides the boilerplate of constructing the
445/// `BackendImpl` struct so registration sites read as a flat list of
446/// `(id, backend, dtypes, kernel, cost, precision)` tuples.
447///
448/// Caps default to [`KernelCaps::empty()`] and revision to
449/// [`KernelRevisionHash::UNTRACKED`]; pass `caps = ...` and
450/// `revision = ...` to override.
451///
452/// # Examples
453///
454/// ```ignore
455/// register_fused!(
456/// registry,
457/// FusedOps::FUSED_LINEAR,
458/// BackendId::Cpu,
459/// &[F32, F32, F32, F32],
460/// fused_linear_f32_cpu_wrapper,
461/// cost = cost_fused_linear_cpu,
462/// precision = PrecisionGuarantee::REFERENCE,
463/// );
464/// ```
465#[macro_export]
466macro_rules! register_fused {
467 (
468 $registry:expr,
469 $id:expr,
470 $backend:expr,
471 $dtypes:expr,
472 $kernel:expr,
473 cost = $cost:expr,
474 precision = $precision:expr
475 $(, caps = $caps:expr)?
476 $(, revision = $revision:expr)?
477 $(,)?
478 ) => {{
479 #[allow(unused_mut, unused_assignments)]
480 let mut caps = $crate::kernel::KernelCaps::empty();
481 $( caps = $caps; )?
482 #[allow(unused_mut, unused_assignments)]
483 let mut revision = $crate::fused::KernelRevisionHash::UNTRACKED;
484 $( revision = $revision; )?
485 $registry.register(
486 $id,
487 $backend,
488 $crate::fused::BackendImpl {
489 kernel: $kernel,
490 dtypes: $dtypes,
491 cost: $cost,
492 precision: $precision,
493 caps,
494 revision,
495 cost_expr: None,
496 },
497 );
498 }};
499}
500
501/// Process-wide default kernel registry: every backend's fused-op
502/// `BackendImpl`s registered at startup. Built once on first access
503/// via [`std::sync::OnceLock`]; immutable thereafter (architecture
504/// v1.0: no runtime extensibility).
505///
506/// Today's coverage:
507/// - `FUSED_LINEAR` × `Cpu` × `{F32, F64, BF16, F16}` — four
508/// bit-stable per-dtype impls registered via [`register_default_kernels`].
509///
510/// Backend-side crates (fuel-cuda-backend, fuel-vulkan-backend) extend
511/// this set by either (a) registering during their own startup, or
512/// (b) the step-9 binding-table refactor where the route picker pulls
513/// from this registry. Today's executors continue to lookup via the
514/// per-dtype [`KernelBindingTable`](crate::kernel::KernelBindingTable); this
515/// registry is
516/// the architecture-target shape for CUTLASS / cuBLAS alternative
517/// registrations and step-9's pre-resolved `KernelRef` pipeline.
518pub fn default_kernel_registry() -> &'static FusedKernelRegistry {
519 use std::sync::OnceLock;
520 static REGISTRY: OnceLock<FusedKernelRegistry> = OnceLock::new();
521 REGISTRY.get_or_init(|| {
522 let mut r = FusedKernelRegistry::new();
523 register_default_kernels(&mut r);
524 r
525 })
526}
527
528/// Populate a [`FusedKernelRegistry`] with the always-built kernels
529/// (today: `fuel-cpu-backend` FusedLinear F32/F64/BF16/F16). Called
530/// from [`default_kernel_registry`]'s OnceLock initializer; exposed as
531/// a free function so backend crates can compose against a custom
532/// registry in tests.
533pub fn register_default_kernels(r: &mut FusedKernelRegistry) {
534 crate::dispatch::register_default_fused_kernels(r);
535}
536
537/// Static cost model for `(FUSED_LINEAR, Cpu)` kernels — the conservative
538/// FLOP + bandwidth model per architecture v1.0 §"Layer-1 cost model."
539/// Identical for F32/F64/BF16/F16 (the kernels accumulate in F32
540/// regardless of input dtype); the empirical Layer-2 refinement
541/// framework will tighten per-dtype later.
542///
543/// Shapes: `[a, b, bias]` where `a = [..., M, K]`, `b = [..., K, N]`,
544/// `bias = [N]`.
545pub fn cost_fused_linear_cpu(
546 shapes: &[Shape],
547 _params: &FusedOpParams,
548 _caps: &BackendCapabilities,
549) -> CostEstimate {
550 debug_assert_eq!(shapes.len(), 3, "FusedLinear cost: expected 3 input shapes");
551 let a_dims = shapes[0].dims();
552 let b_dims = shapes[1].dims();
553 let rank = a_dims.len();
554 if rank < 2 || b_dims.len() < 2 {
555 return CostEstimate::default();
556 }
557 let m = a_dims[rank - 2] as u64;
558 let k = a_dims[rank - 1] as u64;
559 let n = b_dims[b_dims.len() - 1] as u64;
560 let batch: u64 = a_dims[..rank - 2]
561 .iter()
562 .map(|d| *d as u64)
563 .product::<u64>()
564 .max(1);
565 // FMA counts as 2 FLOPs. Matmul: 2·M·N·K per batch. Bias-add: M·N per batch.
566 let mm_flops = 2u64 * m * n * k * batch;
567 let bias_flops = m * n * batch;
568 // Conservative bandwidth: assume 4 B/elem (F32-equivalent). Empirical
569 // refinement adjusts per-dtype; the static layer is intentionally coarse.
570 let elems = batch * (m * k + k * n + m * n) + n;
571 let bytes_moved = elems * 4;
572 CostEstimate {
573 flops: mm_flops + bias_flops,
574 bytes_moved,
575 kernel_overhead_ns: 50, // CPU launch overhead, ballpark
576 }
577}
578
579/// Precision guarantee shared by every `(FUSED_LINEAR, Cpu, *)` impl
580/// in fuel-cpu-backend: deterministic per-element accumulation in F32
581/// (BF16/F16 inputs upcast and narrow at the end), bit-identical on
582/// same hardware. Bounded error claims (max_ulp / max_relative) depend
583/// on the contracting dim K and so stay `None` at this layer — the
584/// empirical calibration framework derives them per-shape.
585pub const FUSED_LINEAR_CPU_PRECISION: PrecisionGuarantee = PrecisionGuarantee {
586 bit_stable_on_same_hardware: true,
587 max_ulp: None,
588 max_relative: None,
589 max_absolute: None,
590 notes: "fuel-cpu-backend FusedLinear: deterministic matmul + bias-add; \
591 BF16/F16 accumulate in F32 and narrow at end; same-hardware re-run \
592 bit-identical.",
593};
594
595/// Static cost model for `(FUSED_SOFTMAX_CROSS_ENTROPY, Cpu)` — row-by-row
596/// stable log-softmax + gather. FLOPs scale with `n_rows × vocab`
597/// (two passes per row: one for max+sum_exp, one for the loss
598/// accumulator); memory is `n_rows × vocab` reads plus the targets and
599/// the scalar/vector output.
600///
601/// Shapes: `[logits, targets]` where `logits = [..., V]` (F32) and
602/// `targets = [...]` (I64); the per-row reduction count comes from
603/// the logits shape's leading dims.
604pub fn cost_fused_softmax_cross_entropy_cpu(
605 shapes: &[Shape],
606 _params: &FusedOpParams,
607 _caps: &BackendCapabilities,
608) -> CostEstimate {
609 debug_assert_eq!(
610 shapes.len(),
611 2,
612 "FusedSoftmaxCrossEntropy cost: expected 2 input shapes (logits, targets)",
613 );
614 let logits_dims = shapes[0].dims();
615 if logits_dims.is_empty() {
616 return CostEstimate::default();
617 }
618 let vocab = *logits_dims.last().unwrap() as u64;
619 let n_rows: u64 = logits_dims[..logits_dims.len() - 1]
620 .iter()
621 .map(|d| *d as u64)
622 .product::<u64>()
623 .max(1);
624 // Two passes over vocab per row: pass 1 finds max (V compares),
625 // pass 2 sums exp(x - max) (V FMAs ≈ 2V FLOPs). Plus one log
626 // call. The transcendental `exp` counts as ~10 FLOPs by convention.
627 let per_row_flops = vocab + 2 * vocab + 10 * vocab + 10;
628 let total_flops = n_rows * per_row_flops;
629 // Bandwidth: logits (n_rows × vocab × 4) + targets (n_rows × 8) +
630 // output (4 bytes for Mean/Sum, ~n_rows × 4 for None — average to
631 // the worst case here for the conservative layer-1 estimate).
632 let bytes_moved = n_rows * vocab * 4 + n_rows * 8 + n_rows * 4;
633 CostEstimate {
634 flops: total_flops,
635 bytes_moved,
636 kernel_overhead_ns: 50,
637 }
638}
639
640/// Precision guarantee for `(FUSED_SOFTMAX_CROSS_ENTROPY, Cpu, *)` —
641/// stable log-sum-exp accumulated in F64, gather + NLL in F64,
642/// narrow to F32 at end. Bit-identical re-run on same hardware. The
643/// kernel's iteration order is fixed, so the only sources of
644/// non-determinism (reduction-order changes, denormal flushing) are
645/// excluded by construction.
646pub const FUSED_SOFTMAX_CROSS_ENTROPY_CPU_PRECISION: PrecisionGuarantee = PrecisionGuarantee {
647 bit_stable_on_same_hardware: true,
648 max_ulp: None,
649 max_relative: None,
650 max_absolute: None,
651 notes: "fuel-cpu-backend FusedSoftmaxCrossEntropy: stable log-sum-exp \
652 in F64 accumulator; gather + NLL in F64; narrow to F32 at end; \
653 same-hardware re-run bit-identical.",
654};
655
656/// Static cost model for `(CAUSAL_CONV1D, Cpu)` — depthwise 1-D conv.
657/// FLOPs scale with `batch × channels × seq_out × kernel`; bandwidth
658/// is `batch × channels × seq_in` (x reads) + `channels × kernel`
659/// (weight reads) + `channels` (bias) + `batch × channels × seq_out`
660/// (output writes). SiLU adds one transcendental per output element.
661///
662/// Shapes: `[x, weight, bias]` where `x = [batch, channels, seq_in]`,
663/// `weight = [channels, 1, kernel]`, `bias = [channels]`.
664pub fn cost_causal_conv1d_cpu(
665 shapes: &[Shape],
666 params: &FusedOpParams,
667 _caps: &BackendCapabilities,
668) -> CostEstimate {
669 debug_assert_eq!(
670 shapes.len(),
671 3,
672 "CausalConv1d cost: expected 3 input shapes (x, weight, bias)",
673 );
674 let use_silu = match params {
675 FusedOpParams::CausalConv1d { use_silu } => *use_silu,
676 _ => return CostEstimate::default(),
677 };
678 let x_dims = shapes[0].dims();
679 let w_dims = shapes[1].dims();
680 if x_dims.len() != 3 || w_dims.len() != 3 {
681 return CostEstimate::default();
682 }
683 let batch = x_dims[0] as u64;
684 let channels = x_dims[1] as u64;
685 let seq_in = x_dims[2] as u64;
686 let kernel = w_dims[2] as u64;
687 if seq_in < kernel - 1 {
688 return CostEstimate::default();
689 }
690 let seq_out = seq_in - (kernel - 1);
691 // 2·kernel FLOPs per output element (FMA = 2). SiLU adds ~10
692 // FLOPs per element (transcendental convention).
693 let per_out_flops = 2 * kernel + if use_silu { 10 } else { 0 };
694 let flops = batch * channels * seq_out * per_out_flops;
695 let bytes_moved = batch * channels * seq_in * 4
696 + channels * kernel * 4
697 + channels * 4
698 + batch * channels * seq_out * 4;
699 CostEstimate {
700 flops,
701 bytes_moved,
702 kernel_overhead_ns: 50,
703 }
704}
705
706/// Precision guarantee for `(CAUSAL_CONV1D, Cpu, *)` — textbook
707/// nested-loop depthwise conv with F32 accumulator; SiLU computed
708/// element-wise via `x / (1 + exp(-x))`. Iteration order fixed;
709/// bit-identical re-run on same hardware.
710pub const CAUSAL_CONV1D_CPU_PRECISION: PrecisionGuarantee = PrecisionGuarantee {
711 bit_stable_on_same_hardware: true,
712 max_ulp: None,
713 max_relative: None,
714 max_absolute: None,
715 notes: "fuel-cpu-backend CausalConv1d: textbook depthwise nested-loop \
716 with F32 accumulator; optional SiLU via x/(1+exp(-x)); \
717 same-hardware re-run bit-identical.",
718};
719
720/// Static cost model for `(SELECTIVE_SCAN, Cpu)` — Mamba's selective
721/// state-space scan. FLOPs scale with `batch · seqlen · dim · dstate`
722/// (the inner triple-nested loop). Bandwidth is dominated by reading
723/// u/delta/b/c per timestep + writing y.
724///
725/// Shapes: `[u, delta, a, b, c]`. Per-step cost: ~4·dstate FMAs (exp +
726/// mul + add for h update; mul + accumulate for y).
727pub fn cost_selective_scan_cpu(
728 shapes: &[Shape],
729 _params: &FusedOpParams,
730 _caps: &BackendCapabilities,
731) -> CostEstimate {
732 debug_assert_eq!(
733 shapes.len(),
734 5,
735 "SelectiveScan cost: expected 5 input shapes (u, delta, a, b, c)",
736 );
737 let u_dims = shapes[0].dims();
738 let a_dims = shapes[2].dims();
739 if u_dims.len() != 3 || a_dims.len() != 2 {
740 return CostEstimate::default();
741 }
742 let batch = u_dims[0] as u64;
743 let seqlen = u_dims[1] as u64;
744 let dim = u_dims[2] as u64;
745 let dstate = a_dims[1] as u64;
746 // ~8 FLOPs per (b, t, i, j) iteration: exp (10), mul (1), mul (1),
747 // add (1), mul (1), add (1). Conservative average ~16/iter once
748 // exp is amortized.
749 let per_iter_flops = 16;
750 let flops = batch * seqlen * dim * dstate * per_iter_flops;
751 // u + delta + b + c (read per step) + a (read once) + out (written once).
752 let bytes_moved = 2 * batch * seqlen * dim * 4
753 + 2 * batch * seqlen * dstate * 4
754 + dim * dstate * 4
755 + batch * seqlen * dim * 4;
756 CostEstimate {
757 flops,
758 bytes_moved,
759 kernel_overhead_ns: 50,
760 }
761}
762
763/// Precision guarantee for `(SELECTIVE_SCAN, Cpu, *)` — recurrence
764/// accumulated in F64 (h is `Vec<f64>` internally), final y narrowed
765/// to F32. Iteration order fixed; bit-identical re-run on same
766/// hardware. The F64 accumulator buys ~7 extra decimal digits of
767/// headroom vs naive F32 over the per-step state update.
768pub const SELECTIVE_SCAN_CPU_PRECISION: PrecisionGuarantee = PrecisionGuarantee {
769 bit_stable_on_same_hardware: true,
770 max_ulp: None,
771 max_relative: None,
772 max_absolute: None,
773 notes: "fuel-cpu-backend SelectiveScan: state recurrence in F64 \
774 accumulator; narrow to F32 on y store; deterministic \
775 iteration order; same-hardware re-run bit-identical.",
776};
777
778/// Static cost model for `(SSD_CHUNK_SCAN, Cpu)` — Mamba-2's chunked
779/// SSD scan. FLOPs scale with
780/// `batch · seqlen · heads · head_dim · state_dim` (the inner
781/// quadruple-nested loop). Bandwidth reads x/dt/b/c per timestep +
782/// a once + writes y.
783///
784/// Shapes: `[x, dt, a, b, c]`. v1 single-chunk only.
785pub fn cost_ssd_chunk_scan_cpu(
786 shapes: &[Shape],
787 _params: &FusedOpParams,
788 _caps: &BackendCapabilities,
789) -> CostEstimate {
790 debug_assert_eq!(
791 shapes.len(),
792 5,
793 "SsdChunkScan cost: expected 5 input shapes (x, dt, a, b, c)",
794 );
795 let x_dims = shapes[0].dims();
796 let b_dims = shapes[3].dims();
797 if x_dims.len() != 4 || b_dims.len() != 4 {
798 return CostEstimate::default();
799 }
800 let batch = x_dims[0] as u64;
801 let seqlen = x_dims[1] as u64;
802 let heads = x_dims[2] as u64;
803 let head_dim = x_dims[3] as u64;
804 let state_dim = b_dims[3] as u64;
805 // ~8 FLOPs per (b, t, h, i, j) inner iteration: mul-mul-add for
806 // h-update + mul-add for y-accumulate, plus exp amortized per
807 // outer (b, t, h).
808 let per_iter_flops = 8;
809 let flops = batch * seqlen * heads * head_dim * state_dim * per_iter_flops;
810 let bytes_moved = 2 * batch * seqlen * heads * head_dim * 4
811 + batch * seqlen * heads * 4
812 + 2 * batch * seqlen * heads * state_dim * 4
813 + heads * 4;
814 CostEstimate {
815 flops,
816 bytes_moved,
817 kernel_overhead_ns: 50,
818 }
819}
820
821/// Precision guarantee for `(SSD_CHUNK_SCAN, Cpu, *)` — same shape
822/// as `SELECTIVE_SCAN_CPU_PRECISION`.
823pub const SSD_CHUNK_SCAN_CPU_PRECISION: PrecisionGuarantee = PrecisionGuarantee {
824 bit_stable_on_same_hardware: true,
825 max_ulp: None,
826 max_relative: None,
827 max_absolute: None,
828 notes: "fuel-cpu-backend SsdChunkScan: per-head state recurrence \
829 in F64 accumulator; narrow to F32 on y store; \
830 deterministic iteration order; same-hardware re-run \
831 bit-identical. chunk_size is a GPU-parallelism knob; \
832 CPU runs sequential, any positive chunk_size dividing \
833 seqlen produces the same answer.",
834};
835
836/// Static cost model for `(NF4_MATMUL, Cpu)` — bitsandbytes 4-bit
837/// quantized matmul. FLOPs scale with `batch · m · n · k`
838/// (one FMA per k step, after dequant). Bandwidth reads
839/// `batch·m·k` activations + `n·k/2` packed weight bytes + `n·k/block_size`
840/// absmax scales + writes `batch·m·n` outputs.
841pub fn cost_nf4_matmul_cpu(
842 shapes: &[Shape],
843 params: &FusedOpParams,
844 _caps: &BackendCapabilities,
845) -> CostEstimate {
846 debug_assert_eq!(
847 shapes.len(),
848 3,
849 "Nf4Matmul cost: expected 3 input shapes (activations, w_packed, absmax)",
850 );
851 let block_size = match params {
852 FusedOpParams::Nf4Matmul { block_size } => *block_size as u64,
853 _ => return CostEstimate::default(),
854 };
855 let a_dims = shapes[0].dims();
856 let w_dims = shapes[1].dims();
857 if a_dims.len() < 2 || w_dims.len() != 2 {
858 return CostEstimate::default();
859 }
860 let m = a_dims[a_dims.len() - 2] as u64;
861 let k = a_dims[a_dims.len() - 1] as u64;
862 let n = w_dims[0] as u64;
863 let batch: u64 = a_dims[..a_dims.len() - 2]
864 .iter()
865 .map(|d| *d as u64)
866 .product::<u64>()
867 .max(1);
868 if block_size == 0 || !k.is_multiple_of(block_size) {
869 return CostEstimate::default();
870 }
871 // 2 FLOPs per FMA (mul + add). Dequant lookup is one mul per k step.
872 let flops = batch * m * n * k * 3;
873 // Bandwidth: 4 B/elem default (F32-ish), 1 B/elem for w_packed.
874 let elem_bytes = 4;
875 let bytes_moved = batch * m * k * elem_bytes
876 + n * (k / 2)
877 + n * (k / block_size) * 4
878 + batch * m * n * elem_bytes;
879 CostEstimate {
880 flops,
881 bytes_moved,
882 kernel_overhead_ns: 50,
883 }
884}
885
886/// Precision guarantee for `(NF4_MATMUL, Cpu, *)` — inner product
887/// accumulated in F32 regardless of activation/output dtype (F16 +
888/// BF16 variants up-cast on load and narrow on store, matching
889/// bitsandbytes' fp16/bf16 GEMV behavior). Iteration order fixed;
890/// bit-identical re-run on same hardware. The 16-entry NF4 LUT is
891/// baked in as a const so quantization roundtrips are reproducible.
892pub const NF4_MATMUL_CPU_PRECISION: PrecisionGuarantee = PrecisionGuarantee {
893 bit_stable_on_same_hardware: true,
894 max_ulp: None,
895 max_relative: None,
896 max_absolute: None,
897 notes: "fuel-cpu-backend Nf4Matmul: dequant via baked NF4_LUT × \
898 per-block absmax; F32 accumulator for the inner product \
899 (F16/BF16 up-cast on load, narrow on store); \
900 deterministic iteration order; same-hardware re-run \
901 bit-identical.",
902};
903
904/// Static cost model for `(CONV2D, Cpu)` kernels — FLOP + bandwidth
905/// per architecture v1.0 §"Layer-1 cost model."
906///
907/// Shapes: `[x, weight, (bias)]` where `x = [N, Cin, H, W]`,
908/// `weight = [Cout, Cin/groups, Kh, Kw]`, optional `bias = [Cout]`.
909/// The per-instance `FusedOpParams::Conv2D { stride, padding, groups }`
910/// supply the rest; output spatial dims are recomputed from input
911/// shape + params via the standard `(H + 2·pad - Kh) / stride + 1`.
912pub fn cost_conv2d_cpu(
913 shapes: &[Shape],
914 params: &FusedOpParams,
915 _caps: &BackendCapabilities,
916) -> CostEstimate {
917 debug_assert!(
918 shapes.len() == 2 || shapes.len() == 3,
919 "Conv2D cost: expected 2 or 3 input shapes",
920 );
921 let (stride, padding, groups) = match params {
922 FusedOpParams::Conv2D {
923 stride,
924 padding,
925 groups,
926 } => (*stride, *padding, *groups),
927 _ => return CostEstimate::default(),
928 };
929 let x_dims = shapes[0].dims();
930 let w_dims = shapes[1].dims();
931 if x_dims.len() != 4 || w_dims.len() != 4 || groups == 0 {
932 return CostEstimate::default();
933 }
934 let (n, cin, h_in, w_in) = (
935 x_dims[0] as u64,
936 x_dims[1] as u64,
937 x_dims[2] as u64,
938 x_dims[3] as u64,
939 );
940 let (cout, cin_per_g, kh, kw) = (
941 w_dims[0] as u64,
942 w_dims[1] as u64,
943 w_dims[2] as u64,
944 w_dims[3] as u64,
945 );
946 let (sh, sw) = (stride.0 as u64, stride.1 as u64);
947 let (ph, pw) = (padding.0 as u64, padding.1 as u64);
948 if sh == 0 || sw == 0 || kh > h_in + 2 * ph || kw > w_in + 2 * pw {
949 return CostEstimate::default();
950 }
951 let h_out = (h_in + 2 * ph - kh) / sh + 1;
952 let w_out = (w_in + 2 * pw - kw) / sw + 1;
953 // FMA counts as 2 FLOPs per (Cin/g · Kh · Kw) inner-product step,
954 // summed across N · Cout · Hout · Wout output positions. Bias-add
955 // contributes one FLOP per output element.
956 let conv_flops = 2u64 * n * cout * h_out * w_out * cin_per_g * kh * kw;
957 let bias_flops = if shapes.len() == 3 {
958 n * cout * h_out * w_out
959 } else {
960 0
961 };
962 let elems_in = n * cin * h_in * w_in;
963 let elems_w = cout * cin_per_g * kh * kw;
964 let elems_out = n * cout * h_out * w_out;
965 let elems_bias = if shapes.len() == 3 { cout } else { 0 };
966 let bytes_moved = (elems_in + elems_w + elems_out + elems_bias) * 4;
967 CostEstimate {
968 flops: conv_flops + bias_flops,
969 bytes_moved,
970 kernel_overhead_ns: 50,
971 }
972}
973
974/// Precision guarantee shared by every `(CONV2D, Cpu, *)` impl in
975/// fuel-cpu-backend: textbook nested-loop accumulation in F32 (F64
976/// kernels accumulate in F64; BF16/F16 upcast each multiply to F32
977/// and narrow at end), bit-identical re-run on same hardware.
978/// Same shape as [`FUSED_LINEAR_CPU_PRECISION`] — the conv kernel's
979/// numerical character is governed by the same "F32 accumulator,
980/// deterministic iteration order" properties.
981pub const CONV2D_CPU_PRECISION: PrecisionGuarantee = PrecisionGuarantee {
982 bit_stable_on_same_hardware: true,
983 max_ulp: None,
984 max_relative: None,
985 max_absolute: None,
986 notes: "fuel-cpu-backend Conv2D: textbook nested-loop \
987 cross-correlation; F32 accumulator (F64 for F64 input); \
988 BF16/F16 multiply in F32 and narrow at end; same-hardware \
989 re-run bit-identical.",
990};
991
992// =============================================================================
993// Phase 7.6 step 6 — cost functions + PrecisionGuarantees for the
994// remaining 8 ops that have CPU byte-level wrappers in
995// `fuel-storage::dispatch`. The four backward helpers
996// (SoftmaxLastDimBackward / LayerNormLastDimBackward /
997// RmsNormLastDimBackward / ReduceMaxToBackward) are not covered
998// here — their CPU dispatch flows through the `GraphBackend` trait
999// methods in `fuel-graph-executor`, not the byte-level binding
1000// table. Wrapper conversion + step-6 registration for those is a
1001// follow-up.
1002// =============================================================================
1003
1004/// Shared precision guarantee for the norm/softmax family — softmax,
1005/// rms_norm, layer_norm. All three CPU kernels use deterministic
1006/// elementwise + scalar-reduction patterns with F32 accumulator
1007/// (F64 for F64 input; BF16/F16 multiply in F32 and narrow at end).
1008/// Same shape as [`FUSED_LINEAR_CPU_PRECISION`] — the numerical
1009/// character is governed by the same "F32 accumulator, deterministic
1010/// iteration order" properties.
1011pub const NORM_FAMILY_CPU_PRECISION: PrecisionGuarantee = PrecisionGuarantee {
1012 bit_stable_on_same_hardware: true,
1013 max_ulp: None,
1014 max_relative: None,
1015 max_absolute: None,
1016 notes: "fuel-cpu-backend Softmax/RmsNorm/LayerNorm: deterministic \
1017 elementwise + per-row reduction; F32 accumulator (F64 for \
1018 F64 input); BF16/F16 multiply in F32 and narrow at end; \
1019 same-hardware re-run bit-identical.",
1020};
1021
1022/// Cost model for `(SoftmaxLastDim | RmsNormLastDim | LayerNormLastDim,
1023/// Cpu)` kernels — outer-product structure: `outer × last_dim`
1024/// elementwise pass + scalar reduction. FLOPs scale linearly with
1025/// element count, bandwidth dominates for typical token-dim sizes.
1026///
1027/// Phase 7.6 step 8 — branches on `FusedOpParams` to use a per-op
1028/// FLOP/element count: softmax ≈ 5, rms_norm ≈ 4, layer_norm ≈ 7
1029/// (plus their backwards, which do comparable per-element work).
1030/// Replaces the step-6 midpoint-of-5 estimate. Both forward and
1031/// backward variants of each norm use the same FLOP/element count
1032/// because they touch the data the same way per row.
1033///
1034/// Shapes: `[x]` (forward, 1 input) or `[x_or_y, g]` (backward,
1035/// 2 inputs). Total element count comes from `input[0]` either way.
1036pub fn cost_norm_family_cpu(
1037 shapes: &[Shape],
1038 params: &FusedOpParams,
1039 _caps: &BackendCapabilities,
1040) -> CostEstimate {
1041 debug_assert!(
1042 !shapes.is_empty(),
1043 "Norm-family cost: expected ≥1 input shape",
1044 );
1045 let dims = shapes[0].dims();
1046 if dims.is_empty() {
1047 return CostEstimate::default();
1048 }
1049 let elems: u64 = dims.iter().map(|&d| d as u64).product();
1050 let flops_per_elem: u64 = match params {
1051 FusedOpParams::SoftmaxLastDim | FusedOpParams::SoftmaxLastDimBackward => 5, // max-sub + exp + sum + divide
1052 FusedOpParams::RmsNormLastDim { .. } | FusedOpParams::RmsNormLastDimBackward { .. } => 4, // sqr + sum + sqrt + divide
1053 FusedOpParams::LayerNormLastDim { .. } | FusedOpParams::LayerNormLastDimBackward { .. } => {
1054 7
1055 } // mean-sub + sqr + sum + sqrt + divide + center
1056 _ => 5, // fallback if a future caller mis-dispatches
1057 };
1058 // Backwards have 2 inputs + 1 output (3 element-count touches);
1059 // forwards have 1 input + 1 output (2 element-count touches).
1060 let bandwidth_factor: u64 = if shapes.len() >= 2 { 3 } else { 2 };
1061 CostEstimate {
1062 flops: flops_per_elem * elems,
1063 bytes_moved: bandwidth_factor * elems * 4,
1064 kernel_overhead_ns: 50,
1065 }
1066}
1067
1068/// Precision guarantee for `(ROPE, Cpu, *)` impls. Rotary position
1069/// embedding is a rotation in 2D subspaces — multiplies + adds with
1070/// the cos/sin tables; F32 accumulator semantics match the norm
1071/// family.
1072pub const ROPE_CPU_PRECISION: PrecisionGuarantee = PrecisionGuarantee {
1073 bit_stable_on_same_hardware: true,
1074 max_ulp: None,
1075 max_relative: None,
1076 max_absolute: None,
1077 notes: "fuel-cpu-backend Rope: per-head rotation `out = x·cos + \
1078 rotated(x)·sin` with deterministic iteration order. \
1079 BF16/F16 multiply in F32 and narrow at end; same-hardware \
1080 re-run bit-identical.",
1081};
1082
1083/// Cost model for `(ROPE, Cpu)` — per-element rotation costs 4 FMAs
1084/// (2 multiplies + 2 adds in two 2D planes).
1085///
1086/// Shapes: `[x, cos, sin]` where `x = [..., seq, head_dim]`,
1087/// `cos = sin = [seq, head_dim]`.
1088pub fn cost_rope_cpu(
1089 shapes: &[Shape],
1090 _params: &FusedOpParams,
1091 _caps: &BackendCapabilities,
1092) -> CostEstimate {
1093 debug_assert_eq!(
1094 shapes.len(),
1095 3,
1096 "Rope cost: expected 3 input shapes (x, cos, sin)"
1097 );
1098 let dims = shapes[0].dims();
1099 if dims.is_empty() {
1100 return CostEstimate::default();
1101 }
1102 let elems: u64 = dims.iter().map(|&d| d as u64).product();
1103 // 4 FLOPs per element (2 FMA pairs across the two rotation planes).
1104 let flops = 4 * elems;
1105 // Read x + cos/sin tables (small), write x_out.
1106 let cs_elems: u64 = shapes[1].dims().iter().map(|&d| d as u64).product::<u64>() * 2;
1107 let bytes_moved = (2 * elems + cs_elems) * 4;
1108 CostEstimate {
1109 flops,
1110 bytes_moved,
1111 kernel_overhead_ns: 50,
1112 }
1113}
1114
1115/// Precision guarantee for `(CONV_TRANSPOSE2D, Cpu, *)` impls.
1116/// Same structure as Conv2D — textbook nested-loop accumulation in
1117/// F32 (F64 for F64 input).
1118pub const CONV_TRANSPOSE2D_CPU_PRECISION: PrecisionGuarantee = PrecisionGuarantee {
1119 bit_stable_on_same_hardware: true,
1120 max_ulp: None,
1121 max_relative: None,
1122 max_absolute: None,
1123 notes: "fuel-cpu-backend ConvTranspose2D: textbook scatter-with-stride \
1124 + nested loops; F32 accumulator (F64 for F64); BF16/F16 \
1125 multiply in F32 and narrow at end; same-hardware bit-identical.",
1126};
1127
1128/// Cost model for `(CONV_TRANSPOSE2D, Cpu)` — FLOP count parallels
1129/// Conv2D: `2·N·Cout·Hout·Wout·(Cin/g)·Kh·Kw` (each output position
1130/// accumulates contributions from `Cin/g · Kh · Kw` input positions,
1131/// reading transposed).
1132///
1133/// Shapes: `[x, weight]` where `x = [N, Cin, H, W]`,
1134/// `weight = [Cin, Cout/groups, Kh, Kw]` (note transposed channel
1135/// order vs Conv2D).
1136pub fn cost_conv_transpose2d_cpu(
1137 shapes: &[Shape],
1138 params: &FusedOpParams,
1139 _caps: &BackendCapabilities,
1140) -> CostEstimate {
1141 debug_assert_eq!(
1142 shapes.len(),
1143 2,
1144 "ConvTranspose2D cost: expected 2 input shapes"
1145 );
1146 let (stride, padding, output_padding, dilation, groups) = match params {
1147 FusedOpParams::ConvTranspose2D {
1148 stride,
1149 padding,
1150 output_padding,
1151 dilation,
1152 groups,
1153 } => (*stride, *padding, *output_padding, *dilation, *groups),
1154 _ => return CostEstimate::default(),
1155 };
1156 let x_dims = shapes[0].dims();
1157 let w_dims = shapes[1].dims();
1158 if x_dims.len() != 4 || w_dims.len() != 4 || groups == 0 {
1159 return CostEstimate::default();
1160 }
1161 let (n, cin, h_in, w_in) = (
1162 x_dims[0] as u64,
1163 x_dims[1] as u64,
1164 x_dims[2] as u64,
1165 x_dims[3] as u64,
1166 );
1167 let (_, cout_per_g, kh, kw) = (
1168 w_dims[0] as u64,
1169 w_dims[1] as u64,
1170 w_dims[2] as u64,
1171 w_dims[3] as u64,
1172 );
1173 let cout = cout_per_g * groups as u64;
1174 // Output spatial dims: `(H-1)·stride - 2·pad + dilation·(K-1) + out_pad + 1`.
1175 let h_out = h_in.saturating_sub(1) * stride.0 as u64
1176 + dilation.0 as u64 * kh.saturating_sub(1)
1177 + output_padding.0 as u64
1178 + 1;
1179 let h_out = h_out.saturating_sub(2 * padding.0 as u64);
1180 let w_out = w_in.saturating_sub(1) * stride.1 as u64
1181 + dilation.1 as u64 * kw.saturating_sub(1)
1182 + output_padding.1 as u64
1183 + 1;
1184 let w_out = w_out.saturating_sub(2 * padding.1 as u64);
1185 let cin_per_g = cin / groups as u64;
1186 let flops = 2u64 * n * cout * h_out * w_out * cin_per_g * kh * kw;
1187 let elems_in = n * cin * h_in * w_in;
1188 let elems_w = cin * cout_per_g * kh * kw;
1189 let elems_out = n * cout * h_out * w_out;
1190 let bytes_moved = (elems_in + elems_w + elems_out) * 4;
1191 CostEstimate {
1192 flops,
1193 bytes_moved,
1194 kernel_overhead_ns: 50,
1195 }
1196}
1197
1198/// Precision guarantee for `(FLASH_ATTN | PAGED_ATTN, Cpu, *)` impls.
1199/// The CPU naive-attention reference is bit-stable per-hardware
1200/// because both kernels iterate in fixed order. However the tiled
1201/// flash-attention GPU kernel produces different numerics than the
1202/// naive reference (different reduction tree); that's a GPU-side
1203/// PrecisionGuarantee concern — the CPU side stays bit-stable.
1204pub const ATTN_CPU_PRECISION: PrecisionGuarantee = PrecisionGuarantee {
1205 bit_stable_on_same_hardware: true,
1206 max_ulp: None,
1207 max_relative: None,
1208 max_absolute: None,
1209 notes: "fuel-cpu-backend FlashAttn/PagedAttn: naive scaled-dot- \
1210 product reference; F32 accumulator; deterministic \
1211 iteration order; bit-identical re-run on same hardware. \
1212 GPU kernels produce different numerics (tiled softmax) — \
1213 their PrecisionGuarantee is declared separately when they \
1214 register.",
1215};
1216
1217/// Cost model for `(FLASH_ATTN, Cpu)` and `(PAGED_ATTN, Cpu)` kernels
1218/// — scaled-dot-product attention is `O(B·Hq·Sq·Sk·D)` multiplies
1219/// for the QK matmul + same for the PV matmul, plus softmax (small).
1220///
1221/// For FlashAttn shapes are `[q, k, v, (alibi)]` with
1222/// `q = [B, Hq, Sq, D]`, `k = v = [B, Hkv, Sk, D]`.
1223/// For PagedAttn the cache shapes complicate the calculation; we
1224/// approximate using q's `Sq` × the maximum context length implied
1225/// by k_cache's `num_blocks · block_size`. Both share this function
1226/// because the dominant FLOP term has the same shape.
1227pub fn cost_attn_cpu(
1228 shapes: &[Shape],
1229 _params: &FusedOpParams,
1230 _caps: &BackendCapabilities,
1231) -> CostEstimate {
1232 if shapes.is_empty() {
1233 return CostEstimate::default();
1234 }
1235 let q_dims = shapes[0].dims();
1236 if q_dims.len() != 4 {
1237 return CostEstimate::default();
1238 }
1239 let (b, hq, sq, d) = (
1240 q_dims[0] as u64,
1241 q_dims[1] as u64,
1242 q_dims[2] as u64,
1243 q_dims[3] as u64,
1244 );
1245 // Approximate K-len from input[1] (k or k_cache). For FlashAttn
1246 // k.shape[-2] is Sk; for PagedAttn k_cache.shape is `[num_blocks,
1247 // block_size, Hkv, D]` so the effective Sk per query is bounded
1248 // by `num_blocks · block_size`.
1249 let sk: u64 = if shapes.len() >= 2 {
1250 let k_dims = shapes[1].dims();
1251 if k_dims.len() == 4 {
1252 // FlashAttn shape: [B, Hkv, Sk, D] → Sk at index 2.
1253 // PagedAttn shape: [num_blocks, block_size, Hkv, D] →
1254 // total slots = dim[0] · dim[1]; conservative upper bound.
1255 (k_dims[2] as u64).max(k_dims[0] as u64 * k_dims[1] as u64)
1256 } else {
1257 sq
1258 }
1259 } else {
1260 sq
1261 };
1262 // QK matmul: 2·B·Hq·Sq·Sk·D FLOPs.
1263 // PV matmul: 2·B·Hq·Sq·Sk·D FLOPs.
1264 // Softmax: ~5·B·Hq·Sq·Sk FLOPs (small relative to the matmuls).
1265 let mm_flops = 4u64 * b * hq * sq * sk * d;
1266 let sm_flops = 5u64 * b * hq * sq * sk;
1267 // Bandwidth: q, k, v reads + output write; approximate.
1268 let elems_qkv = b * hq * sq * d + 2 * b * hq * sk * d;
1269 let elems_out = b * hq * sq * d;
1270 let bytes_moved = (elems_qkv + elems_out) * 4;
1271 CostEstimate {
1272 flops: mm_flops + sm_flops,
1273 bytes_moved,
1274 // Attention has higher launch overhead than elementwise.
1275 kernel_overhead_ns: 200,
1276 }
1277}
1278
1279/// Precision guarantee for `(FLASH_ATTN_BACKWARD_{Q,K,V}, Cpu, *)`
1280/// impls. The CPU backward is the math-definition gradient: it
1281/// recomputes the forward softmax probabilities row-by-row and
1282/// accumulates dQ/dK/dV with sequential nested loops — fixed
1283/// iteration order, no atomics, no parallel reductions. F32/F64 run
1284/// natively; BF16/F16 accumulate in F32 and narrow on store.
1285/// Bit-identical re-run on same hardware.
1286pub const ATTN_BACKWARD_CPU_PRECISION: PrecisionGuarantee = PrecisionGuarantee {
1287 bit_stable_on_same_hardware: true,
1288 max_ulp: None,
1289 max_relative: None,
1290 max_absolute: None,
1291 notes: "fuel-cpu-backend FlashAttnBackward{Q,K,V}: math-definition \
1292 gradient with recomputed softmax; sequential nested-loop \
1293 accumulation (F32 accumulator for half types); \
1294 deterministic iteration order; bit-identical re-run on \
1295 same hardware. GPU backward kernels (tiled, atomics) \
1296 declare their own PrecisionGuarantee when they register.",
1297};
1298
1299/// Cost model for `(FLASH_ATTN_BACKWARD_{Q,K,V}, Cpu)` kernels. The
1300/// math-definition backward recomputes the attention probabilities
1301/// (one QKᵀ matmul) and then forms dV, dP, dQ and dK — five
1302/// `O(B·Hq·Sq·Sk·D)` matmul-shaped passes vs the forward's two.
1303/// The CPU byte wrapper additionally computes ALL THREE gradients
1304/// per call and persists only the requested one (the Q/K/V OpKind
1305/// split has one output each), so the per-call cost does not shrink
1306/// with the selected gradient.
1307///
1308/// Shapes are `[q, k, v, do, (alibi)]` with `q = do = [B, Hq, Sq, D]`,
1309/// `k = v = [B, Hkv, Sk, D]`.
1310pub fn cost_attn_backward_cpu(
1311 shapes: &[Shape],
1312 _params: &FusedOpParams,
1313 _caps: &BackendCapabilities,
1314) -> CostEstimate {
1315 if shapes.is_empty() {
1316 return CostEstimate::default();
1317 }
1318 let q_dims = shapes[0].dims();
1319 if q_dims.len() != 4 {
1320 return CostEstimate::default();
1321 }
1322 let (b, hq, sq, d) = (
1323 q_dims[0] as u64,
1324 q_dims[1] as u64,
1325 q_dims[2] as u64,
1326 q_dims[3] as u64,
1327 );
1328 let sk: u64 = if shapes.len() >= 2 {
1329 let k_dims = shapes[1].dims();
1330 if k_dims.len() == 4 {
1331 k_dims[2] as u64
1332 } else {
1333 sq
1334 }
1335 } else {
1336 sq
1337 };
1338 // Five matmul-shaped passes (P recompute, dV, dP, dQ, dK):
1339 // 2·B·Hq·Sq·Sk·D FLOPs each.
1340 let mm_flops = 10u64 * b * hq * sq * sk * d;
1341 // Softmax recompute + dS row reductions: ~8·B·Hq·Sq·Sk.
1342 let sm_flops = 8u64 * b * hq * sq * sk;
1343 // Bandwidth: q + do reads, k + v reads (Hkv ≈ Hq approximation,
1344 // mirroring cost_attn_cpu), plus all three gradient buffers
1345 // written (the wrapper materializes dQ, dK and dV internally
1346 // even though only one persists).
1347 let elems_read = 2 * b * hq * sq * d + 2 * b * hq * sk * d;
1348 let elems_written = b * hq * sq * d + 2 * b * hq * sk * d;
1349 let bytes_moved = (elems_read + elems_written) * 4;
1350 CostEstimate {
1351 flops: mm_flops + sm_flops,
1352 bytes_moved,
1353 // Same launch-overhead class as the attention forward.
1354 kernel_overhead_ns: 200,
1355 }
1356}
1357
1358/// Precision guarantee for `(QMATMUL, Cpu, *)` impls. Quantized
1359/// matmul dequantizes inline; the dequant arithmetic is exact for
1360/// the quantization scheme's block format, and the F32 matmul
1361/// accumulator stays deterministic. Bit-identical on same hardware.
1362pub const QMATMUL_CPU_PRECISION: PrecisionGuarantee = PrecisionGuarantee {
1363 bit_stable_on_same_hardware: true,
1364 max_ulp: None,
1365 max_relative: None,
1366 max_absolute: None,
1367 notes: "fuel-cpu-backend QMatMul: per-block dequant + F32 matmul \
1368 accumulate; deterministic iteration order; bit-identical \
1369 re-run on same hardware. Inherent precision loss from \
1370 the quantization itself is a property of the QuantType, \
1371 not the kernel — captured separately when the calibration \
1372 framework lands.",
1373};
1374
1375/// Precision guarantee for `(REDUCE_MAX_TO_BACKWARD, Cpu, *)` impls.
1376/// The kernel recomputes the forward max, builds a tie-mask,
1377/// counts ties via reduce_sum, divides upstream by the count, then
1378/// broadcasts back and gates by the mask. All deterministic on
1379/// same hardware (no atomics, no parallel reductions); F32
1380/// accumulator for half-precision dtypes via the shared
1381/// `reduce_max_to_backward_impl` adapter.
1382pub const REDUCE_MAX_TO_BACKWARD_CPU_PRECISION: PrecisionGuarantee = PrecisionGuarantee {
1383 bit_stable_on_same_hardware: true,
1384 max_ulp: None,
1385 max_relative: None,
1386 max_absolute: None,
1387 notes: "fuel-cpu-backend ReduceMaxTo backward: recomputed-max + \
1388 tie-mask + scale + gate; deterministic iteration order; \
1389 F32 accumulator for half-precision; bit-identical same-\
1390 hardware re-run.",
1391};
1392
1393/// Precision guarantee for `(INPLACE_AFFINE, Cpu)`. Single-pass
1394/// `x = mul · x + add` per element with no parallel accumulation;
1395/// deterministic iteration order; bit-identical same-hardware re-run.
1396/// The half-precision dtypes (bf16/f16) pivot through f32 for the
1397/// arithmetic, identical to the non-inplace Affine family.
1398pub const INPLACE_AFFINE_CPU_PRECISION: PrecisionGuarantee = PrecisionGuarantee {
1399 bit_stable_on_same_hardware: true,
1400 max_ulp: None,
1401 max_relative: None,
1402 max_absolute: None,
1403 notes: "fuel-cpu-backend in-place affine: single-pass `mul · x + \
1404 add` per element; deterministic iteration order; F32 pivot \
1405 for half-precision dtypes; bit-identical same-hardware re-run.",
1406};
1407
1408/// Cost model for `(INPLACE_AFFINE, Cpu)`. Single pass: 1 FMA per
1409/// element. Bandwidth is one read + one write per element (in place,
1410/// so the buffer is touched once for read + once for write through
1411/// the same cache line).
1412pub fn cost_inplace_affine_cpu(
1413 shapes: &[Shape],
1414 _params: &FusedOpParams,
1415 _caps: &BackendCapabilities,
1416) -> CostEstimate {
1417 debug_assert_eq!(
1418 shapes.len(),
1419 1,
1420 "InplaceAffine cost: expected 1 input shape"
1421 );
1422 let n: u64 = shapes[0].dims().iter().map(|&d| d as u64).product();
1423 // mul + add per element = 1 FMA. Treat as 1 FLOP for simplicity
1424 // (matches Affine's accounting; the fused mul-add is one tier-2
1425 // float op).
1426 let flops = n;
1427 // In-place: the buffer is read once + written once. Use
1428 // dtype-agnostic 4 bytes/elem default — the cost model's caller
1429 // scales by the actual dtype size if it cares.
1430 let bytes_moved = n * 4 * 2;
1431 CostEstimate {
1432 flops,
1433 bytes_moved,
1434 kernel_overhead_ns: 50,
1435 }
1436}
1437
1438/// Precision guarantee for `(POWI_BACKWARD, Cpu)`. Per-element
1439/// `grad_x = exp · x^(exp-1) · upstream` with the same floating-point
1440/// determinism guarantees as the forward PowI primitive: deterministic
1441/// iteration order, no parallel reductions, F32 compute for half-
1442/// precision dtypes via the shared `powi_backward_half_kernel`.
1443pub const POWI_BACKWARD_CPU_PRECISION: PrecisionGuarantee = PrecisionGuarantee {
1444 bit_stable_on_same_hardware: true,
1445 max_ulp: None,
1446 max_relative: None,
1447 max_absolute: None,
1448 notes: "fuel-cpu-backend PowI backward: single-pass `exp · \
1449 x.powi(exp-1) · upstream` per element; deterministic \
1450 iteration order; F32 compute for half-precision; bit-\
1451 identical same-hardware re-run.",
1452};
1453
1454/// Cost model for `(POWI_BACKWARD, Cpu)` — single pass over the input:
1455/// per-element `powi(exp-1)` (≈ log2(|exp|) FMAs) + 2 multiplies. Bandwidth
1456/// dominated by reading `x` + `upstream` and writing `grad_x`.
1457pub fn cost_powi_backward_cpu(
1458 shapes: &[Shape],
1459 params: &FusedOpParams,
1460 _caps: &BackendCapabilities,
1461) -> CostEstimate {
1462 debug_assert_eq!(
1463 shapes.len(),
1464 2,
1465 "PowIBackward cost: expected 2 input shapes"
1466 );
1467 let in_count: u64 = shapes[0].dims().iter().map(|&d| d as u64).product();
1468 let exp_abs = match params {
1469 FusedOpParams::PowIBackward { exp } => (*exp).unsigned_abs().max(1) as u64,
1470 _ => 1,
1471 };
1472 // power-by-squaring: ceil(log2(|exp|)) multiplies for the powi,
1473 // +2 multiplies for the coefficient and upstream factors.
1474 let powi_muls = 64 - exp_abs.leading_zeros() as u64;
1475 let flops = in_count * (powi_muls + 2);
1476 let bytes_moved = in_count * 4 /* x */ + in_count * 4 /* upstream */ + in_count * 4 /* grad_x */;
1477 CostEstimate {
1478 flops,
1479 bytes_moved,
1480 kernel_overhead_ns: 60,
1481 }
1482}
1483
1484/// Cost model for `(REDUCE_MAX_TO_BACKWARD, Cpu)` — five passes over
1485/// the input: forward max, mask build, count, scale, broadcast +
1486/// gate. Roughly `5 · in_count` FLOPs, with bandwidth dominated by
1487/// 5× the input element count.
1488pub fn cost_reduce_max_to_backward_cpu(
1489 shapes: &[Shape],
1490 _params: &FusedOpParams,
1491 _caps: &BackendCapabilities,
1492) -> CostEstimate {
1493 debug_assert_eq!(
1494 shapes.len(),
1495 2,
1496 "ReduceMaxToBackward cost: expected 2 input shapes"
1497 );
1498 let in_count: u64 = shapes[0].dims().iter().map(|&d| d as u64).product();
1499 let out_count: u64 = shapes[1].dims().iter().map(|&d| d as u64).product();
1500 let flops = 5 * in_count + 2 * out_count;
1501 let bytes_moved = (5 * in_count + 3 * out_count) * 4;
1502 CostEstimate {
1503 flops,
1504 bytes_moved,
1505 kernel_overhead_ns: 80,
1506 }
1507}
1508
1509/// Cost model for `(QMATMUL, Cpu)` — same FLOP count as a regular
1510/// matmul (the dequant adds a small per-block overhead that's
1511/// dwarfed by the FMA count for any useful K).
1512///
1513/// Shapes: `[a, w_q_bytes]` where `a = [..., M, K]`. `N` comes from
1514/// the FusedOpParams::QMatMul payload.
1515pub fn cost_qmatmul_cpu(
1516 shapes: &[Shape],
1517 params: &FusedOpParams,
1518 _caps: &BackendCapabilities,
1519) -> CostEstimate {
1520 debug_assert_eq!(shapes.len(), 2, "QMatMul cost: expected 2 input shapes");
1521 let (k, n) = match params {
1522 FusedOpParams::QMatMul { k, n, .. } => (*k as u64, *n as u64),
1523 _ => return CostEstimate::default(),
1524 };
1525 let a_dims = shapes[0].dims();
1526 let rank = a_dims.len();
1527 if rank < 2 {
1528 return CostEstimate::default();
1529 }
1530 let m = a_dims[rank - 2] as u64;
1531 let batch: u64 = a_dims[..rank - 2]
1532 .iter()
1533 .map(|&d| d as u64)
1534 .product::<u64>()
1535 .max(1);
1536 // 2·M·N·K FLOPs per batch (FMA).
1537 let flops = 2 * batch * m * n * k;
1538 // Bandwidth: read A + W_Q (counted as bytes via shapes[1]
1539 // elem_count of U32 = 4 bytes), write C.
1540 let a_elems = batch * m * k;
1541 let w_u32_elems: u64 = shapes[1].dims().iter().map(|&d| d as u64).product();
1542 let c_elems = batch * m * n;
1543 let bytes_moved = (a_elems + c_elems) * 4 + w_u32_elems * 4;
1544 CostEstimate {
1545 flops,
1546 bytes_moved,
1547 kernel_overhead_ns: 50,
1548 }
1549}
1550
1551// =============================================================================
1552// Vulkan PrecisionGuarantee constants (Phase 7.6 step 9c follow-up,
1553// 2026-05-23). Per-kernel precision claims so the optimizer's
1554// tolerance-budget pass can admit Vulkan alternatives. The constants
1555// group Vulkan kernels by their numerical character: pointwise IEEE-
1556// 754 ops (bit-stable, ~1 ULP), half-precision pointwise (bit-stable
1557// at lower mantissa), transcendentals (vendor lib ~2-4 ULP),
1558// reductions (NOT bit-stable due to subgroup composition), matmul
1559// (deterministic accumulation), tensor-core matmul (FMA chains),
1560// byte-level (memcpy, bit-identical), cast, and qmatmul.
1561// =============================================================================
1562
1563/// Vulkan f32 / f64 pointwise ops — Add, Sub, Mul, Div, Maximum,
1564/// Minimum, Neg, Sqr, Sqrt, Relu, Step, Affine, Clamp.
1565///
1566/// IEEE-754 conformant per the Vulkan SPIR-V spec: direct hardware
1567/// FADD / FMUL / FMA per thread, no atomic accumulators, no
1568/// cross-thread communication. Bit-stable on same hardware (kernel
1569/// scheduling doesn't affect per-thread outputs). ULP is the
1570/// standard IEEE-754 round-to-nearest tolerance (≤0.5 ULP for FADD/
1571/// FMUL/FDIV, ≤1 ULP for FMA chains).
1572///
1573/// Not REFERENCE-grade because we don't claim ULP=0 across all
1574/// implementations — Vulkan compilers may reorder commutative ops
1575/// or fuse multiply-adds in ways that differ from a strict
1576/// IEEE-754-strict reference. Within those bounds, every conforming
1577/// Vulkan implementation produces results within 1 ULP.
1578pub const VULKAN_FLOAT_POINTWISE_PRECISION: PrecisionGuarantee = PrecisionGuarantee {
1579 bit_stable_on_same_hardware: true,
1580 max_ulp: Some(1),
1581 max_relative: None,
1582 max_absolute: None,
1583 notes: "fuel-vulkan-backend f32/f64 pointwise: direct hardware FADD/\
1584 FMUL/FMA per thread; no atomics; bit-stable on same hardware. \
1585 Within 1 ULP of IEEE-754 round-to-nearest; small variation \
1586 across implementations due to commutative-reorder + FMA fusion.",
1587};
1588
1589/// Vulkan f16 / bf16 pointwise ops — same op surface as
1590/// [`VULKAN_FLOAT_POINTWISE_PRECISION`] at half precision. f16 uses
1591/// shaderFloat16 native ops (where available); bf16 packs two values
1592/// per u32 with software upcast/downcast through f32 for the arithmetic.
1593///
1594/// Bit-stable on same hardware (no atomics, deterministic per-thread).
1595/// ULP claim is 1 in the dtype's mantissa space — meaningful for f16
1596/// (10-bit mantissa) and bf16 (7-bit mantissa) precision budgeting.
1597/// Absolute error scales with magnitude × 2^(-mantissa_bits).
1598pub const VULKAN_HALF_POINTWISE_PRECISION: PrecisionGuarantee = PrecisionGuarantee {
1599 bit_stable_on_same_hardware: true,
1600 max_ulp: Some(1),
1601 max_relative: None,
1602 max_absolute: None,
1603 notes: "fuel-vulkan-backend f16/bf16 pointwise: shaderFloat16 native or \
1604 u32-packed bf16; bit-stable on same hardware; within 1 ULP in \
1605 the dtype's mantissa space (10-bit f16, 7-bit bf16).",
1606};
1607
1608/// Vulkan transcendentals — Exp, Log, Sin, Cos, Tanh, Sigmoid, Silu,
1609/// Gelu (f32/f16/bf16/f64). f32/f16 go through GLSL.std.450's vendor-
1610/// library implementations (variable ULP per GPU/driver); f64 uses
1611/// the Horner-polynomial approximations in `unary_f64.slang`
1612/// (target ~1e-12 relative error).
1613///
1614/// Bit-stable on same hardware (driver-deterministic; the
1615/// transcendental implementation is fixed per build). ULP bound is
1616/// loose: Vulkan spec allows 3-4 ULP for GLSL.std.450 transcendentals;
1617/// real implementations are typically tighter (~1-2 ULP on NVIDIA's
1618/// libdevice).
1619pub const VULKAN_TRANSCENDENTAL_PRECISION: PrecisionGuarantee = PrecisionGuarantee {
1620 bit_stable_on_same_hardware: true,
1621 max_ulp: Some(4),
1622 max_relative: None,
1623 max_absolute: None,
1624 notes: "fuel-vulkan-backend transcendentals: GLSL.std.450 vendor lib \
1625 (f32/f16/bf16) or Horner polynomial (f64); bit-stable on same \
1626 hardware; Vulkan spec allows ≤4 ULP, real GPUs typically tighter.",
1627};
1628
1629// Vulkan reductions — SumReduce, MaxReduce, MinReduce, MeanReduce,
1630// SoftmaxLastDim, RmsNormLastDim, LayerNormLastDim, and any kernel
1631// that uses subgroup tree reductions — use `PrecisionGuarantee::
1632// UNAUDITED` (audited result of "no static bound to claim"). The
1633// reasoning: subgroup composition is scheduler-determined per
1634// dispatch, so reordering FADDs across subgroups produces different
1635// floating-point results; no static ULP / relative / absolute bound
1636// applies. Their (op, dtypes, Vulkan) tuples are listed in the
1637// `vulkan_dispatch_per_kernel_precision_and_cost_coverage` lint's
1638// `KNOWN_GAPS` allowlist so the lint accepts UNAUDITED for these
1639// specific kernels; new Vulkan reduction kernels must be added to
1640// KNOWN_GAPS at registration time. The architectural decision is
1641// thus visible at review time rather than buried in a per-kernel
1642// constant.
1643
1644/// Vulkan matmul (f32 standard, no tensor cores) — tiled / reg-tile /
1645/// matvec kernels. Accumulation order is deterministic per kernel
1646/// dispatch (each output element's reduction runs in a single thread
1647/// with no cross-thread atomic adds); the kernel is bit-stable on
1648/// same hardware.
1649///
1650/// ULP claim left unspecified; the per-element error is bounded by
1651/// `K · ULP(2 · max_abs_product)` from the K-deep FMA chain.
1652pub const VULKAN_MATMUL_PRECISION: PrecisionGuarantee = PrecisionGuarantee {
1653 bit_stable_on_same_hardware: true,
1654 max_ulp: None,
1655 max_relative: None,
1656 max_absolute: None,
1657 notes: "fuel-vulkan-backend f32 matmul: tiled / reg-tile / matvec \
1658 kernels with per-thread sequential FMA accumulation; bit-stable \
1659 on same hardware; error bounded by O(K · ULP) from K-deep \
1660 reduction.",
1661};
1662
1663/// Vulkan tensor-core mixed-precision matmul (f32 × bf16 → f32, via
1664/// `matmul_coop` cooperative kernels on Ampere+ tensor cores).
1665///
1666/// Tensor-core FMAs accumulate in extended precision (f32 internal,
1667/// IEEE-754 conformant FMA chain); the cross-tile reduction happens
1668/// with full f32 precision. Bit-stable on same hardware per the
1669/// Ampere/Ada spec (the tensor cores are deterministic given the
1670/// same inputs + scheduling). ULP claim is wider than pure f32
1671/// because the bf16 inputs lose 16 mantissa bits before the FMA.
1672pub const VULKAN_MATMUL_TENSORCORE_PRECISION: PrecisionGuarantee = PrecisionGuarantee {
1673 bit_stable_on_same_hardware: true,
1674 max_ulp: None,
1675 max_relative: None,
1676 max_absolute: None,
1677 notes: "fuel-vulkan-backend tensor-core matmul (f32 × bf16 → f32): \
1678 cooperative-matrix FMAs with f32 internal accumulator; bit-stable \
1679 on same hardware; per-tile error bounded by O(K · ULP_bf16 + \
1680 tile_K · ULP_f32).",
1681};
1682
1683/// Vulkan byte-level (memcpy-style) ops — Triu, Tril, Flip, Roll,
1684/// WriteSlice, IndexSelect, Concat, Copy (D2H/H2D). Pure data movement
1685/// with no FP arithmetic; output bytes are bit-identical to input
1686/// bytes selected/permuted according to the op.
1687///
1688/// Bit-stable on same hardware, ULP/relative/absolute = 0 (byte-level
1689/// equivalence). The strongest precision claim, equivalent to
1690/// REFERENCE but earned through "no FP math" rather than "explicit
1691/// IEEE-754 evaluation."
1692pub const VULKAN_BYTE_LEVEL_PRECISION: PrecisionGuarantee = PrecisionGuarantee {
1693 bit_stable_on_same_hardware: true,
1694 max_ulp: Some(0),
1695 max_relative: Some(0.0),
1696 max_absolute: Some(0.0),
1697 notes: "fuel-vulkan-backend byte-level ops (Triu/Tril/Flip/Roll/\
1698 WriteSlice/IndexSelect/Concat/Copy): pure data movement, no FP \
1699 math; bit-identical to input bytes.",
1700};
1701
1702/// Vulkan dtype cast — f32↔f16, f32↔bf16, F8E4M3↔{f32,f16,bf16}.
1703/// Pure conversion with no accumulation; output is the IEEE-754
1704/// round-to-nearest representation of the input in the target dtype.
1705///
1706/// Bit-stable on same hardware (no atomics, no cross-thread comm).
1707/// ULP=0 vs the IEEE-754 round-to-nearest reference for the target
1708/// dtype (the cast itself is exact within the target's representable
1709/// range; any precision loss is inherent to the dtype, not the
1710/// implementation).
1711pub const VULKAN_CAST_PRECISION: PrecisionGuarantee = PrecisionGuarantee {
1712 bit_stable_on_same_hardware: true,
1713 max_ulp: Some(0),
1714 max_relative: None,
1715 max_absolute: None,
1716 notes: "fuel-vulkan-backend Cast: pure dtype conversion via SPIR-V \
1717 OpFConvert / OpConvertFToS / OpConvertSToF; bit-stable on same \
1718 hardware; ULP=0 vs IEEE-754 round-to-nearest in target dtype \
1719 (loss is inherent to the target dtype, not the implementation).",
1720};
1721
1722/// Vulkan QMatMul (Q4_0 / Q4_K_M / Q8_0 weights × F32 activation).
1723/// Per-block dequant + per-output-element FMA accumulation; same
1724/// deterministic structure as the CPU QMatMul kernel (bit-stable on
1725/// same hardware).
1726///
1727/// The inherent precision loss from quantization is a property of
1728/// the QuantType (captured separately when the calibration framework
1729/// lands); the kernel itself contributes only the matmul accumulation
1730/// error.
1731pub const VULKAN_QMATMUL_PRECISION: PrecisionGuarantee = PrecisionGuarantee {
1732 bit_stable_on_same_hardware: true,
1733 max_ulp: None,
1734 max_relative: None,
1735 max_absolute: None,
1736 notes: "fuel-vulkan-backend QMatMul (Q4_0/Q4_K_M/Q8_0 × F32): per-block \
1737 dequant + per-output-element FMA; bit-stable on same hardware. \
1738 Quantization precision loss is a QuantType property, not the \
1739 kernel's contribution.",
1740};
1741
1742#[cfg(test)]
1743mod tests {
1744 use super::*;
1745 use fuel_ir::{DType, Layout, Result};
1746 use std::sync::Arc;
1747 use std::sync::RwLock;
1748
1749 fn dummy_kernel(
1750 _inputs: &[Arc<RwLock<fuel_memory::Storage>>],
1751 _outputs: &mut [Arc<RwLock<fuel_memory::Storage>>],
1752 _layouts: &[Layout],
1753 _params: &crate::kernel::OpParams,
1754 ) -> Result<()> {
1755 Ok(())
1756 }
1757
1758 fn dummy_cost(_s: &[Shape], _p: &FusedOpParams, _c: &BackendCapabilities) -> CostEstimate {
1759 CostEstimate::default()
1760 }
1761
1762 const DUMMY_DTYPES: &[DType] = &[DType::F32];
1763
1764 fn make_impl() -> BackendImpl {
1765 BackendImpl {
1766 kernel: dummy_kernel,
1767 dtypes: DUMMY_DTYPES,
1768 cost: dummy_cost,
1769 precision: PrecisionGuarantee::UNAUDITED,
1770 caps: KernelCaps::empty(),
1771 revision: KernelRevisionHash::UNTRACKED,
1772 cost_expr: None,
1773 }
1774 }
1775
1776 /// Smoke: empty registry has no impls.
1777 #[test]
1778 fn fused_kernel_registry_empty() {
1779 let r = FusedKernelRegistry::new();
1780 assert!(r.is_empty());
1781 assert_eq!(r.len(), 0);
1782 assert!(r.lookup(FusedOpId(1), BackendId::Cpu).is_none());
1783 assert!(r.impls_for(FusedOpId(1)).is_empty());
1784 // Suppress unused-warning for DType import on no-feature builds.
1785 let _ = DType::F32;
1786 }
1787
1788 /// Smoke: register and look up a single impl.
1789 #[test]
1790 fn fused_kernel_registry_register_and_lookup() {
1791 let mut r = FusedKernelRegistry::new();
1792 r.register(FusedOpId(1), BackendId::Cpu, make_impl());
1793 assert!(!r.is_empty());
1794 assert_eq!(r.len(), 1);
1795 let got = r.lookup(FusedOpId(1), BackendId::Cpu);
1796 assert!(got.is_some());
1797 let impls = r.impls_for(FusedOpId(1));
1798 assert_eq!(impls.len(), 1);
1799 assert_eq!(impls[0].0, BackendId::Cpu);
1800 }
1801
1802 /// Re-registering at the same `(id, backend)` appends — multiple
1803 /// alternatives at the same decision point are a feature (e.g.
1804 /// cuBLAS vs CUTLASS bf16 matmul at the same dtype). The route
1805 /// picker filters by dtypes via `lookup_by_dtypes` and ranks
1806 /// remaining alternatives.
1807 #[test]
1808 fn fused_kernel_registry_register_appends_alternatives() {
1809 let mut r = FusedKernelRegistry::new();
1810 r.register(FusedOpId(1), BackendId::Cpu, make_impl());
1811 r.register(FusedOpId(1), BackendId::Cpu, make_impl());
1812 assert_eq!(r.len(), 1, "one id");
1813 assert_eq!(
1814 r.impls_for(FusedOpId(1)).len(),
1815 2,
1816 "two alternatives registered",
1817 );
1818 }
1819
1820 /// PrecisionGuarantee::REFERENCE has the strongest properties.
1821 #[test]
1822 fn precision_guarantee_reference_is_strict() {
1823 let p = PrecisionGuarantee::REFERENCE;
1824 assert!(p.bit_stable_on_same_hardware);
1825 assert_eq!(p.max_ulp, Some(0));
1826 }
1827
1828 /// CostEstimate::default is all-zero.
1829 #[test]
1830 fn cost_estimate_default_zero() {
1831 let c = CostEstimate::default();
1832 assert_eq!(c.flops, 0);
1833 assert_eq!(c.bytes_moved, 0);
1834 assert_eq!(c.kernel_overhead_ns, 0);
1835 }
1836
1837 /// Step 6 — `default_kernel_registry` populates the four CPU
1838 /// FusedLinear impls under `FUSED_LINEAR × Cpu × (F32|F64|BF16|F16)`.
1839 #[test]
1840 fn default_kernel_registry_has_fused_linear_cpu_quartet() {
1841 use fuel_graph::registry::FusedOps;
1842
1843 let r = default_kernel_registry();
1844 let impls = r.impls_for(FusedOps::FUSED_LINEAR);
1845 assert_eq!(
1846 impls.len(),
1847 4,
1848 "expected 4 FusedLinear × Cpu × dtype registrations, got {}",
1849 impls.len(),
1850 );
1851 for (backend, _) in impls {
1852 assert_eq!(*backend, BackendId::Cpu);
1853 }
1854 // Per-dtype lookup hits each registration.
1855 for dtype in [DType::F32, DType::F64, DType::BF16, DType::F16] {
1856 let want = [dtype; 4];
1857 let got = r.lookup_by_dtypes(FusedOps::FUSED_LINEAR, BackendId::Cpu, &want);
1858 assert!(
1859 got.is_some(),
1860 "lookup_by_dtypes missed FusedLinear × Cpu × {dtype:?}",
1861 );
1862 let impl_ = got.unwrap();
1863 assert!(
1864 impl_.precision.bit_stable_on_same_hardware,
1865 "CPU FusedLinear should be bit-stable on same hardware",
1866 );
1867 }
1868 }
1869
1870 /// Step 6 (Conv2D extension) — `default_kernel_registry` populates
1871 /// eight CPU Conv2D impls: four dtypes × {no-bias, with-bias}.
1872 /// The route picker filters by the dtype tuple length to dispatch
1873 /// the right variant per call site.
1874 #[test]
1875 fn default_kernel_registry_has_conv2d_cpu_octet() {
1876 use fuel_graph::registry::FusedOps;
1877
1878 let r = default_kernel_registry();
1879 let impls = r.impls_for(FusedOps::CONV2D);
1880 assert_eq!(
1881 impls.len(),
1882 8,
1883 "expected 8 Conv2D × Cpu × dtype × (no-bias|with-bias) \
1884 registrations, got {}",
1885 impls.len(),
1886 );
1887 for (backend, _) in impls {
1888 assert_eq!(*backend, BackendId::Cpu);
1889 }
1890 // No-bias (rank-3) and with-bias (rank-4) lookups both hit per dtype.
1891 for dtype in [DType::F32, DType::F64, DType::BF16, DType::F16] {
1892 let no_bias = [dtype; 3];
1893 let with_bias = [dtype; 4];
1894 assert!(
1895 r.lookup_by_dtypes(FusedOps::CONV2D, BackendId::Cpu, &no_bias)
1896 .is_some(),
1897 "lookup_by_dtypes missed Conv2D × Cpu × {dtype:?} (no-bias)",
1898 );
1899 let with_bias_impl = r.lookup_by_dtypes(FusedOps::CONV2D, BackendId::Cpu, &with_bias);
1900 assert!(
1901 with_bias_impl.is_some(),
1902 "lookup_by_dtypes missed Conv2D × Cpu × {dtype:?} (with-bias)",
1903 );
1904 let impl_ = with_bias_impl.unwrap();
1905 assert!(
1906 impl_.precision.bit_stable_on_same_hardware,
1907 "CPU Conv2D should be bit-stable on same hardware",
1908 );
1909 }
1910 }
1911
1912 /// Phase 7.6 step 7 — the architecture v1.0 §05 "always-built
1913 /// backend bit-stable coverage commitment" lint.
1914 ///
1915 /// Every fused op registered in
1916 /// `fuel_graph::registry::default_registry()` MUST have at least
1917 /// one CPU `BackendImpl` in
1918 /// `fuel_memory::fused::default_kernel_registry()` with
1919 /// `precision.bit_stable_on_same_hardware == true`. This is the
1920 /// architecture's correctness anchor: the always-built backend
1921 /// gives every downstream consumer a deterministic
1922 /// implementation to fall back on, so cross-backend equivalence
1923 /// tests have a fixed reference.
1924 ///
1925 /// The lint runs as a unit test so violations surface in CI
1926 /// rather than at runtime. Adding a new `FusedOpEntry` without
1927 /// a matching CPU registration fails this test immediately.
1928 ///
1929 /// As of step-6 follow-up (2026-05-11), the previously-empty-
1930 /// covered backward helpers (SoftmaxLastDimBackward,
1931 /// LayerNormLastDimBackward, RmsNormLastDimBackward,
1932 /// ReduceMaxToBackward) gained byte-level CPU wrappers +
1933 /// binding-table registrations + BackendImpls. The `KNOWN_GAPS`
1934 /// allowlist is therefore **empty** — every registered fused
1935 /// op has bit-stable CPU coverage.
1936 ///
1937 /// Note on primitive Ops: this lint covers the **fused-op**
1938 /// registry only. Primitive ops (Op::Add, Op::MatMul, etc.)
1939 /// dispatch through `KernelBindingTable` and don't carry a
1940 /// `PrecisionGuarantee` field today. Extending the architecture
1941 /// commitment to primitives is step 7b — pending a binding-table
1942 /// refactor or a parallel PrecisionGuarantee side-table per
1943 /// OpKind.
1944 #[test]
1945 fn precision_guarantee_lint_bit_stable_cpu_coverage() {
1946 use fuel_graph::registry::{FusedOpId, FusedOps, default_registry};
1947
1948 // If a future commit introduces a new entry without immediate
1949 // CPU coverage, add it here with a documented reason AND file
1950 // a follow-up to remove the line. This check already detects
1951 // its own expiry: if a CPU registration later appears for an
1952 // allowlisted id, the `has_cpu` check below fails loudly
1953 // rather than silently granting double coverage.
1954 //
1955 // AwqMatmul (fuel-graph's AWQ weight-only GEMM op) ships
1956 // decompose-only in this increment: its dequant-then-matmul
1957 // decompose already provides correctness on every backend via
1958 // existing primitive ops, which independently carry their own
1959 // CPU coverage -- but this lint checks for a DIRECT
1960 // binding-table CPU registration of the fused op itself,
1961 // which AwqMatmul deliberately does not have yet. Reaching
1962 // the real, measured CUDA kernel
1963 // (fuel-cuda-backend/src/baracuda/quant_w4a16.rs::awq_gemm_f16)
1964 // is separate follow-on work named in the op's own module doc;
1965 // a CPU binding-table wrapper is the same class of follow-up.
1966 const KNOWN_GAPS: &[(FusedOpId, &str)] = &[(
1967 FusedOps::AWQ_MATMUL,
1968 "decompose-only in this increment; no direct CPU binding-table \
1969 registration yet -- see fuel-graph/src/registry/awq_matmul.rs's \
1970 module doc for the follow-on dispatch-wiring work",
1971 )];
1972
1973 let meta = default_registry();
1974 let kernels = default_kernel_registry();
1975
1976 let mut failures: Vec<String> = Vec::new();
1977 let mut covered = 0usize;
1978 let mut allowlisted = 0usize;
1979
1980 for entry in meta.entries_iter() {
1981 let id = entry.id;
1982 let name = entry.name;
1983
1984 if let Some((_, reason)) = KNOWN_GAPS.iter().find(|(g, _)| *g == id) {
1985 allowlisted += 1;
1986 // Sanity: a gap entry should have no CPU coverage.
1987 // If a CPU registration shows up for an
1988 // allowlisted id, the gap entry should be removed.
1989 let has_cpu = kernels
1990 .impls_for(id)
1991 .iter()
1992 .any(|(b, _)| *b == BackendId::Cpu);
1993 if has_cpu {
1994 failures.push(format!(
1995 "FusedOpId {id:?} ({name}) is on the KNOWN_GAPS allowlist \
1996 but DOES have a CPU registration now. Reason given was: \
1997 {reason:?}. Remove the allowlist entry to enable the \
1998 bit-stable lint.",
1999 ));
2000 }
2001 continue;
2002 }
2003
2004 let impls = kernels.impls_for(id);
2005 let has_bit_stable_cpu = impls.iter().any(|(backend, impl_)| {
2006 *backend == BackendId::Cpu && impl_.precision.bit_stable_on_same_hardware
2007 });
2008 if has_bit_stable_cpu {
2009 covered += 1;
2010 } else {
2011 failures.push(format!(
2012 "FusedOpId {id:?} ({name}) has no bit-stable CPU BackendImpl. \
2013 Architecture v1.0 §05 requires the always-built backend \
2014 (fuel-cpu-backend) to provide at least one \
2015 `bit_stable_on_same_hardware: true` kernel per registered \
2016 fused op. Either add a CPU registration in \
2017 fuel-storage::dispatch::register_default_fused_kernels with \
2018 a matching PrecisionGuarantee, or add a line to KNOWN_GAPS \
2019 above with a documented reason.",
2020 ));
2021 }
2022 }
2023
2024 assert!(
2025 failures.is_empty(),
2026 "Architecture v1.0 bit-stable CPU coverage lint failed:\n{}",
2027 failures.join("\n"),
2028 );
2029 // Sanity: the lint should be exercising real ops. If both
2030 // counts are zero, something is wrong with the registry
2031 // population.
2032 assert!(
2033 covered > 0 || allowlisted > 0,
2034 "lint covered 0 ops — registry appears empty"
2035 );
2036 // Stronger commitment: exactly the one documented gap above,
2037 // no silent additions.
2038 assert_eq!(
2039 allowlisted, 1,
2040 "KNOWN_GAPS allowlist has exactly 1 documented entry (AwqMatmul); \
2041 saw {allowlisted} allowlisted",
2042 );
2043 // Sanity: we should see all 24 registered fused ops covered.
2044 // 14 from the original Phase 7.6 lineup + PowIBackward
2045 // (autograd switched from emitting the primitive decomposition
2046 // to emitting Op::Fused(POWI_BACKWARD, _)) + INPLACE_AFFINE
2047 // (Phase 3 of the in-place ops infrastructure) +
2048 // FUSED_SOFTMAX_CROSS_ENTROPY + CAUSAL_CONV1D + SELECTIVE_SCAN
2049 // + SSD_CHUNK_SCAN + NF4_MATMUL (the full CPU OpKind coverage
2050 // plan: FSCE + Mamba-adjacent trio + NF4) +
2051 // FLASH_ATTN_BACKWARD_{Q,K,V} (reusing the binding-table CPU
2052 // dispatch wrappers). AWQ_MATMUL is the registry's 25th entry
2053 // and is the one documented KNOWN_GAPS allowlist line above,
2054 // not counted here.
2055 assert_eq!(
2056 covered, 24,
2057 "expected 24 fused ops covered by bit-stable CPU impls, got {covered}",
2058 );
2059 }
2060
2061 /// Negative-path check for the bit-stable lint logic: a
2062 /// `FusedKernelRegistry` with no CPU impl for a registered id
2063 /// must fail the bit-stable check. Verifies the lint's failure
2064 /// detection works rather than just relying on the positive
2065 /// path being green.
2066 ///
2067 /// Doesn't touch `default_kernel_registry()` — runs the check
2068 /// against a freshly-constructed empty registry so the failure
2069 /// is deterministic regardless of global state.
2070 #[test]
2071 fn precision_guarantee_lint_detects_missing_cpu() {
2072 use fuel_graph::registry::FusedOpId;
2073 let empty = FusedKernelRegistry::new();
2074 let id = FusedOpId(1);
2075 // Same predicate the production lint uses.
2076 let has_bit_stable_cpu = empty.impls_for(id).iter().any(|(backend, impl_)| {
2077 *backend == BackendId::Cpu && impl_.precision.bit_stable_on_same_hardware
2078 });
2079 assert!(
2080 !has_bit_stable_cpu,
2081 "empty registry must not report bit-stable CPU coverage"
2082 );
2083 }
2084
2085 /// Negative-path check: a CPU registration without
2086 /// `bit_stable_on_same_hardware: true` must NOT satisfy the
2087 /// lint. Captures the difference between "any CPU impl" and "a
2088 /// bit-stable CPU impl" — the architecture commits to the
2089 /// stronger property.
2090 #[test]
2091 fn precision_guarantee_lint_rejects_non_bit_stable_cpu() {
2092 use fuel_graph::registry::FusedOpId;
2093 let mut r = FusedKernelRegistry::new();
2094 let id = FusedOpId(42);
2095 let weak = BackendImpl {
2096 kernel: dummy_kernel,
2097 dtypes: DUMMY_DTYPES,
2098 cost: dummy_cost,
2099 // UNAUDITED has bit_stable_on_same_hardware: false.
2100 precision: PrecisionGuarantee::UNAUDITED,
2101 caps: KernelCaps::empty(),
2102 revision: KernelRevisionHash::UNTRACKED,
2103 cost_expr: None,
2104 };
2105 r.register(id, BackendId::Cpu, weak);
2106 let has_bit_stable_cpu = r.impls_for(id).iter().any(|(backend, impl_)| {
2107 *backend == BackendId::Cpu && impl_.precision.bit_stable_on_same_hardware
2108 });
2109 assert!(
2110 !has_bit_stable_cpu,
2111 "an UNAUDITED-precision CPU impl must not satisfy the bit-stable lint"
2112 );
2113 }
2114
2115 /// Step 6 + backward-helper follow-up — coverage assertion for
2116 /// the 12 ops that gained BackendImpls (8 forwards from step 6
2117 /// + 4 backward helpers from the follow-up), plus the
2118 ///
2119 /// FlashAttnBackward{Q,K,V} trio (4 dtypes × 2 alibi shapes
2120 /// each, reusing the binding-table CPU wrappers). Each entry
2121 /// should have at least the expected CPU impl count after
2122 /// `default_kernel_registry()` populates.
2123 #[test]
2124 fn default_kernel_registry_step6_coverage() {
2125 use fuel_graph::registry::FusedOps;
2126
2127 let r = default_kernel_registry();
2128 // (id, expected_impl_count) — derived from the dtype-tuple
2129 // shapes registered. SoftmaxLastDim/RmsNormLastDim/
2130 // LayerNormLastDim/Rope = 4 dtypes each. ConvTranspose2D /
2131 // FlashAttn / PagedAttn = 4 dtypes × 2 shapes = 8 each.
2132 // QMatMul = 1 (F32 only). The 4 backward helpers = 4 dtypes
2133 // each.
2134 for (id, want) in [
2135 (FusedOps::SOFTMAX_LAST_DIM, 4usize),
2136 (FusedOps::RMS_NORM_LAST_DIM, 4),
2137 (FusedOps::LAYER_NORM_LAST_DIM, 4),
2138 (FusedOps::ROPE, 4),
2139 (FusedOps::CONV_TRANSPOSE2D, 8),
2140 (FusedOps::FLASH_ATTN, 8),
2141 (FusedOps::FLASH_ATTN_BACKWARD_Q, 8),
2142 (FusedOps::FLASH_ATTN_BACKWARD_K, 8),
2143 (FusedOps::FLASH_ATTN_BACKWARD_V, 8),
2144 (FusedOps::PAGED_ATTN, 8),
2145 (FusedOps::QMATMUL, 1),
2146 (FusedOps::SOFTMAX_LAST_DIM_BACKWARD, 4),
2147 (FusedOps::LAYER_NORM_LAST_DIM_BACKWARD, 4),
2148 (FusedOps::RMS_NORM_LAST_DIM_BACKWARD, 4),
2149 (FusedOps::REDUCE_MAX_TO_BACKWARD, 4),
2150 ] {
2151 let impls = r.impls_for(id);
2152 assert_eq!(
2153 impls.len(),
2154 want,
2155 "expected {want} CPU impls for FusedOpId {id:?}, got {}",
2156 impls.len(),
2157 );
2158 for (backend, impl_) in impls {
2159 assert_eq!(*backend, BackendId::Cpu);
2160 assert!(
2161 impl_.precision.bit_stable_on_same_hardware,
2162 "CPU impl for {id:?} should be bit-stable on same hardware",
2163 );
2164 }
2165 }
2166 }
2167
2168 /// Conv2D cost — FLOPs scale with `2·N·Cout·Hout·Wave·(Cin/g)·Kh·Kw`
2169 /// + bias-add FLOPs (when bias is present).
2170 #[test]
2171 fn cost_conv2d_cpu_flops_scale() {
2172 use crate::fused::cost_conv2d_cpu;
2173 use fuel_ir::DeviceLocation;
2174 use std::collections::HashSet;
2175
2176 // x: [N=2, Cin=4, H=8, W=8], w: [Cout=6, Cin/g=4, Kh=3, Kw=3],
2177 // stride=(1,1), padding=(1,1), groups=1.
2178 // Hout = Wout = (8 + 2 - 3) + 1 = 8.
2179 // Conv FLOPs (FMA = 2): 2·N·Cout·Hout·Wout·Cin_per_g·Kh·Kw
2180 // = 2·2·6·8·8·4·3·3 = 55296.
2181 // Bias FLOPs: N·Cout·Hout·Wout = 2·6·8·8 = 768.
2182 let x = Shape::from_dims(&[2, 4, 8, 8]);
2183 let w = Shape::from_dims(&[6, 4, 3, 3]);
2184 let bias = Shape::from_dims(&[6]);
2185 let caps = BackendCapabilities {
2186 backend_id: BackendId::Cpu,
2187 device_location: DeviceLocation::Cpu,
2188 op_dtype_support: HashSet::new(),
2189 required_alignment: 1,
2190 access_granularity_bits: 8,
2191 transfer_paths: vec![],
2192 storage_substrate: fuel_ir::backend::SubstrateClass::HostBytes,
2193 compute_throughput_flops_per_ns: 1.0,
2194 mem_bandwidth_bytes_per_ns: 4.0,
2195 };
2196 let params = FusedOpParams::Conv2D {
2197 stride: (1, 1),
2198 padding: (1, 1),
2199 groups: 1,
2200 };
2201 let c = cost_conv2d_cpu(&[x, w, bias], ¶ms, &caps);
2202 assert_eq!(c.flops, 55296 + 768);
2203 assert!(c.bytes_moved > 0);
2204 }
2205
2206 /// Cost model — FLOPs scale with 2·M·N·K + M·N, batch multiplies.
2207 #[test]
2208 fn cost_fused_linear_cpu_flops_scale() {
2209 use fuel_ir::DeviceLocation;
2210 use std::collections::HashSet;
2211
2212 let a = Shape::from_dims(&[2, 4, 8]); // batch=2, M=4, K=8
2213 let b = Shape::from_dims(&[2, 8, 16]); // batch=2, K=8, N=16
2214 let bias = Shape::from_dims(&[16]);
2215 let caps = BackendCapabilities {
2216 backend_id: BackendId::Cpu,
2217 device_location: DeviceLocation::Cpu,
2218 op_dtype_support: HashSet::new(),
2219 required_alignment: 1,
2220 access_granularity_bits: 8,
2221 transfer_paths: vec![],
2222 storage_substrate: fuel_ir::backend::SubstrateClass::HostBytes,
2223 compute_throughput_flops_per_ns: 1.0,
2224 mem_bandwidth_bytes_per_ns: 4.0,
2225 };
2226 let c = cost_fused_linear_cpu(&[a, b, bias], &FusedOpParams::FusedLinear, &caps);
2227 // 2 · 2 · 4 · 16 · 8 = 2048 matmul FLOPs + 2 · 4 · 16 = 128 bias FLOPs
2228 assert_eq!(c.flops, 2048 + 128);
2229 assert!(c.bytes_moved > 0);
2230 }
2231
2232 /// Finding 5.4 (FKC side, Task 3.6) — a `return.bundle`'s per-slot NAMES
2233 /// round-trip through a side-table keyed on `(FusedOpId, BackendId,
2234 /// dtypes)`. Before this task the names were parsed then discarded;
2235 /// `record_bundle_slot_names` / `bundle_slot_names` preserve them so a
2236 /// later consumer (e.g. FDX output-view labeling) can look them up by
2237 /// the same key the kernel itself was registered under. A different
2238 /// dtype key must miss (`None`), never fall back to an unrelated entry.
2239 #[test]
2240 fn bundle_slot_names_round_trip_through_the_fused_registry() {
2241 use fuel_graph::registry::FusedOps;
2242 use fuel_ir::{DType, probe::BackendId};
2243 let mut reg = FusedKernelRegistry::new();
2244 let dtypes = &[DType::F32, DType::F32][..];
2245 reg.record_bundle_slot_names(
2246 FusedOps::SELECTIVE_SCAN,
2247 BackendId::Cpu,
2248 dtypes,
2249 &["y".to_string(), "last_state".to_string()],
2250 );
2251 assert_eq!(
2252 reg.bundle_slot_names(FusedOps::SELECTIVE_SCAN, BackendId::Cpu, dtypes),
2253 Some(&["y".to_string(), "last_state".to_string()][..])
2254 );
2255 assert_eq!(
2256 reg.bundle_slot_names(FusedOps::SELECTIVE_SCAN, BackendId::Cpu, &[DType::F16][..]),
2257 None
2258 );
2259 }
2260}