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, ¶ms),
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}