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    /// Four inputs, the quantized three in the baracuda W4A16 kernel
327    /// layout (OC-major):
328    /// - `activations`: `[..., M, K]`
329    /// - `weight`:      `[N, K/8]` U32 (eight int4 codes per word,
330    ///   sequential nibble order, K must be a multiple of 8).
331    /// - `zeros`:       `[N, zeros_w]` U32 (group `g` is nibble
332    ///   `g % 8` of word `g / 8`); `zeros_w` is
333    ///   [`crate::registry::awq_matmul::awq_zeros_w`].
334    /// - `scales`:      `[N, 8 * zeros_w]` F16 (entry `g` is group
335    ///   `g`'s scale).
336    ///
337    /// Output: `[..., M, N]` matching the activations' dtype.
338    ///
339    /// `group_size` must be 64 or 128 and evenly divide `K`.
340    /// `split_k_iters` is a CUDA-kernel launch-tuning parameter (unused
341    /// by the decompose path; carried so the fused CUDA dispatch does
342    /// not need a second params shape).
343    ///
344    /// See the module doc on [`crate::registry::awq_matmul`] for the
345    /// layout rationale and the decompose recipe.
346    AwqMatmul {
347        group_size: usize,
348        split_k_iters: i32,
349    },
350    /// FlashAttnBackward — shared params for the Q/K/V backward
351    /// variants. Carries the same shape parameters as the forward
352    /// `FlashAttn` so the recompute pass produces identical scores.
353    /// All three FusedOpId-marked variants (FLASH_ATTN_BACKWARD_Q/K/V)
354    /// use this single variant; the FusedOpId distinguishes which
355    /// gradient is being computed.
356    FlashAttnBackward {
357        softmax_scale: f32,
358        causal: bool,
359        window_size_left: Option<usize>,
360        window_size_right: Option<usize>,
361        softcap: Option<f32>,
362    },
363    /// SelectiveScan — Mamba-1's selective state-space scan
364    /// (forward only). Five inputs:
365    /// - `u`:     `[batch, seqlen, dim]` — input sequence.
366    /// - `delta`: `[batch, seqlen, dim]` — per-step state-update rate.
367    /// - `a`:     `[dim, dstate]` — recurrence matrix log.
368    /// - `b`:     `[batch, seqlen, dstate]` — selective input matrix.
369    /// - `c`:     `[batch, seqlen, dstate]` — selective output matrix.
370    ///
371    /// Output: `y: [batch, seqlen, dim]` — the scanned sequence.
372    ///
373    /// `delta_softplus` toggles the standard softplus(delta)
374    /// activation before use (matches baracuda's `selective_scan_*_run`
375    /// flag). The optional inputs (d_skip, z, delta_bias) from the
376    /// full Mamba algorithm are NOT exposed in v1 — they're
377    /// mechanical extensions when a consumer needs them.
378    ///
379    /// Multi-output Option C (item 3, 2026-06-01): the op now always
380    /// produces a bundled output with two slots — slot 0 `y` (the
381    /// scanned sequence) and slot 1 `last_state` (the final
382    /// h-state). Consumers project via `Op::View` / `Op::ViewOwned`.
383    /// The earlier `return_state: bool` flag retired here.
384    SelectiveScan { delta_softplus: bool },
385    /// CausalConv1d — depthwise 1-D convolution with causal masking
386    /// (left-pad-only) + optional fused SiLU. Three inputs:
387    /// - `x`: `[batch, channels, seq + (kernel - 1)]` — caller pre-pads
388    ///   with `kernel - 1` zeros on the left for the causal mask.
389    /// - `weight`: `[channels, 1, kernel]` — depthwise, one filter per
390    ///   channel (groups == channels in conv terminology).
391    /// - `bias`: `[channels]`.
392    ///
393    /// Output: `[batch, channels, seq]`, same dtype as inputs.
394    ///
395    /// Designed to mirror baracuda's `causal_conv1d_*_run` signature
396    /// (which already carries a `use_silu` flag) and the Mamba-1 /
397    /// Mamba-2 prefill convolution. See
398    /// [[match-external-convention-for-well-known-ops]] for the
399    /// "match baracuda's contract exactly" reasoning.
400    CausalConv1d { use_silu: bool },
401    /// FusedSoftmaxCrossEntropy — fused softmax + negative log-likelihood
402    /// over class-indexed targets, the standard PyTorch / Liger-Kernel
403    /// training-time loss. Two inputs:
404    /// - `logits: [..., V]` (F32) — pre-computed unnormalized class scores
405    /// - `targets: [...]` (I64) — class indices, matching the rest of
406    ///   PyTorch / baracuda CE convention (see
407    ///   [[match-external-convention-for-well-known-ops]])
408    ///
409    /// Output is F32 regardless of input dtype (loss values are
410    /// accumulated in F32 for stability):
411    /// - `Reduction::Mean` / `Reduction::Sum` → scalar `[]`
412    /// - `Reduction::None` → same shape as `targets` (`[...]`)
413    ///
414    /// `ignore_index` rows are dropped from the loss accumulator and
415    /// from the Mean denominator. The conventional sentinel is `-100`.
416    FusedSoftmaxCrossEntropy {
417        reduction: Reduction,
418        ignore_index: i64,
419    },
420    /// A **runtime-registered** fused op (JIT-synthesized or import-time). The
421    /// op's identity is the runtime [`FusedOpId`] in `Op::Fused`; its recipe is
422    /// the region in the `runtime_fused` sidecar. This variant carries only the
423    /// extracted scalar args (the `extract:` slots, in slot order) — a
424    /// parameterless runtime op carries `scalars: []`. See
425    /// `docs/specs/runtime-fused-op-registration.md`.
426    Runtime { scalars: Vec<f64> },
427}
428
429/// Reduction mode for losses with per-sample outputs. Matches PyTorch's
430/// `reduction` parameter shape (`'mean' | 'sum' | 'none'`).
431#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
432pub enum Reduction {
433    /// Sum the per-sample losses and divide by the count of contributing
434    /// samples (rows not masked by `ignore_index`). Standard training-time
435    /// scalar.
436    Mean,
437    /// Sum the per-sample losses without dividing.
438    Sum,
439    /// No reduction — return the per-sample losses with `targets.shape`.
440    None,
441}
442
443impl Reduction {
444    /// Stable per-variant key for hashable encodings (`FusedOpParams::key`
445    /// and the kernel-side `OpParams` payload). The variant tag space is
446    /// local to whichever enum carries this discriminant.
447    pub(crate) fn key(self) -> i64 {
448        match self {
449            Reduction::Mean => 0,
450            Reduction::Sum => 1,
451            Reduction::None => 2,
452        }
453    }
454}
455
456/// Hashable key for [`FusedOpParams`]. Used by `op_key`/CSE so that two
457/// `Op::Fused(id, params)` nodes with identical params dedupe.
458///
459/// The encoding is variant-tag + payload as a `Vec<u64>` (bit patterns
460/// for floats, repr for ints/usize). Mirrors the existing
461/// [`crate::opt`]-style `OpKey` encoding so future extensions slot in
462/// without rebuilding the CSE map.
463#[derive(Debug, Clone, PartialEq, Eq, Hash)]
464pub struct FusedOpParamsKey {
465    pub tag: u16,
466    pub bits: Vec<u64>,
467    pub ints: Vec<i64>,
468}
469
470impl FusedOpParams {
471    /// Hashable encoding. Step 4 adds variants per-fused-op as each
472    /// migrates; the tag uniquely identifies the variant and the
473    /// bits/ints slots carry any payload (none for parameterless ops
474    /// like SoftmaxLastDim and FusedLinear).
475    pub fn key(&self) -> FusedOpParamsKey {
476        match self {
477            FusedOpParams::SoftmaxLastDim => FusedOpParamsKey {
478                tag: 1,
479                bits: Vec::new(),
480                ints: Vec::new(),
481            },
482            FusedOpParams::FusedLinear => FusedOpParamsKey {
483                tag: 2,
484                bits: Vec::new(),
485                ints: Vec::new(),
486            },
487            FusedOpParams::RmsNormLastDim { eps } => FusedOpParamsKey {
488                tag: 3,
489                // Encode eps as its raw bit pattern so CSE on two
490                // RmsNormLastDim nodes with the same eps dedupes
491                // (and two with different eps don't).
492                bits: vec![eps.to_bits()],
493                ints: Vec::new(),
494            },
495            FusedOpParams::LayerNormLastDim { eps } => FusedOpParamsKey {
496                tag: 4,
497                bits: vec![eps.to_bits()],
498                ints: Vec::new(),
499            },
500            FusedOpParams::Rope => FusedOpParamsKey {
501                tag: 5,
502                bits: Vec::new(),
503                ints: Vec::new(),
504            },
505            FusedOpParams::Conv2D {
506                stride,
507                padding,
508                groups,
509            } => FusedOpParamsKey {
510                tag: 6,
511                bits: Vec::new(),
512                // Stride.{0,1}, padding.{0,1}, groups. Five i64 slots
513                // — enough that two Conv2D nodes with different
514                // strides or paddings hash to distinct CSE keys.
515                ints: vec![
516                    stride.0 as i64,
517                    stride.1 as i64,
518                    padding.0 as i64,
519                    padding.1 as i64,
520                    *groups as i64,
521                ],
522            },
523            FusedOpParams::SoftmaxLastDimBackward => FusedOpParamsKey {
524                tag: 7,
525                bits: Vec::new(),
526                ints: Vec::new(),
527            },
528            FusedOpParams::LayerNormLastDimBackward { eps } => FusedOpParamsKey {
529                tag: 8,
530                bits: vec![eps.to_bits()],
531                ints: Vec::new(),
532            },
533            FusedOpParams::RmsNormLastDimBackward { eps } => FusedOpParamsKey {
534                tag: 9,
535                bits: vec![eps.to_bits()],
536                ints: Vec::new(),
537            },
538            FusedOpParams::ReduceMaxToBackward => FusedOpParamsKey {
539                tag: 10,
540                bits: Vec::new(),
541                ints: Vec::new(),
542            },
543            FusedOpParams::ConvTranspose2D {
544                stride,
545                padding,
546                output_padding,
547                dilation,
548                groups,
549            } => FusedOpParamsKey {
550                tag: 11,
551                bits: Vec::new(),
552                // 9 i64 slots: stride.{0,1}, padding.{0,1},
553                // output_padding.{0,1}, dilation.{0,1}, groups.
554                ints: vec![
555                    stride.0 as i64,
556                    stride.1 as i64,
557                    padding.0 as i64,
558                    padding.1 as i64,
559                    output_padding.0 as i64,
560                    output_padding.1 as i64,
561                    dilation.0 as i64,
562                    dilation.1 as i64,
563                    *groups as i64,
564                ],
565            },
566            FusedOpParams::FlashAttn {
567                softmax_scale,
568                causal,
569                window_size_left,
570                window_size_right,
571                softcap,
572                k_len,
573            } => {
574                // Encode the optional dynamic k_len as (tag, value) so
575                // two flash nodes whose attended-length symbol differs
576                // are NOT CSE-merged. None → (0,0); Concrete(v) → (1,v);
577                // Sym(s) → (2, s.0).
578                let (k_tag, k_val): (i64, i64) = match k_len {
579                    None => (0, 0),
580                    Some(DynScalar::Concrete(v)) => (1, *v as i64),
581                    Some(DynScalar::Sym(s)) => (2, s.0 as i64),
582                };
583                FusedOpParamsKey {
584                    tag: 12,
585                    // softmax_scale f32 → u64 bit pattern; softcap
586                    // f32 → u64 bit pattern (NaN sentinel for None so
587                    // two FlashAttn nodes with no softcap still hash
588                    // equal).
589                    bits: vec![
590                        softmax_scale.to_bits() as u64,
591                        softcap.map(|s| s.to_bits() as u64).unwrap_or(u64::MAX),
592                    ],
593                    // causal as i64; window sizes carry MIN as the
594                    // "None" sentinel so a real Some(0) still distinguishes.
595                    ints: vec![
596                        if *causal { 1 } else { 0 },
597                        window_size_left.map(|w| w as i64).unwrap_or(i64::MIN),
598                        window_size_right.map(|w| w as i64).unwrap_or(i64::MIN),
599                        k_tag,
600                        k_val,
601                    ],
602                }
603            }
604            FusedOpParams::PagedAttn {
605                softmax_scale,
606                block_size,
607                softcap,
608            } => FusedOpParamsKey {
609                tag: 13,
610                bits: vec![
611                    softmax_scale.to_bits() as u64,
612                    softcap.map(|s| s.to_bits() as u64).unwrap_or(u64::MAX),
613                ],
614                ints: vec![*block_size as i64],
615            },
616            FusedOpParams::QMatMul { quant_type, k, n } => FusedOpParamsKey {
617                tag: 14,
618                bits: Vec::new(),
619                // QuantType encoded as stable per-variant i64; k, n
620                // as usize.
621                ints: vec![quant_type_key(*quant_type), *k as i64, *n as i64],
622            },
623            FusedOpParams::PowIBackward { exp } => FusedOpParamsKey {
624                tag: 15,
625                bits: Vec::new(),
626                ints: vec![*exp as i64],
627            },
628            FusedOpParams::InplaceAffine { mul, add } => FusedOpParamsKey {
629                tag: 16,
630                bits: vec![mul.to_bits(), add.to_bits()],
631                ints: Vec::new(),
632            },
633            FusedOpParams::FusedSoftmaxCrossEntropy {
634                reduction,
635                ignore_index,
636            } => FusedOpParamsKey {
637                tag: 17,
638                bits: Vec::new(),
639                ints: vec![reduction.key(), *ignore_index],
640            },
641            FusedOpParams::CausalConv1d { use_silu } => FusedOpParamsKey {
642                tag: 18,
643                bits: Vec::new(),
644                ints: vec![*use_silu as i64],
645            },
646            FusedOpParams::SelectiveScan { delta_softplus } => FusedOpParamsKey {
647                tag: 19,
648                bits: Vec::new(),
649                ints: vec![*delta_softplus as i64],
650            },
651            FusedOpParams::SsdChunkScan { chunk_size } => FusedOpParamsKey {
652                tag: 20,
653                bits: Vec::new(),
654                ints: vec![*chunk_size as i64],
655            },
656            FusedOpParams::Nf4Matmul { block_size } => FusedOpParamsKey {
657                tag: 21,
658                bits: Vec::new(),
659                ints: vec![*block_size as i64],
660            },
661            FusedOpParams::AwqMatmul {
662                group_size,
663                split_k_iters,
664            } => FusedOpParamsKey {
665                tag: 23,
666                bits: Vec::new(),
667                ints: vec![*group_size as i64, *split_k_iters as i64],
668            },
669            FusedOpParams::FlashAttnBackward {
670                softmax_scale,
671                causal,
672                window_size_left,
673                window_size_right,
674                softcap,
675            } => FusedOpParamsKey {
676                // Distinct tag from forward FlashAttn (12) so the
677                // forward and backward nodes can't collide in CSE.
678                tag: 22,
679                bits: vec![
680                    softmax_scale.to_bits() as u64,
681                    softcap.map(|s| s.to_bits() as u64).unwrap_or(u64::MAX),
682                ],
683                ints: vec![
684                    if *causal { 1 } else { 0 },
685                    window_size_left.map(|w| w as i64).unwrap_or(i64::MIN),
686                    window_size_right.map(|w| w as i64).unwrap_or(i64::MIN),
687                ],
688            },
689            // Runtime-registered fused ops share one tag (0xF000); the runtime
690            // FusedOpId in `Op::Fused` disambiguates distinct ops, and the
691            // scalar bits disambiguate instances of the same op.
692            FusedOpParams::Runtime { scalars } => FusedOpParamsKey {
693                tag: 0xF000,
694                bits: scalars.iter().map(|s| s.to_bits()).collect(),
695                ints: Vec::new(),
696            },
697        }
698    }
699}
700
701/// Stable per-variant key for [`crate::QuantType`]. Used by
702/// [`FusedOpParams::key`] so CSE on two `Op::Fused(QMATMUL, _)`
703/// nodes with the same `(quant_type, k, n)` collapses correctly.
704fn quant_type_key(q: crate::QuantType) -> i64 {
705    use crate::QuantType::*;
706    match q {
707        Q4_0 => 1,
708        Q4_1 => 2,
709        Q5_0 => 3,
710        Q5_1 => 4,
711        Q8_0 => 5,
712        Q8_1 => 6,
713        Q2K => 7,
714        Q3K => 8,
715        Q4_K_M => 9,
716        Q5K => 10,
717        Q6K => 11,
718    }
719}
720
721/// What kind of backward this fused op has.
722///
723/// - `Fused(id)` — emit a fused-backward op with the given id. Used by
724///   ops whose backward is awkward to express as a primitive
725///   decomposition (e.g. SoftmaxLastDimBackward's fused subtract-and-
726///   project formula).
727/// - `Decompose` — autograd derives the backward from the primitive
728///   decomposition (lower the fused node, then run autograd over the
729///   primitives).
730/// - `NotDifferentiable` — backward returns Err / panics cleanly.
731///   Matches today's QMatMul / ArgMaxDim treatment.
732#[derive(Copy, Clone, Debug, PartialEq, Eq)]
733pub enum BackwardKind {
734    Fused(FusedOpId),
735    Decompose,
736    NotDifferentiable,
737}
738
739/// Canonical primitive-subgraph pattern that a fused op represents.
740/// Used by fusion rules.
741///
742/// Two reasonable shapes; the registry supports both:
743/// - `Callable` — a closure-style matcher, mirrors PR 3's
744///   `canonical_softmax_pattern`. Maximally flexible (consumer-count
745///   guards, cross-checks); less analyzable.
746/// - `Declarative` — a recursive `PatternTree` that the rule engine
747///   compiles to a matcher. More analyzable; auto-generation from the
748///   registry entry is straightforward.
749///
750/// Step 3 ports SoftmaxLastDim's existing matcher to `Callable`. The
751/// declarative form (see Q1 in `docs/fused-op-registry.md`) is wired up
752/// in step 4 once a second op exercises the abstraction.
753#[expect(
754    clippy::large_enum_variant,
755    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"
756)]
757pub enum SubgraphPattern {
758    Declarative(PatternTree),
759    Callable(fn(&Graph, NodeId) -> Option<PatternMatch>),
760}
761
762/// A declarative subgraph pattern: the §3 grammar root plus the
763/// [`FusedOpParams`] to stamp on the matched fused node. The rule engine
764/// ([`crate::opt::FusionRule`] / `PatternKind::Declarative`) walks `root`
765/// against the graph (via [`crate::jit::match_region`]) and emits
766/// `Op::Fused(id, params)`.
767#[derive(Debug, Clone)]
768pub struct PatternTree {
769    /// The region/pattern root (the subgraph SINK, fkc-fusion-patterns §3a.1).
770    pub root: crate::jit::PatternNode,
771    /// The per-instance params stamped on the emitted fused node. A
772    /// parameterless declarative op carries its unit variant; scalar-param ops
773    /// recover their payload via the `extract:` layer (a follow-up) — for now
774    /// the template lives here.
775    pub params: FusedOpParams,
776}
777
778/// Result of a successful pattern match. Carries the bindings — the
779/// concrete `NodeId`s that the pattern's variables matched against in
780/// the host graph — plus the [`FusedOpParams`] payload the fusion rule
781/// should stamp onto the emitted `Op::Fused(id, params)` node.
782///
783/// `bindings` is index-keyed: bindings sorted by index become the
784/// fused-op node's input list (`inputs[0]` ← index 0, `inputs[1]` ←
785/// index 1, …). SoftmaxLastDim's match has one binding `(0, x_id)`
786/// and so emits a single-input node; FusedLinear (3 inputs:
787/// `[a, b, bias]`) emits three bindings indexed 0–2.
788///
789/// `params` is the matcher's authority on the resulting fused-op's
790/// per-instance parameters. The matcher knows what variant of
791/// [`FusedOpParams`] it's recognizing; carrying that decision in the
792/// match result keeps `crate::opt::FusionRule::rewrite` generic
793/// across all registered fused ops.
794#[derive(Debug, Clone)]
795pub struct PatternMatch {
796    /// Variable-id → resolved NodeId. The fusion rule sorts by index
797    /// and uses the resolved ids in order as the emitted node's inputs.
798    pub bindings: Vec<(usize, NodeId)>,
799    /// Per-instance parameters the matcher stamps onto the fused-op
800    /// node it produces. Parameterless ops (SoftmaxLastDim, FusedLinear)
801    /// stamp their unit variant; parameterized ops (RmsNormLastDim,
802    /// FlashAttn) recover their payload from the matched subgraph.
803    pub params: FusedOpParams,
804}
805
806/// The metadata-side registry. Built at process startup, frozen
807/// thereafter (architecture v1.0: no runtime extensibility). Lookups
808/// are by [`FusedOpId`] (O(1)) or by name.
809#[derive(Default)]
810pub struct FusedOpRegistry {
811    entries: Vec<FusedOpEntry>,
812    by_name: HashMap<&'static str, FusedOpId>,
813}
814
815impl FusedOpRegistry {
816    /// Empty registry. Step 3 builds one with `with_entry(...)` calls.
817    pub fn new() -> Self {
818        Self::default()
819    }
820
821    /// Register an entry. Returns self for builder-style chaining.
822    /// Idempotent on (id, name); a duplicate id is a programming bug
823    /// (asserts in debug builds, last-writer-wins in release).
824    ///
825    /// Pattern-hash indexing is reserved for step 4's declarative
826    /// pattern engine; the `Declarative` arm wires into it then.
827    pub fn with_entry(mut self, entry: FusedOpEntry) -> Self {
828        debug_assert!(
829            !self.by_name.contains_key(entry.name),
830            "FusedOpRegistry: duplicate entry name {:?}",
831            entry.name
832        );
833        debug_assert!(
834            entry.id.0 as usize == self.entries.len() + 1 || self.entry(entry.id).is_none(),
835            "FusedOpRegistry: id {:?} already populated",
836            entry.id
837        );
838        self.by_name.insert(entry.name, entry.id);
839        // Grow `entries` so direct id-indexing works. Slot 0 stays
840        // reserved (FusedOpId::UNASSIGNED).
841        let slot = entry.id.0 as usize;
842        if self.entries.len() <= slot {
843            self.entries.reserve(slot + 1 - self.entries.len());
844            // Step 1 doesn't need to fill placeholder slots — production
845            // paths register every id starting from 1 contiguously, so
846            // the Vec stays dense. If a gap arises, the lookup helper
847            // returns `None` for missing ids.
848        }
849        // Insert at the matching index when contiguous; otherwise
850        // append. Step 3's first registration is FusedOpId(1), so the
851        // simple append-with-slot-0-empty path is what we'll exercise.
852        if self.entries.is_empty() {
853            // Reserve slot 0 with a unit placeholder. Direct indexing is
854            // the hot path; we accept one wasted slot.
855            self.entries.push(placeholder_entry());
856        }
857        if slot < self.entries.len() {
858            self.entries[slot] = entry;
859        } else {
860            // Pad if necessary, then push.
861            while self.entries.len() < slot {
862                self.entries.push(placeholder_entry());
863            }
864            self.entries.push(entry);
865        }
866        self
867    }
868
869    /// Look up an entry by id. Returns `None` for `FusedOpId::UNASSIGNED`
870    /// or any unregistered id.
871    pub fn entry(&self, id: FusedOpId) -> Option<&FusedOpEntry> {
872        let slot = id.0 as usize;
873        if slot == 0 || slot >= self.entries.len() {
874            return None;
875        }
876        let e = &self.entries[slot];
877        if e.id == FusedOpId::UNASSIGNED {
878            None
879        } else {
880            Some(e)
881        }
882    }
883
884    /// Look up an entry by name. Returns the [`FusedOpId`] when present.
885    pub fn id_for_name(&self, name: &str) -> Option<FusedOpId> {
886        self.by_name.get(name).copied()
887    }
888
889    /// Iterate over every registered (non-placeholder) entry. Used by
890    /// [`crate::opt::RuleRegistry::default_rules`] to auto-generate
891    /// one [`crate::opt::LoweringRule`] + [`crate::opt::FusionRule`]
892    /// pair per fused op without naming each entry by hand.
893    pub fn entries_iter(&self) -> impl Iterator<Item = &FusedOpEntry> {
894        self.entries
895            .iter()
896            .filter(|e| e.id != FusedOpId::UNASSIGNED)
897    }
898
899    /// Number of registered (non-placeholder) entries.
900    pub fn len(&self) -> usize {
901        self.entries
902            .iter()
903            .filter(|e| e.id != FusedOpId::UNASSIGNED)
904            .count()
905    }
906
907    /// Whether the registry has any registered entries.
908    pub fn is_empty(&self) -> bool {
909        self.len() == 0
910    }
911}
912
913/// Hash of a primitive-subgraph pattern. Reserved for step 4's
914/// declarative pattern engine; the hashing function is filled in alongside
915/// `PatternTree`. (The static catalog's `by_pattern_hash` index placeholder
916/// was removed unread; re-add it with that engine.)
917#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
918pub struct PatternHash(pub u64);
919
920/// Internal placeholder used to keep id-indexed `entries` dense. Never
921/// Decompose the fused node `id` by re-emitting a **data recipe** — the
922/// static-registry bridge onto the one region re-emitter (Increment C
923/// slice 1, T5 / design D6). A migrated entry's `decompose` body becomes:
924///
925/// ```ignore
926/// fn recipe() -> &'static PatternNode              // OnceLock-built data
927/// fn scalars(params: &FusedOpParams) -> Option<Vec<f64>>  // per-entry projection
928/// pub fn decompose(g, id, params) -> NodeId {
929///     decompose_via_recipe(g, id, recipe(), scalars(params))
930/// }
931/// ```
932///
933/// The fused node's `inputs` are the recipe's binds (in bind-index order);
934/// `scalars` fill the recipe's open scalar slots in pattern pre-order (the
935/// per-entry projection maps the params payload — e.g. an `eps` — onto
936/// them). The `FusedOpEntry.decompose` surface is UNCHANGED — imperative
937/// bodies remain legal for param-conditional ops.
938///
939/// **Totality (G2, the recipe principle):** ANY failure returns `id` — the
940/// fused node stays in place as a surfaced opaque-op gap (the lowering
941/// driver records no progress), NEVER a panic:
942/// * `scalars == None` — a wrong params payload for this entry;
943/// * a recipe that fails `crate::runtime_fused::validate_recipe` — a
944///   semantics-absent op token with no primitive re-emission (the
945///   flip-withdrawal posture: unknown/non-registry tokens are surfaced
946///   honest-miss declines, never accepted), non-contiguous binds, a
947///   matcher-only node, or structurally-unresolvable rel attrs;
948/// * a bind-count / node-input arity mismatch;
949/// * a scalar-slot count mismatch;
950/// * a shape-relative resolution decline at THESE input shapes (symbolic
951///   extent, negative result, …);
952/// * anything else — a `catch_unwind` last-resort guard converts an
953///   unanticipated panic into the same fixpoint.
954///
955/// The first four decline BEFORE any emission (no partial nodes). An
956/// emit-time decline can leave already-pushed child nodes behind as
957/// unreferenced dead nodes — inert, the documented push-only-graph posture.
958/// Declines are telemetered on stderr (the surfaced-gap channel until a
959/// structured gap inventory exists).
960/// **A fused node's recipe as portable data — the generic accessor the JIT seam
961/// needs.** Returns the same [`PatternNode`] the op's `decompose` lowers,
962/// *without touching the graph*.
963///
964/// `fuel_kernel_seam::JitRequest::region` is documented as "the recipe's
965/// `decompose` (the primitive subgraph)", so a synthesizer can be asked to build
966/// one kernel for a region the consumer discovered at runtime. Lowering needs
967/// every primitive bound on the target device; **handing the region over needs
968/// none of them** — which is the whole reason this accessor exists.
969///
970/// Dispatches on the fused op id. `None` means "this op does not expose its
971/// recipe as data (yet)", which is a *surfaced gap*, never a crash — the same
972/// G2 posture as a `decompose` fixpoint self-return. Adding an op is one arm.
973///
974/// Today only [`FusedOps::PAGED_ATTN`] implements it, because it is the op that
975/// forced the question: no GPU backend implements the fused form at all, so the
976/// JIT route is the only one that does not depend on every primitive being
977/// pre-registered.
978pub fn recipe_for(graph: &Graph, id: NodeId) -> Option<PatternNode> {
979    let (fused_id, params) = match &graph.node(id).op {
980        crate::Op::Fused(fid, p) => (*fid, p.clone()),
981        _ => return None,
982    };
983    match fused_id {
984        FusedOps::PAGED_ATTN => crate::registry::paged_attn::recipe_for(graph, id, &params),
985        _ => None,
986    }
987}
988
989pub fn decompose_via_recipe(
990    graph: &mut Graph,
991    id: NodeId,
992    recipe: &PatternNode,
993    scalars: Option<Vec<f64>>,
994) -> NodeId {
995    let Some(scalars) = scalars else {
996        eprintln!(
997            "decompose_via_recipe: wrong params payload for node {id:?} — \
998             typed decline, node stays fused (G2 fixpoint)"
999        );
1000        return id;
1001    };
1002    if let Err(err) = crate::runtime_fused::validate_recipe(recipe) {
1003        eprintln!(
1004            "decompose_via_recipe: recipe declined for node {id:?}: {err:?} — \
1005             surfaced honest miss, node stays fused (G2 fixpoint)"
1006        );
1007        return id;
1008    }
1009    let inputs = graph.node(id).inputs.clone();
1010    if recipe.bind_indices().len() != inputs.len() {
1011        eprintln!(
1012            "decompose_via_recipe: recipe binds {} != node inputs {} for node {id:?} — \
1013             typed decline, node stays fused (G2 fixpoint)",
1014            recipe.bind_indices().len(),
1015            inputs.len(),
1016        );
1017        return id;
1018    }
1019    if scalars.len() != crate::runtime_fused::count_scalar_slots(recipe) {
1020        eprintln!(
1021            "decompose_via_recipe: {} projected scalars for {} open slots on node {id:?} — \
1022             typed decline, node stays fused (G2 fixpoint)",
1023            scalars.len(),
1024            crate::runtime_fused::count_scalar_slots(recipe),
1025        );
1026        return id;
1027    }
1028    // The resolving emit. A rel-attr decline at these shapes is a
1029    // value-dependent gap (`Err` arm); `catch_unwind` is the never-panic
1030    // last resort for anything validation didn't anticipate (same posture as
1031    // `register_runtime_fused`'s hash guard) — a partial emission left by
1032    // either failure mode is inert dead nodes in the push-only graph.
1033    let emitted = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1034        crate::runtime_fused::try_emit_region(graph, recipe, &inputs, &scalars)
1035    }));
1036    match emitted {
1037        Ok(Ok(root)) => root,
1038        Ok(Err(err)) => {
1039            eprintln!(
1040                "decompose_via_recipe: rel-attr resolution declined for node {id:?}: {err:?} — \
1041                 surfaced gap, node stays fused (G2 fixpoint)"
1042            );
1043            id
1044        }
1045        Err(_) => {
1046            eprintln!(
1047                "decompose_via_recipe: emit panicked for node {id:?} — \
1048                 caught (never-panic last resort), node stays fused (G2 fixpoint)"
1049            );
1050            id
1051        }
1052    }
1053}
1054
1055/// returned by [`FusedOpRegistry::entry`].
1056fn placeholder_entry() -> FusedOpEntry {
1057    FusedOpEntry {
1058        destructive_input: None,
1059        id: FusedOpId::UNASSIGNED,
1060        name: "<unassigned>",
1061        family: FusedOpFamily::Forward,
1062        pattern: SubgraphPattern::Callable(|_g, _id| None),
1063        decompose: |_g, id, _p| id,
1064        backward: BackwardKind::NotDifferentiable,
1065        shape_rule: |_shapes, _params| Shape::from_dims(&[]),
1066        dtype_rule: |_dtypes, _params| DType::F32,
1067        output_views: None,
1068    }
1069}
1070
1071/// Associated constants for the well-known fused-op ids. Rule code
1072/// matches `Op::Fused(FusedOps::SOFTMAX_LAST_DIM, _)` — almost as
1073/// ergonomic as today's `Op::SoftmaxLastDim`. Steps 3-4 fill these in
1074/// as each fused op migrates to the registry.
1075pub struct FusedOps;
1076
1077impl FusedOps {
1078    /// SoftmaxLastDim — proof-of-concept migration target (step 3).
1079    pub const SOFTMAX_LAST_DIM: FusedOpId = FusedOpId(1);
1080    /// FusedLinear — first multi-input fused op migrated to the
1081    /// registry (step 4). Three inputs `[a, b, bias]`, output
1082    /// `(a @ b) + bias`. The CUTLASS bias-epilogue integration in the
1083    /// baracuda-cutlass alpha.13 plan registers here.
1084    pub const FUSED_LINEAR: FusedOpId = FusedOpId(2);
1085    /// RmsNormLastDim — `x / sqrt(mean(x²) + eps)` along the last dim.
1086    /// Migrated in step 4 (continued). Single input + eps param.
1087    pub const RMS_NORM_LAST_DIM: FusedOpId = FusedOpId(3);
1088    /// LayerNormLastDim — `(x - mean) / sqrt(var + eps)` along the
1089    /// last dim (no affine). Single input + eps param.
1090    pub const LAYER_NORM_LAST_DIM: FusedOpId = FusedOpId(4);
1091    /// Rope — rotary position embedding with caller-supplied cos/sin
1092    /// tables. Three inputs `[x, cos, sin]`, parameterless (the seq/
1093    /// head_dim shape parameters live in the input shapes).
1094    pub const ROPE: FusedOpId = FusedOpId(5);
1095    /// Conv2D — 2-D cross-correlation. Two or three inputs `[x,
1096    /// weight]` or `[x, weight, bias]`; carries stride / padding /
1097    /// groups in [`FusedOpParams::Conv2D`].
1098    pub const CONV2D: FusedOpId = FusedOpId(6);
1099    /// SoftmaxLastDimBackward — fused backward helper for the
1100    /// SoftmaxLastDim forward. Two inputs `[y, upstream]`,
1101    /// parameterless. Wired through `BackwardKind::Fused` from
1102    /// `SOFTMAX_LAST_DIM`'s entry.
1103    pub const SOFTMAX_LAST_DIM_BACKWARD: FusedOpId = FusedOpId(7);
1104    /// LayerNormLastDimBackward — fused backward helper for the
1105    /// LayerNormLastDim forward. Two inputs `[x, upstream]` + eps.
1106    pub const LAYER_NORM_LAST_DIM_BACKWARD: FusedOpId = FusedOpId(8);
1107    /// RmsNormLastDimBackward — fused backward helper for the
1108    /// RmsNormLastDim forward. Two inputs `[x, upstream]` + eps.
1109    pub const RMS_NORM_LAST_DIM_BACKWARD: FusedOpId = FusedOpId(9);
1110    /// ReduceMaxToBackward — fused backward helper for the
1111    /// `Op::ReduceMaxTo` primitive (routes upstream to argmax
1112    /// positions, fair-share on ties). Two inputs `[x, upstream]`,
1113    /// parameterless. Note this is the backward of a *primitive*
1114    /// (`Op::ReduceMaxTo`), not of a fused forward — there's no
1115    /// `BackwardKind::Fused` edge wired to this helper, autograd
1116    /// reaches it directly from `Op::ReduceMaxTo`'s arm.
1117    pub const REDUCE_MAX_TO_BACKWARD: FusedOpId = FusedOpId(10);
1118    /// ConvTranspose2D — fractionally-strided 2-D convolution.
1119    /// Two inputs `[x, weight]` (no bias); carries
1120    /// stride/padding/output_padding/dilation/groups in
1121    /// [`FusedOpParams::ConvTranspose2D`].
1122    pub const CONV_TRANSPOSE2D: FusedOpId = FusedOpId(11);
1123    /// FlashAttn — multi-head attention with FlashAttention-shaped
1124    /// kernel hooks. 4-5 inputs (q, k, v, optional alibi).
1125    pub const FLASH_ATTN: FusedOpId = FusedOpId(12);
1126    /// PagedAttn — paged-cache attention. 5-6 inputs.
1127    pub const PAGED_ATTN: FusedOpId = FusedOpId(13);
1128    /// QMatMul — quantized matmul `C = A @ dequant(W_Q)`. Two
1129    /// inputs `[a, w_q_bytes]`.
1130    pub const QMATMUL: FusedOpId = FusedOpId(14);
1131    /// PowIBackward — fused backward helper for `Op::PowI(n)`.
1132    /// `grad_x = n · x^(n-1) · upstream`. Two inputs `[x, upstream]`,
1133    /// carries `exp: i32` in [`FusedOpParams::PowIBackward`]. Replaces
1134    /// the autograd primitive decomposition (PowI(n-1) → MulScalar →
1135    /// Mul, 3 nodes) with a single launch of baracuda alpha.31's
1136    /// `unary_powi_backward_*` kernel. Note this is the backward of a
1137    /// primitive (`Op::PowI`), not of a fused forward — autograd
1138    /// reaches it directly from `Op::PowI`'s arm rather than via a
1139    /// `BackwardKind::Fused` edge.
1140    pub const POWI_BACKWARD: FusedOpId = FusedOpId(15);
1141
1142    /// In-place affine: `x = mul·x + add`. Single input (the mutated
1143    /// tensor), single launch into baracuda's `affine_inplace_*`
1144    /// symbol on CUDA. Carries `mul: f64, add: f64` in
1145    /// [`FusedOpParams::InplaceAffine`]. Marked destructive on input
1146    /// 0 via `Op::destructive_input`, so `opt::derive_ordering` pins
1147    /// it to run after every non-destructive reader. Phase 1 of the
1148    /// in-place ops infrastructure
1149    /// (`docs/session-prompts/in-place-ops-infrastructure.md`);
1150    /// dispatch + autograd integration deferred to Phases 3 + 4.
1151    pub const INPLACE_AFFINE: FusedOpId = FusedOpId(16);
1152
1153    /// FusedSoftmaxCrossEntropy — fused softmax + NLL over class-indexed
1154    /// targets. Two inputs `[logits, targets]`; carries
1155    /// `{ reduction, ignore_index }` in
1156    /// [`FusedOpParams::FusedSoftmaxCrossEntropy`]. The forward kernel
1157    /// avoids materializing the `[..., V]` softmax/log-softmax
1158    /// intermediates that the primitive composition does (`fuel-core::
1159    /// train::cross_entropy_with_logits`). Backward via Decompose runs
1160    /// the primitive chain; the in-place Liger-style fused backward is a
1161    /// later session (needs in-place mutation infrastructure).
1162    pub const FUSED_SOFTMAX_CROSS_ENTROPY: FusedOpId = FusedOpId(17);
1163
1164    /// CausalConv1d — depthwise 1-D convolution + causal masking +
1165    /// optional fused SiLU. Three inputs `[x, weight, bias]` where
1166    /// `x: [batch, channels, seq + kernel - 1]`,
1167    /// `weight: [channels, 1, kernel]`, `bias: [channels]`. Carries
1168    /// `use_silu: bool` in [`FusedOpParams::CausalConv1d`]. The forward
1169    /// kernel matches baracuda's `causal_conv1d_*_run` contract.
1170    /// `BackwardKind::NotDifferentiable` for v1 (Mamba inference path
1171    /// today is inference-only).
1172    pub const CAUSAL_CONV1D: FusedOpId = FusedOpId(18);
1173
1174    /// SelectiveScan — Mamba-1's selective state-space scan
1175    /// (forward only). Five inputs `[u, delta, a, b, c]`; one output
1176    /// `y: [batch, seqlen, dim]`. Carries `delta_softplus: bool` in
1177    /// [`FusedOpParams::SelectiveScan`]. v1 drops the optional
1178    /// inputs (d_skip, z, delta_bias) and the `last_state` second
1179    /// output — both are mechanical follow-ups when a consumer needs
1180    /// them. `BackwardKind::NotDifferentiable` for v1.
1181    pub const SELECTIVE_SCAN: FusedOpId = FusedOpId(19);
1182
1183    /// SsdChunkScan — Mamba-2's State-Space Duality chunked scan
1184    /// (forward only). Five inputs `[x, dt, a, b, c]`; one output
1185    /// `y: [batch, seqlen, heads, head_dim]`. Carries
1186    /// `chunk_size: usize` in [`FusedOpParams::SsdChunkScan`]
1187    /// (parallelism granularity for the GPU path; the CPU kernel
1188    /// runs sequential and any positive `chunk_size` dividing
1189    /// `seqlen` produces the same mathematical answer).
1190    /// `BackwardKind::NotDifferentiable` for v1.
1191    pub const SSD_CHUNK_SCAN: FusedOpId = FusedOpId(20);
1192
1193    /// Nf4Matmul — bitsandbytes-style 4-bit NormalFloat quantized
1194    /// matrix multiply. Three inputs `[activations, w_packed, absmax]`
1195    /// where `w_packed: [N, K/2] U8` and `absmax: [N, K/block_size] F32`.
1196    /// Output `[..., M, N]` matching activations' dtype (F32/F16/BF16
1197    /// in v1). Carries `block_size: usize` in
1198    /// [`FusedOpParams::Nf4Matmul`]. `BackwardKind::NotDifferentiable`
1199    /// (NF4 weights are frozen; the dequant-then-matmul fallback is
1200    /// the wrong path for the cases NF4 exists to optimize).
1201    pub const NF4_MATMUL: FusedOpId = FusedOpId(21);
1202
1203    /// FlashAttnBackwardQ — produces dQ from `(q, k, v, do, [alibi])`.
1204    /// See [`fuel_ir::dispatch::OpKind::FlashAttnBackwardQ`]. Three
1205    /// separate FusedOp ids (Q/K/V) is the v1 design — a single
1206    /// multi-output op would share the recompute pass across all three
1207    /// gradients but needs multi-output infrastructure that doesn't
1208    /// exist yet. Reuses [`FusedOpParams::FlashAttnBackward`].
1209    pub const FLASH_ATTN_BACKWARD_Q: FusedOpId = FusedOpId(22);
1210    /// FlashAttnBackwardK — produces dK. See [`Self::FLASH_ATTN_BACKWARD_Q`].
1211    pub const FLASH_ATTN_BACKWARD_K: FusedOpId = FusedOpId(23);
1212    /// FlashAttnBackwardV — produces dV. See [`Self::FLASH_ATTN_BACKWARD_Q`].
1213    pub const FLASH_ATTN_BACKWARD_V: FusedOpId = FusedOpId(24);
1214
1215    /// AwqMatmul — AWQ (mit-han-lab) asymmetric int4 weight-only
1216    /// matmul. Four inputs `[activations, weight, zeros, scales]`
1217    /// (one more than NF4's three — AWQ's zero-point is a fourth
1218    /// operand, not baked into the dequant like NF4's codebook) where
1219    /// `weight: [N, K/8] U32`, `zeros: [N, zeros_w] U32`,
1220    /// `scales: [N, 8 * zeros_w] F16` — the baracuda kernel layout.
1221    /// Output `[..., M, N]` matching activations' dtype
1222    /// (F32/F16/BF16 in v1). Carries `{ group_size, split_k_iters }`
1223    /// in [`FusedOpParams::AwqMatmul`]. `BackwardKind::Decompose`
1224    /// (the weight is frozen; the activation gradient flows through
1225    /// the decompose). Unlike NF4 (a non-linear codebook with no
1226    /// primitive spelling), AWQ's affine dequant decomposes cleanly
1227    /// into primitives — see the module doc on
1228    /// [`crate::registry::awq_matmul`] for the recipe.
1229    pub const AWQ_MATMUL: FusedOpId = FusedOpId(25);
1230}
1231
1232/// Process-wide default registry: the union of every fused op's
1233/// metadata-side entry. Built once on first access via
1234/// [`std::sync::OnceLock`]; immutable thereafter (architecture v1.0:
1235/// no runtime extensibility).
1236///
1237/// Step 3 populates only SoftmaxLastDim; step 4 fills in the other
1238/// 12 fused ops as they migrate.
1239pub fn default_registry() -> &'static FusedOpRegistry {
1240    use std::sync::OnceLock;
1241    static REGISTRY: OnceLock<FusedOpRegistry> = OnceLock::new();
1242    REGISTRY.get_or_init(|| {
1243        FusedOpRegistry::new()
1244            .with_entry(softmax_last_dim::entry())
1245            .with_entry(fused_linear::entry())
1246            .with_entry(rms_norm_last_dim::entry())
1247            .with_entry(layer_norm_last_dim::entry())
1248            .with_entry(rope::entry())
1249            .with_entry(conv2d::entry())
1250            .with_entry(softmax_last_dim_backward::entry())
1251            .with_entry(layer_norm_last_dim_backward::entry())
1252            .with_entry(rms_norm_last_dim_backward::entry())
1253            .with_entry(reduce_max_to_backward::entry())
1254            .with_entry(conv_transpose_2d::entry())
1255            .with_entry(flash_attn::entry())
1256            .with_entry(flash_attn_backward::entry_q())
1257            .with_entry(flash_attn_backward::entry_k())
1258            .with_entry(flash_attn_backward::entry_v())
1259            .with_entry(paged_attn::entry())
1260            .with_entry(qmatmul::entry())
1261            .with_entry(powi_backward::entry())
1262            .with_entry(inplace_affine::entry())
1263            .with_entry(fused_softmax_cross_entropy::entry())
1264            .with_entry(causal_conv1d::entry())
1265            .with_entry(selective_scan::entry())
1266            .with_entry(ssd_chunk_scan::entry())
1267            .with_entry(nf4_matmul::entry())
1268            .with_entry(awq_matmul::entry())
1269    })
1270}
1271
1272#[cfg(test)]
1273mod tests {
1274    use super::*;
1275
1276    /// Smoke: id newtype is comparable + hashable.
1277    #[test]
1278    fn fused_op_id_basic() {
1279        let a = FusedOpId(1);
1280        let b = FusedOpId(1);
1281        let c = FusedOpId(2);
1282        assert_eq!(a, b);
1283        assert_ne!(a, c);
1284    }
1285
1286    /// Smoke: empty registry has no entries.
1287    #[test]
1288    fn registry_empty() {
1289        let r = FusedOpRegistry::new();
1290        assert!(r.is_empty());
1291        assert_eq!(r.len(), 0);
1292        assert!(r.entry(FusedOpId(1)).is_none());
1293        assert!(r.entry(FusedOpId::UNASSIGNED).is_none());
1294        assert!(r.id_for_name("anything").is_none());
1295    }
1296
1297    /// Smoke: register one entry, look it up by id and by name.
1298    #[test]
1299    fn registry_register_one() {
1300        fn dummy_decompose(_g: &mut Graph, id: NodeId, _p: &FusedOpParams) -> NodeId {
1301            id
1302        }
1303        fn dummy_pattern(_g: &Graph, _id: NodeId) -> Option<PatternMatch> {
1304            None
1305        }
1306        fn dummy_shape(_s: &[Shape], _p: &FusedOpParams) -> Shape {
1307            Shape::from_dims(&[1])
1308        }
1309        fn dummy_dtype(_d: &[DType], _p: &FusedOpParams) -> DType {
1310            DType::F32
1311        }
1312
1313        let r = FusedOpRegistry::new().with_entry(FusedOpEntry {
1314            destructive_input: None,
1315            id: FusedOps::SOFTMAX_LAST_DIM,
1316            name: "SoftmaxLastDim",
1317            family: FusedOpFamily::Forward,
1318            pattern: SubgraphPattern::Callable(dummy_pattern),
1319            decompose: dummy_decompose,
1320            backward: BackwardKind::NotDifferentiable,
1321            shape_rule: dummy_shape,
1322            dtype_rule: dummy_dtype,
1323            output_views: None,
1324        });
1325
1326        assert_eq!(r.len(), 1);
1327        assert!(!r.is_empty());
1328        let entry = r.entry(FusedOps::SOFTMAX_LAST_DIM).expect("registered");
1329        assert_eq!(entry.name, "SoftmaxLastDim");
1330        assert_eq!(entry.family, FusedOpFamily::Forward);
1331        assert_eq!(
1332            r.id_for_name("SoftmaxLastDim"),
1333            Some(FusedOps::SOFTMAX_LAST_DIM)
1334        );
1335    }
1336
1337    /// FusedOpParams::key produces a stable encoding.
1338    #[test]
1339    fn fused_op_params_key_softmax() {
1340        let k1 = FusedOpParams::SoftmaxLastDim.key();
1341        let k2 = FusedOpParams::SoftmaxLastDim.key();
1342        assert_eq!(k1, k2);
1343        assert_eq!(k1.tag, 1);
1344        assert!(k1.bits.is_empty());
1345        assert!(k1.ints.is_empty());
1346    }
1347}