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