Skip to main content

fuel_graph/
registry.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! FusedOpRegistry — metadata side. Phase 7.6 step 1 (skeleton).
3//!
4//! Architecture v1.0 splits op identity into two layers:
5//! - the closed [`crate::Op`] enum carries primitive variants exhaustively;
6//! - one arm — `Op::Fused(FusedOpId, FusedOpParams)` — delegates to an
7//!   open registry of fused-op entries populated at process startup.
8//!
9//! This module holds the *graph-side metadata* half of the registry:
10//! identity ([`FusedOpId`], [`FusedOps`]), pattern + decomposition + backward
11//! identity, and shape/dtype rules. The *kernel side* (BackendImpl with
12//! its function-pointer KernelRef, cost estimate, PrecisionGuarantee, caps,
13//! revision hash) lives in `fuel-storage::fused` because it carries
14//! `KernelRef`, which fuel-graph cannot import without inverting the
15//! existing fuel-storage → fuel-graph dependency direction. The two halves
16//! are joined at runtime by [`FusedOpId`].
17//!
18//! See [docs/fused-op-registry.md](../../docs/fused-op-registry.md) for the
19//! full design and [docs/architecture/03-ir.md](../../docs/architecture/03-ir.md)
20//! for the architectural commitment.
21//!
22//! ## Status (step 1)
23//!
24//! Types only. No callers; no behavior change. Subsequent steps:
25//! - Step 2: extend `Op` with `Op::Fused(FusedOpId, FusedOpParams)` arm.
26//! - Step 3: migrate SoftmaxLastDim end-to-end through the registry.
27//! - Step 4: migrate the remaining 12 fused ops.
28//! - Step 5: drop the per-fused-op `Op` variants once nothing emits them.
29
30use crate::{Graph, NodeId};
31use fuel_ir::storage::OutputViewSpec;
32use fuel_ir::{DType, DynScalar, Shape};
33use fuel_kernel_seam_types::PatternNode;
34use std::collections::HashMap;
35
36pub mod awq_matmul;
37pub mod causal_conv1d;
38pub mod conv2d;
39pub mod conv_transpose_2d;
40pub mod flash_attn;
41pub mod flash_attn_backward;
42pub mod fused_linear;
43pub mod fused_softmax_cross_entropy;
44pub mod inplace_affine;
45pub mod layer_norm_last_dim;
46pub mod layer_norm_last_dim_backward;
47pub mod nf4_matmul;
48pub mod paged_attn;
49pub mod powi_backward;
50pub mod qmatmul;
51pub mod reduce_max_to_backward;
52pub mod rms_norm_last_dim;
53pub mod rms_norm_last_dim_backward;
54pub mod rope;
55pub mod selective_scan;
56pub mod softmax_last_dim;
57pub mod softmax_last_dim_backward;
58pub mod ssd_chunk_scan;
59
60/// Stable identifier for a registered fused op. Indexes into
61/// `FusedOpRegistry::entries`. Newtype over `u16` (~65K capacity is
62/// plenty; today's catalog is 13-14 entries).
63///
64/// Constants for the well-known ids are exposed via [`FusedOps`]
65/// associated constants for ergonomic pattern matching in rule code.
66#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
67pub struct FusedOpId(pub u16);
68
69/// Reserved sentinel: an unallocated id. Never appears in a populated
70/// registry; useful as a placeholder during step-2-and-step-3 migration
71/// where a `BackwardKind::Fused(id)` may be written before the backward's
72/// own id is assigned.
73impl FusedOpId {
74    pub const UNASSIGNED: FusedOpId = FusedOpId(0);
75
76    /// The first id reserved for **runtime-registered** (e.g. JIT-synthesized
77    /// or import-time) fused ops. Static build-time ids are dense
78    /// `1..RUNTIME_FUSED_BASE`; runtime ids are allocated from
79    /// `RUNTIME_FUSED_BASE..` so the two spaces never collide and
80    /// [`Self::is_runtime`] is the routing bit. See
81    /// `docs/specs/runtime-fused-op-registration.md`.
82    pub const RUNTIME_FUSED_BASE: u16 = 0x8000;
83
84    /// Whether this id names a runtime-registered fused op — its metadata lives
85    /// in the `runtime_fused` sidecar, not the static [`default_registry`].
86    pub fn is_runtime(self) -> bool {
87        self.0 >= Self::RUNTIME_FUSED_BASE
88    }
89}
90
91/// Per-fused-op metadata. Identity, pattern, decomposition, backward,
92/// shape/dtype rules. The kernel-side metadata (per-backend KernelRef +
93/// cost + PrecisionGuarantee + caps) lives in `fuel-storage::fused` and is
94/// joined to this entry by [`FusedOpEntry::id`].
95pub struct FusedOpEntry {
96    /// Stable id for this fused op.
97    pub id: FusedOpId,
98    /// Stable human-readable name. Shows up in op_short_name, error
99    /// messages, telemetry.
100    pub name: &'static str,
101    /// Categorical bucket. Used by telemetry and for cost-model defaults.
102    pub family: FusedOpFamily,
103    /// Canonical primitive subgraph this fused op represents. Used by
104    /// fusion rules to recognize the pattern in a base map.
105    pub pattern: SubgraphPattern,
106    /// Decompose a fused-op node into its primitive subgraph. Used by
107    /// lowering rules (and by autograd when [`Self::backward`] is
108    /// `BackwardKind::Decompose`).
109    ///
110    /// Contract: the function appends primitive nodes to `graph` and
111    /// returns the [`NodeId`] of the new root that replaces the fused
112    /// node identified by the second argument. The fused node itself
113    /// remains in the arena; the driver rewrites consumer edges to point
114    /// at the returned id.
115    pub decompose: fn(&mut Graph, NodeId, &FusedOpParams) -> NodeId,
116    /// Backward identity for this fused op.
117    pub backward: BackwardKind,
118    /// Output shape rule, computed from input shapes and params.
119    /// For a multi-output op, this must return slot 0's shape (which
120    /// is also the primary `Node::shape` of the producer).
121    pub shape_rule: fn(&[Shape], &FusedOpParams) -> Shape,
122    /// Output dtype rule, computed from input dtypes and params.
123    /// For a multi-output op, this must return slot 0's dtype (which
124    /// is also the primary `Node::dtype` of the producer).
125    pub dtype_rule: fn(&[DType], &FusedOpParams) -> DType,
126    /// Multi-output authoring contract (Option C, Session 2).
127    ///
128    /// `None` for single-output ops (the default) — the op produces
129    /// one logical output whose shape/dtype come from `shape_rule`
130    /// and `dtype_rule`, and the realized storage is a plain
131    /// non-bundled [`fuel_backend_contract::Storage`].
132    ///
133    /// `Some(fn)` for multi-output ops: returns the per-slot specs
134    /// from input shapes + dtypes + params. The Graph and the realized
135    /// storage allocator both consume the same Vec — `set_output_views`
136    /// on `Graph::push` and `allocate_bundled_storage` at realization
137    /// time — so the slot count and per-slot shapes/dtypes are a
138    /// single source of truth.
139    ///
140    /// Contract for multi-output ops:
141    /// - `output_views(...)[0].dtype` MUST equal `dtype_rule(...)`.
142    /// - `output_views(...)[0].shape` MUST equal `shape_rule(...)`.
143    /// - The returned Vec must be non-empty; a "multi-output op" with
144    ///   one slot is just a single-output op (drop `output_views`).
145    ///
146    /// `Graph::push_fused` (the multi-output-aware emit path that
147    /// Session 2 introduces) checks these at graph-build time. The
148    /// raw `Graph::push` + `Graph::set_output_views` route preserves
149    /// the same invariants via `set_output_views`'s validation pass.
150    #[expect(
151        clippy::type_complexity,
152        reason = "optional output-view rule fn-pointer; a type alias would add indirection for a single field"
153    )]
154    pub output_views: Option<fn(&[Shape], &[DType], &FusedOpParams) -> Vec<OutputViewSpec>>,
155
156    /// Which input index this fused op DESTROYS (mutates in place) on execution,
157    /// or `None` if it is non-destructive (produces a fresh output, every input
158    /// stays readable). **Mandatory** — declared here, beside `decompose`/
159    /// `pattern`, so a fused op cannot be registered without stating it (the
160    /// recipe principle extended: a third required property of the same object).
161    /// `Op::destructive_input` reads this via `default_registry().entry(id)`
162    /// instead of a separate hand-maintained match, so the fact lives with the
163    /// thing and cannot drift out of sync. A destructive op needs the scheduler
164    /// to pin it after every non-destructive reader of the destroyed input
165    /// (`opt::derive_ordering`); a wrong `None` here is a silent data race. There
166    /// is deliberately no default: `Option<usize>` is the *value* (which input,
167    /// or none), but the field itself must be set at every construction site.
168    /// GAP-076.
169    pub destructive_input: Option<usize>,
170}
171
172/// Categorical bucket for a fused op. Drives telemetry grouping and
173/// some cost-model defaults; not load-bearing for dispatch.
174#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
175pub enum FusedOpFamily {
176    Forward,
177    Backward,
178    Quantized,
179    Attention,
180    Norm,
181}
182
183/// Per-instance parameters carried by `Op::Fused(id, params)` nodes.
184/// Step 1 ships only the SoftmaxLastDim variant — the proof-of-concept
185/// migration target. Step 4 extends this enum with the remaining 12
186/// fused-op variants, one per migrated op.
187///
188/// The variant tag is implicitly aligned with the registry entry's
189/// [`FusedOpId`] via the convention that registering an id places its
190/// matching variant here. CSE and `op_key` derive a hashable key from
191/// the tuple `(FusedOpId, FusedOpParams)` so identical fused-op nodes
192/// with identical params dedupe.
193#[derive(Debug, Clone, PartialEq)]
194pub enum FusedOpParams {
195    /// SoftmaxLastDim has no per-instance parameters; the last-dim axis
196    /// is implicit in the input shape.
197    SoftmaxLastDim,
198    /// FusedLinear ((a @ b) + bias). No per-instance parameters: the
199    /// matmul shape and bias rank are implicit in the three inputs.
200    FusedLinear,
201    /// RmsNormLastDim — carries the epsilon term used to stabilize the
202    /// division by RMS magnitude. The last-dim axis itself is implicit
203    /// in the input shape.
204    RmsNormLastDim { eps: f64 },
205    /// LayerNormLastDim — carries the epsilon term used to stabilize
206    /// the division by sqrt(variance + eps). Like RmsNormLastDim, the
207    /// last-dim axis is implicit in the input shape.
208    LayerNormLastDim { eps: f64 },
209    /// Rope — rotary position embedding with caller-supplied cos/sin
210    /// tables. No per-instance parameters: `seq` and `head_dim` are
211    /// recovered from the inputs at execution time (see
212    /// `fuel-storage::pipelined::op_to_op_params`).
213    Rope,
214    /// Conv2D — 2-D cross-correlation with stride / padding / groups.
215    /// Spatial dilation is always 1 today; the field is omitted from
216    /// the param payload until backward's dilation-as-stride trick
217    /// forces the primitive variant to grow one (per the comments
218    /// in `NodeHandle::backward`'s `Op::Conv2D` arm).
219    Conv2D {
220        stride: (usize, usize),
221        padding: (usize, usize),
222        groups: usize,
223    },
224    /// SoftmaxLastDimBackward — `s * (g - sum(g * s, last, keepdim))`.
225    /// Two inputs (forward_y, upstream); parameterless. Higher-order
226    /// gradients panic per `NodeHandle::backward`'s MVP behavior.
227    SoftmaxLastDimBackward,
228    /// LayerNormLastDimBackward — recomputes mean/variance from the
229    /// original x. Two inputs (x, upstream) + eps.
230    LayerNormLastDimBackward { eps: f64 },
231    /// RmsNormLastDimBackward — `r_rms · (g − x·s / (n·(mean_sq + eps)))`.
232    /// Two inputs (x, upstream) + eps.
233    RmsNormLastDimBackward { eps: f64 },
234    /// ReduceMaxToBackward — routes upstream to argmax positions
235    /// (fair-share on ties). Two inputs (x, upstream); parameterless.
236    ReduceMaxToBackward,
237    /// PowIBackward — `grad_x = exp · x^(exp-1) · upstream`. Two
238    /// inputs `(x, upstream)`. Carries the same `exp` as the forward
239    /// `Op::PowI(exp)` so the backward kernel can evaluate the
240    /// derivative coefficient directly.
241    PowIBackward { exp: i32 },
242    /// ConvTranspose2D — fractionally-strided 2-D convolution.
243    /// Two inputs `[x, weight]` (no bias). Carries the full
244    /// stride/padding/output_padding/dilation/groups bundle.
245    ConvTranspose2D {
246        stride: (usize, usize),
247        padding: (usize, usize),
248        output_padding: (usize, usize),
249        dilation: (usize, usize),
250        groups: usize,
251    },
252    /// FlashAttn — multi-head scaled-dot-product attention with
253    /// FlashAttention-shaped kernel hooks. 4-5 inputs (q, k, v,
254    /// optional alibi).
255    ///
256    /// `k_len` (Phase D symbolic extents): `None` ⇒ the attended K/V
257    /// length is the full K extent `k.dims()[2]` (today's behavior).
258    /// `Some(dyn)` ⇒ the K/V buffers are a fixed **capacity**
259    /// `[B, Hkv, max_seq, D]` and only the first `dyn.resolve(env)` rows
260    /// are attended (the live prefix), with the causal mask
261    /// bottom-right-aligned at offset `k_len - Sq` (the standard FA2
262    /// convention, = `cached_len` in decode). This is the runtime
263    /// `k_len` decoupled from K's allocated shape that backs flash
264    /// decode over a persistent capacity KV-cache. Build via
265    /// [`NodeHandle::flash_attn_dyn`](crate::NodeHandle::flash_attn_dyn).
266    FlashAttn {
267        softmax_scale: f32,
268        causal: bool,
269        window_size_left: Option<usize>,
270        window_size_right: Option<usize>,
271        softcap: Option<f32>,
272        k_len: Option<DynScalar>,
273    },
274    /// PagedAttn — paged-cache attention. 5-6 inputs (q, k_cache,
275    /// v_cache, block_table, context_lens, optional alibi).
276    PagedAttn {
277        softmax_scale: f32,
278        block_size: usize,
279        softcap: Option<f32>,
280    },
281    /// QMatMul — quantized matrix multiply `C = A @ dequant(W_Q)`.
282    /// Two inputs `[a, w_q_bytes]`. The U32-typed w_q_bytes carries
283    /// a Q-type block stream; `quant_type` selects the dequant
284    /// implementation; `k` / `n` are the logical weight dims.
285    QMatMul {
286        quant_type: crate::QuantType,
287        k: usize,
288        n: usize,
289    },
290    /// InplaceAffine — `x = mul·x + add`, mutating input 0. Single
291    /// input. Destructive on index 0 (marked via
292    /// `Op::destructive_input`). Phase 1 of the in-place ops
293    /// infrastructure.
294    InplaceAffine { mul: f64, add: f64 },
295    /// SsdChunkScan — Mamba-2's State-Space Duality chunked scan
296    /// (forward only). Five inputs:
297    /// - `x`:   `[batch, seqlen, heads, head_dim]`
298    /// - `dt`:  `[batch, seqlen, heads]`
299    /// - `a`:   `[heads]` — per-head scalar log A.
300    /// - `b`/`c`: `[batch, seqlen, heads, state_dim]`
301    ///
302    /// Output: `y: [batch, seqlen, heads, head_dim]`.
303    ///
304    /// `chunk_size` is the SSD block size (typically 256 in Mamba-2).
305    /// It controls GPU parallelism granularity but doesn't affect the
306    /// mathematical result — the CPU kernel runs a sequential scan
307    /// regardless. Any `chunk_size ∈ [1, seqlen]` that divides
308    /// `seqlen` is valid.
309    SsdChunkScan { chunk_size: usize },
310    /// Nf4Matmul — bitsandbytes-style 4-bit NormalFloat quantized
311    /// matrix multiply. Three inputs:
312    /// - `activations`: `[..., M, K]`
313    /// - `w_packed`:    `[N, K/2]` U8 (two 4-bit codes per byte,
314    ///   K must be even).
315    /// - `absmax`:      `[N, K/block_size]` F32 (per-output-row,
316    ///   per-block scales).
317    ///
318    /// Output: `[..., M, N]` matching the activations' dtype.
319    ///
320    /// `block_size` is the NF4 quantization block size (typically
321    /// 64 in bitsandbytes). The NF4 NormalFloat lookup table is
322    /// baked into the kernel — not a runtime input.
323    Nf4Matmul { block_size: usize },
324    /// AwqMatmul — AWQ (mit-han-lab) asymmetric int4 weight-only
325    /// matmul: `dequant(q) = (q - zero_point) * scale`, per-group.
326    /// Three inputs:
327    /// - `activations`: `[..., M, K]`
328    /// - `qweight`:     `[N, K/8]` U32 (eight int4 codes packed
329    ///   per word, K must be a multiple of 8).
330    /// - `qzeros`:      `[K/group_size, N/8]` U32 (eight int4
331    ///   zero-point codes packed per word, N must be a multiple
332    ///   of 8).
333    /// - `scales`:      `[K/group_size, N]` F32 (per-group,
334    ///   per-output-channel scale).
335    ///
336    /// Output: `[..., M, N]` matching the activations' dtype.
337    ///
338    /// `group_size` (∈ {64, 128} per AWQ's format) must evenly
339    /// divide `K`. `split_k_iters` is a CUDA-kernel launch-tuning
340    /// parameter (unused by the decompose path; carried so a future
341    /// fused CUDA dispatch doesn't need a second params shape).
342    ///
343    /// ⚠️ The packed-int4 UNPACK ORDER within each `U32` word
344    /// (`qweight`/`qzeros`) is NOT independently verified against a
345    /// real AutoAWQ/llm-awq checkpoint in this change — see the
346    /// module doc on [`crate::registry::awq_matmul`] for what IS and
347    /// isn't confirmed. Treat dequantized values as unverified
348    /// against real checkpoints until that is checked.
349    AwqMatmul {
350        group_size: usize,
351        split_k_iters: i32,
352    },
353    /// FlashAttnBackward — shared params for the Q/K/V backward
354    /// variants. Carries the same shape parameters as the forward
355    /// `FlashAttn` so the recompute pass produces identical scores.
356    /// All three FusedOpId-marked variants (FLASH_ATTN_BACKWARD_Q/K/V)
357    /// use this single variant; the FusedOpId distinguishes which
358    /// gradient is being computed.
359    FlashAttnBackward {
360        softmax_scale: f32,
361        causal: bool,
362        window_size_left: Option<usize>,
363        window_size_right: Option<usize>,
364        softcap: Option<f32>,
365    },
366    /// SelectiveScan — Mamba-1's selective state-space scan
367    /// (forward only). Five inputs:
368    /// - `u`:     `[batch, seqlen, dim]` — input sequence.
369    /// - `delta`: `[batch, seqlen, dim]` — per-step state-update rate.
370    /// - `a`:     `[dim, dstate]` — recurrence matrix log.
371    /// - `b`:     `[batch, seqlen, dstate]` — selective input matrix.
372    /// - `c`:     `[batch, seqlen, dstate]` — selective output matrix.
373    ///
374    /// Output: `y: [batch, seqlen, dim]` — the scanned sequence.
375    ///
376    /// `delta_softplus` toggles the standard softplus(delta)
377    /// activation before use (matches baracuda's `selective_scan_*_run`
378    /// flag). The optional inputs (d_skip, z, delta_bias) from the
379    /// full Mamba algorithm are NOT exposed in v1 — they're
380    /// mechanical extensions when a consumer needs them.
381    ///
382    /// Multi-output Option C (item 3, 2026-06-01): the op now always
383    /// produces a bundled output with two slots — slot 0 `y` (the
384    /// scanned sequence) and slot 1 `last_state` (the final
385    /// h-state). Consumers project via `Op::View` / `Op::ViewOwned`.
386    /// The earlier `return_state: bool` flag retired here.
387    SelectiveScan { delta_softplus: bool },
388    /// CausalConv1d — depthwise 1-D convolution with causal masking
389    /// (left-pad-only) + optional fused SiLU. Three inputs:
390    /// - `x`: `[batch, channels, seq + (kernel - 1)]` — caller pre-pads
391    ///   with `kernel - 1` zeros on the left for the causal mask.
392    /// - `weight`: `[channels, 1, kernel]` — depthwise, one filter per
393    ///   channel (groups == channels in conv terminology).
394    /// - `bias`: `[channels]`.
395    ///
396    /// Output: `[batch, channels, seq]`, same dtype as inputs.
397    ///
398    /// Designed to mirror baracuda's `causal_conv1d_*_run` signature
399    /// (which already carries a `use_silu` flag) and the Mamba-1 /
400    /// Mamba-2 prefill convolution. See
401    /// [[match-external-convention-for-well-known-ops]] for the
402    /// "match baracuda's contract exactly" reasoning.
403    CausalConv1d { use_silu: bool },
404    /// FusedSoftmaxCrossEntropy — fused softmax + negative log-likelihood
405    /// over class-indexed targets, the standard PyTorch / Liger-Kernel
406    /// training-time loss. Two inputs:
407    /// - `logits: [..., V]` (F32) — pre-computed unnormalized class scores
408    /// - `targets: [...]` (I64) — class indices, matching the rest of
409    ///   PyTorch / baracuda CE convention (see
410    ///   [[match-external-convention-for-well-known-ops]])
411    ///
412    /// Output is F32 regardless of input dtype (loss values are
413    /// accumulated in F32 for stability):
414    /// - `Reduction::Mean` / `Reduction::Sum` → scalar `[]`
415    /// - `Reduction::None` → same shape as `targets` (`[...]`)
416    ///
417    /// `ignore_index` rows are dropped from the loss accumulator and
418    /// from the Mean denominator. The conventional sentinel is `-100`.
419    FusedSoftmaxCrossEntropy {
420        reduction: Reduction,
421        ignore_index: i64,
422    },
423    /// A **runtime-registered** fused op (JIT-synthesized or import-time). The
424    /// op's identity is the runtime [`FusedOpId`] in `Op::Fused`; its recipe is
425    /// the region in the `runtime_fused` sidecar. This variant carries only the
426    /// extracted scalar args (the `extract:` slots, in slot order) — a
427    /// parameterless runtime op carries `scalars: []`. See
428    /// `docs/specs/runtime-fused-op-registration.md`.
429    Runtime { scalars: Vec<f64> },
430}
431
432/// Reduction mode for losses with per-sample outputs. Matches PyTorch's
433/// `reduction` parameter shape (`'mean' | 'sum' | 'none'`).
434#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
435pub enum Reduction {
436    /// Sum the per-sample losses and divide by the count of contributing
437    /// samples (rows not masked by `ignore_index`). Standard training-time
438    /// scalar.
439    Mean,
440    /// Sum the per-sample losses without dividing.
441    Sum,
442    /// No reduction — return the per-sample losses with `targets.shape`.
443    None,
444}
445
446impl Reduction {
447    /// Stable per-variant key for hashable encodings (`FusedOpParams::key`
448    /// and the kernel-side `OpParams` payload). The variant tag space is
449    /// local to whichever enum carries this discriminant.
450    pub(crate) fn key(self) -> i64 {
451        match self {
452            Reduction::Mean => 0,
453            Reduction::Sum => 1,
454            Reduction::None => 2,
455        }
456    }
457}
458
459/// Hashable key for [`FusedOpParams`]. Used by `op_key`/CSE so that two
460/// `Op::Fused(id, params)` nodes with identical params dedupe.
461///
462/// The encoding is variant-tag + payload as a `Vec<u64>` (bit patterns
463/// for floats, repr for ints/usize). Mirrors the existing
464/// [`crate::opt`]-style `OpKey` encoding so future extensions slot in
465/// without rebuilding the CSE map.
466#[derive(Debug, Clone, PartialEq, Eq, Hash)]
467pub struct FusedOpParamsKey {
468    pub tag: u16,
469    pub bits: Vec<u64>,
470    pub ints: Vec<i64>,
471}
472
473impl FusedOpParams {
474    /// Hashable encoding. Step 4 adds variants per-fused-op as each
475    /// migrates; the tag uniquely identifies the variant and the
476    /// bits/ints slots carry any payload (none for parameterless ops
477    /// like SoftmaxLastDim and FusedLinear).
478    pub fn key(&self) -> FusedOpParamsKey {
479        match self {
480            FusedOpParams::SoftmaxLastDim => FusedOpParamsKey {
481                tag: 1,
482                bits: Vec::new(),
483                ints: Vec::new(),
484            },
485            FusedOpParams::FusedLinear => FusedOpParamsKey {
486                tag: 2,
487                bits: Vec::new(),
488                ints: Vec::new(),
489            },
490            FusedOpParams::RmsNormLastDim { eps } => FusedOpParamsKey {
491                tag: 3,
492                // Encode eps as its raw bit pattern so CSE on two
493                // RmsNormLastDim nodes with the same eps dedupes
494                // (and two with different eps don't).
495                bits: vec![eps.to_bits()],
496                ints: Vec::new(),
497            },
498            FusedOpParams::LayerNormLastDim { eps } => FusedOpParamsKey {
499                tag: 4,
500                bits: vec![eps.to_bits()],
501                ints: Vec::new(),
502            },
503            FusedOpParams::Rope => FusedOpParamsKey {
504                tag: 5,
505                bits: Vec::new(),
506                ints: Vec::new(),
507            },
508            FusedOpParams::Conv2D {
509                stride,
510                padding,
511                groups,
512            } => FusedOpParamsKey {
513                tag: 6,
514                bits: Vec::new(),
515                // Stride.{0,1}, padding.{0,1}, groups. Five i64 slots
516                // — enough that two Conv2D nodes with different
517                // strides or paddings hash to distinct CSE keys.
518                ints: vec![
519                    stride.0 as i64,
520                    stride.1 as i64,
521                    padding.0 as i64,
522                    padding.1 as i64,
523                    *groups as i64,
524                ],
525            },
526            FusedOpParams::SoftmaxLastDimBackward => FusedOpParamsKey {
527                tag: 7,
528                bits: Vec::new(),
529                ints: Vec::new(),
530            },
531            FusedOpParams::LayerNormLastDimBackward { eps } => FusedOpParamsKey {
532                tag: 8,
533                bits: vec![eps.to_bits()],
534                ints: Vec::new(),
535            },
536            FusedOpParams::RmsNormLastDimBackward { eps } => FusedOpParamsKey {
537                tag: 9,
538                bits: vec![eps.to_bits()],
539                ints: Vec::new(),
540            },
541            FusedOpParams::ReduceMaxToBackward => FusedOpParamsKey {
542                tag: 10,
543                bits: Vec::new(),
544                ints: Vec::new(),
545            },
546            FusedOpParams::ConvTranspose2D {
547                stride,
548                padding,
549                output_padding,
550                dilation,
551                groups,
552            } => FusedOpParamsKey {
553                tag: 11,
554                bits: Vec::new(),
555                // 9 i64 slots: stride.{0,1}, padding.{0,1},
556                // output_padding.{0,1}, dilation.{0,1}, groups.
557                ints: vec![
558                    stride.0 as i64,
559                    stride.1 as i64,
560                    padding.0 as i64,
561                    padding.1 as i64,
562                    output_padding.0 as i64,
563                    output_padding.1 as i64,
564                    dilation.0 as i64,
565                    dilation.1 as i64,
566                    *groups as i64,
567                ],
568            },
569            FusedOpParams::FlashAttn {
570                softmax_scale,
571                causal,
572                window_size_left,
573                window_size_right,
574                softcap,
575                k_len,
576            } => {
577                // Encode the optional dynamic k_len as (tag, value) so
578                // two flash nodes whose attended-length symbol differs
579                // are NOT CSE-merged. None → (0,0); Concrete(v) → (1,v);
580                // Sym(s) → (2, s.0).
581                let (k_tag, k_val): (i64, i64) = match k_len {
582                    None => (0, 0),
583                    Some(DynScalar::Concrete(v)) => (1, *v as i64),
584                    Some(DynScalar::Sym(s)) => (2, s.0 as i64),
585                };
586                FusedOpParamsKey {
587                    tag: 12,
588                    // softmax_scale f32 → u64 bit pattern; softcap
589                    // f32 → u64 bit pattern (NaN sentinel for None so
590                    // two FlashAttn nodes with no softcap still hash
591                    // equal).
592                    bits: vec![
593                        softmax_scale.to_bits() as u64,
594                        softcap.map(|s| s.to_bits() as u64).unwrap_or(u64::MAX),
595                    ],
596                    // causal as i64; window sizes carry MIN as the
597                    // "None" sentinel so a real Some(0) still distinguishes.
598                    ints: vec![
599                        if *causal { 1 } else { 0 },
600                        window_size_left.map(|w| w as i64).unwrap_or(i64::MIN),
601                        window_size_right.map(|w| w as i64).unwrap_or(i64::MIN),
602                        k_tag,
603                        k_val,
604                    ],
605                }
606            }
607            FusedOpParams::PagedAttn {
608                softmax_scale,
609                block_size,
610                softcap,
611            } => FusedOpParamsKey {
612                tag: 13,
613                bits: vec![
614                    softmax_scale.to_bits() as u64,
615                    softcap.map(|s| s.to_bits() as u64).unwrap_or(u64::MAX),
616                ],
617                ints: vec![*block_size as i64],
618            },
619            FusedOpParams::QMatMul { quant_type, k, n } => FusedOpParamsKey {
620                tag: 14,
621                bits: Vec::new(),
622                // QuantType encoded as stable per-variant i64; k, n
623                // as usize.
624                ints: vec![quant_type_key(*quant_type), *k as i64, *n as i64],
625            },
626            FusedOpParams::PowIBackward { exp } => FusedOpParamsKey {
627                tag: 15,
628                bits: Vec::new(),
629                ints: vec![*exp as i64],
630            },
631            FusedOpParams::InplaceAffine { mul, add } => FusedOpParamsKey {
632                tag: 16,
633                bits: vec![mul.to_bits(), add.to_bits()],
634                ints: Vec::new(),
635            },
636            FusedOpParams::FusedSoftmaxCrossEntropy {
637                reduction,
638                ignore_index,
639            } => FusedOpParamsKey {
640                tag: 17,
641                bits: Vec::new(),
642                ints: vec![reduction.key(), *ignore_index],
643            },
644            FusedOpParams::CausalConv1d { use_silu } => FusedOpParamsKey {
645                tag: 18,
646                bits: Vec::new(),
647                ints: vec![*use_silu as i64],
648            },
649            FusedOpParams::SelectiveScan { delta_softplus } => FusedOpParamsKey {
650                tag: 19,
651                bits: Vec::new(),
652                ints: vec![*delta_softplus as i64],
653            },
654            FusedOpParams::SsdChunkScan { chunk_size } => FusedOpParamsKey {
655                tag: 20,
656                bits: Vec::new(),
657                ints: vec![*chunk_size as i64],
658            },
659            FusedOpParams::Nf4Matmul { block_size } => FusedOpParamsKey {
660                tag: 21,
661                bits: Vec::new(),
662                ints: vec![*block_size as i64],
663            },
664            FusedOpParams::AwqMatmul {
665                group_size,
666                split_k_iters,
667            } => FusedOpParamsKey {
668                tag: 23,
669                bits: Vec::new(),
670                ints: vec![*group_size as i64, *split_k_iters as i64],
671            },
672            FusedOpParams::FlashAttnBackward {
673                softmax_scale,
674                causal,
675                window_size_left,
676                window_size_right,
677                softcap,
678            } => FusedOpParamsKey {
679                // Distinct tag from forward FlashAttn (12) so the
680                // forward and backward nodes can't collide in CSE.
681                tag: 22,
682                bits: vec![
683                    softmax_scale.to_bits() as u64,
684                    softcap.map(|s| s.to_bits() as u64).unwrap_or(u64::MAX),
685                ],
686                ints: vec![
687                    if *causal { 1 } else { 0 },
688                    window_size_left.map(|w| w as i64).unwrap_or(i64::MIN),
689                    window_size_right.map(|w| w as i64).unwrap_or(i64::MIN),
690                ],
691            },
692            // Runtime-registered fused ops share one tag (0xF000); the runtime
693            // FusedOpId in `Op::Fused` disambiguates distinct ops, and the
694            // scalar bits disambiguate instances of the same op.
695            FusedOpParams::Runtime { scalars } => FusedOpParamsKey {
696                tag: 0xF000,
697                bits: scalars.iter().map(|s| s.to_bits()).collect(),
698                ints: Vec::new(),
699            },
700        }
701    }
702}
703
704/// Stable per-variant key for [`crate::QuantType`]. Used by
705/// [`FusedOpParams::key`] so CSE on two `Op::Fused(QMATMUL, _)`
706/// nodes with the same `(quant_type, k, n)` collapses correctly.
707fn quant_type_key(q: crate::QuantType) -> i64 {
708    use crate::QuantType::*;
709    match q {
710        Q4_0 => 1,
711        Q4_1 => 2,
712        Q5_0 => 3,
713        Q5_1 => 4,
714        Q8_0 => 5,
715        Q8_1 => 6,
716        Q2K => 7,
717        Q3K => 8,
718        Q4_K_M => 9,
719        Q5K => 10,
720        Q6K => 11,
721    }
722}
723
724/// What kind of backward this fused op has.
725///
726/// - `Fused(id)` — emit a fused-backward op with the given id. Used by
727///   ops whose backward is awkward to express as a primitive
728///   decomposition (e.g. SoftmaxLastDimBackward's fused subtract-and-
729///   project formula).
730/// - `Decompose` — autograd derives the backward from the primitive
731///   decomposition (lower the fused node, then run autograd over the
732///   primitives).
733/// - `NotDifferentiable` — backward returns Err / panics cleanly.
734///   Matches today's QMatMul / ArgMaxDim treatment.
735#[derive(Copy, Clone, Debug, PartialEq, Eq)]
736pub enum BackwardKind {
737    Fused(FusedOpId),
738    Decompose,
739    NotDifferentiable,
740}
741
742/// Canonical primitive-subgraph pattern that a fused op represents.
743/// Used by fusion rules.
744///
745/// Two reasonable shapes; the registry supports both:
746/// - `Callable` — a closure-style matcher, mirrors PR 3's
747///   `canonical_softmax_pattern`. Maximally flexible (consumer-count
748///   guards, cross-checks); less analyzable.
749/// - `Declarative` — a recursive `PatternTree` that the rule engine
750///   compiles to a matcher. More analyzable; auto-generation from the
751///   registry entry is straightforward.
752///
753/// Step 3 ports SoftmaxLastDim's existing matcher to `Callable`. The
754/// declarative form (see Q1 in `docs/fused-op-registry.md`) is wired up
755/// in step 4 once a second op exercises the abstraction.
756#[expect(
757    clippy::large_enum_variant,
758    reason = "matcher pattern enum; boxing the large variant is deferred to the same follow-up as PatternNode (docs/gaps.md GAP-215) -- a cross-cutting change to construction/match sites, not a lint-commit change"
759)]
760pub enum SubgraphPattern {
761    Declarative(PatternTree),
762    Callable(fn(&Graph, NodeId) -> Option<PatternMatch>),
763}
764
765/// A declarative subgraph pattern: the §3 grammar root plus the
766/// [`FusedOpParams`] to stamp on the matched fused node. The rule engine
767/// ([`crate::opt::FusionRule`] / `PatternKind::Declarative`) walks `root`
768/// against the graph (via [`crate::jit::match_region`]) and emits
769/// `Op::Fused(id, params)`.
770#[derive(Debug, Clone)]
771pub struct PatternTree {
772    /// The region/pattern root (the subgraph SINK, fkc-fusion-patterns §3a.1).
773    pub root: crate::jit::PatternNode,
774    /// The per-instance params stamped on the emitted fused node. A
775    /// parameterless declarative op carries its unit variant; scalar-param ops
776    /// recover their payload via the `extract:` layer (a follow-up) — for now
777    /// the template lives here.
778    pub params: FusedOpParams,
779}
780
781/// Result of a successful pattern match. Carries the bindings — the
782/// concrete `NodeId`s that the pattern's variables matched against in
783/// the host graph — plus the [`FusedOpParams`] payload the fusion rule
784/// should stamp onto the emitted `Op::Fused(id, params)` node.
785///
786/// `bindings` is index-keyed: bindings sorted by index become the
787/// fused-op node's input list (`inputs[0]` ← index 0, `inputs[1]` ←
788/// index 1, …). SoftmaxLastDim's match has one binding `(0, x_id)`
789/// and so emits a single-input node; FusedLinear (3 inputs:
790/// `[a, b, bias]`) emits three bindings indexed 0–2.
791///
792/// `params` is the matcher's authority on the resulting fused-op's
793/// per-instance parameters. The matcher knows what variant of
794/// [`FusedOpParams`] it's recognizing; carrying that decision in the
795/// match result keeps `crate::opt::FusionRule::rewrite` generic
796/// across all registered fused ops.
797#[derive(Debug, Clone)]
798pub struct PatternMatch {
799    /// Variable-id → resolved NodeId. The fusion rule sorts by index
800    /// and uses the resolved ids in order as the emitted node's inputs.
801    pub bindings: Vec<(usize, NodeId)>,
802    /// Per-instance parameters the matcher stamps onto the fused-op
803    /// node it produces. Parameterless ops (SoftmaxLastDim, FusedLinear)
804    /// stamp their unit variant; parameterized ops (RmsNormLastDim,
805    /// FlashAttn) recover their payload from the matched subgraph.
806    pub params: FusedOpParams,
807}
808
809/// The metadata-side registry. Built at process startup, frozen
810/// thereafter (architecture v1.0: no runtime extensibility). Lookups
811/// are by [`FusedOpId`] (O(1)) or by name.
812#[derive(Default)]
813pub struct FusedOpRegistry {
814    entries: Vec<FusedOpEntry>,
815    by_name: HashMap<&'static str, FusedOpId>,
816}
817
818impl FusedOpRegistry {
819    /// Empty registry. Step 3 builds one with `with_entry(...)` calls.
820    pub fn new() -> Self {
821        Self::default()
822    }
823
824    /// Register an entry. Returns self for builder-style chaining.
825    /// Idempotent on (id, name); a duplicate id is a programming bug
826    /// (asserts in debug builds, last-writer-wins in release).
827    ///
828    /// Pattern-hash indexing is reserved for step 4's declarative
829    /// pattern engine; the `Declarative` arm wires into it then.
830    pub fn with_entry(mut self, entry: FusedOpEntry) -> Self {
831        debug_assert!(
832            !self.by_name.contains_key(entry.name),
833            "FusedOpRegistry: duplicate entry name {:?}",
834            entry.name
835        );
836        debug_assert!(
837            entry.id.0 as usize == self.entries.len() + 1 || self.entry(entry.id).is_none(),
838            "FusedOpRegistry: id {:?} already populated",
839            entry.id
840        );
841        self.by_name.insert(entry.name, entry.id);
842        // Grow `entries` so direct id-indexing works. Slot 0 stays
843        // reserved (FusedOpId::UNASSIGNED).
844        let slot = entry.id.0 as usize;
845        if self.entries.len() <= slot {
846            self.entries.reserve(slot + 1 - self.entries.len());
847            // Step 1 doesn't need to fill placeholder slots — production
848            // paths register every id starting from 1 contiguously, so
849            // the Vec stays dense. If a gap arises, the lookup helper
850            // returns `None` for missing ids.
851        }
852        // Insert at the matching index when contiguous; otherwise
853        // append. Step 3's first registration is FusedOpId(1), so the
854        // simple append-with-slot-0-empty path is what we'll exercise.
855        if self.entries.is_empty() {
856            // Reserve slot 0 with a unit placeholder. Direct indexing is
857            // the hot path; we accept one wasted slot.
858            self.entries.push(placeholder_entry());
859        }
860        if slot < self.entries.len() {
861            self.entries[slot] = entry;
862        } else {
863            // Pad if necessary, then push.
864            while self.entries.len() < slot {
865                self.entries.push(placeholder_entry());
866            }
867            self.entries.push(entry);
868        }
869        self
870    }
871
872    /// Look up an entry by id. Returns `None` for `FusedOpId::UNASSIGNED`
873    /// or any unregistered id.
874    pub fn entry(&self, id: FusedOpId) -> Option<&FusedOpEntry> {
875        let slot = id.0 as usize;
876        if slot == 0 || slot >= self.entries.len() {
877            return None;
878        }
879        let e = &self.entries[slot];
880        if e.id == FusedOpId::UNASSIGNED {
881            None
882        } else {
883            Some(e)
884        }
885    }
886
887    /// Look up an entry by name. Returns the [`FusedOpId`] when present.
888    pub fn id_for_name(&self, name: &str) -> Option<FusedOpId> {
889        self.by_name.get(name).copied()
890    }
891
892    /// Iterate over every registered (non-placeholder) entry. Used by
893    /// [`crate::opt::RuleRegistry::default_rules`] to auto-generate
894    /// one [`crate::opt::LoweringRule`] + [`crate::opt::FusionRule`]
895    /// pair per fused op without naming each entry by hand.
896    pub fn entries_iter(&self) -> impl Iterator<Item = &FusedOpEntry> {
897        self.entries
898            .iter()
899            .filter(|e| e.id != FusedOpId::UNASSIGNED)
900    }
901
902    /// Number of registered (non-placeholder) entries.
903    pub fn len(&self) -> usize {
904        self.entries
905            .iter()
906            .filter(|e| e.id != FusedOpId::UNASSIGNED)
907            .count()
908    }
909
910    /// Whether the registry has any registered entries.
911    pub fn is_empty(&self) -> bool {
912        self.len() == 0
913    }
914}
915
916/// Hash of a primitive-subgraph pattern. Reserved for step 4's
917/// declarative pattern engine; the hashing function is filled in alongside
918/// `PatternTree`. (The static catalog's `by_pattern_hash` index placeholder
919/// was removed unread; re-add it with that engine.)
920#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
921pub struct PatternHash(pub u64);
922
923/// Internal placeholder used to keep id-indexed `entries` dense. Never
924/// Decompose the fused node `id` by re-emitting a **data recipe** — the
925/// static-registry bridge onto the one region re-emitter (Increment C
926/// slice 1, T5 / design D6). A migrated entry's `decompose` body becomes:
927///
928/// ```ignore
929/// fn recipe() -> &'static PatternNode              // OnceLock-built data
930/// fn scalars(params: &FusedOpParams) -> Option<Vec<f64>>  // per-entry projection
931/// pub fn decompose(g, id, params) -> NodeId {
932///     decompose_via_recipe(g, id, recipe(), scalars(params))
933/// }
934/// ```
935///
936/// The fused node's `inputs` are the recipe's binds (in bind-index order);
937/// `scalars` fill the recipe's open scalar slots in pattern pre-order (the
938/// per-entry projection maps the params payload — e.g. an `eps` — onto
939/// them). The `FusedOpEntry.decompose` surface is UNCHANGED — imperative
940/// bodies remain legal for param-conditional ops.
941///
942/// **Totality (G2, the recipe principle):** ANY failure returns `id` — the
943/// fused node stays in place as a surfaced opaque-op gap (the lowering
944/// driver records no progress), NEVER a panic:
945/// * `scalars == None` — a wrong params payload for this entry;
946/// * a recipe that fails `crate::runtime_fused::validate_recipe` — a
947///   semantics-absent op token with no primitive re-emission (the
948///   flip-withdrawal posture: unknown/non-registry tokens are surfaced
949///   honest-miss declines, never accepted), non-contiguous binds, a
950///   matcher-only node, or structurally-unresolvable rel attrs;
951/// * a bind-count / node-input arity mismatch;
952/// * a scalar-slot count mismatch;
953/// * a shape-relative resolution decline at THESE input shapes (symbolic
954///   extent, negative result, …);
955/// * anything else — a `catch_unwind` last-resort guard converts an
956///   unanticipated panic into the same fixpoint.
957///
958/// The first four decline BEFORE any emission (no partial nodes). An
959/// emit-time decline can leave already-pushed child nodes behind as
960/// unreferenced dead nodes — inert, the documented push-only-graph posture.
961/// Declines are telemetered on stderr (the surfaced-gap channel until a
962/// structured gap inventory exists).
963/// **A fused node's recipe as portable data — the generic accessor the JIT seam
964/// needs.** Returns the same [`PatternNode`] the op's `decompose` lowers,
965/// *without touching the graph*.
966///
967/// `fuel_kernel_seam::JitRequest::region` is documented as "the recipe's
968/// `decompose` (the primitive subgraph)", so a synthesizer can be asked to build
969/// one kernel for a region the consumer discovered at runtime. Lowering needs
970/// every primitive bound on the target device; **handing the region over needs
971/// none of them** — which is the whole reason this accessor exists.
972///
973/// Dispatches on the fused op id. `None` means "this op does not expose its
974/// recipe as data (yet)", which is a *surfaced gap*, never a crash — the same
975/// G2 posture as a `decompose` fixpoint self-return. Adding an op is one arm.
976///
977/// Today only [`FusedOps::PAGED_ATTN`] implements it, because it is the op that
978/// forced the question: no GPU backend implements the fused form at all, so the
979/// JIT route is the only one that does not depend on every primitive being
980/// pre-registered.
981pub fn recipe_for(graph: &Graph, id: NodeId) -> Option<PatternNode> {
982    let (fused_id, params) = match &graph.node(id).op {
983        crate::Op::Fused(fid, p) => (*fid, p.clone()),
984        _ => return None,
985    };
986    match fused_id {
987        FusedOps::PAGED_ATTN => crate::registry::paged_attn::recipe_for(graph, id, &params),
988        _ => None,
989    }
990}
991
992pub fn decompose_via_recipe(
993    graph: &mut Graph,
994    id: NodeId,
995    recipe: &PatternNode,
996    scalars: Option<Vec<f64>>,
997) -> NodeId {
998    let Some(scalars) = scalars else {
999        eprintln!(
1000            "decompose_via_recipe: wrong params payload for node {id:?} — \
1001             typed decline, node stays fused (G2 fixpoint)"
1002        );
1003        return id;
1004    };
1005    if let Err(err) = crate::runtime_fused::validate_recipe(recipe) {
1006        eprintln!(
1007            "decompose_via_recipe: recipe declined for node {id:?}: {err:?} — \
1008             surfaced honest miss, node stays fused (G2 fixpoint)"
1009        );
1010        return id;
1011    }
1012    let inputs = graph.node(id).inputs.clone();
1013    if recipe.bind_indices().len() != inputs.len() {
1014        eprintln!(
1015            "decompose_via_recipe: recipe binds {} != node inputs {} for node {id:?} — \
1016             typed decline, node stays fused (G2 fixpoint)",
1017            recipe.bind_indices().len(),
1018            inputs.len(),
1019        );
1020        return id;
1021    }
1022    if scalars.len() != crate::runtime_fused::count_scalar_slots(recipe) {
1023        eprintln!(
1024            "decompose_via_recipe: {} projected scalars for {} open slots on node {id:?} — \
1025             typed decline, node stays fused (G2 fixpoint)",
1026            scalars.len(),
1027            crate::runtime_fused::count_scalar_slots(recipe),
1028        );
1029        return id;
1030    }
1031    // The resolving emit. A rel-attr decline at these shapes is a
1032    // value-dependent gap (`Err` arm); `catch_unwind` is the never-panic
1033    // last resort for anything validation didn't anticipate (same posture as
1034    // `register_runtime_fused`'s hash guard) — a partial emission left by
1035    // either failure mode is inert dead nodes in the push-only graph.
1036    let emitted = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1037        crate::runtime_fused::try_emit_region(graph, recipe, &inputs, &scalars)
1038    }));
1039    match emitted {
1040        Ok(Ok(root)) => root,
1041        Ok(Err(err)) => {
1042            eprintln!(
1043                "decompose_via_recipe: rel-attr resolution declined for node {id:?}: {err:?} — \
1044                 surfaced gap, node stays fused (G2 fixpoint)"
1045            );
1046            id
1047        }
1048        Err(_) => {
1049            eprintln!(
1050                "decompose_via_recipe: emit panicked for node {id:?} — \
1051                 caught (never-panic last resort), node stays fused (G2 fixpoint)"
1052            );
1053            id
1054        }
1055    }
1056}
1057
1058/// returned by [`FusedOpRegistry::entry`].
1059fn placeholder_entry() -> FusedOpEntry {
1060    FusedOpEntry {
1061        destructive_input: None,
1062        id: FusedOpId::UNASSIGNED,
1063        name: "<unassigned>",
1064        family: FusedOpFamily::Forward,
1065        pattern: SubgraphPattern::Callable(|_g, _id| None),
1066        decompose: |_g, id, _p| id,
1067        backward: BackwardKind::NotDifferentiable,
1068        shape_rule: |_shapes, _params| Shape::from_dims(&[]),
1069        dtype_rule: |_dtypes, _params| DType::F32,
1070        output_views: None,
1071    }
1072}
1073
1074/// Associated constants for the well-known fused-op ids. Rule code
1075/// matches `Op::Fused(FusedOps::SOFTMAX_LAST_DIM, _)` — almost as
1076/// ergonomic as today's `Op::SoftmaxLastDim`. Steps 3-4 fill these in
1077/// as each fused op migrates to the registry.
1078pub struct FusedOps;
1079
1080impl FusedOps {
1081    /// SoftmaxLastDim — proof-of-concept migration target (step 3).
1082    pub const SOFTMAX_LAST_DIM: FusedOpId = FusedOpId(1);
1083    /// FusedLinear — first multi-input fused op migrated to the
1084    /// registry (step 4). Three inputs `[a, b, bias]`, output
1085    /// `(a @ b) + bias`. The CUTLASS bias-epilogue integration in the
1086    /// baracuda-cutlass alpha.13 plan registers here.
1087    pub const FUSED_LINEAR: FusedOpId = FusedOpId(2);
1088    /// RmsNormLastDim — `x / sqrt(mean(x²) + eps)` along the last dim.
1089    /// Migrated in step 4 (continued). Single input + eps param.
1090    pub const RMS_NORM_LAST_DIM: FusedOpId = FusedOpId(3);
1091    /// LayerNormLastDim — `(x - mean) / sqrt(var + eps)` along the
1092    /// last dim (no affine). Single input + eps param.
1093    pub const LAYER_NORM_LAST_DIM: FusedOpId = FusedOpId(4);
1094    /// Rope — rotary position embedding with caller-supplied cos/sin
1095    /// tables. Three inputs `[x, cos, sin]`, parameterless (the seq/
1096    /// head_dim shape parameters live in the input shapes).
1097    pub const ROPE: FusedOpId = FusedOpId(5);
1098    /// Conv2D — 2-D cross-correlation. Two or three inputs `[x,
1099    /// weight]` or `[x, weight, bias]`; carries stride / padding /
1100    /// groups in [`FusedOpParams::Conv2D`].
1101    pub const CONV2D: FusedOpId = FusedOpId(6);
1102    /// SoftmaxLastDimBackward — fused backward helper for the
1103    /// SoftmaxLastDim forward. Two inputs `[y, upstream]`,
1104    /// parameterless. Wired through `BackwardKind::Fused` from
1105    /// `SOFTMAX_LAST_DIM`'s entry.
1106    pub const SOFTMAX_LAST_DIM_BACKWARD: FusedOpId = FusedOpId(7);
1107    /// LayerNormLastDimBackward — fused backward helper for the
1108    /// LayerNormLastDim forward. Two inputs `[x, upstream]` + eps.
1109    pub const LAYER_NORM_LAST_DIM_BACKWARD: FusedOpId = FusedOpId(8);
1110    /// RmsNormLastDimBackward — fused backward helper for the
1111    /// RmsNormLastDim forward. Two inputs `[x, upstream]` + eps.
1112    pub const RMS_NORM_LAST_DIM_BACKWARD: FusedOpId = FusedOpId(9);
1113    /// ReduceMaxToBackward — fused backward helper for the
1114    /// `Op::ReduceMaxTo` primitive (routes upstream to argmax
1115    /// positions, fair-share on ties). Two inputs `[x, upstream]`,
1116    /// parameterless. Note this is the backward of a *primitive*
1117    /// (`Op::ReduceMaxTo`), not of a fused forward — there's no
1118    /// `BackwardKind::Fused` edge wired to this helper, autograd
1119    /// reaches it directly from `Op::ReduceMaxTo`'s arm.
1120    pub const REDUCE_MAX_TO_BACKWARD: FusedOpId = FusedOpId(10);
1121    /// ConvTranspose2D — fractionally-strided 2-D convolution.
1122    /// Two inputs `[x, weight]` (no bias); carries
1123    /// stride/padding/output_padding/dilation/groups in
1124    /// [`FusedOpParams::ConvTranspose2D`].
1125    pub const CONV_TRANSPOSE2D: FusedOpId = FusedOpId(11);
1126    /// FlashAttn — multi-head attention with FlashAttention-shaped
1127    /// kernel hooks. 4-5 inputs (q, k, v, optional alibi).
1128    pub const FLASH_ATTN: FusedOpId = FusedOpId(12);
1129    /// PagedAttn — paged-cache attention. 5-6 inputs.
1130    pub const PAGED_ATTN: FusedOpId = FusedOpId(13);
1131    /// QMatMul — quantized matmul `C = A @ dequant(W_Q)`. Two
1132    /// inputs `[a, w_q_bytes]`.
1133    pub const QMATMUL: FusedOpId = FusedOpId(14);
1134    /// PowIBackward — fused backward helper for `Op::PowI(n)`.
1135    /// `grad_x = n · x^(n-1) · upstream`. Two inputs `[x, upstream]`,
1136    /// carries `exp: i32` in [`FusedOpParams::PowIBackward`]. Replaces
1137    /// the autograd primitive decomposition (PowI(n-1) → MulScalar →
1138    /// Mul, 3 nodes) with a single launch of baracuda alpha.31's
1139    /// `unary_powi_backward_*` kernel. Note this is the backward of a
1140    /// primitive (`Op::PowI`), not of a fused forward — autograd
1141    /// reaches it directly from `Op::PowI`'s arm rather than via a
1142    /// `BackwardKind::Fused` edge.
1143    pub const POWI_BACKWARD: FusedOpId = FusedOpId(15);
1144
1145    /// In-place affine: `x = mul·x + add`. Single input (the mutated
1146    /// tensor), single launch into baracuda's `affine_inplace_*`
1147    /// symbol on CUDA. Carries `mul: f64, add: f64` in
1148    /// [`FusedOpParams::InplaceAffine`]. Marked destructive on input
1149    /// 0 via `Op::destructive_input`, so `opt::derive_ordering` pins
1150    /// it to run after every non-destructive reader. Phase 1 of the
1151    /// in-place ops infrastructure
1152    /// (`docs/session-prompts/in-place-ops-infrastructure.md`);
1153    /// dispatch + autograd integration deferred to Phases 3 + 4.
1154    pub const INPLACE_AFFINE: FusedOpId = FusedOpId(16);
1155
1156    /// FusedSoftmaxCrossEntropy — fused softmax + NLL over class-indexed
1157    /// targets. Two inputs `[logits, targets]`; carries
1158    /// `{ reduction, ignore_index }` in
1159    /// [`FusedOpParams::FusedSoftmaxCrossEntropy`]. The forward kernel
1160    /// avoids materializing the `[..., V]` softmax/log-softmax
1161    /// intermediates that the primitive composition does (`fuel-core::
1162    /// train::cross_entropy_with_logits`). Backward via Decompose runs
1163    /// the primitive chain; the in-place Liger-style fused backward is a
1164    /// later session (needs in-place mutation infrastructure).
1165    pub const FUSED_SOFTMAX_CROSS_ENTROPY: FusedOpId = FusedOpId(17);
1166
1167    /// CausalConv1d — depthwise 1-D convolution + causal masking +
1168    /// optional fused SiLU. Three inputs `[x, weight, bias]` where
1169    /// `x: [batch, channels, seq + kernel - 1]`,
1170    /// `weight: [channels, 1, kernel]`, `bias: [channels]`. Carries
1171    /// `use_silu: bool` in [`FusedOpParams::CausalConv1d`]. The forward
1172    /// kernel matches baracuda's `causal_conv1d_*_run` contract.
1173    /// `BackwardKind::NotDifferentiable` for v1 (Mamba inference path
1174    /// today is inference-only).
1175    pub const CAUSAL_CONV1D: FusedOpId = FusedOpId(18);
1176
1177    /// SelectiveScan — Mamba-1's selective state-space scan
1178    /// (forward only). Five inputs `[u, delta, a, b, c]`; one output
1179    /// `y: [batch, seqlen, dim]`. Carries `delta_softplus: bool` in
1180    /// [`FusedOpParams::SelectiveScan`]. v1 drops the optional
1181    /// inputs (d_skip, z, delta_bias) and the `last_state` second
1182    /// output — both are mechanical follow-ups when a consumer needs
1183    /// them. `BackwardKind::NotDifferentiable` for v1.
1184    pub const SELECTIVE_SCAN: FusedOpId = FusedOpId(19);
1185
1186    /// SsdChunkScan — Mamba-2's State-Space Duality chunked scan
1187    /// (forward only). Five inputs `[x, dt, a, b, c]`; one output
1188    /// `y: [batch, seqlen, heads, head_dim]`. Carries
1189    /// `chunk_size: usize` in [`FusedOpParams::SsdChunkScan`]
1190    /// (parallelism granularity for the GPU path; the CPU kernel
1191    /// runs sequential and any positive `chunk_size` dividing
1192    /// `seqlen` produces the same mathematical answer).
1193    /// `BackwardKind::NotDifferentiable` for v1.
1194    pub const SSD_CHUNK_SCAN: FusedOpId = FusedOpId(20);
1195
1196    /// Nf4Matmul — bitsandbytes-style 4-bit NormalFloat quantized
1197    /// matrix multiply. Three inputs `[activations, w_packed, absmax]`
1198    /// where `w_packed: [N, K/2] U8` and `absmax: [N, K/block_size] F32`.
1199    /// Output `[..., M, N]` matching activations' dtype (F32/F16/BF16
1200    /// in v1). Carries `block_size: usize` in
1201    /// [`FusedOpParams::Nf4Matmul`]. `BackwardKind::NotDifferentiable`
1202    /// (NF4 weights are frozen; the dequant-then-matmul fallback is
1203    /// the wrong path for the cases NF4 exists to optimize).
1204    pub const NF4_MATMUL: FusedOpId = FusedOpId(21);
1205
1206    /// FlashAttnBackwardQ — produces dQ from `(q, k, v, do, [alibi])`.
1207    /// See [`fuel_ir::dispatch::OpKind::FlashAttnBackwardQ`]. Three
1208    /// separate FusedOp ids (Q/K/V) is the v1 design — a single
1209    /// multi-output op would share the recompute pass across all three
1210    /// gradients but needs multi-output infrastructure that doesn't
1211    /// exist yet. Reuses [`FusedOpParams::FlashAttnBackward`].
1212    pub const FLASH_ATTN_BACKWARD_Q: FusedOpId = FusedOpId(22);
1213    /// FlashAttnBackwardK — produces dK. See [`Self::FLASH_ATTN_BACKWARD_Q`].
1214    pub const FLASH_ATTN_BACKWARD_K: FusedOpId = FusedOpId(23);
1215    /// FlashAttnBackwardV — produces dV. See [`Self::FLASH_ATTN_BACKWARD_Q`].
1216    pub const FLASH_ATTN_BACKWARD_V: FusedOpId = FusedOpId(24);
1217
1218    /// AwqMatmul — AWQ (mit-han-lab) asymmetric int4 weight-only
1219    /// matmul. Four inputs `[activations, qweight, qzeros, scales]`
1220    /// (one more than NF4's three — AWQ's zero-point is a fourth
1221    /// operand, not baked into the dequant like NF4's codebook) where
1222    /// `qweight: [N, K/8] U32`,
1223    /// `qzeros: [K/group_size, N/8] U32`, `scales: [K/group_size, N]
1224    /// F32`. Output `[..., M, N]` matching activations' dtype
1225    /// (F32/F16/BF16 in v1). Carries `{ group_size, split_k_iters }`
1226    /// in [`FusedOpParams::AwqMatmul`]. `BackwardKind::NotDifferentiable`
1227    /// (AWQ weights are frozen, same reasoning as [`Self::NF4_MATMUL`]).
1228    /// Unlike NF4 (a non-linear codebook with no primitive spelling),
1229    /// AWQ's affine dequant decomposes cleanly into primitives — see
1230    /// the module doc on [`crate::registry::awq_matmul`] for the
1231    /// recipe and its current verification status.
1232    pub const AWQ_MATMUL: FusedOpId = FusedOpId(25);
1233}
1234
1235/// Process-wide default registry: the union of every fused op's
1236/// metadata-side entry. Built once on first access via
1237/// [`std::sync::OnceLock`]; immutable thereafter (architecture v1.0:
1238/// no runtime extensibility).
1239///
1240/// Step 3 populates only SoftmaxLastDim; step 4 fills in the other
1241/// 12 fused ops as they migrate.
1242pub fn default_registry() -> &'static FusedOpRegistry {
1243    use std::sync::OnceLock;
1244    static REGISTRY: OnceLock<FusedOpRegistry> = OnceLock::new();
1245    REGISTRY.get_or_init(|| {
1246        FusedOpRegistry::new()
1247            .with_entry(softmax_last_dim::entry())
1248            .with_entry(fused_linear::entry())
1249            .with_entry(rms_norm_last_dim::entry())
1250            .with_entry(layer_norm_last_dim::entry())
1251            .with_entry(rope::entry())
1252            .with_entry(conv2d::entry())
1253            .with_entry(softmax_last_dim_backward::entry())
1254            .with_entry(layer_norm_last_dim_backward::entry())
1255            .with_entry(rms_norm_last_dim_backward::entry())
1256            .with_entry(reduce_max_to_backward::entry())
1257            .with_entry(conv_transpose_2d::entry())
1258            .with_entry(flash_attn::entry())
1259            .with_entry(flash_attn_backward::entry_q())
1260            .with_entry(flash_attn_backward::entry_k())
1261            .with_entry(flash_attn_backward::entry_v())
1262            .with_entry(paged_attn::entry())
1263            .with_entry(qmatmul::entry())
1264            .with_entry(powi_backward::entry())
1265            .with_entry(inplace_affine::entry())
1266            .with_entry(fused_softmax_cross_entropy::entry())
1267            .with_entry(causal_conv1d::entry())
1268            .with_entry(selective_scan::entry())
1269            .with_entry(ssd_chunk_scan::entry())
1270            .with_entry(nf4_matmul::entry())
1271            .with_entry(awq_matmul::entry())
1272    })
1273}
1274
1275#[cfg(test)]
1276mod tests {
1277    use super::*;
1278
1279    /// Smoke: id newtype is comparable + hashable.
1280    #[test]
1281    fn fused_op_id_basic() {
1282        let a = FusedOpId(1);
1283        let b = FusedOpId(1);
1284        let c = FusedOpId(2);
1285        assert_eq!(a, b);
1286        assert_ne!(a, c);
1287    }
1288
1289    /// Smoke: empty registry has no entries.
1290    #[test]
1291    fn registry_empty() {
1292        let r = FusedOpRegistry::new();
1293        assert!(r.is_empty());
1294        assert_eq!(r.len(), 0);
1295        assert!(r.entry(FusedOpId(1)).is_none());
1296        assert!(r.entry(FusedOpId::UNASSIGNED).is_none());
1297        assert!(r.id_for_name("anything").is_none());
1298    }
1299
1300    /// Smoke: register one entry, look it up by id and by name.
1301    #[test]
1302    fn registry_register_one() {
1303        fn dummy_decompose(_g: &mut Graph, id: NodeId, _p: &FusedOpParams) -> NodeId {
1304            id
1305        }
1306        fn dummy_pattern(_g: &Graph, _id: NodeId) -> Option<PatternMatch> {
1307            None
1308        }
1309        fn dummy_shape(_s: &[Shape], _p: &FusedOpParams) -> Shape {
1310            Shape::from_dims(&[1])
1311        }
1312        fn dummy_dtype(_d: &[DType], _p: &FusedOpParams) -> DType {
1313            DType::F32
1314        }
1315
1316        let r = FusedOpRegistry::new().with_entry(FusedOpEntry {
1317            destructive_input: None,
1318            id: FusedOps::SOFTMAX_LAST_DIM,
1319            name: "SoftmaxLastDim",
1320            family: FusedOpFamily::Forward,
1321            pattern: SubgraphPattern::Callable(dummy_pattern),
1322            decompose: dummy_decompose,
1323            backward: BackwardKind::NotDifferentiable,
1324            shape_rule: dummy_shape,
1325            dtype_rule: dummy_dtype,
1326            output_views: None,
1327        });
1328
1329        assert_eq!(r.len(), 1);
1330        assert!(!r.is_empty());
1331        let entry = r.entry(FusedOps::SOFTMAX_LAST_DIM).expect("registered");
1332        assert_eq!(entry.name, "SoftmaxLastDim");
1333        assert_eq!(entry.family, FusedOpFamily::Forward);
1334        assert_eq!(
1335            r.id_for_name("SoftmaxLastDim"),
1336            Some(FusedOps::SOFTMAX_LAST_DIM)
1337        );
1338    }
1339
1340    /// FusedOpParams::key produces a stable encoding.
1341    #[test]
1342    fn fused_op_params_key_softmax() {
1343        let k1 = FusedOpParams::SoftmaxLastDim.key();
1344        let k2 = FusedOpParams::SoftmaxLastDim.key();
1345        assert_eq!(k1, k2);
1346        assert_eq!(k1.tag, 1);
1347        assert!(k1.bits.is_empty());
1348        assert!(k1.ints.is_empty());
1349    }
1350}