Skip to main content

fuel_dispatch/
cost.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Phase 7.6 step 8 — Layer-1 cost model for primitive ops.
3//!
4//! Each primitive `OpKind` registered in [`crate::kernel::KernelBindingTable`]
5//! gets a [`crate::kernel::CostFn`] that returns a [`crate::fused::CostEstimate`]
6//! (FLOPs + bytes moved + launch overhead). The architecture's
7//! Layer-1 model is conservative + static: it counts FLOPs from
8//! shapes and `OpParams`, estimates bandwidth as operand count ×
9//! element count × dtype byte width, and assigns a per-family
10//! launch-overhead constant (CPU launches measure in tens of ns).
11//! Layer-2 — empirical refinement from per-deployment telemetry —
12//! composes on top via the framework that lands with step 11
13//! (community-aggregated cache).
14//!
15//! ## Structure
16//!
17//! - Per-family cost functions ([`cost_elementwise_unary_cpu`],
18//!   [`cost_elementwise_binary_cpu`], [`cost_reduction_cpu`], etc.).
19//!   Each is the [`crate::kernel::CostFn`] signature and operates
20//!   on whatever shapes + `OpParams` payload its OpKind family
21//!   carries.
22//! - [`default_cost_for_op_kind`] — the dispatcher consumed by
23//!   [`crate::kernel::KernelBindingTable::fill_unset_cpu_cost`].
24//!   Maps every `OpKind` variant to the family function appropriate
25//!   for its shape contract.
26//!
27//! ## Conventions
28//!
29//! - FLOP counts use the "FMA = 2 FLOPs" convention matching the
30//!   step-6 fused-op cost functions (matmul = 2·M·N·K, etc.).
31//! - Bandwidth assumes 4 B/element as a midpoint (F32 reference);
32//!   half-precision is conservatively over-counted, F64 under. The
33//!   step-8 Layer-1 model is intentionally coarse — empirical
34//!   refinement tightens per-dtype later.
35//! - Launch overhead: 50 ns for elementwise + reduction families
36//!   (matches the fused-op precedent); 100-200 ns for matmul /
37//!   attention (more setup work).
38
39use crate::fused::CostEstimate;
40use crate::kernel::{CostFn, OpParams, unknown_cost};
41use fuel_ir::backend::BackendCapabilities;
42use fuel_ir::dispatch::OpKind;
43use fuel_ir::{DType, Shape};
44
45// =============================================================================
46// Helpers
47// =============================================================================
48
49/// Bytes per element for the supplied dtype. Used to convert
50/// element counts into bandwidth. Returns 4 (F32 byte width) for
51/// non-numeric dtypes that the cost model doesn't differentiate.
52///
53/// `pub(crate)` so the fused-op cost-from-decompose fold
54/// ([`crate::fused_cost`]) prices the fused op's boundary I/O with the
55/// same per-dtype widths the primitive cost families use.
56pub(crate) fn dtype_bytes(dt: DType) -> u64 {
57    match dt {
58        DType::F32 | DType::U32 | DType::I32 => 4,
59        DType::F64 | DType::I64 => 8,
60        DType::BF16 | DType::F16 | DType::I16 => 2,
61        DType::U8 | DType::I8 => 1,
62        // Bool is one byte per element (storage width equals u8).
63        DType::Bool => 1,
64        DType::F8E4M3 | DType::F8E5M2 | DType::F8E8M0 | DType::F8E6M2 => 1,
65        // 6-bit and 4-bit float micro-types are byte-packed; treat
66        // them as 1 byte/elem for the bandwidth estimate.
67        DType::F6E2M3 | DType::F6E3M2 | DType::F4 => 1,
68    }
69}
70
71/// Output element count from the **last** shape in the operand
72/// list (the output shape, per binding-table convention).
73fn out_elem_count(shapes: &[Shape]) -> u64 {
74    shapes
75        .last()
76        .map(|s| s.dims().iter().map(|&d| d as u64).product::<u64>())
77        .unwrap_or(0)
78}
79
80/// Total element count summed across the input operands (every
81/// shape except the last). Used for bandwidth claims when inputs
82/// have differing shapes (e.g. Concat).
83fn input_elem_count(shapes: &[Shape]) -> u64 {
84    if shapes.len() <= 1 {
85        return 0;
86    }
87    shapes[..shapes.len() - 1]
88        .iter()
89        .map(|s| s.dims().iter().map(|&d| d as u64).product::<u64>())
90        .sum()
91}
92
93// =============================================================================
94// Cost-family functions
95// =============================================================================
96
97/// Cost for elementwise unary ops with cheap math (Neg/Sqr/Recip/
98/// Abs/Relu/Step/Floor/Ceil/Round/Sign/Sqrt). ~1 FLOP/element;
99/// 1 read + 1 write per element.
100pub fn cost_elementwise_unary_cpu(
101    shapes: &[Shape],
102    dtypes: &[DType],
103    _params: &OpParams,
104    _caps: &BackendCapabilities,
105) -> CostEstimate {
106    let n = out_elem_count(shapes);
107    let dsize = dtypes.first().map(|d| dtype_bytes(*d)).unwrap_or(4);
108    CostEstimate {
109        flops: n,
110        bytes_moved: 2 * n * dsize,
111        kernel_overhead_ns: 50,
112    }
113}
114
115/// Cost for elementwise unary transcendentals (Exp/Log/Sin/Cos/
116/// Tanh/Sigmoid/Silu/GeluTanh/Gelu/Erf/Rsqrt). ~10 FLOPs/element
117/// (transcendentals lower to 5-15 hardware ops on average).
118pub fn cost_elementwise_unary_transcendental_cpu(
119    shapes: &[Shape],
120    dtypes: &[DType],
121    _params: &OpParams,
122    _caps: &BackendCapabilities,
123) -> CostEstimate {
124    let n = out_elem_count(shapes);
125    let dsize = dtypes.first().map(|d| dtype_bytes(*d)).unwrap_or(4);
126    CostEstimate {
127        flops: 10 * n,
128        bytes_moved: 2 * n * dsize,
129        kernel_overhead_ns: 50,
130    }
131}
132
133/// Cost for elementwise binary ops (Add/Sub/Mul/Div/Maximum/Minimum/
134/// Pow/Rem). ~1 FLOP/element (Pow/Rem run higher but the bandwidth
135/// dominates for typical sizes); 2 reads + 1 write per element.
136pub fn cost_elementwise_binary_cpu(
137    shapes: &[Shape],
138    dtypes: &[DType],
139    _params: &OpParams,
140    _caps: &BackendCapabilities,
141) -> CostEstimate {
142    let n = out_elem_count(shapes);
143    let dsize = dtypes.first().map(|d| dtype_bytes(*d)).unwrap_or(4);
144    CostEstimate {
145        flops: n,
146        bytes_moved: 3 * n * dsize,
147        kernel_overhead_ns: 50,
148    }
149}
150
151/// Cost for comparison ops (Equal/Ne/Lt/Le/Gt/Ge). Same shape
152/// contract as elementwise binary but output is U8 (1 B/element).
153pub fn cost_comparison_cpu(
154    shapes: &[Shape],
155    dtypes: &[DType],
156    _params: &OpParams,
157    _caps: &BackendCapabilities,
158) -> CostEstimate {
159    let n = out_elem_count(shapes);
160    let dsize_in = dtypes.first().map(|d| dtype_bytes(*d)).unwrap_or(4);
161    CostEstimate {
162        flops: n,
163        // 2 reads × input dtype size + 1 write × 1 byte (U8).
164        bytes_moved: 2 * n * dsize_in + n,
165        kernel_overhead_ns: 50,
166    }
167}
168
169/// Cost for `Where` (ternary select). Inputs `[cond:U8, a:T, b:T, out:T]`.
170pub fn cost_where_cpu(
171    shapes: &[Shape],
172    dtypes: &[DType],
173    _params: &OpParams,
174    _caps: &BackendCapabilities,
175) -> CostEstimate {
176    let n = out_elem_count(shapes);
177    let dsize = dtypes.get(1).map(|d| dtype_bytes(*d)).unwrap_or(4);
178    CostEstimate {
179        flops: n,
180        bytes_moved: n + 3 * n * dsize, // 1 byte cond + 3 × dsize values
181        kernel_overhead_ns: 50,
182    }
183}
184
185/// Cost for full-tensor reductions (SumReduce/MaxReduce/MinReduce/
186/// MeanReduce). FLOPs ≈ input element count; one write per output
187/// element.
188pub fn cost_reduction_cpu(
189    shapes: &[Shape],
190    dtypes: &[DType],
191    _params: &OpParams,
192    _caps: &BackendCapabilities,
193) -> CostEstimate {
194    let n_in = input_elem_count(shapes);
195    let n_out = out_elem_count(shapes);
196    let dsize = dtypes.first().map(|d| dtype_bytes(*d)).unwrap_or(4);
197    CostEstimate {
198        flops: n_in,
199        bytes_moved: (n_in + n_out) * dsize,
200        kernel_overhead_ns: 50,
201    }
202}
203
204/// Cost for `ReduceSumTo` / `ReduceMaxTo`. Same shape as reductions
205/// but the broadcast-to-target structure may iterate the input
206/// multiple times depending on which axes reduce; we approximate
207/// as `in_count` FLOPs.
208pub fn cost_reduce_to_cpu(
209    shapes: &[Shape],
210    dtypes: &[DType],
211    _params: &OpParams,
212    _caps: &BackendCapabilities,
213) -> CostEstimate {
214    let n_in = input_elem_count(shapes);
215    let n_out = out_elem_count(shapes);
216    let dsize = dtypes.first().map(|d| dtype_bytes(*d)).unwrap_or(4);
217    CostEstimate {
218        flops: n_in,
219        bytes_moved: (n_in + n_out) * dsize,
220        kernel_overhead_ns: 50,
221    }
222}
223
224/// Cost for `MatMul`. Reads `Matmul { m, n, k, lhs_batch_dims,
225/// rhs_batch_dims }` from `OpParams` to compute the exact FLOP
226/// count (`2·batch·M·N·K`).
227pub fn cost_matmul_cpu(
228    _shapes: &[Shape],
229    dtypes: &[DType],
230    params: &OpParams,
231    _caps: &BackendCapabilities,
232) -> CostEstimate {
233    let (m, n, k, batch) = match params {
234        OpParams::Matmul {
235            m,
236            n,
237            k,
238            lhs_batch_dims,
239            ..
240        } => {
241            let batch: u64 = lhs_batch_dims
242                .iter()
243                .map(|&d| d as u64)
244                .product::<u64>()
245                .max(1);
246            (*m as u64, *n as u64, *k as u64, batch)
247        }
248        _ => return CostEstimate::default(),
249    };
250    let dsize = dtypes.first().map(|d| dtype_bytes(*d)).unwrap_or(4);
251    let flops = 2 * batch * m * n * k;
252    let bytes_moved = batch * (m * k + k * n + m * n) * dsize;
253    CostEstimate {
254        flops,
255        bytes_moved,
256        kernel_overhead_ns: 100,
257    }
258}
259
260/// Cost for `FusedLinear` (matmul + bias-add). Reads `Matmul`
261/// params (same as `MatMul`) — bias-add contributes M·N FLOPs per
262/// batch on top of the matmul.
263pub fn cost_fused_linear_primitive_cpu(
264    shapes: &[Shape],
265    dtypes: &[DType],
266    params: &OpParams,
267    caps: &BackendCapabilities,
268) -> CostEstimate {
269    let mm = cost_matmul_cpu(shapes, dtypes, params, caps);
270    let (m, n, batch) = match params {
271        OpParams::Matmul {
272            m,
273            n,
274            lhs_batch_dims,
275            ..
276        } => {
277            let batch: u64 = lhs_batch_dims
278                .iter()
279                .map(|&d| d as u64)
280                .product::<u64>()
281                .max(1);
282            (*m as u64, *n as u64, batch)
283        }
284        _ => return mm,
285    };
286    let dsize = dtypes.first().map(|d| dtype_bytes(*d)).unwrap_or(4);
287    let bias_flops = batch * m * n;
288    let bias_bytes = n * dsize;
289    CostEstimate {
290        flops: mm.flops + bias_flops,
291        bytes_moved: mm.bytes_moved + bias_bytes,
292        kernel_overhead_ns: 100,
293    }
294}
295
296/// Cost for `Cast`. Pure dtype conversion — no FLOPs (or ~1
297/// conversion/element, dwarfed by bandwidth). Bytes are
298/// `in_size + out_size` per element with their respective dtype
299/// widths.
300pub fn cost_cast_cpu(
301    shapes: &[Shape],
302    dtypes: &[DType],
303    _params: &OpParams,
304    _caps: &BackendCapabilities,
305) -> CostEstimate {
306    let n = out_elem_count(shapes);
307    let dsize_in = dtypes.first().map(|d| dtype_bytes(*d)).unwrap_or(4);
308    let dsize_out = dtypes.last().map(|d| dtype_bytes(*d)).unwrap_or(dsize_in);
309    CostEstimate {
310        flops: 0,
311        bytes_moved: n * (dsize_in + dsize_out),
312        kernel_overhead_ns: 50,
313    }
314}
315
316/// Cost for scalar-by-tensor ops (Affine = mul·x + add, Clamp,
317/// PowI). 1-2 FLOPs/element; 1 read + 1 write per element.
318pub fn cost_scalar_op_cpu(
319    shapes: &[Shape],
320    dtypes: &[DType],
321    _params: &OpParams,
322    _caps: &BackendCapabilities,
323) -> CostEstimate {
324    let n = out_elem_count(shapes);
325    let dsize = dtypes.first().map(|d| dtype_bytes(*d)).unwrap_or(4);
326    CostEstimate {
327        flops: 2 * n,
328        bytes_moved: 2 * n * dsize,
329        kernel_overhead_ns: 50,
330    }
331}
332
333/// Cost for `MaskedFill` (x, mask:U8 → fill where mask != 0).
334pub fn cost_masked_fill_cpu(
335    shapes: &[Shape],
336    dtypes: &[DType],
337    _params: &OpParams,
338    _caps: &BackendCapabilities,
339) -> CostEstimate {
340    let n = out_elem_count(shapes);
341    let dsize = dtypes.first().map(|d| dtype_bytes(*d)).unwrap_or(4);
342    CostEstimate {
343        flops: 0,
344        bytes_moved: n * dsize + n + n * dsize, // value-in + mask + value-out
345        kernel_overhead_ns: 50,
346    }
347}
348
349/// Cost for materializing shape-rearrangement ops (Flip, Roll,
350/// Triu, Tril, CumSum, Pad, PadBackward). FLOPs are zero (CumSum
351/// is the exception — it does N adds — but bandwidth dominates).
352pub fn cost_shape_op_cpu(
353    shapes: &[Shape],
354    dtypes: &[DType],
355    _params: &OpParams,
356    _caps: &BackendCapabilities,
357) -> CostEstimate {
358    let n_in = input_elem_count(shapes);
359    let n_out = out_elem_count(shapes);
360    let dsize = dtypes.first().map(|d| dtype_bytes(*d)).unwrap_or(4);
361    CostEstimate {
362        flops: 0,
363        bytes_moved: (n_in + n_out) * dsize,
364        kernel_overhead_ns: 50,
365    }
366}
367
368/// Cost for `Concat`. Sum of N input element counts; one output
369/// of equal total size. No FLOPs (memcpy).
370pub fn cost_concat_cpu(
371    shapes: &[Shape],
372    dtypes: &[DType],
373    _params: &OpParams,
374    _caps: &BackendCapabilities,
375) -> CostEstimate {
376    let n_in = input_elem_count(shapes);
377    let n_out = out_elem_count(shapes);
378    let dsize = dtypes.first().map(|d| dtype_bytes(*d)).unwrap_or(4);
379    CostEstimate {
380        flops: 0,
381        bytes_moved: (n_in + n_out) * dsize,
382        kernel_overhead_ns: 50,
383    }
384}
385
386/// Cost for indexing-style ops (IndexSelect, Gather, IndexAdd,
387/// ScatterAdd). FLOPs = 0 for select/gather, = src_count for add/
388/// scatter (accumulation). Bytes ≈ (data + indices + out) ×
389/// respective dsizes.
390pub fn cost_indexing_cpu(
391    shapes: &[Shape],
392    dtypes: &[DType],
393    _params: &OpParams,
394    _caps: &BackendCapabilities,
395) -> CostEstimate {
396    let n_out = out_elem_count(shapes);
397    let dsize_data = dtypes.first().map(|d| dtype_bytes(*d)).unwrap_or(4);
398    let dsize_idx = dtypes.get(1).map(|d| dtype_bytes(*d)).unwrap_or(4);
399    CostEstimate {
400        flops: n_out, // conservative — gather is 0, scatter is N; midpoint.
401        bytes_moved: 2 * n_out * dsize_data + n_out * dsize_idx,
402        kernel_overhead_ns: 50,
403    }
404}
405
406/// Cost for `ArgMaxDim` / `ArgMinDim`. Inputs `[in:T, out:U32]`.
407/// FLOPs ≈ input element count (per-row compare); bytes are
408/// in×dsize + out×4 bytes.
409pub fn cost_argindex_cpu(
410    shapes: &[Shape],
411    dtypes: &[DType],
412    _params: &OpParams,
413    _caps: &BackendCapabilities,
414) -> CostEstimate {
415    let n_in = input_elem_count(shapes);
416    let n_out = out_elem_count(shapes);
417    let dsize_in = dtypes.first().map(|d| dtype_bytes(*d)).unwrap_or(4);
418    CostEstimate {
419        flops: n_in,
420        bytes_moved: n_in * dsize_in + n_out * 4,
421        kernel_overhead_ns: 50,
422    }
423}
424
425/// Cost for `Conv2D` (binding-table form). Reads
426/// `OpParams::Conv2D` for shape geometry.
427pub fn cost_conv2d_primitive_cpu(
428    _shapes: &[Shape],
429    dtypes: &[DType],
430    params: &OpParams,
431    _caps: &BackendCapabilities,
432) -> CostEstimate {
433    let (x_shape, w_shape, out_shape, groups) = match params {
434        OpParams::Conv2D {
435            x_shape,
436            w_shape,
437            out_shape,
438            groups,
439            ..
440        } => (x_shape, w_shape, out_shape, *groups as u64),
441        _ => return CostEstimate::default(),
442    };
443    let dsize = dtypes.first().map(|d| dtype_bytes(*d)).unwrap_or(4);
444    let n = out_shape[0] as u64;
445    let cout = out_shape[1] as u64;
446    let h_out = out_shape[2] as u64;
447    let w_out = out_shape[3] as u64;
448    let cin_per_g = w_shape[1] as u64;
449    let kh = w_shape[2] as u64;
450    let kw = w_shape[3] as u64;
451    let conv_flops = 2 * n * cout * h_out * w_out * cin_per_g * kh * kw;
452    let elems_in = (x_shape[0] * x_shape[1] * x_shape[2] * x_shape[3]) as u64;
453    let elems_w = (w_shape[0] * w_shape[1] * w_shape[2] * w_shape[3]) as u64;
454    let elems_out = n * cout * h_out * w_out;
455    let _ = groups; // accounted for via cin_per_g already.
456    CostEstimate {
457        flops: conv_flops,
458        bytes_moved: (elems_in + elems_w + elems_out) * dsize,
459        kernel_overhead_ns: 100,
460    }
461}
462
463/// Cost for `ConvTranspose2D` (binding-table form). Same shape
464/// algorithm as Conv2D — the transposed pass moves comparable
465/// bytes and does comparable FLOPs.
466pub fn cost_conv_transpose2d_primitive_cpu(
467    _shapes: &[Shape],
468    dtypes: &[DType],
469    params: &OpParams,
470    _caps: &BackendCapabilities,
471) -> CostEstimate {
472    let (x_shape, w_shape, out_shape, groups) = match params {
473        OpParams::ConvTranspose2D {
474            x_shape,
475            w_shape,
476            out_shape,
477            groups,
478            ..
479        } => (x_shape, w_shape, out_shape, *groups as u64),
480        _ => return CostEstimate::default(),
481    };
482    let dsize = dtypes.first().map(|d| dtype_bytes(*d)).unwrap_or(4);
483    let n = out_shape[0] as u64;
484    let cout = out_shape[1] as u64;
485    let h_out = out_shape[2] as u64;
486    let w_out = out_shape[3] as u64;
487    let cin_per_g = (x_shape[1] as u64) / groups;
488    let kh = w_shape[2] as u64;
489    let kw = w_shape[3] as u64;
490    let flops = 2 * n * cout * h_out * w_out * cin_per_g * kh * kw;
491    let elems_in = (x_shape[0] * x_shape[1] * x_shape[2] * x_shape[3]) as u64;
492    let elems_w = (w_shape[0] * w_shape[1] * w_shape[2] * w_shape[3]) as u64;
493    let elems_out = n * cout * h_out * w_out;
494    CostEstimate {
495        flops,
496        bytes_moved: (elems_in + elems_w + elems_out) * dsize,
497        kernel_overhead_ns: 100,
498    }
499}
500
501/// Cost for `FlashAttn` (binding-table form). Reads geometry from
502/// `OpParams::FlashAttn`.
503pub fn cost_flash_attn_primitive_cpu(
504    _shapes: &[Shape],
505    dtypes: &[DType],
506    params: &OpParams,
507    _caps: &BackendCapabilities,
508) -> CostEstimate {
509    let (b, hq, _hkv, sq, sk, d) = match params {
510        OpParams::FlashAttn {
511            b,
512            hq,
513            hkv,
514            sq,
515            sk,
516            d,
517            ..
518        } => (
519            *b as u64,
520            *hq as u64,
521            *hkv as u64,
522            *sq as u64,
523            *sk as u64,
524            *d as u64,
525        ),
526        _ => return CostEstimate::default(),
527    };
528    let dsize = dtypes.first().map(|d| dtype_bytes(*d)).unwrap_or(4);
529    let mm_flops = 4 * b * hq * sq * sk * d;
530    let sm_flops = 5 * b * hq * sq * sk;
531    let elems_qkv = b * hq * sq * d + 2 * b * hq * sk * d;
532    let elems_out = b * hq * sq * d;
533    CostEstimate {
534        flops: mm_flops + sm_flops,
535        bytes_moved: (elems_qkv + elems_out) * dsize,
536        kernel_overhead_ns: 200,
537    }
538}
539
540/// Cost for the CUDA `flash_decoding` decode arm (binding-table form).
541///
542/// Doubles as the **static ranker gate** for the decode kernel's supported
543/// set. Fuel dispatch is fail-fast (a registered kernel that returns `Err`
544/// fails `realize`; there is NO runtime fallback to the decomposed base
545/// map — see `pipelined.rs` "Fail-fast dispatch"), so an unsupported shape
546/// must never be PLACED on this kernel. This returns an INFEASIBLE cost
547/// (saturates `composite_ns` to `u64::MAX`) for anything the kernel can't
548/// do — `seq_q != 1`, or `head_dim` outside `[1, 128]` — so the ranker
549/// keeps the decomposed base map / CPU oracle. Inside the supported set it
550/// prices a real decode: work scales with the live prefix `k_len`, not the
551/// physical capacity `sk`.
552///
553/// NOTE (design): cost DEPRIORITIZES but does not HARD-EXCLUDE a candidate
554/// (`composite_ns` ranks; it never drops). For a decode node whose K/V are
555/// already CUDA-resident this is sufficient — the decomposed arm is cheaper
556/// once the infeasible cost saturates. The GUARANTEED clean decline for a
557/// KV-resident *prefill* (`seq_q > 1`) is the `Op::Branch` decode-arm
558/// emission (symbolic-extent persistent decode; D2/D3), which only ever
559/// creates the CUDA flash arm for `seq_q == 1` shapes. See the gating note
560/// in `fuel-cuda-backend/src/baracuda/attention.rs`.
561pub fn cost_flash_decoding_cuda(
562    _shapes: &[Shape],
563    dtypes: &[DType],
564    params: &OpParams,
565    _caps: &BackendCapabilities,
566) -> CostEstimate {
567    let (b, hq, sq, d, k_len) = match params {
568        OpParams::FlashAttn {
569            b,
570            hq,
571            sq,
572            d,
573            k_len,
574            ..
575        } => (*b as u64, *hq as u64, *sq as u64, *d as u64, *k_len as u64),
576        _ => return CostEstimate::default(),
577    };
578    // Out-of-support shapes → infeasible (ranker never places here).
579    if sq != 1 || d == 0 || d > 128 {
580        return CostEstimate {
581            flops: u64::MAX,
582            bytes_moved: u64::MAX,
583            kernel_overhead_ns: u32::MAX,
584        };
585    }
586    let dsize = dtypes.first().map(|dt| dtype_bytes(*dt)).unwrap_or(2);
587    // Decode (seq_q == 1): QK^T + softmax·V over the live prefix.
588    let kl = k_len.max(1);
589    let mm_flops = 4 * b * hq * kl * d;
590    let sm_flops = 5 * b * hq * kl;
591    // Read q [b,hq,d] + k,v live prefix (hq upper bound) + write out.
592    let elems = 2 * b * hq * d + 2 * b * hq * kl * d;
593    CostEstimate {
594        flops: mm_flops + sm_flops,
595        bytes_moved: elems * dsize,
596        kernel_overhead_ns: 200,
597    }
598}
599
600/// Cost for `PagedAttn` (binding-table form). Reads geometry from
601/// `OpParams::PagedAttn`; treats `num_blocks · block_size` as the
602/// effective `Sk` upper bound.
603pub fn cost_paged_attn_primitive_cpu(
604    _shapes: &[Shape],
605    dtypes: &[DType],
606    params: &OpParams,
607    _caps: &BackendCapabilities,
608) -> CostEstimate {
609    let (b, hq, sq, d, block_size, num_blocks) = match params {
610        OpParams::PagedAttn {
611            b,
612            hq,
613            sq,
614            d,
615            block_size,
616            num_blocks,
617            ..
618        } => (
619            *b as u64,
620            *hq as u64,
621            *sq as u64,
622            *d as u64,
623            *block_size as u64,
624            *num_blocks as u64,
625        ),
626        _ => return CostEstimate::default(),
627    };
628    let dsize = dtypes.first().map(|d| dtype_bytes(*d)).unwrap_or(4);
629    let sk_upper = block_size * num_blocks;
630    let mm_flops = 4 * b * hq * sq * sk_upper * d;
631    let elems_q = b * hq * sq * d;
632    let elems_kv = 2 * num_blocks * block_size * d;
633    let elems_out = elems_q;
634    CostEstimate {
635        flops: mm_flops,
636        bytes_moved: (elems_q + elems_kv + elems_out) * dsize,
637        kernel_overhead_ns: 200,
638    }
639}
640
641/// Cost for `SoftmaxLastDim` / `SoftmaxLastDimBackward` —
642/// outer_count rows × last_dim with constant FLOPs/element.
643pub fn cost_softmax_last_dim_primitive_cpu(
644    _shapes: &[Shape],
645    dtypes: &[DType],
646    params: &OpParams,
647    _caps: &BackendCapabilities,
648) -> CostEstimate {
649    let (outer, last_dim) = match params {
650        OpParams::SoftmaxLastDim {
651            outer_count,
652            last_dim,
653        } => (*outer_count as u64, *last_dim as u64),
654        _ => return CostEstimate::default(),
655    };
656    let n = outer * last_dim;
657    let dsize = dtypes.first().map(|d| dtype_bytes(*d)).unwrap_or(4);
658    CostEstimate {
659        flops: 5 * n, // max-sub + exp + sum + div ≈ 5 FLOPs/elem (Exp counted as 1 for the elementwise body; transcendental cost is in cost_elementwise_unary_transcendental_cpu)
660        bytes_moved: 3 * n * dsize,
661        kernel_overhead_ns: 50,
662    }
663}
664
665/// Cost for `Nf4Matmul` (binding-table form). FLOPs scale with
666/// `batch × m × n × k`. Bandwidth: activations + w_packed (4-bit per
667/// elem, so n*k/2 bytes) + absmax + output.
668pub fn cost_nf4_matmul_primitive_cpu(
669    _shapes: &[Shape],
670    dtypes: &[DType],
671    params: &OpParams,
672    _caps: &BackendCapabilities,
673) -> CostEstimate {
674    let (batch, m, n, k, block_size) = match params {
675        OpParams::Nf4Matmul {
676            batch,
677            m,
678            n,
679            k,
680            block_size,
681        } => (
682            *batch as u64,
683            *m as u64,
684            *n as u64,
685            *k as u64,
686            *block_size as u64,
687        ),
688        _ => return CostEstimate::default(),
689    };
690    if block_size == 0 || k % block_size != 0 {
691        return CostEstimate::default();
692    }
693    let flops = batch * m * n * k * 3;
694    let elem_bytes = dtypes.first().map(|d| dtype_bytes(*d)).unwrap_or(4);
695    let bytes_moved = batch * m * k * elem_bytes
696        + n * (k / 2)
697        + n * (k / block_size) * 4
698        + batch * m * n * elem_bytes;
699    CostEstimate {
700        flops,
701        bytes_moved,
702        kernel_overhead_ns: 50,
703    }
704}
705
706/// Cost for `SsdChunkScan` (binding-table form). FLOPs scale with
707/// `batch × seqlen × heads × head_dim × state_dim`. Conservative
708/// ~8 FLOPs per inner iteration (mul-mul-add for h update + mul-add
709/// for y accumulate, with exp amortized per outer (b, t, h)).
710pub fn cost_ssd_chunk_scan_primitive_cpu(
711    _shapes: &[Shape],
712    _dtypes: &[DType],
713    params: &OpParams,
714    _caps: &BackendCapabilities,
715) -> CostEstimate {
716    let (batch, seqlen, heads, head_dim, state_dim) = match params {
717        OpParams::SsdChunkScan {
718            batch,
719            seqlen,
720            heads,
721            head_dim,
722            state_dim,
723            ..
724        } => (
725            *batch as u64,
726            *seqlen as u64,
727            *heads as u64,
728            *head_dim as u64,
729            *state_dim as u64,
730        ),
731        _ => return CostEstimate::default(),
732    };
733    let flops = batch * seqlen * heads * head_dim * state_dim * 8;
734    let bytes_moved = 2 * batch * seqlen * heads * head_dim * 4
735        + batch * seqlen * heads * 4
736        + 2 * batch * seqlen * heads * state_dim * 4
737        + heads * 4;
738    CostEstimate {
739        flops,
740        bytes_moved,
741        kernel_overhead_ns: 50,
742    }
743}
744
745/// Cost for `SelectiveScan` (binding-table form). FLOPs scale with
746/// `batch × seqlen × dim × dstate`. Conservative ~16 FLOPs per inner
747/// iteration (exp ≈ 10, plus the FMAs for h update and y accumulate).
748pub fn cost_selective_scan_primitive_cpu(
749    _shapes: &[Shape],
750    _dtypes: &[DType],
751    params: &OpParams,
752    _caps: &BackendCapabilities,
753) -> CostEstimate {
754    let (batch, seqlen, dim, dstate) = match params {
755        OpParams::SelectiveScan {
756            batch,
757            seqlen,
758            dim,
759            dstate,
760            ..
761        } => (*batch as u64, *seqlen as u64, *dim as u64, *dstate as u64),
762        _ => return CostEstimate::default(),
763    };
764    let flops = batch * seqlen * dim * dstate * 16;
765    let bytes_moved = 2 * batch * seqlen * dim * 4
766        + 2 * batch * seqlen * dstate * 4
767        + dim * dstate * 4
768        + batch * seqlen * dim * 4;
769    CostEstimate {
770        flops,
771        bytes_moved,
772        kernel_overhead_ns: 50,
773    }
774}
775
776/// Cost for `CausalConv1d` (binding-table form). Reads
777/// `batch × channels × seq_out × kernel` FLOPs (2 per FMA) plus an
778/// optional ~10-FLOP SiLU per output. Bandwidth approximation: x +
779/// weight + bias + out, all F32.
780pub fn cost_causal_conv1d_primitive_cpu(
781    _shapes: &[Shape],
782    _dtypes: &[DType],
783    params: &OpParams,
784    _caps: &BackendCapabilities,
785) -> CostEstimate {
786    let (batch, channels, seq_in, seq_out, kernel, use_silu) = match params {
787        OpParams::CausalConv1d {
788            batch,
789            channels,
790            seq_in,
791            seq_out,
792            kernel,
793            use_silu,
794        } => (
795            *batch as u64,
796            *channels as u64,
797            *seq_in as u64,
798            *seq_out as u64,
799            *kernel as u64,
800            *use_silu,
801        ),
802        _ => return CostEstimate::default(),
803    };
804    let per_out_flops = 2 * kernel + if use_silu { 10 } else { 0 };
805    let flops = batch * channels * seq_out * per_out_flops;
806    let bytes_moved = batch * channels * seq_in * 4
807        + channels * kernel * 4
808        + channels * 4
809        + batch * channels * seq_out * 4;
810    CostEstimate {
811        flops,
812        bytes_moved,
813        kernel_overhead_ns: 50,
814    }
815}
816
817/// Cost for `FusedSoftmaxCrossEntropy` (binding-table form). Reads
818/// `n_rows × vocab` from `OpParams::FusedSoftmaxCrossEntropy`. Two
819/// passes per row (max + sum_exp), plus one transcendental log per
820/// row; bandwidth is logits + targets + scalar output. The static
821/// estimate intentionally underestimates the kernel-launch /
822/// per-row overhead — the empirical layer compensates.
823pub fn cost_fused_softmax_cross_entropy_primitive_cpu(
824    _shapes: &[Shape],
825    _dtypes: &[DType],
826    params: &OpParams,
827    _caps: &BackendCapabilities,
828) -> CostEstimate {
829    let (n_rows, vocab) = match params {
830        OpParams::FusedSoftmaxCrossEntropy { n_rows, vocab, .. } => (*n_rows as u64, *vocab as u64),
831        _ => return CostEstimate::default(),
832    };
833    // 1 max-compare + 2-FLOP exp+sum per row element + ~10 FLOP log
834    // (the transcendental). Comparable shape to the fused-side
835    // estimate in fuel-storage::fused::cost_fused_softmax_cross_entropy_cpu;
836    // duplicated here because the dispatcher needs a CostFn over
837    // OpParams, not FusedOpParams.
838    let per_row_flops = 3 * vocab + 10;
839    let flops = n_rows * per_row_flops;
840    // logits (f32) + targets (i64) + output (4 bytes scalar; conservative).
841    let bytes_moved = n_rows * vocab * 4 + n_rows * 8 + 4;
842    CostEstimate {
843        flops,
844        bytes_moved,
845        kernel_overhead_ns: 50,
846    }
847}
848
849/// Cost for `NormLastDim` family — `LayerNormLastDim`,
850/// `RmsNormLastDim`, and their backwards. Same outer × last_dim
851/// shape as softmax.
852pub fn cost_norm_last_dim_primitive_cpu(
853    _shapes: &[Shape],
854    dtypes: &[DType],
855    params: &OpParams,
856    _caps: &BackendCapabilities,
857) -> CostEstimate {
858    let (outer, last_dim) = match params {
859        OpParams::NormLastDim {
860            outer_count,
861            last_dim,
862            ..
863        } => (*outer_count as u64, *last_dim as u64),
864        _ => return CostEstimate::default(),
865    };
866    let n = outer * last_dim;
867    let dsize = dtypes.first().map(|d| dtype_bytes(*d)).unwrap_or(4);
868    CostEstimate {
869        flops: 7 * n, // mean + sub + sqr + sum + sqrt + div ≈ 7 FLOPs/elem
870        bytes_moved: 3 * n * dsize,
871        kernel_overhead_ns: 50,
872    }
873}
874
875/// Cost for `Rope` (binding-table form).
876pub fn cost_rope_primitive_cpu(
877    _shapes: &[Shape],
878    dtypes: &[DType],
879    params: &OpParams,
880    _caps: &BackendCapabilities,
881) -> CostEstimate {
882    let (outer, seq, head_dim) = match params {
883        OpParams::Rope {
884            outer_count,
885            seq,
886            head_dim,
887        } => (*outer_count as u64, *seq as u64, *head_dim as u64),
888        _ => return CostEstimate::default(),
889    };
890    let n = outer * seq * head_dim;
891    let dsize = dtypes.first().map(|d| dtype_bytes(*d)).unwrap_or(4);
892    CostEstimate {
893        flops: 4 * n, // 2 FMA pairs across the two rotation planes
894        bytes_moved: 2 * n * dsize + 2 * seq * head_dim * dsize, // x + out + cos/sin tables
895        kernel_overhead_ns: 50,
896    }
897}
898
899/// Cost for `QMatMul` (binding-table form). Reads dimensions from
900/// `OpParams::QMatMul`.
901pub fn cost_qmatmul_primitive_cpu(
902    _shapes: &[Shape],
903    dtypes: &[DType],
904    params: &OpParams,
905    _caps: &BackendCapabilities,
906) -> CostEstimate {
907    let (batch_count, m, n, k) = match params {
908        OpParams::QMatMul {
909            batch_count,
910            m,
911            n,
912            k,
913            ..
914        } => (*batch_count as u64, *m as u64, *n as u64, *k as u64),
915        _ => return CostEstimate::default(),
916    };
917    let dsize = dtypes.first().map(|d| dtype_bytes(*d)).unwrap_or(4);
918    let flops = 2 * batch_count * m * n * k;
919    let bytes_moved = batch_count * (m * k + m * n) * dsize + n * k / 2; // packed Q weights
920    CostEstimate {
921        flops,
922        bytes_moved,
923        kernel_overhead_ns: 100,
924    }
925}
926
927/// Cost for `ReduceMaxToBackward` — 5-pass kernel (max recompute +
928/// tie-mask + count + scale + gate).
929pub fn cost_reduce_max_to_backward_primitive_cpu(
930    shapes: &[Shape],
931    dtypes: &[DType],
932    _params: &OpParams,
933    _caps: &BackendCapabilities,
934) -> CostEstimate {
935    let n_in = shapes
936        .first()
937        .map(|s| s.dims().iter().map(|&d| d as u64).product::<u64>())
938        .unwrap_or(0);
939    let n_out = shapes
940        .get(1)
941        .map(|s| s.dims().iter().map(|&d| d as u64).product::<u64>())
942        .unwrap_or(0);
943    let dsize = dtypes.first().map(|d| dtype_bytes(*d)).unwrap_or(4);
944    CostEstimate {
945        flops: 5 * n_in + 2 * n_out,
946        bytes_moved: (5 * n_in + 3 * n_out) * dsize,
947        kernel_overhead_ns: 80,
948    }
949}
950
951// =============================================================================
952// OpKind → cost-family dispatcher
953// =============================================================================
954
955/// Phase 7.6 step 8: maps every `OpKind` variant to its default
956/// cost-family function. Consumed by
957/// [`crate::kernel::KernelBindingTable::fill_unset_cpu_cost`] at
958/// the end of `register_cpu_kernels`.
959///
960/// **Contract**: every `OpKind` variant must return a real
961/// (non-[`unknown_cost`]) function pointer. The step-8 coverage
962/// lint asserts this — if you add a new `OpKind` without an arm
963/// here, the test fails.
964pub fn default_cost_for_op_kind(op: OpKind) -> CostFn {
965    use OpKind::*;
966    match op {
967        // Elementwise unary — cheap math.
968        ReluElementwise | NegElementwise | SqrElementwise | SqrtElementwise | RecipElementwise
969        | AbsElementwise | StepElementwise | FloorElementwise | CeilElementwise
970        | RoundElementwise | SignElementwise => cost_elementwise_unary_cpu,
971
972        // Elementwise unary — transcendental.
973        TanhElementwise | ExpElementwise | LogElementwise | SinElementwise | CosElementwise
974        | SigmoidElementwise | SiluElementwise | GeluTanhElementwise | GeluElementwise
975        | ErfElementwise | RsqrtElementwise => cost_elementwise_unary_transcendental_cpu,
976
977        // Elementwise binary.
978        AddElementwise | SubElementwise | MulElementwise | DivElementwise | MaximumElementwise
979        | MinimumElementwise | PowElementwise | RemElementwise => cost_elementwise_binary_cpu,
980
981        // Comparisons (output U8).
982        EqualElementwise
983        | NotEqualElementwise
984        | LessElementwise
985        | LessEqualElementwise
986        | GreaterElementwise
987        | GreaterEqualElementwise => cost_comparison_cpu,
988
989        Where => cost_where_cpu,
990
991        // Reductions.
992        SumReduce | MaxReduce | MinReduce | MeanReduce => cost_reduction_cpu,
993        ReduceSumTo | ReduceMaxTo => cost_reduce_to_cpu,
994
995        // Dense linear algebra.
996        MatMul => cost_matmul_cpu,
997        FusedLinear => cost_fused_linear_primitive_cpu,
998        QMatMul => cost_qmatmul_primitive_cpu,
999
1000        // Convolutions.
1001        Conv2D => cost_conv2d_primitive_cpu,
1002        ConvTranspose2D => cost_conv_transpose2d_primitive_cpu,
1003
1004        // Attention (forward + backward share the geometry-based cost — the
1005        // backward ops carry `OpParams::FlashAttn`, and forward FLOPs are a
1006        // sound proxy; mirrors the softmax/norm forward+backward arms below).
1007        FlashAttn | FlashAttnBackwardQ | FlashAttnBackwardK | FlashAttnBackwardV => {
1008            cost_flash_attn_primitive_cpu
1009        }
1010        PagedAttn => cost_paged_attn_primitive_cpu,
1011
1012        // Cast.
1013        Cast => cost_cast_cpu,
1014
1015        // Scalar/affine.
1016        Affine | ClampElementwise | PowIElementwise => cost_scalar_op_cpu,
1017
1018        // Shape-rearrangement / pad / mask / cumsum.
1019        Flip | Roll | Triu | Tril | CumSum | Pad | PadBackward => cost_shape_op_cpu,
1020
1021        // MaskedFill.
1022        MaskedFill => cost_masked_fill_cpu,
1023
1024        // Concat.
1025        Concat => cost_concat_cpu,
1026
1027        // Softmax / norm family (forward + backward).
1028        SoftmaxLastDim | SoftmaxLastDimBackward | LogSoftmaxLastDim | LogSoftmaxLastDimBackward => {
1029            cost_softmax_last_dim_primitive_cpu
1030        }
1031        FusedSoftmaxCrossEntropy => cost_fused_softmax_cross_entropy_primitive_cpu,
1032        CausalConv1d => cost_causal_conv1d_primitive_cpu,
1033        SelectiveScan => cost_selective_scan_primitive_cpu,
1034        SsdChunkScan => cost_ssd_chunk_scan_primitive_cpu,
1035        Nf4Matmul => cost_nf4_matmul_primitive_cpu,
1036        RmsNormLastDim | RmsNormLastDimBackward | LayerNormLastDim | LayerNormLastDimBackward => {
1037            cost_norm_last_dim_primitive_cpu
1038        }
1039        ReduceMaxToBackward => cost_reduce_max_to_backward_primitive_cpu,
1040
1041        // Rope.
1042        Rope => cost_rope_primitive_cpu,
1043
1044        // Indexing / scatter.
1045        IndexSelect | Gather | IndexAdd | ScatterAdd => cost_indexing_cpu,
1046
1047        // ArgMax/ArgMin.
1048        ArgMaxDim | ArgMinDim => cost_argindex_cpu,
1049
1050        // Op::Copy — cross-device byte transfer. No FLOPs; bandwidth-
1051        // bound (read source + write destination). Same shape as
1052        // Concat / shape-op costs. The per-source-backend kernel
1053        // overhead (PCIe latency for D2H) is the dominant term for
1054        // small tensors; the family default underestimates that but
1055        // is correct for the bytes-moved axis. A Layer-2 empirical
1056        // refinement can attach later.
1057        Copy => cost_shape_op_cpu,
1058
1059        // Op::WriteSlice — in-place rectangular scatter write. No
1060        // FLOPs; bandwidth-bound (read source slab + write
1061        // destination slab). Same shape-op cost; the per-backend
1062        // overhead (kernel launch on GPU vs nested loop on CPU) is
1063        // a Layer-2 calibration concern.
1064        WriteSlice => cost_shape_op_cpu,
1065
1066        // Op::WriteSliceRotating — same shape as WriteSlice with a
1067        // dynamic mod-`modulus` start on the rotating axis. Cost is
1068        // dominated by the slab walk (bandwidth-bound); the wrap split
1069        // is at most two contiguous memcpys.
1070        WriteSliceRotating => cost_shape_op_cpu,
1071
1072        // Op::WriteSliceDoff — WriteSlice with a device-resident start
1073        // on one axis (no wrap). Same bandwidth-bound slab walk cost as
1074        // WriteSlice; the offset read is a single device deref.
1075        WriteSliceDoff => cost_shape_op_cpu,
1076
1077        // OpKind is `#[non_exhaustive]` — new variants get
1078        // [`unknown_cost`] until an explicit arm is added here.
1079        // The step-8 lint catches this immediately by asserting
1080        // every registered CPU OpKind has a non-`unknown_cost`
1081        // function.
1082        _ => unknown_cost,
1083    }
1084}
1085
1086#[cfg(test)]
1087mod tests {
1088    use super::*;
1089
1090    /// Smoke: every cost-family function returns non-zero values
1091    /// for non-empty shapes. The lint elsewhere asserts the
1092    /// dispatcher covers every OpKind; this just verifies the
1093    /// families themselves compute something.
1094    #[test]
1095    fn elementwise_unary_cost_scales_with_elem_count() {
1096        use fuel_ir::DeviceLocation;
1097        use fuel_ir::backend::{BackendCapabilities, TransferPath};
1098        use fuel_ir::probe::BackendId;
1099        use std::collections::HashSet;
1100
1101        let in_shape = Shape::from_dims(&[8, 16]);
1102        let out_shape = Shape::from_dims(&[8, 16]);
1103        let caps = BackendCapabilities {
1104            backend_id: BackendId::Cpu,
1105            device_location: DeviceLocation::Cpu,
1106            op_dtype_support: HashSet::new(),
1107            required_alignment: 1,
1108            access_granularity_bits: 8,
1109            transfer_paths: vec![(DeviceLocation::Cpu, TransferPath::SameDevice)],
1110            storage_substrate: fuel_ir::backend::SubstrateClass::HostBytes,
1111            compute_throughput_flops_per_ns: 1.0,
1112            mem_bandwidth_bytes_per_ns: 4.0,
1113        };
1114        let c = cost_elementwise_unary_cpu(
1115            &[in_shape, out_shape],
1116            &[DType::F32, DType::F32],
1117            &OpParams::None,
1118            &caps,
1119        );
1120        // 8×16 = 128 elements; 1 FLOP/elem; 2 reads/writes × 4 bytes.
1121        assert_eq!(c.flops, 128);
1122        assert_eq!(c.bytes_moved, 128 * 2 * 4);
1123    }
1124
1125    /// Smoke: unknown_cost returns all-zero estimate.
1126    #[test]
1127    fn unknown_cost_returns_default() {
1128        use fuel_ir::DeviceLocation;
1129        use fuel_ir::backend::BackendCapabilities;
1130        use fuel_ir::probe::BackendId;
1131        use std::collections::HashSet;
1132
1133        let caps = BackendCapabilities {
1134            backend_id: BackendId::Cpu,
1135            device_location: DeviceLocation::Cpu,
1136            op_dtype_support: HashSet::new(),
1137            required_alignment: 1,
1138            access_granularity_bits: 8,
1139            transfer_paths: vec![],
1140            storage_substrate: fuel_ir::backend::SubstrateClass::HostBytes,
1141            compute_throughput_flops_per_ns: 1.0,
1142            mem_bandwidth_bytes_per_ns: 4.0,
1143        };
1144        let c = unknown_cost(&[], &[], &OpParams::None, &caps);
1145        assert_eq!(c, CostEstimate::default());
1146    }
1147}