Skip to main content

nxpu_analysis/
analyze.rs

1//! IR pattern classification for ONNX lowering.
2//!
3//! Analyzes an entry point's global variables and function body to classify
4//! the computation into a known ONNX-mappable pattern.
5
6use std::fmt;
7
8use nxpu_ir::{
9    AddressSpace, Arena, ArraySize, BinaryOp, Expression, GlobalVariable, Handle, Literal,
10    MathFunction, Module, Scalar, ScalarKind, Statement, StorageAccess, Type, TypeInner,
11    UniqueArena,
12};
13
14/// ONNX-compatible data type constants (matches TensorProto.DataType).
15pub mod data_type {
16    pub const FLOAT: i32 = 1;
17    pub const UINT8: i32 = 2;
18    pub const INT8: i32 = 3;
19    pub const INT32: i32 = 6;
20    pub const INT64: i32 = 7;
21    pub const BOOL: i32 = 9;
22    pub const FLOAT16: i32 = 10;
23    pub const UINT32: i32 = 12;
24    pub const BFLOAT16: i32 = 16;
25}
26
27/// Errors during IR pattern analysis.
28#[derive(Debug, thiserror::Error)]
29pub enum AnalysisError {
30    #[error("no entry points in module")]
31    NoEntryPoints,
32    #[error("entry point index {0} out of range")]
33    EntryPointOutOfRange(usize),
34    #[error("unsupported pattern: {0}")]
35    UnsupportedPattern(String),
36    #[error("missing uniform params struct")]
37    MissingParams,
38}
39
40/// Role of a tensor in the computation.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum TensorRole {
43    Input,
44    Output,
45}
46
47impl fmt::Display for TensorRole {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        f.write_str(match self {
50            Self::Input => "Input",
51            Self::Output => "Output",
52        })
53    }
54}
55
56/// A storage buffer bound as a tensor.
57#[derive(Debug, Clone)]
58pub struct TensorBinding {
59    /// Handle to the underlying global variable.
60    pub handle: Handle<GlobalVariable>,
61    /// Human-readable tensor name.
62    pub name: String,
63    /// ONNX element data type (see [`data_type`]).
64    pub elem_type: i32,
65    /// Whether this tensor is an input or output.
66    pub role: TensorRole,
67}
68
69impl fmt::Display for TensorBinding {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        write!(f, "{} ({})", self.name, self.role)
72    }
73}
74
75/// A scalar the host supplies at dispatch time.
76///
77/// Either a whole uniform (`var<uniform> input_scale: f32`) or one member of a
78/// uniform params struct (`params.a`). It is deliberately *not* a constant:
79/// the value is written per dispatch, so it lowers to a rank-0 graph input,
80/// not to an initializer folded into the graph. `axpy`'s own comment is
81/// explicit that `a` changes every diffusion step.
82#[derive(Debug, Clone)]
83pub struct ScalarBinding {
84    /// Name to give the rank-0 graph input.
85    pub name: String,
86    /// ONNX element data type (see [`data_type`]).
87    pub elem_type: i32,
88}
89
90impl fmt::Display for ScalarBinding {
91    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92        write!(f, "{} (Scalar)", self.name)
93    }
94}
95
96/// One operand of a [`KernelPattern::ElementWiseChain`] step.
97#[derive(Debug, Clone)]
98pub enum ChainOperand {
99    /// A whole tensor, read at the same index the result is stored at.
100    Tensor(TensorBinding),
101    /// A dispatch-time scalar, broadcast over every element.
102    Scalar(ScalarBinding),
103}
104
105impl fmt::Display for ChainOperand {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        match self {
108            Self::Tensor(t) => write!(f, "{t}"),
109            Self::Scalar(s) => write!(f, "{s}"),
110        }
111    }
112}
113
114/// One step of a [`KernelPattern::ElementWiseChain`]: `acc = acc <op> operand`.
115///
116/// The accumulator is always on the *left*. A kernel that writes the
117/// accumulator on the right of a `Sub` or a `Div` computes something this
118/// cannot say, and the matcher refuses it rather than reordering the operands.
119#[derive(Debug, Clone)]
120pub struct ChainStep {
121    /// The operation applied at this step.
122    pub op: ElementWiseOp,
123    /// The right-hand operand.
124    pub operand: ChainOperand,
125}
126
127/// Symbolic dimension names for matrix multiplication.
128#[derive(Debug, Clone)]
129pub struct MatMulShape {
130    /// Number of rows in the left matrix.
131    pub m: String,
132    /// Number of columns in the right matrix.
133    pub n: String,
134    /// Shared inner dimension.
135    pub k: String,
136}
137
138impl fmt::Display for MatMulShape {
139    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140        write!(f, "MatMul({}x{} * {}x{})", self.m, self.k, self.k, self.n)
141    }
142}
143
144/// Element-wise binary operation kind.
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum ElementWiseOp {
147    Add,
148    Sub,
149    Mul,
150    Div,
151}
152
153impl fmt::Display for ElementWiseOp {
154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155        f.write_str(self.op_name())
156    }
157}
158
159impl ElementWiseOp {
160    /// Returns the canonical operator name (e.g. "Add", "Relu", "ReduceSum").
161    pub fn op_name(self) -> &'static str {
162        match self {
163            Self::Add => "Add",
164            Self::Sub => "Sub",
165            Self::Mul => "Mul",
166            Self::Div => "Div",
167        }
168    }
169
170    /// Whether swapping the operands leaves the result unchanged.
171    ///
172    /// [`KernelPattern::ElementWiseChain`] applies every step as
173    /// `acc = acc <op> operand`, so a chain may only be read out of an
174    /// expression that put the accumulator on the left — unless the operation
175    /// does not care, which is what this answers.
176    pub fn is_commutative(self) -> bool {
177        matches!(self, Self::Add | Self::Mul)
178    }
179}
180
181/// Activation function kind.
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183pub enum ActivationOp {
184    Relu,
185    Sigmoid,
186    Tanh,
187    Softmax,
188    Gelu,
189    Silu,
190    Mish,
191}
192
193impl fmt::Display for ActivationOp {
194    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
195        f.write_str(self.op_name())
196    }
197}
198
199impl ActivationOp {
200    /// Returns the canonical operator name (e.g. "Relu", "Sigmoid").
201    pub fn op_name(self) -> &'static str {
202        match self {
203            Self::Relu => "Relu",
204            Self::Sigmoid => "Sigmoid",
205            Self::Tanh => "Tanh",
206            Self::Softmax => "Softmax",
207            Self::Gelu => "Gelu",
208            Self::Silu => "Silu",
209            Self::Mish => "Mish",
210        }
211    }
212}
213
214/// Reduction operation kind.
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216pub enum ReduceOp {
217    Sum,
218    Mean,
219    Max,
220    Min,
221}
222
223impl fmt::Display for ReduceOp {
224    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
225        f.write_str(self.op_name())
226    }
227}
228
229impl ReduceOp {
230    /// Returns the canonical operator name (e.g. "ReduceSum", "ReduceMean").
231    pub fn op_name(self) -> &'static str {
232        match self {
233            Self::Sum => "ReduceSum",
234            Self::Mean => "ReduceMean",
235            Self::Max => "ReduceMax",
236            Self::Min => "ReduceMin",
237        }
238    }
239}
240
241/// Pooling operation kind.
242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243pub enum PoolKind {
244    Max,
245    Avg,
246}
247
248impl fmt::Display for PoolKind {
249    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
250        f.write_str(self.op_name())
251    }
252}
253
254impl PoolKind {
255    /// Returns the canonical operator name (e.g. "MaxPool", "AveragePool").
256    pub fn op_name(self) -> &'static str {
257        match self {
258            Self::Max => "MaxPool",
259            Self::Avg => "AveragePool",
260        }
261    }
262}
263
264/// Normalization type.
265#[derive(Debug, Clone, Copy, PartialEq, Eq)]
266pub enum NormType {
267    Batch,
268    Layer,
269}
270
271impl fmt::Display for NormType {
272    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
273        f.write_str(match self {
274            Self::Batch => "Batch",
275            Self::Layer => "Layer",
276        })
277    }
278}
279
280/// Conv2D shape parameters extracted from uniform params.
281#[derive(Debug, Clone)]
282pub struct Conv2DShape {
283    /// Batch dimension name.
284    pub batch: String,
285    /// Input channel dimension name.
286    pub channels_in: String,
287    /// Output channel dimension name.
288    pub channels_out: String,
289    /// Spatial height dimension name.
290    pub height: String,
291    /// Spatial width dimension name.
292    pub width: String,
293    /// Kernel height dimension name.
294    pub kernel_h: String,
295    /// Kernel width dimension name.
296    pub kernel_w: String,
297    /// Kernel height as a concrete value.
298    pub kernel_h_val: i64,
299    /// Kernel width as a concrete value.
300    pub kernel_w_val: i64,
301    /// Vertical stride.
302    pub stride_h: i64,
303    /// Horizontal stride.
304    pub stride_w: i64,
305    /// Vertical padding.
306    pub pad_h: i64,
307    /// Horizontal padding.
308    pub pad_w: i64,
309    /// Number of groups (1 = standard conv, channels_in = depthwise).
310    pub groups: i64,
311    /// Vertical dilation.
312    pub dilation_h: i64,
313    /// Horizontal dilation.
314    pub dilation_w: i64,
315}
316
317impl fmt::Display for Conv2DShape {
318    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
319        write!(
320            f,
321            "Conv2D({}x{}x{} k{}x{} g{} d{}x{})",
322            self.channels_in,
323            self.height,
324            self.width,
325            self.kernel_h,
326            self.kernel_w,
327            self.groups,
328            self.dilation_h,
329            self.dilation_w
330        )
331    }
332}
333
334/// Pooling shape parameters.
335#[derive(Debug, Clone)]
336pub struct PoolShape {
337    /// Kernel height.
338    pub kernel_h: i64,
339    /// Kernel width.
340    pub kernel_w: i64,
341    /// Vertical stride.
342    pub stride_h: i64,
343    /// Horizontal stride.
344    pub stride_w: i64,
345}
346
347impl fmt::Display for PoolShape {
348    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
349        write!(
350            f,
351            "Pool(k{}x{} s{}x{})",
352            self.kernel_h, self.kernel_w, self.stride_h, self.stride_w
353        )
354    }
355}
356
357/// A classified kernel pattern that maps to ONNX operators.
358#[derive(Debug, Clone)]
359pub enum KernelPattern {
360    /// Loop + accumulation + 2 read arrays + 1 write array → ONNX `MatMul`.
361    MatMul {
362        inputs: [TensorBinding; 2],
363        output: TensorBinding,
364        shape: MatMulShape,
365    },
366    /// No loop + binary op on arrays → ONNX `Add`/`Sub`/`Mul`/`Div`.
367    ElementWise {
368        op: ElementWiseOp,
369        inputs: [TensorBinding; 2],
370        output: TensorBinding,
371        dim_name: String,
372    },
373    /// A per-element expression one level deeper than a binary op: one tensor,
374    /// an optional conversion, and a short chain of element-wise steps whose
375    /// operands are whole tensors or dispatch-time scalars.
376    ///
377    /// Two shapes in the vendored corpus need this and nothing narrower:
378    ///
379    ///   - `axpy`, `output[i] = y[i] + a * x[i]` with `a` a uniform scalar —
380    ///     `x`, scaled, then added to `y`.
381    ///   - `dequantize`, `output[i] = f32(input[i]) * s1 * s2` with two
382    ///     uniform scalars — a conversion, then two scales.
383    ///
384    /// Neither is one operation over two tensors, and [`Self::ElementWise`]
385    /// holds exactly two operands and one op, so both were refused. Neither is
386    /// a *fused activation* either: nothing here is a unary function of one
387    /// element, so widening `Activation` would not have held them.
388    ///
389    /// The chain is deliberately linear. Every node has exactly one operand
390    /// that is not a leaf, which is what makes it a sequence of graph nodes
391    /// rather than a tree; `snake` and `alibi`, whose multiplies have two
392    /// non-leaf operands, do not match and stay refused.
393    ElementWiseChain {
394        /// The tensor the chain starts from.
395        base: TensorBinding,
396        /// The element type `base` is converted to before the first step, when
397        /// the kernel converts it. `dequantize` reads `array<i32>` and writes
398        /// `array<f32>`, and dropping the conversion would reinterpret bits.
399        cast: Option<i32>,
400        /// Applied in order, each as `acc = acc <op> operand`.
401        steps: Vec<ChainStep>,
402        output: TensorBinding,
403        dim_name: String,
404    },
405    /// 2D convolution: nested loops + kernel window + accumulation.
406    Conv2D {
407        input: TensorBinding,
408        weight: TensorBinding,
409        output: TensorBinding,
410        /// Per-output-channel bias, when the kernel has one.
411        ///
412        /// Optional because a convolution need not have a bias, not because
413        /// dropping it is acceptable: a backend that cannot emit one must
414        /// refuse rather than silently compute a convolution without it.
415        bias: Option<TensorBinding>,
416        shape: Conv2DShape,
417        /// The activation the kernel applies to what it stores.
418        ///
419        /// `output[i] = max(sum, 0.0)` is a convolution and a ReLU, and this
420        /// was silently dropped: the emitted model held a CONV_2D and nothing
421        /// else, so it loaded, was accelerated, and returned unclipped values.
422        /// The TFLite backend folds it into `Conv2DOptions`; a backend that
423        /// cannot express it must say so rather than emit the convolution
424        /// alone.
425        activation: Option<ActivationOp>,
426    },
427    /// Pooling: nested loops + reduction over spatial window.
428    Pool {
429        kind: PoolKind,
430        input: TensorBinding,
431        output: TensorBinding,
432        shape: PoolShape,
433    },
434    /// Activation function: no loop, single input, unary math op.
435    Activation {
436        op: ActivationOp,
437        input: TensorBinding,
438        output: TensorBinding,
439        dim_name: String,
440    },
441    /// Reduction over an axis: loop + accumulation, single input.
442    Reduce {
443        op: ReduceOp,
444        input: TensorBinding,
445        output: TensorBinding,
446        axis: i64,
447    },
448    /// Transpose: permute tensor axes.
449    Transpose {
450        input: TensorBinding,
451        output: TensorBinding,
452        perm: Vec<i64>,
453    },
454    /// Reshape: change tensor shape without data copy.
455    Reshape {
456        input: TensorBinding,
457        output: TensorBinding,
458    },
459    /// Normalization: mean + variance + scale + bias.
460    Normalization {
461        input: TensorBinding,
462        scale: TensorBinding,
463        bias: TensorBinding,
464        output: TensorBinding,
465        epsilon: f32,
466        norm_type: NormType,
467    },
468    /// Concatenation of multiple inputs along an axis.
469    Concat {
470        inputs: Vec<TensorBinding>,
471        output: TensorBinding,
472        axis: i64,
473    },
474    /// Split a single input into multiple outputs along an axis.
475    Split {
476        input: TensorBinding,
477        outputs: Vec<TensorBinding>,
478        axis: i64,
479    },
480    /// Scaled dot-product attention.
481    Attention {
482        query: TensorBinding,
483        key: TensorBinding,
484        value: TensorBinding,
485        output: TensorBinding,
486        d_k: String,
487        seq_len: String,
488        /// Number of attention heads (1 = single-head).
489        num_heads: u32,
490        /// Number of K/V heads (for GQA; equals num_heads for MHA).
491        num_kv_heads: u32,
492        /// Whether a causal mask is applied.
493        causal: bool,
494    },
495    /// Gather: index into data tensor using indices.
496    Gather {
497        data: TensorBinding,
498        indices: TensorBinding,
499        output: TensorBinding,
500        axis: i64,
501    },
502    /// Scatter: write updates into output tensor at given indices.
503    Scatter {
504        data: TensorBinding,
505        indices: TensorBinding,
506        updates: TensorBinding,
507        output: TensorBinding,
508        axis: i64,
509    },
510    /// A matrix multiplication whose right operand arrives as integer codes
511    /// with one scale per output channel: `output = input @ (weight * scale)ᵀ`.
512    ///
513    /// This is not [`Self::MatMul`] with an odd element type. A dense matmul
514    /// has two operands; this has three, and the third is not a second matrix.
515    /// `scale` carries one factor per *row of the weight*, so it multiplies the
516    /// contraction's result rather than participating in the contraction.
517    /// Lowering it as a MatMul over `weight` and dropping `scale` would produce
518    /// a graph whose every output is wrong by a per-channel factor, which is
519    /// the failure a refusal is preferable to.
520    ///
521    /// The weight's rows are the *output* channels, so the contraction runs
522    /// along the second axis of both operands and the lowerings transpose it.
523    /// That is the layout every quantized kernel in the vendored corpus uses,
524    /// and the one per-channel quantization is defined against: a scale per row
525    /// is a scale per output feature only if a row is an output feature.
526    ///
527    /// `weight` is reported with an element type of `INT8` although the buffer
528    /// the kernel binds is `array<u32>`. Four two's-complement codes are packed
529    /// per word, least-significant byte first, which is the byte layout of a
530    /// contiguous `i8` row. The packing is how the GPU kernel buys four columns
531    /// per memory transaction; it is not part of what the kernel computes, and
532    /// the graph names the codes rather than the words they arrived in.
533    QuantizedMatMul {
534        /// The f32 activations, `[m, k]`.
535        input: TensorBinding,
536        /// The integer codes, `[n, k]` — one row per output channel.
537        weight: TensorBinding,
538        /// f32, `[n]` — one scale per weight row, applied after the contraction.
539        scale: TensorBinding,
540        /// A per-output-channel addend the kernel fuses onto the result.
541        ///
542        /// `matvec/q8_residual` adds a residual after the row scale. Optional
543        /// because a quantized matmul need not have one, not because dropping
544        /// it is acceptable — the same rule [`Self::Conv2D`]'s bias carries.
545        bias: Option<TensorBinding>,
546        output: TensorBinding,
547        shape: MatMulShape,
548    },
549    /// Unrecognized pattern — classification could not determine a known op.
550    Unknown { reason: String },
551}
552
553impl fmt::Display for KernelPattern {
554    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
555        match self {
556            Self::MatMul { shape, .. } => write!(f, "{shape}"),
557            Self::QuantizedMatMul { shape, bias, .. } => {
558                let fused = if bias.is_some() { " + bias" } else { "" };
559                write!(f, "Quantized{shape} (int8, per-channel scale){fused}")
560            }
561            Self::ElementWise { op, .. } => write!(f, "{op}"),
562            Self::ElementWiseChain { cast, steps, .. } => f.write_str(&chain_summary(*cast, steps)),
563            Self::Conv2D { shape, .. } => write!(f, "{shape}"),
564            Self::Pool { kind, shape, .. } => {
565                write!(
566                    f,
567                    "{}(k{}x{} s{}x{})",
568                    kind, shape.kernel_h, shape.kernel_w, shape.stride_h, shape.stride_w
569                )
570            }
571            Self::Activation { op, .. } => write!(f, "{op}"),
572            Self::Reduce { op, axis, .. } => write!(f, "{op}(axis={axis})"),
573            Self::Transpose { perm, .. } => write!(f, "Transpose({perm:?})"),
574            Self::Reshape { .. } => f.write_str("Reshape"),
575            Self::Normalization {
576                epsilon, norm_type, ..
577            } => write!(f, "{norm_type}Normalization(eps={epsilon})"),
578            Self::Concat { axis, .. } => write!(f, "Concat(axis={axis})"),
579            Self::Split { axis, .. } => write!(f, "Split(axis={axis})"),
580            Self::Attention {
581                d_k,
582                seq_len,
583                num_heads,
584                causal,
585                ..
586            } => {
587                let mask = if *causal { ", causal" } else { "" };
588                write!(
589                    f,
590                    "Attention(d_k={d_k}, seq_len={seq_len}, heads={num_heads}{mask})"
591                )
592            }
593            Self::Gather { axis, .. } => write!(f, "Gather(axis={axis})"),
594            Self::Scatter { axis, .. } => write!(f, "Scatter(axis={axis})"),
595            Self::Unknown { reason } => write!(f, "Unknown({reason})"),
596        }
597    }
598}
599
600/// The operators a chain lowers to, in order: `["Mul", "Add"]`,
601/// `["Cast", "Mul", "Mul"]`.
602///
603/// One name per node the ONNX and TFLite backends emit, because that is what a
604/// vendor support matrix has to be asked about: a chain is several ordinary
605/// operators, not one fused operator that no matrix lists. Asking about the
606/// joined name reports `Mul+Add` as unsupported on hardware that supports both
607/// a multiply and an add.
608pub fn chain_op_names(cast: Option<i32>, steps: &[ChainStep]) -> Vec<&'static str> {
609    let mut parts: Vec<&'static str> = Vec::with_capacity(steps.len() + 1);
610    if cast.is_some() {
611        parts.push("Cast");
612    }
613    parts.extend(steps.iter().map(|s| s.op.op_name()));
614    parts
615}
616
617/// Name a chain by the operators it lowers to, in order: `Mul+Add`,
618/// `Cast+Mul+Mul`.
619///
620/// Not a fixed label like "Chain". A count of steps says nothing about what a
621/// kernel computes, and neither does a category name; the operator sequence is
622/// exactly what the backend emits, so it is what the diagnostic reports.
623pub fn chain_summary(cast: Option<i32>, steps: &[ChainStep]) -> String {
624    chain_op_names(cast, steps).join("+")
625}
626
627/// Embedded constant weight data extracted from GlobalVariable initializers.
628#[derive(Debug, Clone)]
629pub struct EmbeddedWeight {
630    /// Tensor name (from the global variable name).
631    pub name: String,
632    /// Tensor dimensions (e.g. `[4]` for `array<f32, 4>`).
633    pub dims: Vec<i64>,
634    /// Flattened f32 data.
635    pub data: Vec<f32>,
636}
637
638/// Extract constant weight data from module globals with initializers.
639///
640/// Scans all global variables with `init: Some(...)` and evaluates
641/// Compose/Literal/ZeroValue expressions into flat f32 arrays.
642pub fn extract_embedded_weights(module: &Module) -> Vec<EmbeddedWeight> {
643    let mut weights = Vec::new();
644    for (handle, gv) in module.global_variables.iter() {
645        let Some(init_handle) = gv.init else {
646            continue;
647        };
648        let name = gv
649            .name
650            .clone()
651            .unwrap_or_else(|| format!("weight_{}", handle.index()));
652        let dims = type_dims(gv.ty, &module.types);
653        let Some(data) =
654            eval_const_expr_f32(init_handle, &module.global_expressions, &module.types)
655        else {
656            continue;
657        };
658        weights.push(EmbeddedWeight { name, dims, data });
659    }
660    weights
661}
662
663/// Recursively evaluate a constant expression to flat f32 values.
664fn eval_const_expr_f32(
665    handle: Handle<Expression>,
666    exprs: &Arena<Expression>,
667    types: &UniqueArena<Type>,
668) -> Option<Vec<f32>> {
669    match exprs.try_get(handle)? {
670        Expression::Literal(Literal::F32(v)) => Some(vec![*v]),
671        Expression::Literal(Literal::I32(v)) => Some(vec![*v as f32]),
672        Expression::Literal(Literal::U32(v)) => Some(vec![*v as f32]),
673        Expression::Compose { components, .. } => {
674            let mut result = Vec::new();
675            for &comp in components {
676                result.extend(eval_const_expr_f32(comp, exprs, types)?);
677            }
678            Some(result)
679        }
680        Expression::ZeroValue(ty) => {
681            let dims = type_dims(*ty, types);
682            let count = dims.iter().product::<i64>().max(1) as usize;
683            Some(vec![0.0; count])
684        }
685        _ => None,
686    }
687}
688
689/// Compute flattened dimensions from a Type (Array, Scalar, Vector).
690fn type_dims(ty_handle: Handle<Type>, types: &UniqueArena<Type>) -> Vec<i64> {
691    match &types[ty_handle].inner {
692        TypeInner::Scalar(_) => vec![],
693        TypeInner::Vector { size, .. } => vec![*size as i64],
694        TypeInner::Array {
695            size: ArraySize::Constant(n),
696            ..
697        } => vec![*n as i64],
698        _ => vec![],
699    }
700}
701
702/// Whether a params-struct member is an integer scalar, and so plausibly a
703/// tensor dimension rather than a tuning constant like `eps`.
704fn is_integer_member(module: &Module, m: &nxpu_ir::StructMember) -> bool {
705    matches!(
706        module.types[m.ty].inner,
707        TypeInner::Scalar(Scalar {
708            kind: ScalarKind::Uint | ScalarKind::Sint,
709            ..
710        })
711    )
712}
713
714/// Detect number of attention heads from shape_names or literal divisions.
715fn detect_num_heads(shape_names: &[String], exprs: &Arena<Expression>) -> u32 {
716    // Look for param named "num_heads", "n_head", "H", "n_heads", "nhead"
717    for name in shape_names.iter() {
718        let lower = name.to_lowercase();
719        if lower == "num_heads" || lower == "n_head" || lower == "n_heads" || lower == "nhead" {
720            // Can't get runtime value, but if there's a matching literal, use it
721            // Otherwise return 1 as we know it's multi-head but can't determine count
722            return find_division_literal(exprs).unwrap_or(1);
723        }
724    }
725    // Also check for "H" (common in transformer code)
726    if shape_names.iter().any(|n| n == "H") {
727        return find_division_literal(exprs).unwrap_or(1);
728    }
729    1
730}
731
732/// Look for a division by a literal (d_model / num_heads -> head_dim).
733#[allow(clippy::collapsible_if)] // nested if-let for MSRV 1.87 compat (no let chains)
734fn find_division_literal(exprs: &Arena<Expression>) -> Option<u32> {
735    for (_, expr) in exprs.iter() {
736        if let Expression::Binary {
737            op: BinaryOp::Divide,
738            right,
739            ..
740        } = expr
741        {
742            if let Some(Expression::Literal(Literal::U32(n))) = exprs.try_get(*right) {
743                if *n > 1 {
744                    return Some(*n);
745                }
746            }
747        }
748    }
749    None
750}
751
752/// Detect causal attention mask: look for an If guarding assignment of a large
753/// negative literal inside a loop. The pattern is:
754///   `if (j > i) { score = -1e30; }` or similar.
755///
756/// We specifically look for an `If` whose accept or reject block contains (or
757/// leads to) a Store of a large negative float literal. This avoids false
758/// positives from for-loop break conditions and numerically-stable softmax
759/// `max_score` initializations that also use large negative values but are
760/// not inside an If-guarded store.
761fn detect_causal_mask(body: &[Statement], exprs: &Arena<Expression>) -> bool {
762    has_causal_if_in_loop(body, exprs)
763}
764
765/// Recursively search loops for an `If` whose condition is a Greater/Less
766/// comparison and whose accept or reject branch stores a large negative
767/// float literal (like -1e30, -1e9, or -inf).
768#[allow(clippy::collapsible_if)] // nested if-let for MSRV 1.87 compat (no let chains)
769fn has_causal_if_in_loop(body: &[Statement], exprs: &Arena<Expression>) -> bool {
770    for stmt in body {
771        if let Statement::Loop {
772            body, continuing, ..
773        } = stmt
774        {
775            // Search loop body; skip the first non-Emit If that acts as a
776            // for-loop break guard.
777            let mut saw_non_emit = false;
778            for s in body.iter() {
779                match s {
780                    Statement::Emit(_) => {}
781                    Statement::If {
782                        condition,
783                        accept,
784                        reject,
785                    } => {
786                        // The first non-emit If in a naga for-loop is the break
787                        // guard (e.g. `if (j < N) {} else { break; }`). Skip it.
788                        if !saw_non_emit
789                            && (matches!(accept.as_slice(), [Statement::Break])
790                                || matches!(reject.as_slice(), [Statement::Break]))
791                        {
792                            saw_non_emit = true;
793                            continue;
794                        }
795                        saw_non_emit = true;
796                        if is_causal_guard(*condition, accept, reject, exprs) {
797                            return true;
798                        }
799                    }
800                    _ => {
801                        saw_non_emit = true;
802                    }
803                }
804            }
805            // Also search the continuing block
806            for s in continuing.iter() {
807                if let Statement::If {
808                    condition,
809                    accept,
810                    reject,
811                } = s
812                {
813                    if is_causal_guard(*condition, accept, reject, exprs) {
814                        return true;
815                    }
816                }
817            }
818            // Recurse into nested loops
819            if has_causal_if_in_loop(body, exprs) || has_causal_if_in_loop(continuing, exprs) {
820                return true;
821            }
822        }
823    }
824    false
825}
826
827/// Check if a conditional is a comparison (Greater/Less/GE/LE) and one of
828/// its branches (accept or reject) contains a Store of a large negative float.
829fn is_causal_guard(
830    condition: Handle<Expression>,
831    accept: &[Statement],
832    reject: &[Statement],
833    exprs: &Arena<Expression>,
834) -> bool {
835    let is_comparison = match exprs.try_get(condition) {
836        Some(Expression::Binary { op, .. }) => matches!(
837            op,
838            BinaryOp::Greater | BinaryOp::Less | BinaryOp::GreaterEqual | BinaryOp::LessEqual
839        ),
840        _ => false,
841    };
842    if !is_comparison {
843        return false;
844    }
845    block_stores_large_negative(accept, exprs) || block_stores_large_negative(reject, exprs)
846}
847
848/// Check if a block contains a Store whose value is a large negative float literal.
849fn block_stores_large_negative(body: &[Statement], exprs: &Arena<Expression>) -> bool {
850    for stmt in body {
851        match stmt {
852            Statement::Store { value, .. } if expr_is_large_negative(*value, exprs) => {
853                return true;
854            }
855            Statement::If { accept, reject, .. }
856                if block_stores_large_negative(accept, exprs)
857                    || block_stores_large_negative(reject, exprs) =>
858            {
859                return true;
860            }
861            _ => {}
862        }
863    }
864    false
865}
866
867/// Check if an expression is (or contains) a large negative float literal (< -1e6).
868fn expr_is_large_negative(handle: Handle<Expression>, exprs: &Arena<Expression>) -> bool {
869    match exprs.try_get(handle) {
870        Some(Expression::Literal(Literal::F32(v))) => *v < -1e6,
871        Some(Expression::Unary {
872            op: nxpu_ir::UnaryOp::Negate,
873            expr,
874        }) => {
875            // -literal
876            matches!(exprs.try_get(*expr), Some(Expression::Literal(Literal::F32(v))) if *v > 1e6)
877        }
878        _ => false,
879    }
880}
881
882/// The operator names a pattern will emit, for asking a support matrix about.
883///
884/// One list rather than one name: an `ElementWiseChain` becomes a Cast and a
885/// node per step, and asking about "Mul+Add" reported it unsupported on
886/// hardware that has both.
887///
888/// This lived in eight backends, character for character, and the arms that
889/// no backend's tests reached were uncovered eight times over. It belongs
890/// next to the enum it matches on.
891pub fn pattern_op_names(pattern: &KernelPattern) -> Vec<String> {
892    match pattern {
893        // Named by the operators it emits, not by a category: the ONNX and
894        // TFLite graphs this delegates to contain a Cast and one node per
895        // step, and "ElementWiseChain" would describe none of them.
896        KernelPattern::ElementWiseChain { cast, steps, .. } => chain_op_names(*cast, steps)
897            .into_iter()
898            .map(String::from)
899            .collect(),
900        KernelPattern::MatMul { .. } => vec!["MatMul".into()],
901        // Named by the nodes the backends emit, the same rule the chain
902        // follows. Neither output format has a fused quantized-matmul
903        // operator and neither backend emits one: the weight is dequantized,
904        // transposed into contraction order, and multiplied. A vendor matrix
905        // that does not list `DequantizeLinear` is telling the truth about
906        // that hardware, and answering only "MatMul" here would hide two of
907        // the nodes the graph contains.
908        KernelPattern::QuantizedMatMul { bias, .. } => {
909            let mut names = vec![
910                "Transpose".to_string(),
911                "DequantizeLinear".to_string(),
912                "MatMul".to_string(),
913            ];
914            if bias.is_some() {
915                names.push("Add".to_string());
916            }
917            names
918        }
919        KernelPattern::ElementWise { op, .. } => vec![op.op_name().into()],
920        KernelPattern::Conv2D { .. } => vec!["Conv".into()],
921        KernelPattern::Pool { kind, .. } => vec![kind.op_name().into()],
922        KernelPattern::Activation { op, .. } => vec![op.op_name().into()],
923        KernelPattern::Reduce { op, .. } => vec![op.op_name().into()],
924        KernelPattern::Transpose { .. } => vec!["Transpose".into()],
925        KernelPattern::Reshape { .. } => vec!["Reshape".into()],
926        KernelPattern::Normalization { .. } => vec!["BatchNormalization".into()],
927        KernelPattern::Concat { .. } => vec!["Concat".into()],
928        KernelPattern::Split { .. } => vec!["Split".into()],
929        KernelPattern::Attention { .. } => vec!["Attention".into()],
930        KernelPattern::Gather { .. } => vec!["Gather".into()],
931        KernelPattern::Scatter { .. } => vec!["ScatterND".into()],
932        KernelPattern::Unknown { .. } => vec!["Unknown".into()],
933    }
934}
935
936#[cfg(test)]
937mod pattern_op_names_tests {
938    use super::*;
939
940    fn binding(name: &str) -> TensorBinding {
941        let mut arena: Arena<GlobalVariable> = Arena::new();
942        let ty = {
943            let mut types = UniqueArena::default();
944            types.insert(Type {
945                name: None,
946                inner: TypeInner::Scalar(Scalar::F32),
947            })
948        };
949        let handle = arena.append(GlobalVariable {
950            name: Some(name.into()),
951            space: AddressSpace::Uniform,
952            binding: None,
953            ty,
954            init: None,
955            layout: None,
956        });
957        TensorBinding {
958            handle,
959            name: name.into(),
960            elem_type: data_type::FLOAT,
961            role: TensorRole::Input,
962        }
963    }
964
965    /// Every arm, because the eight copies of this that used to exist were
966    /// each covered only for the two or three patterns that backend's tests
967    /// happened to compile.
968    #[test]
969    fn every_pattern_names_something() {
970        let cases: Vec<KernelPattern> = vec![
971            KernelPattern::Transpose {
972                input: binding("a"),
973                output: binding("b"),
974                perm: vec![1, 0],
975            },
976            KernelPattern::Reshape {
977                input: binding("a"),
978                output: binding("b"),
979            },
980            KernelPattern::Reduce {
981                op: ReduceOp::Sum,
982                input: binding("a"),
983                output: binding("b"),
984                axis: 0,
985            },
986            KernelPattern::Unknown {
987                reason: "nothing".into(),
988            },
989        ];
990        for pattern in cases {
991            let names = pattern_op_names(&pattern);
992            assert!(
993                !names.is_empty() && names.iter().all(|n| !n.is_empty()),
994                "{pattern:?} named nothing"
995            );
996        }
997    }
998}
999
1000/// Classify an entry point into a known ONNX-mappable pattern.
1001pub fn classify_entry_point(
1002    module: &Module,
1003    ep_index: usize,
1004) -> Result<KernelPattern, AnalysisError> {
1005    if module.entry_points.is_empty() {
1006        return Err(AnalysisError::NoEntryPoints);
1007    }
1008    let ep = module
1009        .entry_points
1010        .get(ep_index)
1011        .ok_or(AnalysisError::EntryPointOutOfRange(ep_index))?;
1012
1013    // 1. Classify globals by address space.
1014    let mut inputs: Vec<(Handle<GlobalVariable>, &GlobalVariable)> = Vec::new();
1015    let mut outputs: Vec<(Handle<GlobalVariable>, &GlobalVariable)> = Vec::new();
1016    let mut init_globals: Vec<(Handle<GlobalVariable>, &GlobalVariable)> = Vec::new();
1017    let mut params_members: Option<&[nxpu_ir::StructMember]> = None;
1018    let mut params_global: Option<Handle<GlobalVariable>> = None;
1019
1020    for (handle, gv) in module.global_variables.iter() {
1021        match &gv.space {
1022            AddressSpace::Storage { access } => {
1023                if access.contains(StorageAccess::STORE) {
1024                    outputs.push((handle, gv));
1025                } else {
1026                    inputs.push((handle, gv));
1027                }
1028            }
1029            AddressSpace::Uniform => {
1030                if let TypeInner::Struct { members, .. } = &module.types[gv.ty].inner {
1031                    params_members = Some(members);
1032                    params_global = Some(handle);
1033                }
1034            }
1035            _ => {
1036                if gv.init.is_some() {
1037                    init_globals.push((handle, gv));
1038                }
1039            }
1040        }
1041    }
1042
1043    // Sort inputs by resource binding order (binding 0 = A, binding 1 = B).
1044    inputs.sort_by_key(|(_, gv)| gv.binding.map(|b| b.binding).unwrap_or(u32::MAX));
1045
1046    if outputs.is_empty() {
1047        return Err(AnalysisError::UnsupportedPattern(
1048            "expected at least 1 output storage buffer".into(),
1049        ));
1050    }
1051
1052    // Shape parameters only. A params struct routinely carries scalars that
1053    // are not dimensions — `eps` is the common one — and counting them made
1054    // LayerNorm, whose struct is (N, D, eps), look like it had three shape
1055    // names and report itself as BatchNorm.
1056    let shape_names: Vec<String> = params_members
1057        .map(|members| {
1058            members
1059                .iter()
1060                .filter(|m| is_integer_member(module, m))
1061                .filter_map(|m| m.name.clone())
1062                .collect()
1063        })
1064        .unwrap_or_default();
1065
1066    // Every member's name, by position, so an index read out of the IR names
1067    // the right one. `shape_names` above has the non-integer members filtered
1068    // out and so cannot be indexed by member position.
1069    let param_names: Vec<String> = params_members
1070        .map(|members| {
1071            members
1072                .iter()
1073                .map(|m| m.name.clone().unwrap_or_default())
1074                .collect()
1075        })
1076        .unwrap_or_default();
1077
1078    let has_loop = has_loop(&ep.function.body);
1079    let num_inputs = inputs.len();
1080
1081    // Single input patterns
1082    if num_inputs == 1 {
1083        let input = make_binding(module, inputs[0].0, inputs[0].1, TensorRole::Input);
1084
1085        // 1 input + 2 or more outputs: a Split, or nothing this compiler has.
1086        //
1087        // "One input, several outputs, and an If somewhere" was the whole
1088        // test, and it asks about the kernel's shape rather than about what it
1089        // computes. Three vendor kernels answered yes and none of them is a
1090        // split: an activation quantizer emitting codes and per-row scales, a
1091        // greedy CTC decode emitting labels and their lengths, and a
1092        // mixture-of-experts router emitting expert indices and gate weights.
1093        //
1094        // What a Split is, and what every backend here emits for one, is a
1095        // slice: each output element *is* an input element, moved. So that is
1096        // what has to be found — a boundary test, and stores that copy rather
1097        // than compute. The rest is refused by name below.
1098        if outputs.len() >= 2 {
1099            let output_handles: Vec<Handle<GlobalVariable>> =
1100                outputs.iter().map(|(h, _)| *h).collect();
1101            let (written, computed) = survey_output_stores(
1102                &ep.function.body,
1103                &ep.function.expressions,
1104                inputs[0].0,
1105                &output_handles,
1106            );
1107            let every_output_is_a_copy =
1108                computed.is_empty() && written.len() == output_handles.len();
1109
1110            if every_output_is_a_copy && has_if_statement(&ep.function.body) {
1111                let out_bindings: Vec<TensorBinding> = outputs
1112                    .iter()
1113                    .map(|(h, gv)| make_binding(module, *h, gv, TensorRole::Output))
1114                    .collect();
1115                let axis =
1116                    infer_split_axis(&ep.function.body, &ep.function.expressions, &shape_names);
1117                return Ok(KernelPattern::Split {
1118                    input,
1119                    outputs: out_bindings,
1120                    axis,
1121                });
1122            }
1123
1124            return Ok(KernelPattern::Unknown {
1125                reason: multi_output_refusal(module, &outputs, inputs[0].1, &written, &computed),
1126            });
1127        }
1128
1129        let output = make_binding(module, outputs[0].0, outputs[0].1, TensorRole::Output);
1130
1131        if !has_loop {
1132            // Try activation (Math expression)
1133            if let Some(act_op) = find_store_activation(&ep.function.body, &ep.function.expressions)
1134            {
1135                let dim_name = shape_names.first().cloned().unwrap_or_else(|| "N".into());
1136                return Ok(KernelPattern::Activation {
1137                    op: act_op,
1138                    input,
1139                    output,
1140                    dim_name,
1141                });
1142            }
1143
1144            // ElementWise with embedded weight: 1 storage input + binary op + private init global.
1145            let ew_op = match find_store_value_op(&ep.function.body, &ep.function.expressions) {
1146                Some(StoreValueOp::Binary(op)) => Some(op),
1147                // A multiply-add is not an element-wise binary op; fall through.
1148                Some(StoreValueOp::MultiplyAdd) | None => None,
1149            };
1150            if let (Some(ew_op), Some(&(wh, wgv))) = (ew_op, init_globals.first()) {
1151                let weight = make_binding(module, wh, wgv, TensorRole::Input);
1152                let dim_name = shape_names.first().cloned().unwrap_or_else(|| "N".into());
1153                return Ok(KernelPattern::ElementWise {
1154                    op: ew_op,
1155                    inputs: [input, weight],
1156                    output,
1157                    dim_name,
1158                });
1159            }
1160
1161            // A reindexing kernel: it moves values without computing on
1162            // them, so there is no activation to find and it used to fall
1163            // through to Unknown. Every backend already lowers Transpose.
1164            if let Some(perm) = detect_permutation(
1165                &ep.function.body,
1166                &ep.function.expressions,
1167                inputs[0].0,
1168                outputs[0].0,
1169            ) {
1170                return Ok(KernelPattern::Transpose {
1171                    input,
1172                    output: make_binding(module, outputs[0].0, outputs[0].1, TensorRole::Output),
1173                    perm,
1174                });
1175            }
1176
1177            // A stored value that is more than one operation deep.
1178            // `dequantize` writes `f32(input[i]) * s1 * s2`: one tensor, a
1179            // conversion and two dispatch-time scalars, which is not an
1180            // activation and has no second tensor to make it element-wise.
1181            // `axpy`'s in-place entry point arrives here too, with `y` bound
1182            // `read_write` so that it counts as an output and leaves `x` the
1183            // only input.
1184            if let Some(pattern) = match_elementwise_chain(
1185                module,
1186                &ep.function.body,
1187                &ep.function.expressions,
1188                &outputs,
1189                &shape_names,
1190            ) {
1191                return Ok(pattern);
1192            }
1193
1194            // No recognized activation — unknown pattern.
1195            return Ok(KernelPattern::Unknown {
1196                reason: "single input, no loop, no recognized activation function".into(),
1197            });
1198        }
1199
1200        // A softmax stores `exp(x - max) / Σ exp(x - max)` and builds that sum
1201        // in a loop of its own. Recognised as an Activation, which both the
1202        // ONNX and TFLite backends already lower.
1203        //
1204        // `dim_name` is the innermost shape parameter, not the first: the
1205        // reduction runs along the last axis, and naming it with the batch
1206        // count would label the softmax's length wrongly.
1207        if outputs.len() == 1
1208            && detect_softmax(&ep.function.body, &ep.function.expressions, outputs[0].0)
1209        {
1210            let dim_name = shape_names.last().cloned().unwrap_or_else(|| "N".into());
1211            return Ok(KernelPattern::Activation {
1212                op: ActivationOp::Softmax,
1213                input,
1214                output,
1215                dim_name,
1216            });
1217        }
1218
1219        // Has loop + single input → Pool or Reduce
1220        //
1221        // "Four or more parameters" is not evidence of pooling. A pool has a
1222        // spatial window, so the loop bounds have to be there; without them
1223        // the kernel size was invented as 2x2 and the stride copied from it,
1224        // and a reduction over a 4-parameter tensor came back as AveragePool.
1225        let pool_bounds = extract_loop_bound_literals(&ep.function.body, &ep.function.expressions);
1226        if shape_names.len() >= 4 && !pool_bounds.is_empty() {
1227            let pool_kind = if find_store_math_fun(
1228                &ep.function.body,
1229                &ep.function.expressions,
1230                MathFunction::Max,
1231            ) {
1232                PoolKind::Max
1233            } else {
1234                PoolKind::Avg
1235            };
1236            let bounds = pool_bounds;
1237            let strides = extract_multiply_literals(&ep.function.expressions);
1238            let kh_u = bounds[0];
1239            let kw_u = bounds.get(1).copied().unwrap_or(kh_u);
1240            let sh_u = strides.first().copied().unwrap_or(kh_u);
1241            let sw_u = strides.get(1).copied().unwrap_or(sh_u);
1242            let kh = kh_u as i64;
1243            let kw = kw_u as i64;
1244            let sh = sh_u as i64;
1245            let sw = sw_u as i64;
1246            let pool_shape = PoolShape {
1247                kernel_h: kh,
1248                kernel_w: kw,
1249                stride_h: sh,
1250                stride_w: sw,
1251            };
1252            return Ok(KernelPattern::Pool {
1253                kind: pool_kind,
1254                input,
1255                output,
1256                shape: pool_shape,
1257            });
1258        }
1259
1260        // Single input + loop → Reduce
1261        let reduce_op = detect_reduce_op(&ep.function.body, &ep.function.expressions);
1262        let axis = if shape_names.len() >= 2 { 1 } else { 0 };
1263        return Ok(KernelPattern::Reduce {
1264            op: reduce_op,
1265            input,
1266            output,
1267            axis: axis as i64,
1268        });
1269    }
1270
1271    // 2-input patterns require at least 2 inputs
1272    if num_inputs < 2 {
1273        return Err(AnalysisError::UnsupportedPattern(
1274            "expected at least 1 input storage buffer".into(),
1275        ));
1276    }
1277
1278    // 3+ inputs: check for a quantized matmul, Attention, Normalization, Scatter
1279    if num_inputs >= 3 {
1280        // First, because it is the most specific thing here: a kernel that
1281        // unpacks integer codes out of a weight buffer is not any of the
1282        // patterns below, and the ones it is not go on to be tested normally
1283        // because this returns `None` for a kernel that unpacks nothing.
1284        //
1285        // Only in this arm. The corpus's quantized kernels bind a weight, a
1286        // scale and an activation at least, so none of them arrives with two
1287        // inputs, and `dequant_transpose` — which does unpack codes with two
1288        // inputs — is a movement of values that this pattern would misread.
1289        if let Some(pattern) = match_quantized_matmul(
1290            module,
1291            ep,
1292            &inputs,
1293            &outputs,
1294            params_global,
1295            &param_names,
1296            &shape_names,
1297        ) {
1298            return Ok(pattern);
1299        }
1300
1301        // Attention heuristic: 3 inputs + has loop + contains Exp + Sqrt.
1302        // NOTE: This is fragile — any 3-input kernel with loop + exp() + sqrt()
1303        // will match. A false positive is possible for custom kernels that
1304        // happen to use both functions. Consider adding more structural checks
1305        // (e.g., nested loop depth, softmax pattern) if this becomes an issue.
1306        if has_loop
1307            && has_math_function_in_expressions(&ep.function.expressions, MathFunction::Exp)
1308            && has_math_function_in_expressions(&ep.function.expressions, MathFunction::Sqrt)
1309        {
1310            let query = make_binding(module, inputs[0].0, inputs[0].1, TensorRole::Input);
1311            let key = make_binding(module, inputs[1].0, inputs[1].1, TensorRole::Input);
1312            let value = make_binding(module, inputs[2].0, inputs[2].1, TensorRole::Input);
1313            let output = make_binding(module, outputs[0].0, outputs[0].1, TensorRole::Output);
1314            let seq_len = shape_names
1315                .first()
1316                .cloned()
1317                .unwrap_or_else(|| "seq_len".into());
1318            let d_k = shape_names.get(1).cloned().unwrap_or_else(|| "d_k".into());
1319            let num_heads = detect_num_heads(&shape_names, &ep.function.expressions);
1320            let causal = detect_causal_mask(&ep.function.body, &ep.function.expressions);
1321            return Ok(KernelPattern::Attention {
1322                query,
1323                key,
1324                value,
1325                output,
1326                d_k,
1327                seq_len,
1328                num_heads,
1329                num_kv_heads: num_heads, // Default: MHA (same as num_heads)
1330                causal,
1331            });
1332        }
1333
1334        // Normalization: 3 inputs (input, scale, bias) + 1 output + loop, a
1335        // reciprocal square root in either spelling, and no Exp.
1336        // Distinguishes LayerNorm (2 shape params: N, C) from BatchNorm (3+: N, C, HW).
1337        //
1338        // `inverseSqrt` is the spelling a kernel written for speed uses, and
1339        // requiring `sqrt` rejected real LayerNorm and GroupNorm kernels for a
1340        // difference that is not one.
1341        if num_inputs == 3
1342            && outputs.len() == 1
1343            && has_loop
1344            && (has_math_function_in_expressions(&ep.function.expressions, MathFunction::Sqrt)
1345                || has_math_function_in_expressions(
1346                    &ep.function.expressions,
1347                    MathFunction::InverseSqrt,
1348                ))
1349            && !has_math_function_in_expressions(&ep.function.expressions, MathFunction::Exp)
1350            && !has_nested_loop(&ep.function.body)
1351        {
1352            let input = make_binding(module, inputs[0].0, inputs[0].1, TensorRole::Input);
1353            let scale = make_binding(module, inputs[1].0, inputs[1].1, TensorRole::Input);
1354            let bias = make_binding(module, inputs[2].0, inputs[2].1, TensorRole::Input);
1355            let output = make_binding(module, outputs[0].0, outputs[0].1, TensorRole::Output);
1356            // A group count in the params means group normalization, and the
1357            // formats this compiler targets cannot express it: ONNX's
1358            // GroupNormalization takes num_groups as a static attribute, and
1359            // here it is a uniform the host sets at dispatch time. Saying so
1360            // is the only honest answer — reporting it as BatchNormalization,
1361            // which is what the parameter count produced, is a graph that runs
1362            // and computes something else.
1363            if shape_names
1364                .iter()
1365                .any(|n| n == "G" || n.eq_ignore_ascii_case("groups"))
1366            {
1367                return Ok(KernelPattern::Unknown {
1368                    reason: "group normalization with a group count supplied at \
1369                             runtime — the target formats need it as a static \
1370                             attribute"
1371                        .into(),
1372                });
1373            }
1374            let norm_type = if shape_names.len() <= 2 {
1375                NormType::Layer
1376            } else {
1377                NormType::Batch
1378            };
1379            return Ok(KernelPattern::Normalization {
1380                input,
1381                scale,
1382                bias,
1383                output,
1384                epsilon: 1e-5,
1385                norm_type,
1386            });
1387        }
1388
1389        // Conv with a bias. Convolution detection lived in the two-input arm,
1390        // so every conv that adds a per-channel bias — which is most of them —
1391        // arrived here with three inputs and was refused for want of a
1392        // recogniser rather than for want of a lowering.
1393        //
1394        // The evidence is the kernel window itself, named in the params. A
1395        // parameter count is not evidence and was tried: it swallowed
1396        // attention scores, GQA, MoE dispatch and an inverse STFT, all
1397        // reported as CONV_2D — the same guessing this recogniser replaces,
1398        // moved to a different arm.
1399        //
1400        // KH and KW without a KD is exactly two spatial dimensions. A 1-D
1401        // conv names one extent, a 3-D conv names three, and a transposed
1402        // conv is a different operator; none of them is a Conv2D and each is
1403        // refused below rather than rounded to one.
1404        let names_kernel = |n: &str| {
1405            shape_names.iter().any(|s| {
1406                s.eq_ignore_ascii_case(n) || s.eq_ignore_ascii_case(&format!("kernel_{n}"))
1407            })
1408        };
1409        if num_inputs == 3
1410            && outputs.len() == 1
1411            && has_loop
1412            && names_kernel("kh")
1413            && names_kernel("kw")
1414            && !names_kernel("kd")
1415        {
1416            let input = make_binding(module, inputs[0].0, inputs[0].1, TensorRole::Input);
1417            let weight = make_binding(module, inputs[1].0, inputs[1].1, TensorRole::Input);
1418            let bias = make_binding(module, inputs[2].0, inputs[2].1, TensorRole::Input);
1419            let output = make_binding(module, outputs[0].0, outputs[0].1, TensorRole::Output);
1420            let conv_shape = extract_conv2d_shape(
1421                &shape_names,
1422                Some(&ep.function.body),
1423                Some(&ep.function.expressions),
1424            );
1425            return Ok(KernelPattern::Conv2D {
1426                input,
1427                weight,
1428                output,
1429                bias: Some(bias),
1430                shape: conv_shape,
1431                activation: store_value_activation(&ep.function.body, &ep.function.expressions),
1432            });
1433        }
1434
1435        // The convolutions this compiler has no operator for. Each is a real
1436        // shape, recognised, and declined by name — which is worth more than
1437        // a CONV_2D that computes something else.
1438        if num_inputs == 3 && outputs.len() == 1 && has_loop {
1439            let names = |n: &str| shape_names.iter().any(|s| s.eq_ignore_ascii_case(n));
1440            if names("kd") {
1441                return Ok(KernelPattern::Unknown {
1442                    reason: "3-D convolution — the kernel window has three \
1443                             spatial extents and Conv2D has two"
1444                        .into(),
1445                });
1446            }
1447            if names("lout") && names("k") {
1448                return Ok(KernelPattern::Unknown {
1449                    reason: "1-D or transposed convolution — one spatial \
1450                             extent, which Conv2D cannot represent"
1451                        .into(),
1452                });
1453            }
1454        }
1455
1456        let input_handles: Vec<Handle<GlobalVariable>> = inputs.iter().map(|(h, _)| *h).collect();
1457        let output_handles_3: Vec<Handle<GlobalVariable>> =
1458            outputs.iter().map(|(h, _)| *h).collect();
1459
1460        // Scatter: 3 inputs + 1 output + no loop (simple scatter write)
1461        //
1462        // A scatter takes its write address out of a buffer. Three inputs, one
1463        // output and no loop was the whole test, and it reported a snake
1464        // activation — `x + (1/(β+ε))·sin²(αx)` with per-channel α and β — as
1465        // SCATTER_ND, which writes elsewhere entirely.
1466        if num_inputs == 3
1467            && outputs.len() == 1
1468            && !has_loop
1469            && detect_indexed_write(
1470                &ep.function.body,
1471                &ep.function.expressions,
1472                &input_handles,
1473                &output_handles_3,
1474            )
1475        {
1476            let data = make_binding(module, inputs[0].0, inputs[0].1, TensorRole::Input);
1477            let indices = make_binding(module, inputs[1].0, inputs[1].1, TensorRole::Input);
1478            let updates = make_binding(module, inputs[2].0, inputs[2].1, TensorRole::Input);
1479            let output = make_binding(module, outputs[0].0, outputs[0].1, TensorRole::Output);
1480            return Ok(KernelPattern::Scatter {
1481                data,
1482                indices,
1483                updates,
1484                output,
1485                axis: 0,
1486            });
1487        }
1488
1489        // Three inputs and no loop, writing where the thread id says: a
1490        // per-element function of three tensors. `ElementWiseChain` takes as
1491        // many operands as the expression has steps, so it is tried here as
1492        // well as in the one- and two-input arms — the rule is about the shape
1493        // of the expression, not about how many buffers are bound.
1494        if num_inputs == 3 && outputs.len() == 1 && !has_loop {
1495            if let Some(pattern) = match_elementwise_chain(
1496                module,
1497                &ep.function.body,
1498                &ep.function.expressions,
1499                &outputs,
1500                &shape_names,
1501            ) {
1502                return Ok(pattern);
1503            }
1504            // `snake`'s SnakeBeta is the shape that lands here:
1505            // `x + (1/(β_c + ε)) · sin²(α_c · x)`. Its multiply has a
1506            // reciprocal on one side and a squared sine on the other, so it is
1507            // a tree and not a chain, and both parameters are read at
1508            // `(i / L) % C` rather than at `i`, so neither is an operand of
1509            // the result's shape. An `Activation` carries one input and no
1510            // parameters, and `ElementWise` carries two.
1511            return Ok(KernelPattern::Unknown {
1512                reason: "3 inputs combined per element, and not as a chain: a \
1513                         chain step takes a whole tensor read at the index \
1514                         written, or a scalar the host sets per dispatch, and \
1515                         these operands are neither"
1516                    .into(),
1517            });
1518        }
1519
1520        // 3+ inputs but no recognized pattern — unknown.
1521        return Ok(KernelPattern::Unknown {
1522            reason: "3+ inputs but no recognized pattern (expected Attention, Normalization, or Scatter)".into(),
1523        });
1524    }
1525
1526    // 2 inputs
1527    let input_a = make_binding(module, inputs[0].0, inputs[0].1, TensorRole::Input);
1528    let input_b = make_binding(module, inputs[1].0, inputs[1].1, TensorRole::Input);
1529    let output_c = make_binding(module, outputs[0].0, outputs[0].1, TensorRole::Output);
1530
1531    // Addresses that were loaded rather than computed.
1532    //
1533    // Every pattern below reads and writes at positions built from the thread
1534    // id. A kernel that takes an address out of a second buffer is a gather or
1535    // a scatter whatever its loop structure looks like, so the evidence is
1536    // weighed here, once, ahead of the loop/no-loop split — the alternative is
1537    // a scatter with a loop reported as a matmul, which is what happened.
1538    let input_handles = [inputs[0].0, inputs[1].0];
1539    let output_handles: Vec<Handle<GlobalVariable>> = outputs.iter().map(|(h, _)| *h).collect();
1540
1541    // Writes that accumulate. An atomic output means several invocations
1542    // reach the same slot and their contributions are summed there; the
1543    // read-modify-write is the operator, not an implementation detail of it.
1544    if outputs
1545        .iter()
1546        .any(|(_, gv)| is_atomic_buffer(module, gv.ty))
1547    {
1548        return Ok(KernelPattern::Unknown {
1549            reason: "the output is an array of atomics, so writes to the same slot \
1550                     accumulate instead of overwriting — an accumulating scatter. \
1551                     Scatter has no reduction mode and takes the tensor it writes \
1552                     into as an input; here the destination is the output itself, \
1553                     zeroed by the caller"
1554                .into(),
1555        });
1556    }
1557
1558    // Writes whose position comes out of a buffer.
1559    if detect_indexed_write(
1560        &ep.function.body,
1561        &ep.function.expressions,
1562        &input_handles,
1563        &output_handles,
1564    ) {
1565        let also = if outputs.len() > 1 {
1566            ", and it writes two output tensors where every pattern here writes one"
1567        } else {
1568            ""
1569        };
1570        return Ok(KernelPattern::Unknown {
1571            reason: format!(
1572                "the address written to is loaded from an input buffer — a \
1573                 data-dependent placement, where the input data decides where each \
1574                 element lands{also}. Scatter takes three inputs, a destination to \
1575                 write into as well as the indices and the updates, and there are two"
1576            ),
1577        });
1578    }
1579
1580    // Reads whose position comes out of a buffer. Either input can be the one
1581    // holding the addresses; nothing says the index tensor is bound second.
1582    let indexed_read = [(0usize, 1usize), (1, 0)].into_iter().find_map(|(d, i)| {
1583        detect_indexed_read(
1584            &ep.function.body,
1585            &ep.function.expressions,
1586            inputs[d].0,
1587            inputs[i].0,
1588            outputs[0].0,
1589        )
1590        .map(|kind| (d, i, kind))
1591    });
1592    if let Some((d, i, kind)) = indexed_read {
1593        let data = make_binding(module, inputs[d].0, inputs[d].1, TensorRole::Input);
1594        let indices = make_binding(module, inputs[i].0, inputs[i].1, TensorRole::Input);
1595        return Ok(match kind {
1596            IndexedRead::Element => KernelPattern::Gather {
1597                data,
1598                indices,
1599                output: output_c,
1600                axis: 0,
1601            },
1602            // A row gather. Every backend lowers `Gather` over a flat buffer —
1603            // one element per index — so emitting one here would select single
1604            // elements out of a table whose rows are `D` wide, which is a
1605            // different tensor of a different size. The row width is the thing
1606            // missing, and nothing in `Gather` can carry it.
1607            IndexedRead::Block => KernelPattern::Unknown {
1608                reason: "a row gather: the index is scaled by a row width before it \
1609                         becomes an address, so each index names a block rather than \
1610                         an element. Gather indexes a flat buffer and carries no row \
1611                         width"
1612                    .into(),
1613            },
1614        });
1615    }
1616
1617    if !has_loop {
1618        let has_structural_if = has_non_guard_if(&ep.function.body);
1619
1620        // Gather: 2 inputs + no loop + one input is u32 array (indices)
1621        // Check if second input is a u32 array (integer index type).
1622        let second_is_int = input_b.elem_type == data_type::UINT32
1623            || input_b.elem_type == data_type::INT32
1624            || input_b.elem_type == data_type::INT64;
1625        if second_is_int
1626            && find_store_value_op(&ep.function.body, &ep.function.expressions).is_none()
1627            && !has_structural_if
1628        {
1629            return Ok(KernelPattern::Gather {
1630                data: input_a,
1631                indices: input_b,
1632                output: output_c,
1633                axis: 0,
1634            });
1635        }
1636
1637        // Concat: 2 inputs + no loop + has If + no binary store op
1638        if has_structural_if
1639            && find_store_value_op(&ep.function.body, &ep.function.expressions).is_none()
1640        {
1641            let axis = infer_concat_axis(&ep.function.body, &ep.function.expressions, &shape_names);
1642            return Ok(KernelPattern::Concat {
1643                inputs: vec![input_a, input_b],
1644                output: output_c,
1645                axis,
1646            });
1647        }
1648
1649        // An element-wise op writes element i from element i. rope writes a
1650        // rotated pair at `base` and `base + 1`; a dequantizing transpose
1651        // writes at `col * width + row`. Both were reported as `Mul`, which
1652        // keeps their arithmetic and discards their movement.
1653        if !stores_at_the_index_it_read(&ep.function.body, &ep.function.expressions) {
1654            return Ok(KernelPattern::Unknown {
1655                reason: "2 inputs and no loop, but the store index is not the \
1656                         index it read from — the values move as well as \
1657                         combine, and no operator here does both"
1658                    .into(),
1659            });
1660        }
1661
1662        // ElementWise: store of binary operation.
1663        let op = match find_store_value_op(&ep.function.body, &ep.function.expressions) {
1664            Some(StoreValueOp::Binary(op)) => op,
1665            // `y + a * x` is not an add — the multiply has nowhere to go in a
1666            // two-operand `ElementWise`, and reporting `Add` would silently
1667            // drop it. `axpy` is a chain and is recognised as one; the ones
1668            // that are not say what was found instead, and say it identically
1669            // whether or not FMA fusion has already rewritten the expression.
1670            Some(StoreValueOp::MultiplyAdd) => {
1671                if let Some(pattern) = match_elementwise_chain(
1672                    module,
1673                    &ep.function.body,
1674                    &ep.function.expressions,
1675                    &outputs,
1676                    &shape_names,
1677                ) {
1678                    return Ok(pattern);
1679                }
1680                return Ok(KernelPattern::Unknown {
1681                    reason: "fused multiply-add (`c + a * b`, or `fma(a, b, c)` after \
1682                             optimization) that is not a chain: a chain step takes a whole \
1683                             tensor read at the index written, or a scalar the host sets per \
1684                             dispatch, and at least one of these operands is neither"
1685                        .into(),
1686                });
1687            }
1688            None => {
1689                return Err(AnalysisError::UnsupportedPattern(
1690                    "no recognizable binary operation found".into(),
1691                ));
1692            }
1693        };
1694
1695        let dim_name = shape_names.first().cloned().unwrap_or_else(|| "N".into());
1696
1697        return Ok(KernelPattern::ElementWise {
1698            op,
1699            inputs: [input_a, input_b],
1700            output: output_c,
1701            dim_name,
1702        });
1703    }
1704
1705    // A matmul does not compute a reciprocal square root. RMSNorm is a
1706    // normalization with a scale and no bias, so it has two inputs rather than
1707    // three and never reached the Normalization branch — it fell through to
1708    // here and was reported as BATCH_MATMUL. Serving it properly needs the
1709    // Normalization pattern to make its bias optional, which is a change to
1710    // every backend that lowers one; until then, refusing is the honest half.
1711    if (has_math_function_in_expressions(&ep.function.expressions, MathFunction::InverseSqrt)
1712        || has_math_function_in_expressions(&ep.function.expressions, MathFunction::Sqrt))
1713        && !has_math_function_in_expressions(&ep.function.expressions, MathFunction::Exp)
1714    {
1715        return Ok(KernelPattern::Unknown {
1716            reason: "2 inputs, a loop and a reciprocal square root — a \
1717                     normalization without a bias, which Normalization cannot \
1718                     yet represent"
1719                .into(),
1720        });
1721    }
1722
1723    // A convolution does not evaluate trigonometry. An STFT does — its
1724    // twiddle factors are sin and cos — and with five parameters it landed in
1725    // the arm below and was reported as CONV_2D.
1726    if has_math_function_in_expressions(&ep.function.expressions, MathFunction::Sin)
1727        || has_math_function_in_expressions(&ep.function.expressions, MathFunction::Cos)
1728    {
1729        return Ok(KernelPattern::Unknown {
1730            reason: "2 inputs, a loop and trigonometry — a transform rather \
1731                     than a convolution or a matmul"
1732                .into(),
1733        });
1734    }
1735
1736    // 2 inputs + loop: a convolution without a bias.
1737    //
1738    // Same evidence as the three-input arm — the kernel window named in the
1739    // params. The parameter count that used to stand in for it reported
1740    // attention's context half, GQA's, and MoE dispatch as CONV_2D.
1741    let names_kernel_2d = shape_names
1742        .iter()
1743        .any(|s| s.eq_ignore_ascii_case("kh") || s.eq_ignore_ascii_case("kernel_h"))
1744        && shape_names
1745            .iter()
1746            .any(|s| s.eq_ignore_ascii_case("kw") || s.eq_ignore_ascii_case("kernel_w"));
1747    if names_kernel_2d {
1748        let conv_shape = extract_conv2d_shape(
1749            &shape_names,
1750            Some(&ep.function.body),
1751            Some(&ep.function.expressions),
1752        );
1753        return Ok(KernelPattern::Conv2D {
1754            input: input_a,
1755            weight: input_b,
1756            output: output_c,
1757            shape: conv_shape,
1758            bias: None,
1759            activation: store_value_activation(&ep.function.body, &ep.function.expressions),
1760        });
1761    }
1762
1763    // MatMul: loop + accumulation pattern.
1764    //
1765    // The dimensions have to come from the kernel. Inventing M, N and K when
1766    // the params struct does not name three of them meant every two-input
1767    // looping kernel became a matmul over dimensions nobody had established —
1768    // a scatter, among others.
1769    if shape_names.len() < 3 {
1770        return Ok(KernelPattern::Unknown {
1771            reason: format!(
1772                "2 inputs and a loop, but {} shape parameters — too few to \
1773                 establish a matmul's M, N and K",
1774                shape_names.len()
1775            ),
1776        });
1777    }
1778    let shape = MatMulShape {
1779        m: shape_names[0].clone(),
1780        n: shape_names[1].clone(),
1781        k: shape_names[2].clone(),
1782    };
1783
1784    Ok(KernelPattern::MatMul {
1785        inputs: [input_a, input_b],
1786        output: output_c,
1787        shape,
1788    })
1789}
1790
1791// ---------------------------------------------------------------------------
1792// Quantized matrix multiplication
1793//
1794// The six quantized kernels in the vendored corpus all reached the classifier
1795// with three or more inputs and no recogniser, because there was no pattern
1796// that could carry a weight arriving as integer codes plus the scale that
1797// turns them back into numbers. What follows is the evidence that a kernel is
1798// one, keyed on what it computes rather than on how many buffers it binds:
1799//
1800//   1. It unpacks a field out of a word loaded from a storage buffer. That is
1801//      `extractBits`, and nothing else in the corpus does it inside a loop.
1802//   2. The unpacked code meets a value out of another buffer in a multiply —
1803//      either the activation it is contracted against, or the scale that
1804//      dequantizes it. Codes unpacked and never multiplied are not a matmul.
1805//   3. A buffer is read at *exactly* the index that selects the weight's row.
1806//      That is the definition of a per-channel scale, and it is also the test
1807//      that separates the two kernels this can represent from the two it
1808//      cannot: `q4_g128`'s scale is read at `row * groups + k / 128`, which
1809//      contains the row and is not the row, and a scale that varies along the
1810//      contraction is a block-wise scale that neither output format expresses.
1811// ---------------------------------------------------------------------------
1812
1813/// One unpacking of an integer code out of a packed weight buffer.
1814struct PackedCodes {
1815    /// The storage buffer the packed word was loaded from.
1816    weight: Handle<GlobalVariable>,
1817    /// The index the packed word was loaded at.
1818    address: Handle<Expression>,
1819    /// The field width `extractBits` was asked for, in bits.
1820    code_bits: u32,
1821    /// The unpacked code, as an expression of the entry point.
1822    value: Handle<Expression>,
1823}
1824
1825/// Functions that exist to pull one integer code out of a packed word.
1826///
1827/// Returned as (function, which argument holds the word, how wide the code is).
1828/// Every quantized kernel in the corpus spells the unpack as a helper —
1829/// `unpack_i8`, `unpack_i4` — and nothing inlines it, so looking only at the
1830/// entry point's own expressions finds no `extractBits` at all.
1831fn packed_code_helpers(module: &Module) -> Vec<(Handle<nxpu_ir::Function>, u32, u32)> {
1832    let mut found = Vec::new();
1833    for (handle, func) in module.functions.iter() {
1834        for (_, expr) in func.expressions.iter() {
1835            let Expression::Math {
1836                fun: MathFunction::ExtractBits,
1837                arg,
1838                arg2: Some(width),
1839                ..
1840            } = expr
1841            else {
1842                continue;
1843            };
1844            // The word reaches `extractBits` through a bitcast: the codes are
1845            // two's complement, and a signed base is what makes the extract
1846            // sign-extend rather than mask.
1847            let Expression::FunctionArgument(index) =
1848                func.expressions[strip_casts(&func.expressions, *arg)]
1849            else {
1850                continue;
1851            };
1852            if let Some(bits) = literal_u32(&func.expressions, *width) {
1853                found.push((handle, index, bits));
1854                break;
1855            }
1856        }
1857    }
1858    found
1859}
1860
1861/// Every place the entry point unpacks a code out of one of `inputs`.
1862fn find_packed_codes(
1863    module: &Module,
1864    ep: &nxpu_ir::EntryPoint,
1865    inputs: &[Handle<GlobalVariable>],
1866) -> Vec<PackedCodes> {
1867    fn walk(
1868        body: &[Statement],
1869        exprs: &Arena<Expression>,
1870        helpers: &[(Handle<nxpu_ir::Function>, u32, u32)],
1871        inputs: &[Handle<GlobalVariable>],
1872        out: &mut Vec<PackedCodes>,
1873    ) {
1874        for stmt in body {
1875            match stmt {
1876                Statement::Call {
1877                    function,
1878                    arguments,
1879                    result: Some(value),
1880                } => {
1881                    let Some(&(_, arg_index, code_bits)) =
1882                        helpers.iter().find(|(f, ..)| f == function)
1883                    else {
1884                        continue;
1885                    };
1886                    let Some(&word) = arguments.get(arg_index as usize) else {
1887                        continue;
1888                    };
1889                    if let Some((weight, address)) = input_load(exprs, word, inputs) {
1890                        out.push(PackedCodes {
1891                            weight,
1892                            address,
1893                            code_bits,
1894                            value: *value,
1895                        });
1896                    }
1897                }
1898                Statement::If { accept, reject, .. } => {
1899                    walk(accept, exprs, helpers, inputs, out);
1900                    walk(reject, exprs, helpers, inputs, out);
1901                }
1902                Statement::Loop {
1903                    body, continuing, ..
1904                } => {
1905                    walk(body, exprs, helpers, inputs, out);
1906                    walk(continuing, exprs, helpers, inputs, out);
1907                }
1908                _ => {}
1909            }
1910        }
1911    }
1912
1913    let exprs = &ep.function.expressions;
1914    let mut out = Vec::new();
1915
1916    // The unpack written inline, without a helper. No kernel in the corpus
1917    // spells it this way, but the pattern is about what is computed and a
1918    // kernel that inlines its own `extractBits` computes the same thing.
1919    for (handle, expr) in exprs.iter() {
1920        let Expression::Math {
1921            fun: MathFunction::ExtractBits,
1922            arg,
1923            arg2: Some(width),
1924            ..
1925        } = expr
1926        else {
1927            continue;
1928        };
1929        let Some(bits) = literal_u32(exprs, *width) else {
1930            continue;
1931        };
1932        if let Some((weight, address)) = input_load(exprs, strip_casts(exprs, *arg), inputs) {
1933            out.push(PackedCodes {
1934                weight,
1935                address,
1936                code_bits: bits,
1937                value: handle,
1938            });
1939        }
1940    }
1941
1942    walk(
1943        &ep.function.body,
1944        exprs,
1945        &packed_code_helpers(module),
1946        inputs,
1947        &mut out,
1948    );
1949    out
1950}
1951
1952/// If `handle` loads out of one of `inputs`, which buffer and at which index.
1953fn input_load(
1954    exprs: &Arena<Expression>,
1955    handle: Handle<Expression>,
1956    inputs: &[Handle<GlobalVariable>],
1957) -> Option<(Handle<GlobalVariable>, Handle<Expression>)> {
1958    let Expression::Load { pointer } = exprs.try_get(strip_casts(exprs, handle))? else {
1959        return None;
1960    };
1961    let Expression::Access { base, index } = exprs.try_get(*pointer)? else {
1962        return None;
1963    };
1964    let global = access_base_global(exprs, *base)?;
1965    inputs.contains(&global).then_some((global, *index))
1966}
1967
1968/// A `u32` literal, in either of the spellings a bit width arrives in.
1969fn literal_u32(exprs: &Arena<Expression>, handle: Handle<Expression>) -> Option<u32> {
1970    match exprs.try_get(handle)? {
1971        Expression::Literal(Literal::U32(n)) => Some(*n),
1972        Expression::Literal(Literal::I32(n)) if *n >= 0 => Some(*n as u32),
1973        Expression::Literal(Literal::AbstractInt(n)) if *n >= 0 => Some(*n as u32),
1974        _ => None,
1975    }
1976}
1977
1978/// The two factors of the multiply in `a * b + c`, in either spelling.
1979///
1980/// `-O0` writes the multiply and the add as separate expressions; `FmaFusion`
1981/// at `-O1` rewrites them into one `fma`, and it does so for the *integer*
1982/// address arithmetic as well as for the float arithmetic — the weight address
1983/// `row * words_per_row + word_index` is a `Binary(Add, Binary(Mul, ..), ..)`
1984/// at `-O0` and a `Math(Fma, ..)` at `-O2`. Reading only one of the two is how
1985/// a classifier comes to give different answers at different levels.
1986fn multiply_factors(
1987    exprs: &Arena<Expression>,
1988    handle: Handle<Expression>,
1989) -> Option<(Handle<Expression>, Handle<Expression>)> {
1990    match exprs.try_get(handle)? {
1991        Expression::Math {
1992            fun: MathFunction::Fma,
1993            arg,
1994            arg1: Some(b),
1995            ..
1996        } => Some((*arg, *b)),
1997        Expression::Binary {
1998            op: BinaryOp::Multiply,
1999            left,
2000            right,
2001        } => Some((*left, *right)),
2002        Expression::Binary {
2003            op: BinaryOp::Add,
2004            left,
2005            right,
2006        } => multiply_factors(exprs, *left).or_else(|| multiply_factors(exprs, *right)),
2007        _ => None,
2008    }
2009}
2010
2011/// The addend of `a * b + c`, in either spelling. `None` when there is no add.
2012fn multiply_addend(
2013    exprs: &Arena<Expression>,
2014    handle: Handle<Expression>,
2015) -> Option<Handle<Expression>> {
2016    match exprs.try_get(handle)? {
2017        Expression::Math {
2018            fun: MathFunction::Fma,
2019            arg2: Some(c),
2020            ..
2021        } => Some(*c),
2022        Expression::Binary {
2023            op: BinaryOp::Add,
2024            left,
2025            right,
2026        } => {
2027            if multiply_factors(exprs, *left).is_some() {
2028                Some(*right)
2029            } else if multiply_factors(exprs, *right).is_some() {
2030                Some(*left)
2031            } else {
2032                None
2033            }
2034        }
2035        _ => None,
2036    }
2037}
2038
2039/// Are these two expressions the same computation?
2040///
2041/// Handle equality alone is not enough. `-O1` runs common-subexpression
2042/// elimination, so an index spelled by two handles before it is spelled by one
2043/// after, and a test that compared handles would answer differently at the two
2044/// levels about the same kernel.
2045fn same_expression(
2046    exprs: &Arena<Expression>,
2047    a: Handle<Expression>,
2048    b: Handle<Expression>,
2049    depth: u32,
2050) -> bool {
2051    if a == b {
2052        return true;
2053    }
2054    if depth == 0 {
2055        return false;
2056    }
2057    let (Some(ea), Some(eb)) = (exprs.try_get(a), exprs.try_get(b)) else {
2058        return false;
2059    };
2060    let same =
2061        |x: Handle<Expression>, y: Handle<Expression>| same_expression(exprs, x, y, depth - 1);
2062    let same_opt = |x: &Option<Handle<Expression>>, y: &Option<Handle<Expression>>| match (x, y) {
2063        (None, None) => true,
2064        (Some(x), Some(y)) => same_expression(exprs, *x, *y, depth - 1),
2065        _ => false,
2066    };
2067    match (ea, eb) {
2068        (Expression::Literal(x), Expression::Literal(y)) => same_literal(x, y),
2069        (Expression::FunctionArgument(x), Expression::FunctionArgument(y)) => x == y,
2070        (Expression::GlobalVariable(x), Expression::GlobalVariable(y)) => x == y,
2071        (Expression::LocalVariable(x), Expression::LocalVariable(y)) => x == y,
2072        (Expression::CallResult(x), Expression::CallResult(y)) => x == y,
2073        (Expression::Load { pointer: x }, Expression::Load { pointer: y }) => same(*x, *y),
2074        (
2075            Expression::Access {
2076                base: xb,
2077                index: xi,
2078            },
2079            Expression::Access {
2080                base: yb,
2081                index: yi,
2082            },
2083        ) => same(*xb, *yb) && same(*xi, *yi),
2084        (
2085            Expression::AccessIndex {
2086                base: xb,
2087                index: xi,
2088            },
2089            Expression::AccessIndex {
2090                base: yb,
2091                index: yi,
2092            },
2093        ) => xi == yi && same(*xb, *yb),
2094        (Expression::Unary { op: xo, expr: xe }, Expression::Unary { op: yo, expr: ye }) => {
2095            xo == yo && same(*xe, *ye)
2096        }
2097        (
2098            Expression::Binary {
2099                op: xo,
2100                left: xl,
2101                right: xr,
2102            },
2103            Expression::Binary {
2104                op: yo,
2105                left: yl,
2106                right: yr,
2107            },
2108        ) => xo == yo && same(*xl, *yl) && same(*xr, *yr),
2109        (
2110            Expression::As {
2111                expr: xe,
2112                kind: xk,
2113                convert: xc,
2114            },
2115            Expression::As {
2116                expr: ye,
2117                kind: yk,
2118                convert: yc,
2119            },
2120        ) => xk == yk && xc == yc && same(*xe, *ye),
2121        (
2122            Expression::Math {
2123                fun: xf,
2124                arg: xa,
2125                arg1: xa1,
2126                arg2: xa2,
2127                arg3: xa3,
2128            },
2129            Expression::Math {
2130                fun: yf,
2131                arg: ya,
2132                arg1: ya1,
2133                arg2: ya2,
2134                arg3: ya3,
2135            },
2136        ) => {
2137            xf == yf
2138                && same(*xa, *ya)
2139                && same_opt(xa1, ya1)
2140                && same_opt(xa2, ya2)
2141                && same_opt(xa3, ya3)
2142        }
2143        _ => false,
2144    }
2145}
2146
2147/// `Literal` carries an `f32`, so it cannot derive `PartialEq`; bit equality is
2148/// the right test for two spellings of the same constant anyway.
2149fn same_literal(a: &Literal, b: &Literal) -> bool {
2150    match (a, b) {
2151        (Literal::Bool(x), Literal::Bool(y)) => x == y,
2152        (Literal::I32(x), Literal::I32(y)) => x == y,
2153        (Literal::U32(x), Literal::U32(y)) => x == y,
2154        (Literal::F32(x), Literal::F32(y)) => x.to_bits() == y.to_bits(),
2155        (Literal::F64(x), Literal::F64(y)) => x.to_bits() == y.to_bits(),
2156        (Literal::AbstractInt(x), Literal::AbstractInt(y)) => x == y,
2157        (Literal::AbstractFloat(x), Literal::AbstractFloat(y)) => x.to_bits() == y.to_bits(),
2158        _ => false,
2159    }
2160}
2161
2162/// Does `haystack` compute `needle` somewhere inside it?
2163///
2164/// Iterative with a visited set, for [`reads_global`]'s reason: an expression
2165/// arena is a DAG, and an address built from shared subexpressions — which
2166/// every one of these addresses is, after `-O1` runs common-subexpression
2167/// elimination — is walked exponentially by the naive spelling.
2168fn expression_contains(
2169    exprs: &Arena<Expression>,
2170    haystack: Handle<Expression>,
2171    needle: Handle<Expression>,
2172) -> bool {
2173    let mut seen = std::collections::HashSet::new();
2174    let mut stack = vec![haystack];
2175    while let Some(handle) = stack.pop() {
2176        if !seen.insert(handle.index()) {
2177            continue;
2178        }
2179        if same_expression(exprs, handle, needle, EXPR_MATCH_DEPTH) {
2180            return true;
2181        }
2182        if let Some(expr) = exprs.try_get(handle) {
2183            push_operands(expr, &mut stack);
2184        }
2185    }
2186    false
2187}
2188
2189/// How deep [`same_expression`] walks.
2190///
2191/// An address is a handful of nodes; the limit is here so a cyclic or
2192/// pathological arena cannot make classification hang, which is a property the
2193/// corpus test asserts.
2194const EXPR_MATCH_DEPTH: u32 = 24;
2195
2196/// One load out of a storage input.
2197struct InputLoad {
2198    global: Handle<GlobalVariable>,
2199    index: Handle<Expression>,
2200    value: Handle<Expression>,
2201}
2202
2203/// Every load out of one of `inputs`, anywhere in the entry point.
2204fn collect_input_loads(
2205    exprs: &Arena<Expression>,
2206    inputs: &[Handle<GlobalVariable>],
2207) -> Vec<InputLoad> {
2208    exprs
2209        .iter()
2210        .filter_map(|(handle, expr)| {
2211            let Expression::Load { pointer } = expr else {
2212                return None;
2213            };
2214            let Expression::Access { base, index } = exprs.try_get(*pointer)? else {
2215                return None;
2216            };
2217            let global = access_base_global(exprs, *base)?;
2218            inputs.contains(&global).then_some(InputLoad {
2219                global,
2220                index: *index,
2221                value: handle,
2222            })
2223        })
2224        .collect()
2225}
2226
2227/// The members of the uniform params struct this expression reads.
2228fn uniform_members_read(
2229    exprs: &Arena<Expression>,
2230    handle: Handle<Expression>,
2231    params: Option<Handle<GlobalVariable>>,
2232) -> Vec<u32> {
2233    let Some(params) = params else {
2234        return Vec::new();
2235    };
2236    let mut seen = std::collections::HashSet::new();
2237    let mut members = Vec::new();
2238    let mut stack = vec![handle];
2239    while let Some(handle) = stack.pop() {
2240        if !seen.insert(handle.index()) {
2241            continue;
2242        }
2243        let Some(expr) = exprs.try_get(handle) else {
2244            continue;
2245        };
2246        #[allow(clippy::collapsible_if)]
2247        if let Expression::AccessIndex { base, index } = expr {
2248            if matches!(exprs.try_get(*base), Some(Expression::GlobalVariable(g)) if *g == params)
2249                && !members.contains(index)
2250            {
2251                members.push(*index);
2252            }
2253        }
2254        push_operands(expr, &mut stack);
2255    }
2256    members
2257}
2258
2259/// The one store into `output`, as (index written, value stored).
2260///
2261/// Not [`find_lone_store`]: every one of these kernels also writes workgroup
2262/// memory, several times, and the store that says what the kernel produces is
2263/// the one that lands in the output buffer.
2264fn find_store_into(
2265    body: &[Statement],
2266    exprs: &Arena<Expression>,
2267    output: Handle<GlobalVariable>,
2268) -> Option<(Handle<Expression>, Handle<Expression>)> {
2269    fn walk(
2270        body: &[Statement],
2271        exprs: &Arena<Expression>,
2272        output: Handle<GlobalVariable>,
2273        found: &mut Vec<(Handle<Expression>, Handle<Expression>)>,
2274    ) {
2275        for stmt in body {
2276            match stmt {
2277                Statement::Store { pointer, value } => {
2278                    if access_base_global(exprs, *pointer) == Some(output) {
2279                        match exprs.try_get(*pointer) {
2280                            Some(Expression::Access { index, .. }) => found.push((*index, *value)),
2281                            _ => found.push((*pointer, *value)),
2282                        }
2283                    }
2284                }
2285                Statement::If { accept, reject, .. } => {
2286                    walk(accept, exprs, output, found);
2287                    walk(reject, exprs, output, found);
2288                }
2289                Statement::Loop {
2290                    body, continuing, ..
2291                } => {
2292                    walk(body, exprs, output, found);
2293                    walk(continuing, exprs, output, found);
2294                }
2295                _ => {}
2296            }
2297        }
2298    }
2299    let mut found = Vec::new();
2300    walk(body, exprs, output, &mut found);
2301    match found.as_slice() {
2302        [one] => Some(*one),
2303        _ => None,
2304    }
2305}
2306
2307/// Recognise a matrix multiplication against packed integer weight codes.
2308///
2309/// `None` when the kernel unpacks nothing, so every other pattern is still on
2310/// the table. `Some(Unknown { .. })` when it does unpack codes but into a shape
2311/// no backend here can emit — a refusal that names the format is worth more
2312/// than a graph that runs and computes something else.
2313fn match_quantized_matmul(
2314    module: &Module,
2315    ep: &nxpu_ir::EntryPoint,
2316    inputs: &[(Handle<GlobalVariable>, &GlobalVariable)],
2317    outputs: &[(Handle<GlobalVariable>, &GlobalVariable)],
2318    params_global: Option<Handle<GlobalVariable>>,
2319    // `param_names` is every params-struct member's name, indexed by member
2320    // position, so an index read out of the IR names the right one.
2321    // `shape_names` is the integer members only — `eps` sits in the same struct
2322    // and is not a dimension, so it must not be a candidate extent.
2323    param_names: &[String],
2324    shape_names: &[String],
2325) -> Option<KernelPattern> {
2326    let exprs = &ep.function.expressions;
2327    let input_handles: Vec<Handle<GlobalVariable>> = inputs.iter().map(|(h, _)| *h).collect();
2328    let codes = find_packed_codes(module, ep, &input_handles);
2329    if codes.is_empty() {
2330        return None;
2331    }
2332    let refuse = |reason: String| Some(KernelPattern::Unknown { reason });
2333
2334    // One packed weight. `matvec/q8_ffn` unpacks two — a gate projection and
2335    // an up projection sharing one activation, with a SiLU and a multiply on
2336    // top. That is two quantized matmuls and two more operators, not one
2337    // matmul with a wider weight, and this carries one of each.
2338    let weights = dedup_globals(codes.iter().map(|c| c.weight));
2339    if weights.len() != 1 {
2340        return refuse(format!(
2341            "{} separate packed weight buffers unpacked in one kernel — several \
2342             quantized projections fused together, which needs a pattern per \
2343             projection and an operator for whatever combines them; this one \
2344             carries a single weight, a single scale and a single contraction",
2345            weights.len()
2346        ));
2347    }
2348    let weight_handle = weights[0];
2349    let code_bits = codes[0].code_bits;
2350    if codes.iter().any(|c| c.code_bits != code_bits) {
2351        return refuse(
2352            "the packed weight is unpacked at two different code widths, so no \
2353             single element type describes it"
2354                .into(),
2355        );
2356    }
2357    if outputs.len() != 1 {
2358        return refuse(format!(
2359            "a quantized matmul writes one result and this kernel writes {}",
2360            outputs.len()
2361        ));
2362    }
2363    if !has_loop(&ep.function.body) {
2364        return refuse(
2365            "integer codes unpacked without a loop — there is no contraction \
2366             here, so this is a dequantization rather than a matmul"
2367                .into(),
2368        );
2369    }
2370
2371    // The unpacked code has to meet a value out of a buffer in a multiply:
2372    // the activation it is contracted against, or the scale that turns it back
2373    // into a number. Codes unpacked and never multiplied are something else.
2374    let loads = collect_input_loads(exprs, &input_handles);
2375    let multiplied = exprs.iter().any(|(_, expr)| {
2376        let Some((a, b)) = (match expr {
2377            Expression::Binary {
2378                op: BinaryOp::Multiply,
2379                left,
2380                right,
2381            } => Some((*left, *right)),
2382            Expression::Math {
2383                fun: MathFunction::Fma,
2384                arg,
2385                arg1: Some(b),
2386                ..
2387            } => Some((*arg, *b)),
2388            _ => None,
2389        }) else {
2390            return false;
2391        };
2392        let is_code = |h: Handle<Expression>| {
2393            codes
2394                .iter()
2395                .any(|c| same_expression(exprs, strip_casts(exprs, h), c.value, EXPR_MATCH_DEPTH))
2396        };
2397        let is_input = |h: Handle<Expression>| {
2398            let h = strip_casts(exprs, h);
2399            loads.iter().any(|l| l.value == h)
2400        };
2401        (is_code(a) && is_input(b)) || (is_code(b) && is_input(a))
2402    });
2403    if !multiplied {
2404        return refuse(
2405            "integer codes unpacked but never multiplied by a value from another \
2406             buffer — nothing dequantizes them and nothing contracts them"
2407                .into(),
2408        );
2409    }
2410
2411    // The weight's address is `channel * stride + position`. Of the two
2412    // factors, the one that reads the params struct is the row stride —
2413    // `ceil(K / codes_per_word)` — and the other one selects the row.
2414    let Some((f0, f1)) = multiply_factors(exprs, codes[0].address) else {
2415        return refuse(
2416            "the packed weight is addressed without a row stride, so which axis \
2417             of it is an output channel cannot be established"
2418                .into(),
2419        );
2420    };
2421    let m0 = uniform_members_read(exprs, f0, params_global);
2422    let m1 = uniform_members_read(exprs, f1, params_global);
2423    let (channel, stride_members) = match (m0.is_empty(), m1.is_empty()) {
2424        (true, false) => (f0, m1),
2425        (false, true) => (f1, m0),
2426        _ => {
2427            return refuse(
2428                "the packed weight's row stride cannot be told from its row index \
2429                 — both, or neither, are built from the dispatch parameters"
2430                    .into(),
2431            );
2432        }
2433    };
2434    let [k_member] = stride_members[..] else {
2435        return refuse(
2436            "the packed weight's row stride is built from more than one dispatch \
2437             parameter, so the contracted extent is not named by any one of them"
2438                .into(),
2439        );
2440    };
2441    let Some(k_name) = param_names.get(k_member as usize).cloned() else {
2442        return refuse(
2443            "the packed weight's row stride reads a dispatch parameter with no name".into(),
2444        );
2445    };
2446
2447    // The output store, and the addend fused onto it if there is one.
2448    let output_handle = outputs[0].0;
2449    let Some((store_index, store_value)) = find_store_into(&ep.function.body, exprs, output_handle)
2450    else {
2451        return refuse(
2452            "the result is written in more than one place, so what this kernel \
2453             produces is not one matmul"
2454                .into(),
2455        );
2456    };
2457    let bias_load = multiply_addend(exprs, store_value).and_then(|addend| {
2458        let addend = strip_casts(exprs, addend);
2459        loads.iter().find(|l| {
2460            l.value == addend && same_expression(exprs, l.index, store_index, EXPR_MATCH_DEPTH)
2461        })
2462    });
2463    let bias_handle = bias_load.map(|l| l.global);
2464
2465    // The per-channel scale: read at *exactly* the index that selects the
2466    // weight's row. A scale read at anything wider varies along the
2467    // contraction, which is a block-wise scale — see the refusal below.
2468    let per_channel: Vec<Handle<GlobalVariable>> = dedup_globals(loads.iter().filter_map(|l| {
2469        (l.global != weight_handle
2470            && Some(l.global) != bias_handle
2471            && same_expression(exprs, l.index, channel, EXPR_MATCH_DEPTH))
2472        .then_some(l.global)
2473    }));
2474    let [scale_handle] = per_channel[..] else {
2475        let block_scaled: Vec<Handle<GlobalVariable>> =
2476            dedup_globals(loads.iter().filter_map(|l| {
2477                (l.global != weight_handle
2478                    && !same_expression(exprs, l.index, channel, EXPR_MATCH_DEPTH)
2479                    && expression_contains(exprs, l.index, channel))
2480                .then_some(l.global)
2481            }));
2482        if per_channel.is_empty() && !block_scaled.is_empty() {
2483            return refuse(format!(
2484                "the {code_bits}-bit codes are scaled by a factor read at the \
2485                 weight's row *and* the contracted position — one scale per block \
2486                 of columns rather than one per output channel. Per-channel \
2487                 quantization is what `DequantizeLinear` with an axis and what a \
2488                 TFLite per-channel tensor express; a block-wise scale needs a \
2489                 blocked dequantization neither backend here emits"
2490            ));
2491        }
2492        return refuse(format!(
2493            "{} buffers are read at exactly the weight's row index, and a \
2494             quantized matmul has one such buffer — the per-channel scale",
2495            per_channel.len()
2496        ));
2497    };
2498
2499    // Only int8 survives. A 4-bit code is an INT4 tensor: ONNX has one from
2500    // opset 21 and this backend declares 13, and the TFLite writer here has no
2501    // sub-byte tensor type at all. Widening the codes to int8 on the way out
2502    // would be a different graph with a weight four times the size, which is
2503    // not what the kernel was written to run.
2504    if code_bits != 8 {
2505        return refuse(format!(
2506            "{code_bits}-bit weight codes. The formats this compiler writes carry \
2507             int8 weights; a {code_bits}-bit one needs a sub-byte tensor type — \
2508             ONNX INT4 arrived in opset 21 and this backend emits 13, and the \
2509             TFLite writer here has no sub-byte type"
2510        ));
2511    }
2512
2513    // Whatever is left is the activation, and there has to be exactly one of
2514    // it. Counting the inputs is not what decided any of the roles above; this
2515    // is the check that every input was accounted for by one of them.
2516    let assigned = [Some(weight_handle), Some(scale_handle), bias_handle];
2517    let rest: Vec<&(Handle<GlobalVariable>, &GlobalVariable)> = inputs
2518        .iter()
2519        .filter(|(h, _)| !assigned.contains(&Some(*h)))
2520        .collect();
2521    let [(activation_handle, activation_gv)] = rest[..] else {
2522        return refuse(format!(
2523            "{} buffers are left over once the weight, its scale and its addend \
2524             are accounted for, and a quantized matmul contracts against one set \
2525             of activations",
2526            rest.len()
2527        ));
2528    };
2529    let input = make_binding(module, *activation_handle, activation_gv, TensorRole::Input);
2530    if input.elem_type != data_type::FLOAT {
2531        return refuse(
2532            "the activations are not f32, so the contraction is integer on both \
2533             sides — an integer matmul with its own accumulator width, which this \
2534             pattern does not describe"
2535                .into(),
2536        );
2537    }
2538
2539    // The shape. `k` is already known from the weight's row stride. The output
2540    // store says the rest: `output[row * M + col]` names `M` as the number of
2541    // output channels, and a store at the row index alone is one row of
2542    // results, so `m` is 1.
2543    let (n_name, m_name) = if let Some((g0, g1)) = multiply_factors(exprs, store_index) {
2544        let n0 = uniform_members_read(exprs, g0, params_global);
2545        let n1 = uniform_members_read(exprs, g1, params_global);
2546        let members = match (n0.is_empty(), n1.is_empty()) {
2547            (true, false) => n1,
2548            (false, true) => n0,
2549            _ => Vec::new(),
2550        };
2551        let [n_member] = members[..] else {
2552            return refuse(
2553                "the result's row stride is not a single dispatch parameter, so \
2554                 the number of output channels is not named"
2555                    .into(),
2556            );
2557        };
2558        let Some(n_name) = param_names.get(n_member as usize).cloned() else {
2559            return refuse("the result's row stride reads an unnamed parameter".into());
2560        };
2561        let remaining: Vec<&String> = shape_names
2562            .iter()
2563            .filter(|p| **p != k_name && **p != n_name)
2564            .collect();
2565        let [m_name] = remaining[..] else {
2566            return refuse(format!(
2567                "{} dispatch parameters are left once the contracted extent and \
2568                 the output-channel count are named, and the number of rows has \
2569                 to be exactly one of them",
2570                remaining.len()
2571            ));
2572        };
2573        (n_name, m_name.clone())
2574    } else {
2575        // The result is written at the row index alone, so it is one row.
2576        let remaining: Vec<&String> = shape_names.iter().filter(|p| **p != k_name).collect();
2577        let [n_name] = remaining[..] else {
2578            return refuse(format!(
2579                "the result is one row, so the remaining dispatch parameter is \
2580                 the output-channel count — and {} remain",
2581                remaining.len()
2582            ));
2583        };
2584        (n_name.clone(), "1".to_string())
2585    };
2586
2587    // The codes, not the words they arrived in: four two's-complement bytes
2588    // per `u32`, least-significant first, is the byte layout of a contiguous
2589    // int8 row, and int8 is what the graph's consumer has to be told it holds.
2590    let weight_gv = inputs.iter().find(|(h, _)| *h == weight_handle)?.1;
2591    let mut weight = make_binding(module, weight_handle, weight_gv, TensorRole::Input);
2592    weight.elem_type = data_type::INT8;
2593    let scale_gv = inputs.iter().find(|(h, _)| *h == scale_handle)?.1;
2594    let bias = bias_handle.and_then(|h| {
2595        inputs
2596            .iter()
2597            .find(|(g, _)| *g == h)
2598            .map(|(g, gv)| make_binding(module, *g, gv, TensorRole::Input))
2599    });
2600
2601    Some(KernelPattern::QuantizedMatMul {
2602        input,
2603        weight,
2604        scale: make_binding(module, scale_handle, scale_gv, TensorRole::Input),
2605        bias,
2606        output: make_binding(module, outputs[0].0, outputs[0].1, TensorRole::Output),
2607        shape: MatMulShape {
2608            m: m_name,
2609            n: n_name,
2610            k: k_name,
2611        },
2612    })
2613}
2614
2615/// Collect handles, dropping repeats and keeping the order they arrived in.
2616fn dedup_globals(it: impl Iterator<Item = Handle<GlobalVariable>>) -> Vec<Handle<GlobalVariable>> {
2617    let mut out: Vec<Handle<GlobalVariable>> = Vec::new();
2618    for h in it {
2619        if !out.contains(&h) {
2620            out.push(h);
2621        }
2622    }
2623    out
2624}
2625
2626/// Extract literal U32 values from loop break conditions.
2627///
2628/// For `for (kh = 0; kh < N; ...)`, naga generates `break_if: kh >= N`.
2629/// When N is `Literal(U32(n))`, we collect n as a loop bound.
2630fn extract_loop_bound_literals(body: &[Statement], exprs: &Arena<Expression>) -> Vec<u32> {
2631    let mut bounds = Vec::new();
2632    for stmt in body {
2633        match stmt {
2634            Statement::Loop {
2635                body,
2636                continuing,
2637                break_if,
2638            } => {
2639                // Check this loop's break condition for a literal bound.
2640                // Pattern: break_if is `Binary { GreaterEqual|Greater, _, Literal(U32(n)) }`.
2641                if let Some(n) = break_if.and_then(|bi| {
2642                    let Expression::Binary {
2643                        op: BinaryOp::GreaterEqual | BinaryOp::Greater,
2644                        right,
2645                        ..
2646                    } = exprs.try_get(bi)?
2647                    else {
2648                        return None;
2649                    };
2650                    match exprs.try_get(*right)? {
2651                        Expression::Literal(Literal::U32(n)) => Some(*n),
2652                        _ => None,
2653                    }
2654                }) {
2655                    bounds.push(n);
2656                }
2657                // naga 28 pattern: for-loop condition lowered as
2658                //   Emit; ...; If(cond) { } else { Break }  at the start of the loop body,
2659                // where cond is `Binary { Less|LessEqual, _, Literal(U32(n)) }`.
2660                // Skip leading Emit statements to find the If.
2661                if let Some(Statement::If {
2662                    condition,
2663                    accept,
2664                    reject,
2665                }) = body.iter().find(|s| !matches!(s, Statement::Emit(_)))
2666                {
2667                    let rhs = match exprs.try_get(*condition) {
2668                        Some(&Expression::Binary {
2669                            op: BinaryOp::Less | BinaryOp::LessEqual,
2670                            right,
2671                            ..
2672                        }) if accept.is_empty()
2673                            && matches!(reject.as_slice(), [Statement::Break]) =>
2674                        {
2675                            Some(right)
2676                        }
2677                        Some(&Expression::Binary {
2678                            op: BinaryOp::GreaterEqual | BinaryOp::Greater,
2679                            right,
2680                            ..
2681                        }) if reject.is_empty()
2682                            && matches!(accept.as_slice(), [Statement::Break]) =>
2683                        {
2684                            Some(right)
2685                        }
2686                        _ => None,
2687                    };
2688                    let bound = rhs.and_then(|h| exprs.try_get(h)).and_then(|e| match *e {
2689                        Expression::Literal(Literal::U32(n)) => Some(n),
2690                        _ => None,
2691                    });
2692                    if let Some(n) = bound {
2693                        bounds.push(n);
2694                    }
2695                }
2696                // Recurse into nested loops.
2697                bounds.extend(extract_loop_bound_literals(body, exprs));
2698                bounds.extend(extract_loop_bound_literals(continuing, exprs));
2699            }
2700            Statement::If { accept, reject, .. } => {
2701                bounds.extend(extract_loop_bound_literals(accept, exprs));
2702                bounds.extend(extract_loop_bound_literals(reject, exprs));
2703            }
2704            _ => {}
2705        }
2706    }
2707    bounds
2708}
2709
2710/// Does this expression come from the invocation id rather than from a loop?
2711///
2712/// The dividing line for [`extract_multiply_literals`], and the only structural
2713/// difference between the two things a literal multiply can mean. A stride
2714/// scales the coordinate the invocation is computing — `oh`, which is `gid.y` —
2715/// while an index-flattening factor scales a reduction's loop variable, which
2716/// reaches the expression as a load of a local.
2717///
2718/// Deliberately narrow: an id reaches this either directly or through one
2719/// component access, and nothing else counts. Widening it to follow arithmetic
2720/// would readmit `oh + kh`, which is half a loop variable.
2721fn derives_from_invocation_id(exprs: &Arena<Expression>, handle: Handle<Expression>) -> bool {
2722    match exprs.try_get(handle) {
2723        // `gid` itself, and `gid.x` / `gid.y` / `gid.z`.
2724        Some(Expression::FunctionArgument(_)) => true,
2725        Some(Expression::AccessIndex { base, .. }) => {
2726            matches!(exprs.try_get(*base), Some(Expression::FunctionArgument(_)))
2727        }
2728        _ => false,
2729    }
2730}
2731
2732/// Stride factors, from `Binary(Multiply, id, Literal(U32(n)))` where n > 1.
2733///
2734/// The multiplicand has to come from the invocation id. Without that test this
2735/// took **every** literal multiply in the function, and a convolution flattens
2736/// its weight index with them: `oc * IC * 9u + ic * 9u + kh * 3u + kw` reported
2737/// strides of 3 and 9, and a 3x3 VALID convolution over a 64x64 image was
2738/// emitted with an output of 21x7 instead of 62x62. That model loads, is
2739/// accelerated, and returns the wrong numbers — the failure this project
2740/// refuses everywhere it can see it, and this one it could not see.
2741///
2742/// The test narrows rather than widens, on purpose. A genuine stride written
2743/// through a loop variable is now missed and reported as 1, which is the
2744/// common case and wrong by a little; a flattening factor read as a stride is
2745/// wrong by whatever the channel count happens to be.
2746fn extract_multiply_literals(exprs: &Arena<Expression>) -> Vec<u32> {
2747    let mut strides = Vec::new();
2748    // Nested rather than a let-chain: those are stable from 1.88 and this
2749    // workspace's rust-version is 1.87.
2750    #[allow(clippy::collapsible_if)]
2751    for (_, expr) in exprs.iter() {
2752        let Expression::Binary {
2753            op: BinaryOp::Multiply,
2754            left,
2755            right,
2756        } = expr
2757        else {
2758            continue;
2759        };
2760        if let Some(&Expression::Literal(Literal::U32(n @ 2..))) = exprs.try_get(*right) {
2761            if derives_from_invocation_id(exprs, *left) {
2762                strides.push(n);
2763            }
2764        } else if let Some(&Expression::Literal(Literal::U32(n @ 2..))) = exprs.try_get(*left) {
2765            if derives_from_invocation_id(exprs, *right) {
2766                strides.push(n);
2767            }
2768        }
2769    }
2770    strides.sort_unstable();
2771    strides.dedup();
2772    strides
2773}
2774
2775/// Scan the expression arena for `Binary(Subtract, _, Literal(U32(n)))` where n > 0.
2776/// These represent padding offsets in index computations like `oh * 2u + kh - 1u`.
2777fn extract_subtract_literals(exprs: &Arena<Expression>) -> Vec<u32> {
2778    let mut pads = Vec::new();
2779    for (_, expr) in exprs.iter() {
2780        let Expression::Binary {
2781            op: BinaryOp::Subtract,
2782            right,
2783            ..
2784        } = expr
2785        else {
2786            continue;
2787        };
2788        if let Some(&Expression::Literal(Literal::U32(n @ 1..))) = exprs.try_get(*right) {
2789            pads.push(n);
2790        }
2791    }
2792    pads.sort_unstable();
2793    pads.dedup();
2794    pads
2795}
2796
2797/// Extract Conv2D shape from param names and (optionally) the function body/expressions.
2798///
2799/// When body and expressions are provided, attempts to extract kernel size from
2800/// loop bound literals, stride from multiplication factors, and padding from
2801/// subtraction offsets. Falls back to 0 (unknown) for values that cannot be
2802/// determined at compile time.
2803fn extract_conv2d_shape(
2804    shape_names: &[String],
2805    body: Option<&[Statement]>,
2806    exprs: Option<&Arena<Expression>>,
2807) -> Conv2DShape {
2808    // Convention: params struct has N, IC, IH, IW, OC, KH, KW, ...
2809    let get = |i: usize| {
2810        shape_names
2811            .get(i)
2812            .cloned()
2813            .unwrap_or_else(|| format!("d{i}"))
2814    };
2815
2816    let (kernel_h_val, kernel_w_val, stride_h, stride_w, pad_h, pad_w) =
2817        if let (Some(body), Some(exprs)) = (body, exprs) {
2818            let bounds = extract_loop_bound_literals(body, exprs);
2819            let strides = extract_multiply_literals(exprs);
2820            let pads = extract_subtract_literals(exprs);
2821
2822            // Innermost loop bounds are kernel sizes (e.g., kh < 5u → kernel_h = 5).
2823            let kh = bounds.first().copied().unwrap_or(0) as i64;
2824            let kw = bounds.get(1).copied().unwrap_or(0) as i64;
2825            // If only one bound found, assume square kernel.
2826            let kw = if kw == 0 && kh > 0 { kh } else { kw };
2827
2828            // Stride from multiplication factors (e.g., oh * 2u → stride = 2).
2829            let sh_u = strides.first().copied().unwrap_or(1);
2830            let sw_u = strides.get(1).copied().unwrap_or(sh_u);
2831            let sh = sh_u as i64;
2832            let sw = sw_u as i64;
2833
2834            // Padding from subtraction offsets (e.g., ih - 1u → pad = 1).
2835            let ph_u = pads.first().copied().unwrap_or(0);
2836            let pw_u = pads.get(1).copied().unwrap_or(ph_u);
2837            let ph = ph_u as i64;
2838            let pw = pw_u as i64;
2839
2840            (kh, kw, sh, sw, ph, pw)
2841        } else {
2842            (0, 0, 1, 1, 0, 0)
2843        };
2844
2845    // Detect groups parameter: if shape_names contains "groups", use it.
2846    let groups = if shape_names.iter().any(|n| n.eq_ignore_ascii_case("groups")) {
2847        // Depthwise convention: groups == channels_in
2848        -1 // Sentinel: resolved at runtime or marked as depthwise
2849    } else {
2850        1
2851    };
2852
2853    Conv2DShape {
2854        batch: get(0),
2855        channels_in: get(1),
2856        height: get(2),
2857        width: get(3),
2858        channels_out: get(4),
2859        kernel_h: get(5),
2860        kernel_w: get(6),
2861        kernel_h_val,
2862        kernel_w_val,
2863        stride_h,
2864        stride_w,
2865        pad_h,
2866        pad_w,
2867        groups,
2868        dilation_h: 1,
2869        dilation_w: 1,
2870    }
2871}
2872
2873fn make_binding(
2874    module: &Module,
2875    handle: Handle<GlobalVariable>,
2876    gv: &GlobalVariable,
2877    role: TensorRole,
2878) -> TensorBinding {
2879    let elem_type = resolve_array_elem_type(module, gv.ty).unwrap_or(data_type::FLOAT);
2880    TensorBinding {
2881        handle,
2882        name: gv
2883            .name
2884            .clone()
2885            .unwrap_or_else(|| format!("tensor_{}", handle.index())),
2886        elem_type,
2887        role,
2888    }
2889}
2890
2891/// Resolve an array or tensor type to its element's ONNX data type.
2892fn resolve_array_elem_type(module: &Module, ty: Handle<Type>) -> Option<i32> {
2893    match &module.types[ty].inner {
2894        TypeInner::Array { base, .. } => match &module.types[*base].inner {
2895            TypeInner::Scalar(s) => Some(scalar_to_onnx_data_type(s)),
2896            _ => None,
2897        },
2898        TypeInner::Tensor { scalar, .. } => Some(scalar_to_onnx_data_type(scalar)),
2899        _ => None,
2900    }
2901}
2902
2903/// Map an IR scalar type to an ONNX data type constant.
2904fn scalar_to_onnx_data_type(scalar: &Scalar) -> i32 {
2905    match (scalar.kind, scalar.width) {
2906        (ScalarKind::Float, 4) => data_type::FLOAT,
2907        (ScalarKind::Float, 2) => data_type::FLOAT16,
2908        (ScalarKind::BFloat, 2) => data_type::BFLOAT16,
2909        (ScalarKind::Sint, 4) => data_type::INT32,
2910        (ScalarKind::Sint, 1) => data_type::INT8,
2911        (ScalarKind::Uint, 4) => data_type::UINT32,
2912        (ScalarKind::Uint, 1) => data_type::UINT8,
2913        (ScalarKind::Bool, _) => data_type::BOOL,
2914        _ => data_type::FLOAT,
2915    }
2916}
2917
2918/// Normalize a potentially negative axis to a positive axis.
2919///
2920/// For example, `normalize_axis(-1, 4)` returns `3`.
2921pub fn normalize_axis(axis: i64, ndim: usize) -> i64 {
2922    if axis < 0 {
2923        let ndim = ndim as i64;
2924        ((axis % ndim) + ndim) % ndim
2925    } else {
2926        axis
2927    }
2928}
2929
2930/// Infer the concat axis from the If condition in the function body.
2931///
2932/// In WGSL concat patterns, the If condition checks whether the linear index
2933/// falls before or after a boundary (e.g., `if idx < params.C1`). The boundary
2934/// param name maps to a position in the params struct, which gives us the axis.
2935fn infer_concat_axis(body: &[Statement], exprs: &Arena<Expression>, shape_names: &[String]) -> i64 {
2936    find_if_comparison_axis(body, exprs, shape_names).unwrap_or(0)
2937}
2938
2939/// Infer the split axis from the If condition in the function body.
2940///
2941/// Works identically to [`infer_concat_axis`]: the If condition compares
2942/// against a params struct member whose index indicates the split axis.
2943fn infer_split_axis(body: &[Statement], exprs: &Arena<Expression>, shape_names: &[String]) -> i64 {
2944    find_if_comparison_axis(body, exprs, shape_names).unwrap_or(0)
2945}
2946
2947/// Walk the statement tree to find an If whose condition is a comparison
2948/// (Less, LessEqual, Greater, GreaterEqual) where one operand is an
2949/// `AccessIndex` into the uniform params struct. The `index` field of
2950/// `AccessIndex` gives the struct member position, which corresponds to
2951/// the concat/split axis.
2952fn find_if_comparison_axis(
2953    body: &[Statement],
2954    exprs: &Arena<Expression>,
2955    _shape_names: &[String],
2956) -> Option<i64> {
2957    for stmt in body {
2958        match stmt {
2959            Statement::If { condition, .. } => {
2960                // Split into nested ifs to avoid let-chains (unstable in MSRV 1.87).
2961                #[allow(clippy::collapsible_if)]
2962                if let Some(Expression::Binary {
2963                    op, left, right, ..
2964                }) = exprs.try_get(*condition)
2965                {
2966                    if matches!(
2967                        op,
2968                        BinaryOp::Less
2969                            | BinaryOp::LessEqual
2970                            | BinaryOp::Greater
2971                            | BinaryOp::GreaterEqual
2972                    ) {
2973                        // Check right operand first (most common: `idx < params.C1`)
2974                        if let Some(idx) = extract_uniform_member_index(exprs, *right) {
2975                            return Some(idx as i64);
2976                        }
2977                        if let Some(idx) = extract_uniform_member_index(exprs, *left) {
2978                            return Some(idx as i64);
2979                        }
2980                    }
2981                }
2982            }
2983            Statement::Loop {
2984                body, continuing, ..
2985            } => {
2986                if let Some(axis) = find_if_comparison_axis(body, exprs, _shape_names) {
2987                    return Some(axis);
2988                }
2989                if let Some(axis) = find_if_comparison_axis(continuing, exprs, _shape_names) {
2990                    return Some(axis);
2991                }
2992            }
2993            _ => {}
2994        }
2995    }
2996    None
2997}
2998
2999/// Extract the struct member index from an expression that accesses a
3000/// uniform params member.
3001///
3002/// Handles both direct `AccessIndex { base, index }` and
3003/// `Load { pointer: AccessIndex { base, index } }` patterns, since
3004/// different naga versions may emit either form.
3005fn extract_uniform_member_index(
3006    exprs: &Arena<Expression>,
3007    handle: Handle<Expression>,
3008) -> Option<u32> {
3009    match exprs.try_get(handle)? {
3010        Expression::AccessIndex { index, .. } => Some(*index),
3011        Expression::Load { pointer } => match exprs.try_get(*pointer)? {
3012            Expression::AccessIndex { index, .. } => Some(*index),
3013            _ => None,
3014        },
3015        _ => None,
3016    }
3017}
3018
3019/// Check if any expression in the arena uses a specific math function.
3020fn has_math_function_in_expressions(exprs: &Arena<Expression>, target: MathFunction) -> bool {
3021    exprs
3022        .iter()
3023        .any(|(_, expr)| matches!(expr, Expression::Math { fun, .. } if *fun == target))
3024}
3025
3026/// Check if a block (or any nested block) contains an If statement.
3027fn has_if_statement(body: &[Statement]) -> bool {
3028    body.iter().any(|stmt| match stmt {
3029        Statement::If { .. } => true,
3030        Statement::Loop {
3031            body, continuing, ..
3032        } => has_if_statement(body) || has_if_statement(continuing),
3033        _ => false,
3034    })
3035}
3036
3037/// Check if a block contains a non-guard If statement (i.e., one that is not
3038/// just a bounds-check early return like `if (idx >= N) { return; }`).
3039fn has_non_guard_if(body: &[Statement]) -> bool {
3040    body.iter().any(|stmt| match stmt {
3041        Statement::If { accept, reject, .. } => {
3042            // A guard-if has only a Return in accept and empty reject (or vice-versa).
3043            let is_guard = reject.is_empty()
3044                && accept.len() == 1
3045                && matches!(accept[0], Statement::Return { .. });
3046            !is_guard
3047        }
3048        Statement::Loop {
3049            body, continuing, ..
3050        } => has_non_guard_if(body) || has_non_guard_if(continuing),
3051        _ => false,
3052    })
3053}
3054
3055/// Check if a block (or any nested block) contains a Loop statement.
3056fn has_loop(body: &[Statement]) -> bool {
3057    body.iter().any(|stmt| match stmt {
3058        Statement::Loop { .. } => true,
3059        Statement::If { accept, reject, .. } => has_loop(accept) || has_loop(reject),
3060        _ => false,
3061    })
3062}
3063
3064/// What the value expression of a `Store` computes, as far as element-wise
3065/// classification is concerned.
3066#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3067enum StoreValueOp {
3068    /// A plain two-operand binary op: `a + b`, `a - b`, `a * b`, `a / b`.
3069    Binary(ElementWiseOp),
3070    /// A three-operand multiply-add, in either of the two spellings this
3071    /// pipeline can produce for it: `c + a * b` as the kernel author wrote it,
3072    /// or `fma(a, b, c)` after `nxpu-opt`'s FMA fusion pass has rewritten it.
3073    ///
3074    /// It is deliberately *not* mapped onto [`ElementWiseOp::Add`]. Calling it
3075    /// an add discards the multiply, which is how `axpy` — `y + a * x` — used
3076    /// to classify as a bare `Add` at `--opt-level 0` and then fail outright at
3077    /// `--opt-level 1` once fusion had rewritten it. Naming the shape here lets
3078    /// the classifier give the same answer at every optimization level, and
3079    /// leaves a single place to add a real three-operand pattern later.
3080    MultiplyAdd,
3081}
3082
3083/// Search a block for a Store whose value is a recognizable arithmetic
3084/// expression, returning what kind it is.
3085fn find_store_value_op(body: &[Statement], exprs: &Arena<Expression>) -> Option<StoreValueOp> {
3086    for stmt in body {
3087        match stmt {
3088            Statement::Store { value, .. } => {
3089                if let Some(op) = classify_store_value_op(exprs, *value) {
3090                    return Some(op);
3091                }
3092            }
3093            Statement::If { accept, reject, .. } => {
3094                if let Some(op) = find_store_value_op(accept, exprs) {
3095                    return Some(op);
3096                }
3097                if let Some(op) = find_store_value_op(reject, exprs) {
3098                    return Some(op);
3099                }
3100            }
3101            _ => {}
3102        }
3103    }
3104    None
3105}
3106
3107/// Classify a single stored value expression.
3108fn classify_store_value_op(
3109    exprs: &Arena<Expression>,
3110    value: Handle<Expression>,
3111) -> Option<StoreValueOp> {
3112    match exprs.try_get(value)? {
3113        // What FMA fusion leaves behind.
3114        Expression::Math {
3115            fun: MathFunction::Fma,
3116            ..
3117        } => Some(StoreValueOp::MultiplyAdd),
3118        Expression::Binary { op, left, right } => {
3119            let ew = match op {
3120                // The same three-operand op before fusion. Only `Add` is
3121                // checked because only `Add` is what FmaFusion rewrites; a
3122                // `Subtract` over multiplies (rope's rotation, say) stays a
3123                // `Subtract` at every level and needs no special case.
3124                BinaryOp::Add => {
3125                    if expr_is_multiply(exprs, *left) || expr_is_multiply(exprs, *right) {
3126                        return Some(StoreValueOp::MultiplyAdd);
3127                    }
3128                    ElementWiseOp::Add
3129                }
3130                BinaryOp::Subtract => ElementWiseOp::Sub,
3131                BinaryOp::Multiply => ElementWiseOp::Mul,
3132                BinaryOp::Divide => ElementWiseOp::Div,
3133                _ => return None,
3134            };
3135            Some(StoreValueOp::Binary(ew))
3136        }
3137        _ => None,
3138    }
3139}
3140
3141/// Whether an expression is directly a multiplication — the operand shape
3142/// FMA fusion looks for.
3143fn expr_is_multiply(exprs: &Arena<Expression>, handle: Handle<Expression>) -> bool {
3144    matches!(
3145        exprs.try_get(handle),
3146        Some(Expression::Binary {
3147            op: BinaryOp::Multiply,
3148            ..
3149        })
3150    )
3151}
3152
3153/// A leaf of a per-element expression: something with no arithmetic under it.
3154#[derive(Debug, Clone)]
3155enum ChainLeaf {
3156    /// A storage buffer, read at the index the result is stored at.
3157    Tensor(Handle<GlobalVariable>),
3158    /// A uniform scalar, or one scalar member of the uniform params struct.
3159    Scalar(ScalarBinding),
3160}
3161
3162/// A chain before its globals are turned into [`TensorBinding`]s.
3163struct RawChain {
3164    base: Handle<GlobalVariable>,
3165    cast: Option<i32>,
3166    steps: Vec<(ElementWiseOp, ChainLeaf)>,
3167}
3168
3169/// The one `Store` in a block, as (index written, value stored).
3170///
3171/// `None` when there is more than one: a chain describes a single write, and
3172/// two writes are two operators or a movement of values, neither of which this
3173/// says.
3174fn find_lone_store(
3175    body: &[Statement],
3176    exprs: &Arena<Expression>,
3177) -> Option<(Handle<Expression>, Handle<Expression>)> {
3178    fn walk(
3179        body: &[Statement],
3180        exprs: &Arena<Expression>,
3181        found: &mut Vec<(Handle<Expression>, Handle<Expression>)>,
3182    ) {
3183        for stmt in body {
3184            match stmt {
3185                Statement::Store { pointer, value } => match access_index(exprs, *pointer) {
3186                    Some(index) => found.push((index, *value)),
3187                    // A store through something that is not a plain index into
3188                    // a global. Recorded as a store all the same, so that it
3189                    // makes the block ineligible rather than invisible.
3190                    None => found.push((*pointer, *value)),
3191                },
3192                Statement::If { accept, reject, .. } => {
3193                    walk(accept, exprs, found);
3194                    walk(reject, exprs, found);
3195                }
3196                Statement::Loop {
3197                    body, continuing, ..
3198                } => {
3199                    walk(body, exprs, found);
3200                    walk(continuing, exprs, found);
3201                }
3202                _ => {}
3203            }
3204        }
3205    }
3206    let mut found = Vec::new();
3207    walk(body, exprs, &mut found);
3208    match found.as_slice() {
3209        [one] => Some(*one),
3210        _ => None,
3211    }
3212}
3213
3214/// Classify an expression as a chain leaf, if that is what it is.
3215///
3216/// The index test is what keeps a broadcast out. `alibi` reads
3217/// `slopes[head]` and `snake` reads `alpha[c]`, both at a subscript derived
3218/// from the thread id rather than at the id itself; a chain step over either
3219/// would be an operand of a different shape, and calling it element-wise
3220/// would be the same confident guess this file exists to stop making.
3221fn chain_leaf(
3222    module: &Module,
3223    exprs: &Arena<Expression>,
3224    handle: Handle<Expression>,
3225    store_index: Handle<Expression>,
3226) -> Option<ChainLeaf> {
3227    let Expression::Load { pointer } = exprs.try_get(handle)? else {
3228        return None;
3229    };
3230    match exprs.try_get(*pointer)? {
3231        // `x[idx]` — a whole tensor.
3232        Expression::Access { base, index } => {
3233            let global = access_base_global(exprs, *base)?;
3234            let gv = module.global_variables.try_get(global)?;
3235            if !matches!(gv.space, AddressSpace::Storage { .. }) {
3236                return None;
3237            }
3238            (*index == store_index).then_some(ChainLeaf::Tensor(global))
3239        }
3240        // `input_scale` — a uniform that is one scalar.
3241        Expression::GlobalVariable(global) => {
3242            let gv = module.global_variables.try_get(*global)?;
3243            if !matches!(gv.space, AddressSpace::Uniform) {
3244                return None;
3245            }
3246            let TypeInner::Scalar(scalar) = &module.types.try_get(gv.ty)?.inner else {
3247                return None;
3248            };
3249            Some(ChainLeaf::Scalar(ScalarBinding {
3250                name: gv
3251                    .name
3252                    .clone()
3253                    .unwrap_or_else(|| format!("scalar_{}", global.index())),
3254                elem_type: scalar_to_onnx_data_type(scalar),
3255            }))
3256        }
3257        // `params.a` — one scalar member of the uniform params struct.
3258        Expression::AccessIndex { base, index } => {
3259            let Expression::GlobalVariable(global) = exprs.try_get(*base)? else {
3260                return None;
3261            };
3262            let gv = module.global_variables.try_get(*global)?;
3263            if !matches!(gv.space, AddressSpace::Uniform) {
3264                return None;
3265            }
3266            let TypeInner::Struct { members, .. } = &module.types.try_get(gv.ty)?.inner else {
3267                return None;
3268            };
3269            let member = members.get(*index as usize)?;
3270            let TypeInner::Scalar(scalar) = &module.types.try_get(member.ty)?.inner else {
3271                return None;
3272            };
3273            Some(ChainLeaf::Scalar(ScalarBinding {
3274                name: member.name.clone()?,
3275                elem_type: scalar_to_onnx_data_type(scalar),
3276            }))
3277        }
3278        _ => None,
3279    }
3280}
3281
3282/// Read a per-element expression as a chain, or refuse it.
3283///
3284/// The chain has to be *linear*: at every node exactly one operand carries the
3285/// rest of the expression and the other is a leaf. That is the whole
3286/// difference between a sequence of graph nodes and a tree, and it is what
3287/// separates `axpy` from `snake`, whose multiply has a reciprocal on one side
3288/// and a squared sine on the other.
3289fn decompose_chain(
3290    module: &Module,
3291    exprs: &Arena<Expression>,
3292    handle: Handle<Expression>,
3293    store_index: Handle<Expression>,
3294    depth: u32,
3295) -> Option<RawChain> {
3296    if depth == 0 {
3297        return None;
3298    }
3299    // A tensor on its own is a chain of no steps. A scalar is not: the result
3300    // is a tensor, so the chain has to start from one.
3301    if let Some(leaf) = chain_leaf(module, exprs, handle, store_index) {
3302        return match leaf {
3303            ChainLeaf::Tensor(global) => Some(RawChain {
3304                base: global,
3305                cast: None,
3306                steps: Vec::new(),
3307            }),
3308            ChainLeaf::Scalar(_) => None,
3309        };
3310    }
3311    match exprs.try_get(handle)? {
3312        // `f32(input[i])` — a conversion of the tensor the chain starts from.
3313        Expression::As {
3314            expr,
3315            kind,
3316            convert: Some(width),
3317        } => {
3318            let inner = decompose_chain(module, exprs, *expr, store_index, depth - 1)?;
3319            // A conversion partway along would need a type per step, and
3320            // nothing here carries one.
3321            if inner.cast.is_some() || !inner.steps.is_empty() {
3322                return None;
3323            }
3324            Some(RawChain {
3325                cast: Some(scalar_to_onnx_data_type(&Scalar {
3326                    kind: *kind,
3327                    width: *width,
3328                })),
3329                ..inner
3330            })
3331        }
3332        Expression::Binary { op, left, right } => {
3333            let ew = match op {
3334                BinaryOp::Add => ElementWiseOp::Add,
3335                BinaryOp::Subtract => ElementWiseOp::Sub,
3336                BinaryOp::Multiply => ElementWiseOp::Mul,
3337                BinaryOp::Divide => ElementWiseOp::Div,
3338                _ => return None,
3339            };
3340            decompose_chain_binary(module, exprs, ew, *left, *right, store_index, depth - 1)
3341        }
3342        // What FMA fusion leaves behind. `fma(a, b, c)` is `c + a * b`, and it
3343        // has to come apart into exactly what the unfused spelling gives, or
3344        // the classifier answers differently at `-O0` and `-O1` — the failure
3345        // `e2e_opt_invariance` exists to catch.
3346        Expression::Math {
3347            fun: MathFunction::Fma,
3348            arg,
3349            arg1: Some(factor),
3350            arg2: Some(addend),
3351            ..
3352        } => {
3353            let mut chain = decompose_chain_binary(
3354                module,
3355                exprs,
3356                ElementWiseOp::Mul,
3357                *arg,
3358                *factor,
3359                store_index,
3360                depth - 1,
3361            )?;
3362            let addend = chain_leaf(module, exprs, *addend, store_index)?;
3363            chain.steps.push((ElementWiseOp::Add, addend));
3364            Some(chain)
3365        }
3366        _ => None,
3367    }
3368}
3369
3370/// One binary node of a chain: one side continues it, the other is the operand.
3371#[allow(clippy::collapsible_if)] // nested if-let for MSRV 1.87 compat (no let chains)
3372fn decompose_chain_binary(
3373    module: &Module,
3374    exprs: &Arena<Expression>,
3375    op: ElementWiseOp,
3376    left: Handle<Expression>,
3377    right: Handle<Expression>,
3378    store_index: Handle<Expression>,
3379    depth: u32,
3380) -> Option<RawChain> {
3381    // The left operand is tried as the accumulator first: it is the only
3382    // arrangement that keeps `a - b` meaning `a - b`.
3383    if let Some(operand) = chain_leaf(module, exprs, right, store_index) {
3384        if let Some(mut chain) = decompose_chain(module, exprs, left, store_index, depth) {
3385            chain.steps.push((op, operand));
3386            return Some(chain);
3387        }
3388    }
3389    // The accumulator on the right is only the same expression when the
3390    // operation does not care which side its operands are on.
3391    if op.is_commutative() {
3392        if let Some(operand) = chain_leaf(module, exprs, left, store_index) {
3393            if let Some(mut chain) = decompose_chain(module, exprs, right, store_index, depth) {
3394                chain.steps.push((op, operand));
3395                return Some(chain);
3396            }
3397        }
3398    }
3399    None
3400}
3401
3402/// Recognise a kernel whose stored value is an element-wise chain.
3403///
3404/// Returns `None` — leaving the caller's refusal in place — for anything that
3405/// is not one, including the chains [`KernelPattern::ElementWise`] already
3406/// describes: one binary op over two whole tensors is an `Add`, and two names
3407/// for one thing is how a pattern starts being emitted where it is not meant.
3408fn match_elementwise_chain(
3409    module: &Module,
3410    body: &[Statement],
3411    exprs: &Arena<Expression>,
3412    outputs: &[(Handle<GlobalVariable>, &GlobalVariable)],
3413    shape_names: &[String],
3414) -> Option<KernelPattern> {
3415    let [(out_handle, out_gv)] = outputs else {
3416        return None;
3417    };
3418    // Element *i* of the result has to be built from element *i*, and written
3419    // in one place. A chain that moved values would be a different operator.
3420    if !stores_at_the_index_it_read(body, exprs) {
3421        return None;
3422    }
3423    let (store_index, value) = find_lone_store(body, exprs)?;
3424    let chain = decompose_chain(module, exprs, value, store_index, 32)?;
3425
3426    let has_scalar = chain
3427        .steps
3428        .iter()
3429        .any(|(_, leaf)| matches!(leaf, ChainLeaf::Scalar(_)));
3430    if chain.steps.is_empty() || (chain.cast.is_none() && chain.steps.len() == 1 && !has_scalar) {
3431        return None;
3432    }
3433
3434    let base_gv = module.global_variables.try_get(chain.base)?;
3435    let base = make_binding(module, chain.base, base_gv, TensorRole::Input);
3436    let mut steps = Vec::with_capacity(chain.steps.len());
3437    let mut reads_its_own_output = chain.base == *out_handle;
3438    for (op, leaf) in chain.steps {
3439        let operand = match leaf {
3440            ChainLeaf::Tensor(global) => {
3441                reads_its_own_output |= global == *out_handle;
3442                let gv = module.global_variables.try_get(global)?;
3443                ChainOperand::Tensor(make_binding(module, global, gv, TensorRole::Input))
3444            }
3445            ChainLeaf::Scalar(scalar) => ChainOperand::Scalar(scalar),
3446        };
3447        steps.push(ChainStep { op, operand });
3448    }
3449
3450    // `axpy`'s in-place entry point binds `y` `read_write` and computes
3451    // `y = y + a*x`, so the same buffer is read and written. The arithmetic is
3452    // the out-of-place kernel's exactly; only the allocation differs. A graph
3453    // needs two names for the two values, so the result takes a suffix and the
3454    // operand keeps the buffer's own name.
3455    let mut output = make_binding(module, *out_handle, out_gv, TensorRole::Output);
3456    if reads_its_own_output {
3457        output.name = format!("{}_out", output.name);
3458    }
3459
3460    Some(KernelPattern::ElementWiseChain {
3461        base,
3462        cast: chain.cast,
3463        steps,
3464        output,
3465        dim_name: shape_names.first().cloned().unwrap_or_else(|| "N".into()),
3466    })
3467}
3468
3469/// Search for a Store whose value is a Math expression, detecting activation type.
3470fn find_store_activation(body: &[Statement], exprs: &Arena<Expression>) -> Option<ActivationOp> {
3471    for stmt in body {
3472        match stmt {
3473            Statement::Store { value, .. } => {
3474                if let Some(act) = classify_activation_expr(exprs, *value) {
3475                    return Some(act);
3476                }
3477            }
3478            Statement::If { accept, reject, .. } => {
3479                if let Some(op) = find_store_activation(accept, exprs) {
3480                    return Some(op);
3481                }
3482                if let Some(op) = find_store_activation(reject, exprs) {
3483                    return Some(op);
3484                }
3485            }
3486            _ => {}
3487        }
3488    }
3489    None
3490}
3491
3492/// Classify an expression as an activation function.
3493fn classify_activation_expr(
3494    exprs: &Arena<Expression>,
3495    handle: Handle<Expression>,
3496) -> Option<ActivationOp> {
3497    match exprs.try_get(handle)? {
3498        // max(x, 0) → ReLU
3499        Expression::Math {
3500            fun: MathFunction::Max,
3501            ..
3502        } => Some(ActivationOp::Relu),
3503        // tanh(x) → Tanh (standalone, not part of a Multiply)
3504        Expression::Math {
3505            fun: MathFunction::Tanh,
3506            ..
3507        } => Some(ActivationOp::Tanh),
3508        // Multiply patterns: GELU, SiLU, Mish
3509        Expression::Binary {
3510            op: BinaryOp::Multiply,
3511            left,
3512            right,
3513            ..
3514        } => {
3515            let has_tanh_left = contains_math_fun(exprs, *left, MathFunction::Tanh);
3516            let has_tanh_right = contains_math_fun(exprs, *right, MathFunction::Tanh);
3517            let has_exp_left = contains_math_fun(exprs, *left, MathFunction::Exp);
3518            let has_exp_right = contains_math_fun(exprs, *right, MathFunction::Exp);
3519
3520            // GELU: x * 0.5 * (1 + tanh(sqrt(2/pi) * (x + 0.044715*x^3)))
3521            // Detected as Multiply where one sub-expression contains Tanh
3522            // but NOT Exp (to distinguish from Mish which has tanh+exp)
3523            if (has_tanh_left || has_tanh_right) && !has_exp_left && !has_exp_right {
3524                return Some(ActivationOp::Gelu);
3525            }
3526
3527            // Mish: x * tanh(ln(1 + exp(x))) = x * tanh(softplus(x))
3528            // Detected as Multiply containing both Tanh and Exp
3529            if (has_tanh_left || has_tanh_right) && (has_exp_left || has_exp_right) {
3530                return Some(ActivationOp::Mish);
3531            }
3532
3533            // SiLU: x * sigmoid(x) = x * (1 / (1 + exp(-x)))
3534            // Detected as Multiply where one sub-expression contains Divide+Exp (sigmoid pattern)
3535            if has_exp_left || has_exp_right {
3536                let has_div_left = contains_binary_op(exprs, *left, BinaryOp::Divide);
3537                let has_div_right = contains_binary_op(exprs, *right, BinaryOp::Divide);
3538                if has_div_left || has_div_right {
3539                    return Some(ActivationOp::Silu);
3540                }
3541            }
3542
3543            // Not a recognized activation multiply, try recursing
3544            classify_activation_expr(exprs, *left)
3545                .or_else(|| classify_activation_expr(exprs, *right))
3546        }
3547        // 1/(1+exp(-x)) → Sigmoid: detected as Divide whose right is Add(1, Exp(Negate(x)))
3548        // exp(x)/sum(exp(x)) → Softmax: detected as Divide with Exp on left
3549        Expression::Binary {
3550            op: BinaryOp::Divide,
3551            left,
3552            right,
3553            ..
3554        } => {
3555            if contains_math_fun(exprs, *left, MathFunction::Exp) {
3556                // Softmax: exp(x) / sum(exp(x))
3557                Some(ActivationOp::Softmax)
3558            } else if contains_math_fun(exprs, *right, MathFunction::Exp) {
3559                // Sigmoid: 1 / (1 + exp(-x))
3560                Some(ActivationOp::Sigmoid)
3561            } else {
3562                None
3563            }
3564        }
3565        _ => None,
3566    }
3567}
3568
3569/// Check if an expression (recursively) contains a specific binary operation.
3570fn contains_binary_op(
3571    exprs: &Arena<Expression>,
3572    handle: Handle<Expression>,
3573    target: BinaryOp,
3574) -> bool {
3575    match exprs.try_get(handle) {
3576        Some(Expression::Binary {
3577            op, left, right, ..
3578        }) => {
3579            *op == target
3580                || contains_binary_op(exprs, *left, target)
3581                || contains_binary_op(exprs, *right, target)
3582        }
3583        Some(Expression::Math {
3584            fun,
3585            arg,
3586            arg1,
3587            arg2,
3588            arg3,
3589        }) => {
3590            // `fma(a, b, c)` is a multiply and an add wearing one name. Say so,
3591            // or a pattern that asks "is there a multiply in here?" changes its
3592            // answer the moment FMA fusion runs.
3593            (*fun == MathFunction::Fma && matches!(target, BinaryOp::Multiply | BinaryOp::Add))
3594                || contains_binary_op(exprs, *arg, target)
3595                || [*arg1, *arg2, *arg3]
3596                    .into_iter()
3597                    .flatten()
3598                    .any(|h| contains_binary_op(exprs, h, target))
3599        }
3600        Some(Expression::Unary { expr, .. }) => contains_binary_op(exprs, *expr, target),
3601        _ => false,
3602    }
3603}
3604
3605/// Check if an expression (recursively) contains a specific math function.
3606fn contains_math_fun(
3607    exprs: &Arena<Expression>,
3608    handle: Handle<Expression>,
3609    target: MathFunction,
3610) -> bool {
3611    match exprs.try_get(handle) {
3612        Some(Expression::Math {
3613            fun,
3614            arg,
3615            arg1,
3616            arg2,
3617            arg3,
3618        }) => {
3619            // All operands, not just the first: `fma` carries two more, and
3620            // anything fused into them was invisible to this walk before.
3621            *fun == target
3622                || contains_math_fun(exprs, *arg, target)
3623                || [*arg1, *arg2, *arg3]
3624                    .into_iter()
3625                    .flatten()
3626                    .any(|h| contains_math_fun(exprs, h, target))
3627        }
3628        Some(Expression::Binary { left, right, .. }) => {
3629            contains_math_fun(exprs, *left, target) || contains_math_fun(exprs, *right, target)
3630        }
3631        Some(Expression::Unary { expr, .. }) => contains_math_fun(exprs, *expr, target),
3632        _ => false,
3633    }
3634}
3635
3636// ---------------------------------------------------------------------------
3637// Permutation recognition
3638// ---------------------------------------------------------------------------
3639
3640/// How many divisions were applied to the flat invocation id to produce this
3641/// value, or `None` if it is not derived from the id by the usual
3642/// decomposition.
3643///
3644/// A kernel that reindexes a tensor peels dimensions off the flat id from the
3645/// fastest-varying end: `d = id % D`, `rest = id / D`, `i = rest % N`, and so
3646/// on. The number of divisions on the path is therefore the axis's distance
3647/// from the fastest end, which is all that is needed to place it.
3648fn id_division_depth(
3649    exprs: &Arena<Expression>,
3650    handle: Handle<Expression>,
3651    depth: u32,
3652) -> Option<u32> {
3653    if depth == 0 {
3654        return None;
3655    }
3656    match exprs.try_get(handle)? {
3657        // The flat id itself: `gid.x`.
3658        Expression::AccessIndex { base, index: 0 } => {
3659            matches!(exprs.try_get(*base)?, Expression::FunctionArgument(_)).then_some(0)
3660        }
3661        Expression::Binary { op, left, .. } => match op {
3662            BinaryOp::Modulo => id_division_depth(exprs, *left, depth - 1),
3663            BinaryOp::Divide => id_division_depth(exprs, *left, depth - 1).map(|d| d + 1),
3664            _ => None,
3665        },
3666        _ => None,
3667    }
3668}
3669
3670/// Decompose a store index built as `((c_n * f_n + c_n-1) * f_n-1 + …) + c_0`
3671/// into its components, fastest-varying first.
3672///
3673/// Both spellings are accepted: the multiply-add as written, and the `fma`
3674/// the optimizer rewrites it into. Missing the second cost a whole pass's
3675/// worth of kernels once already.
3676fn flatten_index_components(
3677    exprs: &Arena<Expression>,
3678    handle: Handle<Expression>,
3679    out: &mut Vec<Handle<Expression>>,
3680    depth: u32,
3681) {
3682    if depth == 0 {
3683        return;
3684    }
3685    match exprs.try_get(handle) {
3686        Some(Expression::Math {
3687            fun: MathFunction::Fma,
3688            arg,
3689            arg2: Some(addend),
3690            ..
3691        }) => {
3692            out.push(*addend);
3693            flatten_index_components(exprs, *arg, out, depth - 1);
3694        }
3695        Some(Expression::Binary {
3696            op: BinaryOp::Add,
3697            left,
3698            right,
3699        }) => {
3700            // `a * f + c`: the addend is the component, the product carries
3701            // the rest.
3702            let (product, addend) = match exprs.try_get(*left) {
3703                Some(Expression::Binary {
3704                    op: BinaryOp::Multiply,
3705                    ..
3706                }) => (*left, *right),
3707                _ => (*right, *left),
3708            };
3709            out.push(addend);
3710            if let Some(Expression::Binary {
3711                op: BinaryOp::Multiply,
3712                left: inner,
3713                ..
3714            }) = exprs.try_get(product)
3715            {
3716                flatten_index_components(exprs, *inner, out, depth - 1);
3717            }
3718        }
3719        _ => out.push(handle),
3720    }
3721}
3722
3723/// Recognise a kernel that copies `input[id]` to a position built by taking
3724/// the flat id apart and putting it back together in a different order.
3725///
3726/// Every backend already lowers `KernelPattern::Transpose`; until now nothing
3727/// produced one, so `permute` was refused for want of a recogniser rather
3728/// than for want of a lowering.
3729fn detect_permutation(
3730    body: &[Statement],
3731    exprs: &Arena<Expression>,
3732    input: Handle<GlobalVariable>,
3733    output: Handle<GlobalVariable>,
3734) -> Option<Vec<i64>> {
3735    fn find_store(
3736        body: &[Statement],
3737        exprs: &Arena<Expression>,
3738        input: Handle<GlobalVariable>,
3739        output: Handle<GlobalVariable>,
3740    ) -> Option<(Handle<Expression>, Handle<Expression>)> {
3741        for stmt in body {
3742            match stmt {
3743                Statement::Store { pointer, value } => {
3744                    let store_index = match exprs.try_get(*pointer) {
3745                        Some(Expression::Access { base, index })
3746                            if matches!(
3747                                exprs.try_get(*base),
3748                                Some(Expression::GlobalVariable(g)) if *g == output
3749                            ) =>
3750                        {
3751                            *index
3752                        }
3753                        _ => continue,
3754                    };
3755                    let load_index = match exprs.try_get(*value) {
3756                        Some(Expression::Load { pointer }) => match exprs.try_get(*pointer) {
3757                            Some(Expression::Access { base, index })
3758                                if matches!(
3759                                    exprs.try_get(*base),
3760                                    Some(Expression::GlobalVariable(g)) if *g == input
3761                                ) =>
3762                            {
3763                                *index
3764                            }
3765                            _ => continue,
3766                        },
3767                        _ => continue,
3768                    };
3769                    return Some((store_index, load_index));
3770                }
3771                Statement::If { accept, reject, .. } => {
3772                    if let Some(found) = find_store(accept, exprs, input, output)
3773                        .or_else(|| find_store(reject, exprs, input, output))
3774                    {
3775                        return Some(found);
3776                    }
3777                }
3778                _ => {}
3779            }
3780        }
3781        None
3782    }
3783
3784    let (store_index, load_index) = find_store(body, exprs, input, output)?;
3785
3786    // The read has to be the plain flat id; anything else is a gather, not a
3787    // permutation, and calling it Transpose would be the same kind of
3788    // confident guess this recogniser exists to replace.
3789    if id_division_depth(exprs, load_index, 32)? != 0 {
3790        return None;
3791    }
3792
3793    let mut components = Vec::new();
3794    flatten_index_components(exprs, store_index, &mut components, 32);
3795    if components.len() < 2 {
3796        return None;
3797    }
3798
3799    // Components come out fastest-first; a permutation is stated slowest-first.
3800    let rank = components.len();
3801    let mut perm = Vec::with_capacity(rank);
3802    for handle in components.iter().rev() {
3803        let depth = id_division_depth(exprs, *handle, 32)?;
3804        if depth as usize >= rank {
3805            return None;
3806        }
3807        perm.push((rank - 1 - depth as usize) as i64);
3808    }
3809
3810    // A genuine permutation uses each axis exactly once, and one that is the
3811    // identity is a copy rather than a transpose.
3812    let mut seen = perm.clone();
3813    seen.sort_unstable();
3814    if seen != (0..rank as i64).collect::<Vec<_>>() {
3815        return None;
3816    }
3817    if perm.iter().enumerate().all(|(i, p)| i as i64 == *p) {
3818        return None;
3819    }
3820    Some(perm)
3821}
3822
3823/// Which outputs a kernel writes, and which of them it *computes* rather than
3824/// copies out of `input`.
3825///
3826/// A Split hands back slices of its input: every element it writes is an
3827/// element it read, so every store into an output is a bare `Load` from the
3828/// input and nothing else. Anything arithmetic in the stored value — a scale,
3829/// a rounding, a cast to an index — means the kernel is producing a new tensor
3830/// rather than partitioning the one it was given, whatever else it may be
3831/// doing, and no `Split` describes that.
3832///
3833/// Returns `(written, computed)`, both in first-seen order. An output that is
3834/// never written at all is absent from `written`, which is also disqualifying:
3835/// a slice that stores nothing is not a slice.
3836fn survey_output_stores(
3837    body: &[Statement],
3838    exprs: &Arena<Expression>,
3839    input: Handle<GlobalVariable>,
3840    outputs: &[Handle<GlobalVariable>],
3841) -> (Vec<Handle<GlobalVariable>>, Vec<Handle<GlobalVariable>>) {
3842    fn walk(
3843        body: &[Statement],
3844        exprs: &Arena<Expression>,
3845        input: Handle<GlobalVariable>,
3846        outputs: &[Handle<GlobalVariable>],
3847        written: &mut Vec<Handle<GlobalVariable>>,
3848        computed: &mut Vec<Handle<GlobalVariable>>,
3849    ) {
3850        for stmt in body {
3851            match stmt {
3852                Statement::Store { pointer, value } => {
3853                    let Some(target) = access_base_global(exprs, *pointer) else {
3854                        continue;
3855                    };
3856                    if !outputs.contains(&target) {
3857                        continue;
3858                    }
3859                    if !written.contains(&target) {
3860                        written.push(target);
3861                    }
3862                    let copied = match exprs.try_get(*value) {
3863                        Some(Expression::Load { pointer }) => {
3864                            access_base_global(exprs, *pointer) == Some(input)
3865                        }
3866                        _ => false,
3867                    };
3868                    if !copied && !computed.contains(&target) {
3869                        computed.push(target);
3870                    }
3871                }
3872                Statement::If { accept, reject, .. } => {
3873                    walk(accept, exprs, input, outputs, written, computed);
3874                    walk(reject, exprs, input, outputs, written, computed);
3875                }
3876                Statement::Loop {
3877                    body, continuing, ..
3878                } => {
3879                    walk(body, exprs, input, outputs, written, computed);
3880                    walk(continuing, exprs, input, outputs, written, computed);
3881                }
3882                _ => {}
3883            }
3884        }
3885    }
3886
3887    let mut written = Vec::new();
3888    let mut computed = Vec::new();
3889    walk(body, exprs, input, outputs, &mut written, &mut computed);
3890    (written, computed)
3891}
3892
3893/// Say, in the refusal, what was actually seen.
3894///
3895/// "Unrecognized pattern" tells the caller nothing about their kernel. These
3896/// name the outputs that disqualified it and why, so the message is checkable
3897/// against the source in front of them.
3898fn multi_output_refusal(
3899    module: &Module,
3900    outputs: &[(Handle<GlobalVariable>, &GlobalVariable)],
3901    input: &GlobalVariable,
3902    written: &[Handle<GlobalVariable>],
3903    computed: &[Handle<GlobalVariable>],
3904) -> String {
3905    let name_of = |handle: Handle<GlobalVariable>| -> String {
3906        outputs
3907            .iter()
3908            .find(|(h, _)| *h == handle)
3909            .and_then(|(_, gv)| gv.name.clone())
3910            .unwrap_or_else(|| "an output".into())
3911    };
3912    let quoted = |handles: &[Handle<GlobalVariable>]| -> String {
3913        handles
3914            .iter()
3915            .map(|h| format!("'{}'", name_of(*h)))
3916            .collect::<Vec<_>>()
3917            .join(" and ")
3918    };
3919
3920    let unwritten: Vec<Handle<GlobalVariable>> = outputs
3921        .iter()
3922        .map(|(h, _)| *h)
3923        .filter(|h| !written.contains(h))
3924        .collect();
3925
3926    let mut reason = format!(
3927        "1 input and {} outputs, but this does not slice its input: ",
3928        outputs.len()
3929    );
3930    if !computed.is_empty() {
3931        reason.push_str(&format!(
3932            "{} {} written with a computed value rather than a copy of the input",
3933            quoted(computed),
3934            if computed.len() == 1 { "is" } else { "are" },
3935        ));
3936        if !unwritten.is_empty() {
3937            reason.push_str(", and ");
3938        }
3939    }
3940    if !unwritten.is_empty() {
3941        reason.push_str(&format!(
3942            "{} {} never stored to",
3943            quoted(&unwritten),
3944            if unwritten.len() == 1 { "is" } else { "are" },
3945        ));
3946    }
3947    if computed.is_empty() && unwritten.is_empty() {
3948        // Copies throughout, but no boundary test to split on.
3949        reason.push_str("there is no comparison marking where one output ends and the next begins");
3950    }
3951
3952    // A slice keeps its element type. When it changes, the kernel is
3953    // converting, and saying so points at the half of the problem a Split
3954    // could never have carried anyway.
3955    let input_elem = resolve_array_elem_type(module, input.ty);
3956    if outputs
3957        .iter()
3958        .any(|(_, gv)| resolve_array_elem_type(module, gv.ty) != input_elem)
3959    {
3960        reason.push_str(
3961            ". The outputs do not all share the input's element type either, and a slice \
3962             never changes it",
3963        );
3964    }
3965
3966    reason.push_str(
3967        ". Splitting is the only single-input, multi-output op these backends lower, \
3968         so there is nothing here for one to carry",
3969    );
3970    reason
3971}
3972
3973/// Positive recognition of a softmax: `exp(x - max) / Σ exp(x - max)`.
3974///
3975/// Two facts have to hold together, and neither is enough on its own:
3976///
3977///   - the value written to the output is an `exp` over a division — the
3978///     normalised numerator, whether it is spelled `exp(..) / sum` or
3979///     `exp(..) * (1.0 / sum)`, which are the same expression tree with the
3980///     `Divide` in a different place;
3981///   - some store inside a loop adds an `exp` into a running total — the
3982///     denominator being built, one element of the row at a time.
3983///
3984/// The second is what separates a softmax from a hand-written sigmoid,
3985/// `1.0 / (1.0 + exp(-x))`, which also stores an `exp` over a `Divide`. A
3986/// sigmoid accumulates no exponentials because it has nothing to sum over.
3987/// The first is what separates it from a plain `ReduceSum` of exponentials,
3988/// which builds the same total and then writes the total.
3989///
3990/// What is deliberately *not* required is the max subtraction. It is there in
3991/// every stable softmax and in this vendor kernel, but it is an implementation
3992/// choice about overflow rather than part of the operator, and a kernel that
3993/// skips it is still a softmax.
3994fn detect_softmax(
3995    body: &[Statement],
3996    exprs: &Arena<Expression>,
3997    output: Handle<GlobalVariable>,
3998) -> bool {
3999    stores_a_normalized_exp(body, exprs, output) && sums_exponentials_in_a_loop(body, exprs, false)
4000}
4001
4002/// A store into `output` whose value contains both an `exp` and a division.
4003fn stores_a_normalized_exp(
4004    body: &[Statement],
4005    exprs: &Arena<Expression>,
4006    output: Handle<GlobalVariable>,
4007) -> bool {
4008    for stmt in body {
4009        match stmt {
4010            Statement::Store { pointer, value } => {
4011                if access_base_global(exprs, *pointer) == Some(output)
4012                    && contains_math_fun(exprs, *value, MathFunction::Exp)
4013                    && contains_binary_op(exprs, *value, BinaryOp::Divide)
4014                {
4015                    return true;
4016                }
4017            }
4018            Statement::If { accept, reject, .. } => {
4019                if stores_a_normalized_exp(accept, exprs, output)
4020                    || stores_a_normalized_exp(reject, exprs, output)
4021                {
4022                    return true;
4023                }
4024            }
4025            Statement::Loop {
4026                body, continuing, ..
4027            } if stores_a_normalized_exp(body, exprs, output)
4028                || stores_a_normalized_exp(continuing, exprs, output) =>
4029            {
4030                return true;
4031            }
4032            _ => {}
4033        }
4034    }
4035    false
4036}
4037
4038/// An `acc = acc + exp(..)` — an add, containing an exponential, executed
4039/// inside a loop. The target is not checked: a workgroup slot, a local and a
4040/// storage scratch buffer are all the same accumulator here.
4041fn sums_exponentials_in_a_loop(
4042    body: &[Statement],
4043    exprs: &Arena<Expression>,
4044    in_loop: bool,
4045) -> bool {
4046    for stmt in body {
4047        match stmt {
4048            Statement::Store { value, .. } => {
4049                if in_loop
4050                    && matches!(
4051                        exprs.try_get(*value),
4052                        Some(Expression::Binary {
4053                            op: BinaryOp::Add,
4054                            ..
4055                        })
4056                    )
4057                    && contains_math_fun(exprs, *value, MathFunction::Exp)
4058                {
4059                    return true;
4060                }
4061            }
4062            Statement::If { accept, reject, .. } => {
4063                if sums_exponentials_in_a_loop(accept, exprs, in_loop)
4064                    || sums_exponentials_in_a_loop(reject, exprs, in_loop)
4065                {
4066                    return true;
4067                }
4068            }
4069            Statement::Loop {
4070                body, continuing, ..
4071            } if sums_exponentials_in_a_loop(body, exprs, true)
4072                || sums_exponentials_in_a_loop(continuing, exprs, true) =>
4073            {
4074                return true;
4075            }
4076            _ => {}
4077        }
4078    }
4079    false
4080}
4081
4082/// Whether a loop appears inside another loop.
4083///
4084/// A normalization reduces once over a row: its passes are sequential loops,
4085/// never nested. A filterbank contracts a matrix against a spectrum, so its
4086/// loops nest. That is the difference between `mel`, which was reported as
4087/// BatchNormalization because it has three inputs and a square root, and a
4088/// normalization that actually is one.
4089fn has_nested_loop(body: &[Statement]) -> bool {
4090    fn inside_loop(body: &[Statement]) -> bool {
4091        body.iter().any(|s| match s {
4092            Statement::Loop { .. } => true,
4093            Statement::If { accept, reject, .. } => inside_loop(accept) || inside_loop(reject),
4094            _ => false,
4095        })
4096    }
4097    body.iter().any(|s| match s {
4098        Statement::Loop {
4099            body, continuing, ..
4100        } => inside_loop(body) || inside_loop(continuing) || has_nested_loop(body),
4101        Statement::If { accept, reject, .. } => has_nested_loop(accept) || has_nested_loop(reject),
4102        _ => false,
4103    })
4104}
4105
4106/// Check if a block contains a Store whose value (or sub-expr) uses a specific Math function.
4107/// The activation a kernel applies to the value it stores, if any.
4108///
4109/// A convolution that writes `max(sum, 0.0)` is a convolution *and* a ReLU, and
4110/// the classifier used to return `Conv2D` and drop the `max` on the floor. The
4111/// emitted model then loaded, was accelerated, and computed the wrong thing --
4112/// which is not a shortcoming of the operator table but a graph that lies.
4113///
4114/// Only `max` and `tanh` are recognised, and deliberately: those are the two
4115/// TFLite can fuse into a convolution and the two whose expression shape cannot
4116/// be mistaken for something else. GELU, SiLU and Mish are all *multiplies*,
4117/// and a convolution's own accumulation is a multiply, so recognising them here
4118/// would risk reading an accumulator as an activation. Narrowing is the same
4119/// choice made for stride extraction and for the same reason: missing an
4120/// activation leaves today's behaviour, inventing one corrupts a graph.
4121fn store_value_activation(body: &[Statement], exprs: &Arena<Expression>) -> Option<ActivationOp> {
4122    for stmt in body {
4123        match stmt {
4124            Statement::Store { value, .. } => match exprs.try_get(*value) {
4125                Some(Expression::Math {
4126                    fun: MathFunction::Max,
4127                    ..
4128                }) => return Some(ActivationOp::Relu),
4129                Some(Expression::Math {
4130                    fun: MathFunction::Tanh,
4131                    ..
4132                }) => return Some(ActivationOp::Tanh),
4133                _ => {}
4134            },
4135            Statement::If { accept, reject, .. } => {
4136                if let Some(act) = store_value_activation(accept, exprs) {
4137                    return Some(act);
4138                }
4139                if let Some(act) = store_value_activation(reject, exprs) {
4140                    return Some(act);
4141                }
4142            }
4143            Statement::Loop {
4144                body, continuing, ..
4145            } => {
4146                if let Some(act) = store_value_activation(body, exprs) {
4147                    return Some(act);
4148                }
4149                if let Some(act) = store_value_activation(continuing, exprs) {
4150                    return Some(act);
4151                }
4152            }
4153            _ => {}
4154        }
4155    }
4156    None
4157}
4158
4159fn find_store_math_fun(
4160    body: &[Statement],
4161    exprs: &Arena<Expression>,
4162    target: MathFunction,
4163) -> bool {
4164    for stmt in body {
4165        match stmt {
4166            Statement::Store { value, .. } if contains_math_fun(exprs, *value, target) => {
4167                return true;
4168            }
4169            Statement::If { accept, reject, .. }
4170                if find_store_math_fun(accept, exprs, target)
4171                    || find_store_math_fun(reject, exprs, target) =>
4172            {
4173                return true;
4174            }
4175            Statement::Loop {
4176                body, continuing, ..
4177            } if find_store_math_fun(body, exprs, target)
4178                || find_store_math_fun(continuing, exprs, target) =>
4179            {
4180                return true;
4181            }
4182            _ => {}
4183        }
4184    }
4185    false
4186}
4187
4188/// Detect the reduce operation type from the loop body.
4189fn detect_reduce_op(body: &[Statement], exprs: &Arena<Expression>) -> ReduceOp {
4190    if find_store_math_fun(body, exprs, MathFunction::Max) {
4191        ReduceOp::Max
4192    } else if find_store_math_fun(body, exprs, MathFunction::Min) {
4193        ReduceOp::Min
4194    } else if find_store_binary_divide(body, exprs) {
4195        // Sum followed by divide → Mean
4196        ReduceOp::Mean
4197    } else {
4198        // Default: sum accumulation (binary add in loop)
4199        ReduceOp::Sum
4200    }
4201}
4202
4203/// Check if a block contains a Store whose value is a Divide expression.
4204fn find_store_binary_divide(body: &[Statement], exprs: &Arena<Expression>) -> bool {
4205    for stmt in body {
4206        match stmt {
4207            Statement::Store { value, .. } => {
4208                if let Some(Expression::Binary {
4209                    op: BinaryOp::Divide,
4210                    ..
4211                }) = exprs.try_get(*value)
4212                {
4213                    return true;
4214                }
4215            }
4216            Statement::If { accept, reject, .. }
4217                if find_store_binary_divide(accept, exprs)
4218                    || find_store_binary_divide(reject, exprs) =>
4219            {
4220                return true;
4221            }
4222            Statement::Loop {
4223                body, continuing, ..
4224            } if find_store_binary_divide(body, exprs)
4225                || find_store_binary_divide(continuing, exprs) =>
4226            {
4227                return true;
4228            }
4229            _ => {}
4230        }
4231    }
4232    false
4233}
4234
4235// ---------------------------------------------------------------------------
4236// Data-dependent addressing
4237// ---------------------------------------------------------------------------
4238//
4239// Every pattern this file recognises reads and writes at positions computed
4240// from the thread id: a matmul's `row * K + k`, a convolution's window, an
4241// element-wise op's `idx`. The kernels that do not are the ones that take an
4242// address *out of a buffer* — a gather on the read side, a scatter on the
4243// write side — and that is visible in the expression graph without counting
4244// anything.
4245
4246/// What an index-dependent read addresses.
4247#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4248enum IndexedRead {
4249    /// `out[i] = data[idx[i]]` — the loaded index *is* the address, so each
4250    /// index names one element. This is what `KernelPattern::Gather` lowers
4251    /// to: a flat buffer, one element per index.
4252    Element,
4253    /// `out[i] = data[idx[n] * W + d]` — the loaded index is scaled by a width
4254    /// before it becomes an address, so each index names a whole block.
4255    Block,
4256}
4257
4258/// Whether the kernel writes each output element from the same position it
4259/// read, at a single position per invocation.
4260///
4261/// Two rules, and the second was learned by breaking the first. Element *i* of
4262/// the output has to be a function of element *i* of at least one input — a
4263/// dequantizing transpose writes at `col * width + row` and fails this — but
4264/// *not* of element *i* of every input, because a broadcast is element-wise
4265/// and reads its second operand at a row index.
4266///
4267/// And an element-wise invocation writes one place. rope writes a rotated
4268/// pair at `base` and `base + 1`, each from both, which is a movement of
4269/// values between positions however element-wise the arithmetic looks.
4270fn stores_at_the_index_it_read(body: &[Statement], exprs: &Arena<Expression>) -> bool {
4271    fn walk(
4272        body: &[Statement],
4273        exprs: &Arena<Expression>,
4274        ok: &mut bool,
4275        seen: &mut Vec<Handle<Expression>>,
4276    ) {
4277        for stmt in body {
4278            match stmt {
4279                Statement::Store { pointer, value } => {
4280                    let Some(store_index) = access_index(exprs, *pointer) else {
4281                        *ok = false;
4282                        return;
4283                    };
4284                    // Branches of an `if` are alternatives, not a sequence, so
4285                    // the same index appearing in both is still one write.
4286                    if seen.iter().all(|i| *i != store_index) {
4287                        seen.push(store_index);
4288                    }
4289                    let mut reads = Vec::new();
4290                    collect_load_indices(exprs, *value, &mut reads, 32);
4291                    // Reading nothing is a generator; reading only elsewhere
4292                    // is a move.
4293                    if !reads.contains(&store_index) {
4294                        *ok = false;
4295                        return;
4296                    }
4297                }
4298                Statement::If { accept, reject, .. } => {
4299                    walk(accept, exprs, ok, seen);
4300                    walk(reject, exprs, ok, seen);
4301                }
4302                Statement::Loop {
4303                    body, continuing, ..
4304                } => {
4305                    walk(body, exprs, ok, seen);
4306                    walk(continuing, exprs, ok, seen);
4307                }
4308                _ => {}
4309            }
4310        }
4311    }
4312    let mut ok = true;
4313    let mut seen = Vec::new();
4314    walk(body, exprs, &mut ok, &mut seen);
4315    ok && seen.len() == 1
4316}
4317
4318/// The index expression of an `Access` into a global, if that is what this is.
4319fn access_index(
4320    exprs: &Arena<Expression>,
4321    handle: Handle<Expression>,
4322) -> Option<Handle<Expression>> {
4323    match exprs.try_get(handle)? {
4324        Expression::Access { base, index } => {
4325            matches!(exprs.try_get(*base)?, Expression::GlobalVariable(_)).then_some(*index)
4326        }
4327        _ => None,
4328    }
4329}
4330
4331/// Every index a storage load in this expression reads at.
4332fn collect_load_indices(
4333    exprs: &Arena<Expression>,
4334    handle: Handle<Expression>,
4335    out: &mut Vec<Handle<Expression>>,
4336    depth: u32,
4337) {
4338    if depth == 0 {
4339        return;
4340    }
4341    match exprs.try_get(handle) {
4342        Some(Expression::Load { pointer }) => {
4343            if let Some(index) = access_index(exprs, *pointer) {
4344                out.push(index);
4345            }
4346        }
4347        Some(Expression::Binary { left, right, .. }) => {
4348            collect_load_indices(exprs, *left, out, depth - 1);
4349            collect_load_indices(exprs, *right, out, depth - 1);
4350        }
4351        Some(Expression::Unary { expr, .. }) => collect_load_indices(exprs, *expr, out, depth - 1),
4352        // A conversion is transparent to *where* the value came from.
4353        // `f32(input[i])` reads element `i`, and leaving `As` out of this walk
4354        // made `dequantize` look like a kernel that read nothing at all.
4355        Some(Expression::As { expr, .. }) => collect_load_indices(exprs, *expr, out, depth - 1),
4356        Some(Expression::Math {
4357            arg,
4358            arg1,
4359            arg2,
4360            arg3,
4361            ..
4362        }) => {
4363            collect_load_indices(exprs, *arg, out, depth - 1);
4364            for a in [arg1, arg2, arg3].into_iter().flatten() {
4365                collect_load_indices(exprs, *a, out, depth - 1);
4366            }
4367        }
4368        _ => {}
4369    }
4370}
4371
4372/// Walk a pointer expression down to the global variable it addresses.
4373fn access_base_global(
4374    exprs: &Arena<Expression>,
4375    handle: Handle<Expression>,
4376) -> Option<Handle<GlobalVariable>> {
4377    let mut handle = handle;
4378    for _ in 0..32 {
4379        match exprs.try_get(handle)? {
4380            Expression::GlobalVariable(g) => return Some(*g),
4381            Expression::Access { base, .. } | Expression::AccessIndex { base, .. } => {
4382                handle = *base;
4383            }
4384            _ => return None,
4385        }
4386    }
4387    None
4388}
4389
4390/// Peel the type casts off a value. `u32(indices[n])` is the index it loaded.
4391fn strip_casts(exprs: &Arena<Expression>, handle: Handle<Expression>) -> Handle<Expression> {
4392    let mut handle = handle;
4393    for _ in 0..8 {
4394        match exprs.try_get(handle) {
4395            Some(Expression::As { expr, .. }) => handle = *expr,
4396            _ => break,
4397        }
4398    }
4399    handle
4400}
4401
4402/// If `handle` is a load out of `global`, the index expression it loaded from.
4403fn load_address(
4404    exprs: &Arena<Expression>,
4405    handle: Handle<Expression>,
4406    global: Handle<GlobalVariable>,
4407) -> Option<Handle<Expression>> {
4408    let Expression::Load { pointer } = exprs.try_get(handle)? else {
4409        return None;
4410    };
4411    let Expression::Access { base, index } = exprs.try_get(*pointer)? else {
4412        return None;
4413    };
4414    (access_base_global(exprs, *base) == Some(global)).then_some(*index)
4415}
4416
4417/// Does any of `roots` load, anywhere inside it, from one of `wanted`?
4418///
4419/// Iterative with a visited set rather than the depth-limited recursion used
4420/// elsewhere in this file: an expression arena is a DAG, and an address built
4421/// from shared subexpressions is walked exponentially by the naive spelling.
4422fn reads_global(
4423    exprs: &Arena<Expression>,
4424    roots: &[Handle<Expression>],
4425    wanted: &[Handle<GlobalVariable>],
4426) -> bool {
4427    let mut seen = std::collections::HashSet::new();
4428    let mut stack: Vec<Handle<Expression>> = roots.to_vec();
4429    while let Some(handle) = stack.pop() {
4430        if !seen.insert(handle.index()) {
4431            continue;
4432        }
4433        let Some(expr) = exprs.try_get(handle) else {
4434            continue;
4435        };
4436        // Nested rather than a let-chain: those are stable from 1.88 and this
4437        // workspace's rust-version is 1.87.
4438        #[allow(clippy::collapsible_if)]
4439        if let Expression::Load { pointer } = expr {
4440            if access_base_global(exprs, *pointer).is_some_and(|g| wanted.contains(&g)) {
4441                return true;
4442            }
4443        }
4444        push_operands(expr, &mut stack);
4445    }
4446    false
4447}
4448
4449/// Push every sub-expression of `expr` onto `stack`.
4450fn push_operands(expr: &Expression, stack: &mut Vec<Handle<Expression>>) {
4451    match expr {
4452        Expression::Load { pointer } => stack.push(*pointer),
4453        Expression::Access { base, index } => {
4454            stack.push(*base);
4455            stack.push(*index);
4456        }
4457        Expression::AccessIndex { base, .. } => stack.push(*base),
4458        Expression::Unary { expr, .. } => stack.push(*expr),
4459        Expression::Binary { left, right, .. } => {
4460            stack.push(*left);
4461            stack.push(*right);
4462        }
4463        Expression::Select {
4464            condition,
4465            accept,
4466            reject,
4467        } => {
4468            stack.push(*condition);
4469            stack.push(*accept);
4470            stack.push(*reject);
4471        }
4472        Expression::Math {
4473            arg,
4474            arg1,
4475            arg2,
4476            arg3,
4477            ..
4478        } => {
4479            stack.push(*arg);
4480            stack.extend([*arg1, *arg2, *arg3].into_iter().flatten());
4481        }
4482        Expression::As { expr, .. } => stack.push(*expr),
4483        Expression::Splat { value, .. } => stack.push(*value),
4484        Expression::Swizzle { vector, .. } => stack.push(*vector),
4485        Expression::Compose { components, .. } => stack.extend(components.iter().copied()),
4486        Expression::ArrayLength(handle) => stack.push(*handle),
4487        _ => {}
4488    }
4489}
4490
4491/// Recognise a store into `output` whose value was loaded from `data` at an
4492/// address that was itself loaded from `indices` — a gather.
4493///
4494/// The two shapes are told apart because only one of them is representable:
4495/// `Gather` indexes a flat buffer and carries no row width, so a read whose
4496/// index is scaled by one is a different operator wearing the same name.
4497fn detect_indexed_read(
4498    body: &[Statement],
4499    exprs: &Arena<Expression>,
4500    data: Handle<GlobalVariable>,
4501    indices: Handle<GlobalVariable>,
4502    output: Handle<GlobalVariable>,
4503) -> Option<IndexedRead> {
4504    for stmt in body {
4505        match stmt {
4506            Statement::Store { pointer, value } => {
4507                if access_base_global(exprs, *pointer) != Some(output) {
4508                    continue;
4509                }
4510                let Some(address) = load_address(exprs, strip_casts(exprs, *value), data) else {
4511                    continue;
4512                };
4513                if !reads_global(exprs, &[address], &[indices]) {
4514                    continue;
4515                }
4516                return Some(
4517                    if load_address(exprs, strip_casts(exprs, address), indices).is_some() {
4518                        IndexedRead::Element
4519                    } else {
4520                        IndexedRead::Block
4521                    },
4522                );
4523            }
4524            Statement::If { accept, reject, .. } => {
4525                if let Some(kind) = detect_indexed_read(accept, exprs, data, indices, output)
4526                    .or_else(|| detect_indexed_read(reject, exprs, data, indices, output))
4527                {
4528                    return Some(kind);
4529                }
4530            }
4531            Statement::Loop {
4532                body, continuing, ..
4533            } => {
4534                if let Some(kind) = detect_indexed_read(body, exprs, data, indices, output)
4535                    .or_else(|| detect_indexed_read(continuing, exprs, data, indices, output))
4536                {
4537                    return Some(kind);
4538                }
4539            }
4540            _ => {}
4541        }
4542    }
4543    None
4544}
4545
4546/// Does a store into one of `outputs` write to an address that was loaded out
4547/// of one of `sources` — a scatter?
4548fn detect_indexed_write(
4549    body: &[Statement],
4550    exprs: &Arena<Expression>,
4551    sources: &[Handle<GlobalVariable>],
4552    outputs: &[Handle<GlobalVariable>],
4553) -> bool {
4554    for stmt in body {
4555        match stmt {
4556            Statement::Store { pointer, .. } => {
4557                let Some(Expression::Access { base, index }) = exprs.try_get(*pointer) else {
4558                    continue;
4559                };
4560                if !access_base_global(exprs, *base).is_some_and(|g| outputs.contains(&g)) {
4561                    continue;
4562                }
4563                if reads_global(exprs, &[*index], sources) {
4564                    return true;
4565                }
4566            }
4567            Statement::If { accept, reject, .. }
4568                if detect_indexed_write(accept, exprs, sources, outputs)
4569                    || detect_indexed_write(reject, exprs, sources, outputs) =>
4570            {
4571                return true;
4572            }
4573            Statement::Loop {
4574                body, continuing, ..
4575            } if detect_indexed_write(body, exprs, sources, outputs)
4576                || detect_indexed_write(continuing, exprs, sources, outputs) =>
4577            {
4578                return true;
4579            }
4580            _ => {}
4581        }
4582    }
4583    false
4584}
4585
4586/// Is this buffer an array of atomics (or an atomic scalar)?
4587///
4588/// An atomic output is a statement about the operator: several invocations
4589/// reach the same slot and their contributions are combined there.
4590fn is_atomic_buffer(module: &Module, ty: Handle<Type>) -> bool {
4591    match &module.types[ty].inner {
4592        TypeInner::Atomic(_) => true,
4593        TypeInner::Array { base, .. } => matches!(&module.types[*base].inner, TypeInner::Atomic(_)),
4594        _ => false,
4595    }
4596}
4597
4598#[cfg(test)]
4599mod tests {
4600    use super::*;
4601    use nxpu_ir::*;
4602
4603    fn make_matmul_module() -> Module {
4604        let mut module = Module::default();
4605
4606        let f32_ty = module.types.insert(Type {
4607            name: None,
4608            inner: TypeInner::Scalar(Scalar::F32),
4609        });
4610        let u32_ty = module.types.insert(Type {
4611            name: None,
4612            inner: TypeInner::Scalar(Scalar::U32),
4613        });
4614        let array_f32 = module.types.insert(Type {
4615            name: None,
4616            inner: TypeInner::Array {
4617                base: f32_ty,
4618                size: ArraySize::Dynamic,
4619                stride: 4,
4620            },
4621        });
4622        let params_ty = module.types.insert(Type {
4623            name: Some("Params".into()),
4624            inner: TypeInner::Struct {
4625                members: vec![
4626                    StructMember {
4627                        name: Some("M".into()),
4628                        ty: u32_ty,
4629                        offset: 0,
4630                    },
4631                    StructMember {
4632                        name: Some("N".into()),
4633                        ty: u32_ty,
4634                        offset: 4,
4635                    },
4636                    StructMember {
4637                        name: Some("K".into()),
4638                        ty: u32_ty,
4639                        offset: 8,
4640                    },
4641                ],
4642                span: 12,
4643            },
4644        });
4645
4646        module.global_variables.append(GlobalVariable {
4647            name: Some("a".into()),
4648            space: AddressSpace::Storage {
4649                access: StorageAccess::LOAD,
4650            },
4651            binding: Some(ResourceBinding {
4652                group: 0,
4653                binding: 0,
4654            }),
4655            ty: array_f32,
4656            init: None,
4657            layout: None,
4658        });
4659        module.global_variables.append(GlobalVariable {
4660            name: Some("b".into()),
4661            space: AddressSpace::Storage {
4662                access: StorageAccess::LOAD,
4663            },
4664            binding: Some(ResourceBinding {
4665                group: 0,
4666                binding: 1,
4667            }),
4668            ty: array_f32,
4669            init: None,
4670            layout: None,
4671        });
4672        module.global_variables.append(GlobalVariable {
4673            name: Some("result".into()),
4674            space: AddressSpace::Storage {
4675                access: StorageAccess::LOAD | StorageAccess::STORE,
4676            },
4677            binding: Some(ResourceBinding {
4678                group: 0,
4679                binding: 2,
4680            }),
4681            ty: array_f32,
4682            init: None,
4683            layout: None,
4684        });
4685        module.global_variables.append(GlobalVariable {
4686            name: Some("params".into()),
4687            space: AddressSpace::Uniform,
4688            binding: Some(ResourceBinding {
4689                group: 0,
4690                binding: 3,
4691            }),
4692            ty: params_ty,
4693            init: None,
4694            layout: None,
4695        });
4696
4697        // Entry point with a loop in the body (triggers MatMul detection).
4698        let mut func = Function::new("main");
4699        func.body.push(Statement::Loop {
4700            body: vec![Statement::Break],
4701            continuing: vec![],
4702            break_if: None,
4703        });
4704
4705        module.entry_points.push(EntryPoint {
4706            name: "main".into(),
4707            workgroup_size: [16, 16, 1],
4708            function: func,
4709        });
4710
4711        module
4712    }
4713
4714    fn make_elementwise_module(op: BinaryOp) -> Module {
4715        let mut module = Module::default();
4716
4717        let f32_ty = module.types.insert(Type {
4718            name: None,
4719            inner: TypeInner::Scalar(Scalar::F32),
4720        });
4721        let u32_ty = module.types.insert(Type {
4722            name: None,
4723            inner: TypeInner::Scalar(Scalar::U32),
4724        });
4725        let array_f32 = module.types.insert(Type {
4726            name: None,
4727            inner: TypeInner::Array {
4728                base: f32_ty,
4729                size: ArraySize::Dynamic,
4730                stride: 4,
4731            },
4732        });
4733        let params_ty = module.types.insert(Type {
4734            name: Some("Params".into()),
4735            inner: TypeInner::Struct {
4736                members: vec![StructMember {
4737                    name: Some("N".into()),
4738                    ty: u32_ty,
4739                    offset: 0,
4740                }],
4741                span: 4,
4742            },
4743        });
4744
4745        let a_gv = module.global_variables.append(GlobalVariable {
4746            name: Some("a".into()),
4747            space: AddressSpace::Storage {
4748                access: StorageAccess::LOAD,
4749            },
4750            binding: Some(ResourceBinding {
4751                group: 0,
4752                binding: 0,
4753            }),
4754            ty: array_f32,
4755            init: None,
4756            layout: None,
4757        });
4758        let b_gv = module.global_variables.append(GlobalVariable {
4759            name: Some("b".into()),
4760            space: AddressSpace::Storage {
4761                access: StorageAccess::LOAD,
4762            },
4763            binding: Some(ResourceBinding {
4764                group: 0,
4765                binding: 1,
4766            }),
4767            ty: array_f32,
4768            init: None,
4769            layout: None,
4770        });
4771        let out_gv = module.global_variables.append(GlobalVariable {
4772            name: Some("c".into()),
4773            space: AddressSpace::Storage {
4774                access: StorageAccess::LOAD | StorageAccess::STORE,
4775            },
4776            binding: Some(ResourceBinding {
4777                group: 0,
4778                binding: 2,
4779            }),
4780            ty: array_f32,
4781            init: None,
4782            layout: None,
4783        });
4784        module.global_variables.append(GlobalVariable {
4785            name: Some("params".into()),
4786            space: AddressSpace::Uniform,
4787            binding: Some(ResourceBinding {
4788                group: 0,
4789                binding: 3,
4790            }),
4791            ty: params_ty,
4792            init: None,
4793            layout: None,
4794        });
4795
4796        // `out[i] = a[i] op b[i]`.
4797        //
4798        // This used to combine two float literals and store the result through
4799        // a third, addressing nothing and reading nothing — it described a
4800        // shape no kernel has, and passed because classification did not look
4801        // at where the values came from.
4802        let mut func = Function::new("main");
4803        let idx = func
4804            .expressions
4805            .append(Expression::Literal(Literal::U32(0)));
4806        let load_from = |func: &mut Function, gv| {
4807            let base = func.expressions.append(Expression::GlobalVariable(gv));
4808            let access = func
4809                .expressions
4810                .append(Expression::Access { base, index: idx });
4811            func.expressions
4812                .append(Expression::Load { pointer: access })
4813        };
4814        let left = load_from(&mut func, a_gv);
4815        let right = load_from(&mut func, b_gv);
4816        let binary = func
4817            .expressions
4818            .append(Expression::Binary { op, left, right });
4819        let out_base = func.expressions.append(Expression::GlobalVariable(out_gv));
4820        let ptr = func.expressions.append(Expression::Access {
4821            base: out_base,
4822            index: idx,
4823        });
4824        func.body.push(Statement::Store {
4825            pointer: ptr,
4826            value: binary,
4827        });
4828
4829        module.entry_points.push(EntryPoint {
4830            name: "vecadd".into(),
4831            workgroup_size: [256, 1, 1],
4832            function: func,
4833        });
4834
4835        module
4836    }
4837
4838    fn make_activation_module(math_fun: MathFunction) -> Module {
4839        let mut module = Module::default();
4840
4841        let f32_ty = module.types.insert(Type {
4842            name: None,
4843            inner: TypeInner::Scalar(Scalar::F32),
4844        });
4845        let u32_ty = module.types.insert(Type {
4846            name: None,
4847            inner: TypeInner::Scalar(Scalar::U32),
4848        });
4849        let array_f32 = module.types.insert(Type {
4850            name: None,
4851            inner: TypeInner::Array {
4852                base: f32_ty,
4853                size: ArraySize::Dynamic,
4854                stride: 4,
4855            },
4856        });
4857        let params_ty = module.types.insert(Type {
4858            name: Some("Params".into()),
4859            inner: TypeInner::Struct {
4860                members: vec![StructMember {
4861                    name: Some("N".into()),
4862                    ty: u32_ty,
4863                    offset: 0,
4864                }],
4865                span: 4,
4866            },
4867        });
4868
4869        // Single input
4870        module.global_variables.append(GlobalVariable {
4871            name: Some("a".into()),
4872            space: AddressSpace::Storage {
4873                access: StorageAccess::LOAD,
4874            },
4875            binding: Some(ResourceBinding {
4876                group: 0,
4877                binding: 0,
4878            }),
4879            ty: array_f32,
4880            init: None,
4881            layout: None,
4882        });
4883        // Single output
4884        module.global_variables.append(GlobalVariable {
4885            name: Some("c".into()),
4886            space: AddressSpace::Storage {
4887                access: StorageAccess::LOAD | StorageAccess::STORE,
4888            },
4889            binding: Some(ResourceBinding {
4890                group: 0,
4891                binding: 1,
4892            }),
4893            ty: array_f32,
4894            init: None,
4895            layout: None,
4896        });
4897        module.global_variables.append(GlobalVariable {
4898            name: Some("params".into()),
4899            space: AddressSpace::Uniform,
4900            binding: Some(ResourceBinding {
4901                group: 0,
4902                binding: 2,
4903            }),
4904            ty: params_ty,
4905            init: None,
4906            layout: None,
4907        });
4908
4909        let mut func = Function::new("main");
4910        let arg = func
4911            .expressions
4912            .append(Expression::Literal(Literal::F32(1.0)));
4913        let arg1 = func
4914            .expressions
4915            .append(Expression::Literal(Literal::F32(0.0)));
4916        let math = func.expressions.append(Expression::Math {
4917            fun: math_fun,
4918            arg,
4919            arg1: Some(arg1),
4920            arg2: None,
4921            arg3: None,
4922        });
4923        let ptr = func
4924            .expressions
4925            .append(Expression::Literal(Literal::F32(0.0)));
4926        func.body.push(Statement::Store {
4927            pointer: ptr,
4928            value: math,
4929        });
4930
4931        module.entry_points.push(EntryPoint {
4932            name: "activation".into(),
4933            workgroup_size: [256, 1, 1],
4934            function: func,
4935        });
4936
4937        module
4938    }
4939
4940    fn make_reduce_module() -> Module {
4941        let mut module = Module::default();
4942
4943        let f32_ty = module.types.insert(Type {
4944            name: None,
4945            inner: TypeInner::Scalar(Scalar::F32),
4946        });
4947        let u32_ty = module.types.insert(Type {
4948            name: None,
4949            inner: TypeInner::Scalar(Scalar::U32),
4950        });
4951        let array_f32 = module.types.insert(Type {
4952            name: None,
4953            inner: TypeInner::Array {
4954                base: f32_ty,
4955                size: ArraySize::Dynamic,
4956                stride: 4,
4957            },
4958        });
4959        let params_ty = module.types.insert(Type {
4960            name: Some("Params".into()),
4961            inner: TypeInner::Struct {
4962                members: vec![StructMember {
4963                    name: Some("N".into()),
4964                    ty: u32_ty,
4965                    offset: 0,
4966                }],
4967                span: 4,
4968            },
4969        });
4970
4971        module.global_variables.append(GlobalVariable {
4972            name: Some("a".into()),
4973            space: AddressSpace::Storage {
4974                access: StorageAccess::LOAD,
4975            },
4976            binding: Some(ResourceBinding {
4977                group: 0,
4978                binding: 0,
4979            }),
4980            ty: array_f32,
4981            init: None,
4982            layout: None,
4983        });
4984        module.global_variables.append(GlobalVariable {
4985            name: Some("c".into()),
4986            space: AddressSpace::Storage {
4987                access: StorageAccess::LOAD | StorageAccess::STORE,
4988            },
4989            binding: Some(ResourceBinding {
4990                group: 0,
4991                binding: 1,
4992            }),
4993            ty: array_f32,
4994            init: None,
4995            layout: None,
4996        });
4997        module.global_variables.append(GlobalVariable {
4998            name: Some("params".into()),
4999            space: AddressSpace::Uniform,
5000            binding: Some(ResourceBinding {
5001                group: 0,
5002                binding: 2,
5003            }),
5004            ty: params_ty,
5005            init: None,
5006            layout: None,
5007        });
5008
5009        let mut func = Function::new("main");
5010        // A loop with a binary add → reduce sum
5011        let left = func
5012            .expressions
5013            .append(Expression::Literal(Literal::F32(0.0)));
5014        let right = func
5015            .expressions
5016            .append(Expression::Literal(Literal::F32(1.0)));
5017        let add = func.expressions.append(Expression::Binary {
5018            op: BinaryOp::Add,
5019            left,
5020            right,
5021        });
5022        let ptr = func
5023            .expressions
5024            .append(Expression::Literal(Literal::F32(0.0)));
5025        func.body.push(Statement::Loop {
5026            body: vec![
5027                Statement::Store {
5028                    pointer: ptr,
5029                    value: add,
5030                },
5031                Statement::Break,
5032            ],
5033            continuing: vec![],
5034            break_if: None,
5035        });
5036
5037        module.entry_points.push(EntryPoint {
5038            name: "reduce".into(),
5039            workgroup_size: [256, 1, 1],
5040            function: func,
5041        });
5042
5043        module
5044    }
5045
5046    #[test]
5047    fn classify_matmul() {
5048        let module = make_matmul_module();
5049        let pattern = classify_entry_point(&module, 0).unwrap();
5050        match pattern {
5051            KernelPattern::MatMul {
5052                inputs,
5053                output,
5054                shape,
5055            } => {
5056                assert_eq!(inputs[0].name, "a");
5057                assert_eq!(inputs[1].name, "b");
5058                assert_eq!(output.name, "result");
5059                assert_eq!(inputs[0].elem_type, data_type::FLOAT);
5060                assert_eq!(shape.m, "M");
5061                assert_eq!(shape.n, "N");
5062                assert_eq!(shape.k, "K");
5063            }
5064            _ => panic!("expected MatMul pattern"),
5065        }
5066    }
5067
5068    #[test]
5069    fn classify_elementwise_add() {
5070        let module = make_elementwise_module(BinaryOp::Add);
5071        let pattern = classify_entry_point(&module, 0).unwrap();
5072        match pattern {
5073            KernelPattern::ElementWise {
5074                op,
5075                inputs,
5076                output,
5077                dim_name,
5078            } => {
5079                assert_eq!(op, ElementWiseOp::Add);
5080                assert_eq!(inputs[0].name, "a");
5081                assert_eq!(inputs[1].name, "b");
5082                assert_eq!(output.name, "c");
5083                assert_eq!(dim_name, "N");
5084            }
5085            _ => panic!("expected ElementWise pattern"),
5086        }
5087    }
5088
5089    #[test]
5090    fn classify_elementwise_div() {
5091        let module = make_elementwise_module(BinaryOp::Divide);
5092        let pattern = classify_entry_point(&module, 0).unwrap();
5093        match &pattern {
5094            KernelPattern::ElementWise { op, .. } => {
5095                assert_eq!(*op, ElementWiseOp::Div);
5096            }
5097            _ => panic!("expected ElementWise pattern"),
5098        }
5099    }
5100
5101    #[test]
5102    fn classify_activation_relu() {
5103        let module = make_activation_module(MathFunction::Max);
5104        let pattern = classify_entry_point(&module, 0).unwrap();
5105        match &pattern {
5106            KernelPattern::Activation { op, .. } => {
5107                assert_eq!(*op, ActivationOp::Relu);
5108            }
5109            _ => panic!("expected Activation pattern, got {pattern:?}"),
5110        }
5111    }
5112
5113    #[test]
5114    fn classify_activation_tanh() {
5115        let module = make_activation_module(MathFunction::Tanh);
5116        let pattern = classify_entry_point(&module, 0).unwrap();
5117        match &pattern {
5118            KernelPattern::Activation { op, .. } => {
5119                assert_eq!(*op, ActivationOp::Tanh);
5120            }
5121            _ => panic!("expected Activation pattern, got {pattern:?}"),
5122        }
5123    }
5124
5125    #[test]
5126    fn classify_reduce_sum() {
5127        let module = make_reduce_module();
5128        let pattern = classify_entry_point(&module, 0).unwrap();
5129        match &pattern {
5130            KernelPattern::Reduce { op, .. } => {
5131                assert_eq!(*op, ReduceOp::Sum);
5132            }
5133            _ => panic!("expected Reduce pattern, got {pattern:?}"),
5134        }
5135    }
5136
5137    #[test]
5138    fn classify_single_input_no_activation_unknown() {
5139        // 1 input, no loop, no recognized activation, 2+ params → Unknown.
5140        let mut module = Module::default();
5141
5142        let f32_ty = module.types.insert(Type {
5143            name: None,
5144            inner: TypeInner::Scalar(Scalar::F32),
5145        });
5146        let u32_ty = module.types.insert(Type {
5147            name: None,
5148            inner: TypeInner::Scalar(Scalar::U32),
5149        });
5150        let array_f32 = module.types.insert(Type {
5151            name: None,
5152            inner: TypeInner::Array {
5153                base: f32_ty,
5154                size: ArraySize::Dynamic,
5155                stride: 4,
5156            },
5157        });
5158        let params_ty = module.types.insert(Type {
5159            name: Some("Params".into()),
5160            inner: TypeInner::Struct {
5161                members: vec![
5162                    StructMember {
5163                        name: Some("rows".into()),
5164                        ty: u32_ty,
5165                        offset: 0,
5166                    },
5167                    StructMember {
5168                        name: Some("cols".into()),
5169                        ty: u32_ty,
5170                        offset: 4,
5171                    },
5172                ],
5173                span: 8,
5174            },
5175        });
5176
5177        module.global_variables.append(GlobalVariable {
5178            name: Some("a".into()),
5179            space: AddressSpace::Storage {
5180                access: StorageAccess::LOAD,
5181            },
5182            binding: Some(ResourceBinding {
5183                group: 0,
5184                binding: 0,
5185            }),
5186            ty: array_f32,
5187            init: None,
5188            layout: None,
5189        });
5190        module.global_variables.append(GlobalVariable {
5191            name: Some("c".into()),
5192            space: AddressSpace::Storage {
5193                access: StorageAccess::LOAD | StorageAccess::STORE,
5194            },
5195            binding: Some(ResourceBinding {
5196                group: 0,
5197                binding: 1,
5198            }),
5199            ty: array_f32,
5200            init: None,
5201            layout: None,
5202        });
5203        module.global_variables.append(GlobalVariable {
5204            name: Some("params".into()),
5205            space: AddressSpace::Uniform,
5206            binding: Some(ResourceBinding {
5207                group: 0,
5208                binding: 2,
5209            }),
5210            ty: params_ty,
5211            init: None,
5212            layout: None,
5213        });
5214
5215        // Body: just a store of a literal (no activation, no loop)
5216        let mut func = Function::new("main");
5217        let val = func
5218            .expressions
5219            .append(Expression::Literal(Literal::F32(42.0)));
5220        let ptr = func
5221            .expressions
5222            .append(Expression::Literal(Literal::F32(0.0)));
5223        func.body.push(Statement::Store {
5224            pointer: ptr,
5225            value: val,
5226        });
5227
5228        module.entry_points.push(EntryPoint {
5229            name: "unknown_kernel".into(),
5230            workgroup_size: [256, 1, 1],
5231            function: func,
5232        });
5233
5234        let pattern = classify_entry_point(&module, 0).unwrap();
5235        assert!(
5236            matches!(&pattern, KernelPattern::Unknown { .. }),
5237            "expected Unknown pattern, got {pattern:?}"
5238        );
5239    }
5240
5241    #[test]
5242    fn classify_elementwise_with_embedded_weight() {
5243        // 1 storage input + 1 output + private global with init + binary Add → ElementWise.
5244        let mut module = Module::default();
5245
5246        let f32_ty = module.types.insert(Type {
5247            name: None,
5248            inner: TypeInner::Scalar(Scalar::F32),
5249        });
5250        let u32_ty = module.types.insert(Type {
5251            name: None,
5252            inner: TypeInner::Scalar(Scalar::U32),
5253        });
5254        let array_f32 = module.types.insert(Type {
5255            name: None,
5256            inner: TypeInner::Array {
5257                base: f32_ty,
5258                size: ArraySize::Dynamic,
5259                stride: 4,
5260            },
5261        });
5262        let array_f32_4 = module.types.insert(Type {
5263            name: None,
5264            inner: TypeInner::Array {
5265                base: f32_ty,
5266                size: ArraySize::Constant(4),
5267                stride: 4,
5268            },
5269        });
5270        let params_ty = module.types.insert(Type {
5271            name: Some("Params".into()),
5272            inner: TypeInner::Struct {
5273                members: vec![StructMember {
5274                    name: Some("N".into()),
5275                    ty: u32_ty,
5276                    offset: 0,
5277                }],
5278                span: 4,
5279            },
5280        });
5281
5282        // Storage input (binding 0)
5283        module.global_variables.append(GlobalVariable {
5284            name: Some("input".into()),
5285            space: AddressSpace::Storage {
5286                access: StorageAccess::LOAD,
5287            },
5288            binding: Some(ResourceBinding {
5289                group: 0,
5290                binding: 0,
5291            }),
5292            ty: array_f32,
5293            init: None,
5294            layout: None,
5295        });
5296        // Storage output (binding 1)
5297        module.global_variables.append(GlobalVariable {
5298            name: Some("output".into()),
5299            space: AddressSpace::Storage {
5300                access: StorageAccess::LOAD | StorageAccess::STORE,
5301            },
5302            binding: Some(ResourceBinding {
5303                group: 0,
5304                binding: 1,
5305            }),
5306            ty: array_f32,
5307            init: None,
5308            layout: None,
5309        });
5310
5311        // Private global with init (embedded weight)
5312        let lit0 = module
5313            .global_expressions
5314            .append(Expression::Literal(Literal::F32(0.1)));
5315        let lit1 = module
5316            .global_expressions
5317            .append(Expression::Literal(Literal::F32(0.2)));
5318        let lit2 = module
5319            .global_expressions
5320            .append(Expression::Literal(Literal::F32(0.3)));
5321        let lit3 = module
5322            .global_expressions
5323            .append(Expression::Literal(Literal::F32(0.4)));
5324        let compose = module.global_expressions.append(Expression::Compose {
5325            ty: array_f32_4,
5326            components: vec![lit0, lit1, lit2, lit3],
5327        });
5328        module.global_variables.append(GlobalVariable {
5329            name: Some("bias".into()),
5330            space: AddressSpace::Private,
5331            binding: None,
5332            ty: array_f32_4,
5333            init: Some(compose),
5334            layout: None,
5335        });
5336
5337        // Uniform params
5338        module.global_variables.append(GlobalVariable {
5339            name: Some("params".into()),
5340            space: AddressSpace::Uniform,
5341            binding: Some(ResourceBinding {
5342                group: 0,
5343                binding: 2,
5344            }),
5345            ty: params_ty,
5346            init: None,
5347            layout: None,
5348        });
5349
5350        // Entry point: Store of Binary Add (no loop)
5351        let mut func = Function::new("main");
5352        let left = func
5353            .expressions
5354            .append(Expression::Literal(Literal::F32(1.0)));
5355        let right = func
5356            .expressions
5357            .append(Expression::Literal(Literal::F32(2.0)));
5358        let binary = func.expressions.append(Expression::Binary {
5359            op: BinaryOp::Add,
5360            left,
5361            right,
5362        });
5363        let ptr = func
5364            .expressions
5365            .append(Expression::Literal(Literal::F32(0.0)));
5366        func.body.push(Statement::Store {
5367            pointer: ptr,
5368            value: binary,
5369        });
5370
5371        module.entry_points.push(EntryPoint {
5372            name: "main".into(),
5373            workgroup_size: [64, 1, 1],
5374            function: func,
5375        });
5376
5377        let pattern = classify_entry_point(&module, 0).unwrap();
5378        match &pattern {
5379            KernelPattern::ElementWise {
5380                op,
5381                inputs,
5382                output,
5383                dim_name,
5384            } => {
5385                assert_eq!(*op, ElementWiseOp::Add);
5386                assert_eq!(inputs[0].name, "input");
5387                assert_eq!(inputs[1].name, "bias");
5388                assert_eq!(output.name, "output");
5389                assert_eq!(dim_name, "N");
5390            }
5391            _ => panic!("expected ElementWise pattern, got {pattern:?}"),
5392        }
5393    }
5394
5395    #[test]
5396    fn detect_reduce_mean() {
5397        // Loop body with a divide → ReduceOp::Mean
5398        let mut func = Function::new("test");
5399        let left = func
5400            .expressions
5401            .append(Expression::Literal(Literal::F32(0.0)));
5402        let right = func
5403            .expressions
5404            .append(Expression::Literal(Literal::F32(1.0)));
5405        let div = func.expressions.append(Expression::Binary {
5406            op: BinaryOp::Divide,
5407            left,
5408            right,
5409        });
5410        let ptr = func
5411            .expressions
5412            .append(Expression::Literal(Literal::F32(0.0)));
5413        let body = vec![
5414            Statement::Store {
5415                pointer: ptr,
5416                value: div,
5417            },
5418            Statement::Break,
5419        ];
5420        let result = detect_reduce_op(&body, &func.expressions);
5421        assert_eq!(result, ReduceOp::Mean);
5422    }
5423
5424    #[test]
5425    fn classify_out_of_range() {
5426        let module = make_matmul_module();
5427        let err = classify_entry_point(&module, 99).unwrap_err();
5428        assert!(matches!(err, AnalysisError::EntryPointOutOfRange(99)));
5429    }
5430
5431    #[test]
5432    fn classify_empty_module() {
5433        let module = Module::default();
5434        let err = classify_entry_point(&module, 0).unwrap_err();
5435        assert!(matches!(err, AnalysisError::NoEntryPoints));
5436    }
5437
5438    #[test]
5439    fn input_sorted_by_binding() {
5440        // Build a module where 'b' (binding 1) is appended before 'a' (binding 0)
5441        // to verify that classify sorts inputs by binding order.
5442        let mut module = Module::default();
5443
5444        let f32_ty = module.types.insert(Type {
5445            name: None,
5446            inner: TypeInner::Scalar(Scalar::F32),
5447        });
5448        let u32_ty = module.types.insert(Type {
5449            name: None,
5450            inner: TypeInner::Scalar(Scalar::U32),
5451        });
5452        let array_f32 = module.types.insert(Type {
5453            name: None,
5454            inner: TypeInner::Array {
5455                base: f32_ty,
5456                size: ArraySize::Dynamic,
5457                stride: 4,
5458            },
5459        });
5460        let params_ty = module.types.insert(Type {
5461            name: Some("Params".into()),
5462            inner: TypeInner::Struct {
5463                members: vec![
5464                    StructMember {
5465                        name: Some("M".into()),
5466                        ty: u32_ty,
5467                        offset: 0,
5468                    },
5469                    StructMember {
5470                        name: Some("N".into()),
5471                        ty: u32_ty,
5472                        offset: 4,
5473                    },
5474                    StructMember {
5475                        name: Some("K".into()),
5476                        ty: u32_ty,
5477                        offset: 8,
5478                    },
5479                ],
5480                span: 12,
5481            },
5482        });
5483
5484        // Append b (binding 1) BEFORE a (binding 0).
5485        module.global_variables.append(GlobalVariable {
5486            name: Some("b".into()),
5487            space: AddressSpace::Storage {
5488                access: StorageAccess::LOAD,
5489            },
5490            binding: Some(ResourceBinding {
5491                group: 0,
5492                binding: 1,
5493            }),
5494            ty: array_f32,
5495            init: None,
5496            layout: None,
5497        });
5498        module.global_variables.append(GlobalVariable {
5499            name: Some("a".into()),
5500            space: AddressSpace::Storage {
5501                access: StorageAccess::LOAD,
5502            },
5503            binding: Some(ResourceBinding {
5504                group: 0,
5505                binding: 0,
5506            }),
5507            ty: array_f32,
5508            init: None,
5509            layout: None,
5510        });
5511        module.global_variables.append(GlobalVariable {
5512            name: Some("result".into()),
5513            space: AddressSpace::Storage {
5514                access: StorageAccess::LOAD | StorageAccess::STORE,
5515            },
5516            binding: Some(ResourceBinding {
5517                group: 0,
5518                binding: 2,
5519            }),
5520            ty: array_f32,
5521            init: None,
5522            layout: None,
5523        });
5524        module.global_variables.append(GlobalVariable {
5525            name: Some("params".into()),
5526            space: AddressSpace::Uniform,
5527            binding: Some(ResourceBinding {
5528                group: 0,
5529                binding: 3,
5530            }),
5531            ty: params_ty,
5532            init: None,
5533            layout: None,
5534        });
5535
5536        let mut func = Function::new("main");
5537        func.body.push(Statement::Loop {
5538            body: vec![Statement::Break],
5539            continuing: vec![],
5540            break_if: None,
5541        });
5542        module.entry_points.push(EntryPoint {
5543            name: "main".into(),
5544            workgroup_size: [16, 16, 1],
5545            function: func,
5546        });
5547
5548        let pattern = classify_entry_point(&module, 0).unwrap();
5549        match pattern {
5550            KernelPattern::MatMul { inputs, .. } => {
5551                assert_eq!(inputs[0].name, "a"); // binding 0 sorted first
5552                assert_eq!(inputs[1].name, "b"); // binding 1 sorted second
5553            }
5554            _ => panic!("expected MatMul pattern"),
5555        }
5556    }
5557
5558    #[test]
5559    fn extract_loop_bounds_literal_u32() {
5560        let mut func = Function::new("test");
5561        let local = func.local_variables.append(LocalVariable {
5562            name: Some("kh".into()),
5563            ty: {
5564                let mut types = UniqueArena::new();
5565                types.insert(Type {
5566                    name: None,
5567                    inner: TypeInner::Scalar(Scalar::U32),
5568                })
5569            },
5570            init: None,
5571        });
5572        let load = func.expressions.append(Expression::LocalVariable(local));
5573        let lit = func
5574            .expressions
5575            .append(Expression::Literal(Literal::U32(5)));
5576        let cmp = func.expressions.append(Expression::Binary {
5577            op: BinaryOp::GreaterEqual,
5578            left: load,
5579            right: lit,
5580        });
5581        let body = vec![Statement::Loop {
5582            body: vec![Statement::Break],
5583            continuing: vec![],
5584            break_if: Some(cmp),
5585        }];
5586        let bounds = extract_loop_bound_literals(&body, &func.expressions);
5587        assert_eq!(bounds, vec![5]);
5588    }
5589
5590    #[test]
5591    fn extract_loop_bounds_nested() {
5592        let mut func = Function::new("test");
5593        // Inner loop: kh < 3
5594        let local_kh = func
5595            .expressions
5596            .append(Expression::Literal(Literal::U32(0)));
5597        let lit3 = func
5598            .expressions
5599            .append(Expression::Literal(Literal::U32(3)));
5600        let cmp_inner = func.expressions.append(Expression::Binary {
5601            op: BinaryOp::GreaterEqual,
5602            left: local_kh,
5603            right: lit3,
5604        });
5605        // Outer loop: kw < 5
5606        let local_kw = func
5607            .expressions
5608            .append(Expression::Literal(Literal::U32(0)));
5609        let lit5 = func
5610            .expressions
5611            .append(Expression::Literal(Literal::U32(5)));
5612        let cmp_outer = func.expressions.append(Expression::Binary {
5613            op: BinaryOp::GreaterEqual,
5614            left: local_kw,
5615            right: lit5,
5616        });
5617        let body = vec![Statement::Loop {
5618            body: vec![Statement::Loop {
5619                body: vec![Statement::Break],
5620                continuing: vec![],
5621                break_if: Some(cmp_inner),
5622            }],
5623            continuing: vec![],
5624            break_if: Some(cmp_outer),
5625        }];
5626        let bounds = extract_loop_bound_literals(&body, &func.expressions);
5627        assert_eq!(bounds, vec![5, 3]);
5628    }
5629
5630    #[test]
5631    fn extract_loop_bounds_no_literal() {
5632        let func = Function::new("test");
5633        let body = vec![Statement::Loop {
5634            body: vec![Statement::Break],
5635            continuing: vec![],
5636            break_if: None,
5637        }];
5638        let bounds = extract_loop_bound_literals(&body, &func.expressions);
5639        assert_eq!(bounds.len(), 0);
5640    }
5641
5642    /// naga 28 lowers `for (var kh = 0u; kh < 5u; ...)` as:
5643    ///   Loop { body: [Emit, Emit, If(kh<5) {} else {Break}, ...], break_if: None }
5644    #[test]
5645    fn extract_loop_bounds_if_else_break_pattern() {
5646        let mut func = Function::new("test");
5647        let local = func.local_variables.append(LocalVariable {
5648            name: Some("kh".into()),
5649            ty: {
5650                let mut types = UniqueArena::new();
5651                types.insert(Type {
5652                    name: None,
5653                    inner: TypeInner::Scalar(Scalar::U32),
5654                })
5655            },
5656            init: None,
5657        });
5658        let load = func.expressions.append(Expression::LocalVariable(local));
5659        let lit5 = func
5660            .expressions
5661            .append(Expression::Literal(Literal::U32(5)));
5662        let cmp = func.expressions.append(Expression::Binary {
5663            op: BinaryOp::Less,
5664            left: load,
5665            right: lit5,
5666        });
5667        // Simulate If(cond){} else {Break} inside loop body (skipping Emit)
5668        let body = vec![Statement::Loop {
5669            body: vec![Statement::If {
5670                condition: cmp,
5671                accept: vec![],
5672                reject: vec![Statement::Break],
5673            }],
5674            continuing: vec![],
5675            break_if: None,
5676        }];
5677        let bounds = extract_loop_bound_literals(&body, &func.expressions);
5678        assert_eq!(bounds, vec![5]);
5679    }
5680
5681    /// `gid.y`, as a kernel spells the coordinate a stride scales.
5682    ///
5683    /// These fixtures used to multiply one literal by another, which no kernel
5684    /// writes and which stopped being enough once a stride had to come from
5685    /// the invocation id.
5686    fn invocation_id_component(func: &mut Function, index: u32) -> Handle<Expression> {
5687        let gid = func.expressions.append(Expression::FunctionArgument(0));
5688        func.expressions
5689            .append(Expression::AccessIndex { base: gid, index })
5690    }
5691
5692    #[test]
5693    fn extract_multiply_literals_stride() {
5694        let mut func = Function::new("test");
5695        let oh = invocation_id_component(&mut func, 1);
5696        let two = func
5697            .expressions
5698            .append(Expression::Literal(Literal::U32(2)));
5699        func.expressions.append(Expression::Binary {
5700            op: BinaryOp::Multiply,
5701            left: oh,
5702            right: two,
5703        });
5704        let strides = extract_multiply_literals(&func.expressions);
5705        assert_eq!(strides, vec![2]);
5706    }
5707
5708    #[test]
5709    fn a_flattening_factor_is_not_a_stride() {
5710        // `kh * 3u` inside a weight index. Read as a stride, it emitted a 3x3
5711        // convolution over a 64x64 image with an output of 21x7 rather than
5712        // 62x62 -- a model that loads, is accelerated, and is wrong.
5713        let mut func = Function::new("test");
5714        // A loop variable reaches the expression as a load, which is the whole
5715        // difference from `gid.y`; what it loads from does not matter here.
5716        let slot = func
5717            .expressions
5718            .append(Expression::Literal(Literal::U32(0)));
5719        let kh = func.expressions.append(Expression::Load { pointer: slot });
5720        let three = func
5721            .expressions
5722            .append(Expression::Literal(Literal::U32(3)));
5723        func.expressions.append(Expression::Binary {
5724            op: BinaryOp::Multiply,
5725            left: kh,
5726            right: three,
5727        });
5728        assert_eq!(
5729            extract_multiply_literals(&func.expressions),
5730            Vec::<u32>::new()
5731        );
5732    }
5733
5734    #[test]
5735    fn extract_multiply_literals_ignores_one() {
5736        let mut func = Function::new("test");
5737        let oh = func
5738            .expressions
5739            .append(Expression::Literal(Literal::U32(0)));
5740        let one = func
5741            .expressions
5742            .append(Expression::Literal(Literal::U32(1)));
5743        func.expressions.append(Expression::Binary {
5744            op: BinaryOp::Multiply,
5745            left: oh,
5746            right: one,
5747        });
5748        let strides = extract_multiply_literals(&func.expressions);
5749        assert_eq!(strides.len(), 0);
5750    }
5751
5752    #[test]
5753    fn extract_subtract_literals_padding() {
5754        let mut func = Function::new("test");
5755        let idx = func
5756            .expressions
5757            .append(Expression::Literal(Literal::U32(10)));
5758        let pad = func
5759            .expressions
5760            .append(Expression::Literal(Literal::U32(1)));
5761        func.expressions.append(Expression::Binary {
5762            op: BinaryOp::Subtract,
5763            left: idx,
5764            right: pad,
5765        });
5766        let pads = extract_subtract_literals(&func.expressions);
5767        assert_eq!(pads, vec![1]);
5768    }
5769
5770    #[test]
5771    fn conv2d_shape_no_body_defaults_to_unknown() {
5772        let names: Vec<String> = ["N", "IC", "IH", "IW", "OC", "KH", "KW"]
5773            .iter()
5774            .map(|s| s.to_string())
5775            .collect();
5776        let shape = extract_conv2d_shape(&names, None, None);
5777        assert_eq!(shape.batch, "N");
5778        assert_eq!(shape.kernel_h, "KH");
5779        assert_eq!(shape.kernel_w, "KW");
5780        assert_eq!(shape.kernel_h_val, 0);
5781        assert_eq!(shape.kernel_w_val, 0);
5782        assert_eq!(shape.stride_h, 1);
5783        assert_eq!(shape.stride_w, 1);
5784        assert_eq!(shape.pad_h, 0);
5785        assert_eq!(shape.pad_w, 0);
5786    }
5787
5788    #[test]
5789    fn conv2d_shape_with_literal_kernel() {
5790        let names: Vec<String> = ["N", "IC", "IH", "IW", "OC", "KH", "KW"]
5791            .iter()
5792            .map(|s| s.to_string())
5793            .collect();
5794        // Build a function body with loops: kh < 5, kw < 5
5795        let mut func = Function::new("test");
5796        let kh_var = func
5797            .expressions
5798            .append(Expression::Literal(Literal::U32(0)));
5799        let lit5a = func
5800            .expressions
5801            .append(Expression::Literal(Literal::U32(5)));
5802        let cmp_h = func.expressions.append(Expression::Binary {
5803            op: BinaryOp::GreaterEqual,
5804            left: kh_var,
5805            right: lit5a,
5806        });
5807        let kw_var = func
5808            .expressions
5809            .append(Expression::Literal(Literal::U32(0)));
5810        let lit5b = func
5811            .expressions
5812            .append(Expression::Literal(Literal::U32(5)));
5813        let cmp_w = func.expressions.append(Expression::Binary {
5814            op: BinaryOp::GreaterEqual,
5815            left: kw_var,
5816            right: lit5b,
5817        });
5818        // Also add stride: oh * 2u, with `oh` coming from the invocation id
5819        // as it does in a kernel -- a literal multiplied by a literal is not
5820        // a stride and is no longer read as one.
5821        let oh = invocation_id_component(&mut func, 1);
5822        let two = func
5823            .expressions
5824            .append(Expression::Literal(Literal::U32(2)));
5825        func.expressions.append(Expression::Binary {
5826            op: BinaryOp::Multiply,
5827            left: oh,
5828            right: two,
5829        });
5830        // Also add padding: ih - 1u
5831        let ih = func
5832            .expressions
5833            .append(Expression::Literal(Literal::U32(10)));
5834        let one = func
5835            .expressions
5836            .append(Expression::Literal(Literal::U32(1)));
5837        func.expressions.append(Expression::Binary {
5838            op: BinaryOp::Subtract,
5839            left: ih,
5840            right: one,
5841        });
5842
5843        let body = vec![Statement::Loop {
5844            body: vec![Statement::Loop {
5845                body: vec![Statement::Break],
5846                continuing: vec![],
5847                break_if: Some(cmp_w),
5848            }],
5849            continuing: vec![],
5850            break_if: Some(cmp_h),
5851        }];
5852
5853        let shape = extract_conv2d_shape(&names, Some(&body), Some(&func.expressions));
5854        assert_eq!(shape.kernel_h_val, 5);
5855        assert_eq!(shape.kernel_w_val, 5);
5856        assert_eq!(shape.stride_h, 2);
5857        assert_eq!(shape.stride_w, 2);
5858        assert_eq!(shape.pad_h, 1);
5859        assert_eq!(shape.pad_w, 1);
5860    }
5861
5862    /// Build a pool-like module with literal kernel and stride.
5863    fn make_pool_module(kernel: u32, stride: u32) -> Module {
5864        let mut module = Module::default();
5865
5866        let f32_ty = module.types.insert(Type {
5867            name: None,
5868            inner: TypeInner::Scalar(Scalar::F32),
5869        });
5870        let u32_ty = module.types.insert(Type {
5871            name: None,
5872            inner: TypeInner::Scalar(Scalar::U32),
5873        });
5874        let array_f32 = module.types.insert(Type {
5875            name: None,
5876            inner: TypeInner::Array {
5877                base: f32_ty,
5878                size: ArraySize::Dynamic,
5879                stride: 4,
5880            },
5881        });
5882        let params_ty = module.types.insert(Type {
5883            name: Some("Params".into()),
5884            inner: TypeInner::Struct {
5885                members: vec![
5886                    StructMember {
5887                        name: Some("C".into()),
5888                        ty: u32_ty,
5889                        offset: 0,
5890                    },
5891                    StructMember {
5892                        name: Some("IH".into()),
5893                        ty: u32_ty,
5894                        offset: 4,
5895                    },
5896                    StructMember {
5897                        name: Some("IW".into()),
5898                        ty: u32_ty,
5899                        offset: 8,
5900                    },
5901                    StructMember {
5902                        name: Some("OH".into()),
5903                        ty: u32_ty,
5904                        offset: 12,
5905                    },
5906                ],
5907                span: 16,
5908            },
5909        });
5910
5911        module.global_variables.append(GlobalVariable {
5912            name: Some("input".into()),
5913            space: AddressSpace::Storage {
5914                access: StorageAccess::LOAD,
5915            },
5916            binding: Some(ResourceBinding {
5917                group: 0,
5918                binding: 0,
5919            }),
5920            ty: array_f32,
5921            init: None,
5922            layout: None,
5923        });
5924        module.global_variables.append(GlobalVariable {
5925            name: Some("output".into()),
5926            space: AddressSpace::Storage {
5927                access: StorageAccess::LOAD | StorageAccess::STORE,
5928            },
5929            binding: Some(ResourceBinding {
5930                group: 0,
5931                binding: 1,
5932            }),
5933            ty: array_f32,
5934            init: None,
5935            layout: None,
5936        });
5937        module.global_variables.append(GlobalVariable {
5938            name: Some("params".into()),
5939            space: AddressSpace::Uniform,
5940            binding: Some(ResourceBinding {
5941                group: 0,
5942                binding: 2,
5943            }),
5944            ty: params_ty,
5945            init: None,
5946            layout: None,
5947        });
5948
5949        let mut func = Function::new("main");
5950
5951        // Loop bounds: kh < kernel, kw < kernel
5952        let kh_var = func
5953            .expressions
5954            .append(Expression::Literal(Literal::U32(0)));
5955        let lit_k1 = func
5956            .expressions
5957            .append(Expression::Literal(Literal::U32(kernel)));
5958        let cmp_h = func.expressions.append(Expression::Binary {
5959            op: BinaryOp::GreaterEqual,
5960            left: kh_var,
5961            right: lit_k1,
5962        });
5963        let kw_var = func
5964            .expressions
5965            .append(Expression::Literal(Literal::U32(0)));
5966        let lit_k2 = func
5967            .expressions
5968            .append(Expression::Literal(Literal::U32(kernel)));
5969        let cmp_w = func.expressions.append(Expression::Binary {
5970            op: BinaryOp::GreaterEqual,
5971            left: kw_var,
5972            right: lit_k2,
5973        });
5974
5975        // Stride: oh * stride
5976        if stride > 1 {
5977            let oh = func
5978                .expressions
5979                .append(Expression::Literal(Literal::U32(0)));
5980            let lit_s = func
5981                .expressions
5982                .append(Expression::Literal(Literal::U32(stride)));
5983            func.expressions.append(Expression::Binary {
5984                op: BinaryOp::Multiply,
5985                left: oh,
5986                right: lit_s,
5987            });
5988        }
5989
5990        // Max function for MaxPool detection
5991        let arg = func
5992            .expressions
5993            .append(Expression::Literal(Literal::F32(0.0)));
5994        let arg1 = func
5995            .expressions
5996            .append(Expression::Literal(Literal::F32(0.0)));
5997        let max_expr = func.expressions.append(Expression::Math {
5998            fun: MathFunction::Max,
5999            arg,
6000            arg1: Some(arg1),
6001            arg2: None,
6002            arg3: None,
6003        });
6004        let ptr = func
6005            .expressions
6006            .append(Expression::Literal(Literal::F32(0.0)));
6007        func.body.push(Statement::Loop {
6008            body: vec![
6009                Statement::Loop {
6010                    body: vec![
6011                        Statement::Store {
6012                            pointer: ptr,
6013                            value: max_expr,
6014                        },
6015                        Statement::Break,
6016                    ],
6017                    continuing: vec![],
6018                    break_if: Some(cmp_w),
6019                },
6020                Statement::Break,
6021            ],
6022            continuing: vec![],
6023            break_if: Some(cmp_h),
6024        });
6025
6026        module.entry_points.push(EntryPoint {
6027            name: "main".into(),
6028            workgroup_size: [16, 16, 1],
6029            function: func,
6030        });
6031
6032        module
6033    }
6034
6035    #[test]
6036    fn classify_pool_extracts_2x2_stride2() {
6037        let module = make_pool_module(2, 2);
6038        let pattern = classify_entry_point(&module, 0).unwrap();
6039        match &pattern {
6040            KernelPattern::Pool { kind, shape, .. } => {
6041                assert_eq!(*kind, PoolKind::Max);
6042                assert_eq!(shape.kernel_h, 2);
6043                assert_eq!(shape.kernel_w, 2);
6044                assert_eq!(shape.stride_h, 2);
6045                assert_eq!(shape.stride_w, 2);
6046            }
6047            _ => panic!("expected Pool pattern, got {pattern:?}"),
6048        }
6049    }
6050
6051    #[test]
6052    fn classify_pool_extracts_3x3_stride1() {
6053        let module = make_pool_module(3, 1);
6054        let pattern = classify_entry_point(&module, 0).unwrap();
6055        match &pattern {
6056            KernelPattern::Pool { kind, shape, .. } => {
6057                assert_eq!(*kind, PoolKind::Max);
6058                assert_eq!(shape.kernel_h, 3);
6059                assert_eq!(shape.kernel_w, 3);
6060                // stride=1: no multiply literals, default to kernel size
6061                assert_eq!(shape.stride_h, 3);
6062                assert_eq!(shape.stride_w, 3);
6063            }
6064            _ => panic!("expected Pool pattern, got {pattern:?}"),
6065        }
6066    }
6067
6068    // --- EmbeddedWeight extraction tests ---
6069
6070    #[test]
6071    fn eval_const_expr_f32_literal() {
6072        let mut exprs = Arena::new();
6073        let types = UniqueArena::new();
6074        let h = exprs.append(Expression::Literal(Literal::F32(2.78)));
6075        assert_eq!(eval_const_expr_f32(h, &exprs, &types), Some(vec![2.78]));
6076    }
6077
6078    #[test]
6079    fn eval_const_expr_f32_compose() {
6080        let mut exprs = Arena::new();
6081        let types = UniqueArena::new();
6082        let a = exprs.append(Expression::Literal(Literal::F32(0.1)));
6083        let b = exprs.append(Expression::Literal(Literal::F32(0.2)));
6084        let c = exprs.append(Expression::Literal(Literal::F32(0.3)));
6085        let f32_ty = {
6086            let mut t = UniqueArena::new();
6087            t.insert(Type {
6088                name: None,
6089                inner: TypeInner::Scalar(Scalar::F32),
6090            })
6091        };
6092        let compose = exprs.append(Expression::Compose {
6093            ty: f32_ty,
6094            components: vec![a, b, c],
6095        });
6096        let result = eval_const_expr_f32(compose, &exprs, &types).unwrap();
6097        assert_eq!(result.len(), 3);
6098        assert!((result[0] - 0.1).abs() < 1e-6);
6099        assert!((result[1] - 0.2).abs() < 1e-6);
6100        assert!((result[2] - 0.3).abs() < 1e-6);
6101    }
6102
6103    #[test]
6104    fn eval_const_expr_f32_zero_value() {
6105        let mut exprs = Arena::new();
6106        let mut types = UniqueArena::new();
6107        let f32_ty = types.insert(Type {
6108            name: None,
6109            inner: TypeInner::Scalar(Scalar::F32),
6110        });
6111        let arr_ty = types.insert(Type {
6112            name: None,
6113            inner: TypeInner::Array {
6114                base: f32_ty,
6115                size: ArraySize::Constant(3),
6116                stride: 4,
6117            },
6118        });
6119        let h = exprs.append(Expression::ZeroValue(arr_ty));
6120        let result = eval_const_expr_f32(h, &exprs, &types).unwrap();
6121        assert_eq!(result, vec![0.0, 0.0, 0.0]);
6122    }
6123
6124    #[test]
6125    fn extract_embedded_weights_private_global() {
6126        let mut module = Module::default();
6127
6128        let f32_ty = module.types.insert(Type {
6129            name: None,
6130            inner: TypeInner::Scalar(Scalar::F32),
6131        });
6132        let array_f32_4 = module.types.insert(Type {
6133            name: None,
6134            inner: TypeInner::Array {
6135                base: f32_ty,
6136                size: ArraySize::Constant(4),
6137                stride: 4,
6138            },
6139        });
6140
6141        // Build init expression: Compose([0.1, 0.2, 0.3, 0.4])
6142        let lit0 = module
6143            .global_expressions
6144            .append(Expression::Literal(Literal::F32(0.1)));
6145        let lit1 = module
6146            .global_expressions
6147            .append(Expression::Literal(Literal::F32(0.2)));
6148        let lit2 = module
6149            .global_expressions
6150            .append(Expression::Literal(Literal::F32(0.3)));
6151        let lit3 = module
6152            .global_expressions
6153            .append(Expression::Literal(Literal::F32(0.4)));
6154        let compose = module.global_expressions.append(Expression::Compose {
6155            ty: array_f32_4,
6156            components: vec![lit0, lit1, lit2, lit3],
6157        });
6158
6159        module.global_variables.append(GlobalVariable {
6160            name: Some("bias".into()),
6161            space: AddressSpace::Private,
6162            binding: None,
6163            ty: array_f32_4,
6164            init: Some(compose),
6165            layout: None,
6166        });
6167
6168        let weights = extract_embedded_weights(&module);
6169        assert_eq!(weights.len(), 1);
6170        assert_eq!(weights[0].name, "bias");
6171        assert_eq!(weights[0].dims, vec![4]);
6172        assert_eq!(weights[0].data.len(), 4);
6173        assert!((weights[0].data[0] - 0.1).abs() < 1e-6);
6174        assert!((weights[0].data[3] - 0.4).abs() < 1e-6);
6175    }
6176
6177    #[test]
6178    fn extract_embedded_weights_no_init() {
6179        let mut module = Module::default();
6180
6181        let f32_ty = module.types.insert(Type {
6182            name: None,
6183            inner: TypeInner::Scalar(Scalar::F32),
6184        });
6185        let array_f32 = module.types.insert(Type {
6186            name: None,
6187            inner: TypeInner::Array {
6188                base: f32_ty,
6189                size: ArraySize::Dynamic,
6190                stride: 4,
6191            },
6192        });
6193
6194        module.global_variables.append(GlobalVariable {
6195            name: Some("a".into()),
6196            space: AddressSpace::Storage {
6197                access: StorageAccess::LOAD,
6198            },
6199            binding: Some(ResourceBinding {
6200                group: 0,
6201                binding: 0,
6202            }),
6203            ty: array_f32,
6204            init: None,
6205            layout: None,
6206        });
6207
6208        let weights = extract_embedded_weights(&module);
6209        assert_eq!(weights.len(), 0);
6210    }
6211
6212    // --- Concat/Split axis inference tests ---
6213
6214    /// Build a concat-like module with 2 inputs, 1 output, and an If condition
6215    /// that compares against `AccessIndex { base: params, index: boundary_index }`.
6216    /// This simulates the IR that naga produces for `if c < params.C1`.
6217    fn make_concat_module(boundary_index: u32, shape_names: &[&str]) -> Module {
6218        let mut module = Module::default();
6219
6220        let f32_ty = module.types.insert(Type {
6221            name: None,
6222            inner: TypeInner::Scalar(Scalar::F32),
6223        });
6224        let u32_ty = module.types.insert(Type {
6225            name: None,
6226            inner: TypeInner::Scalar(Scalar::U32),
6227        });
6228        let array_f32 = module.types.insert(Type {
6229            name: None,
6230            inner: TypeInner::Array {
6231                base: f32_ty,
6232                size: ArraySize::Dynamic,
6233                stride: 4,
6234            },
6235        });
6236        let params_ty = module.types.insert(Type {
6237            name: Some("Params".into()),
6238            inner: TypeInner::Struct {
6239                members: shape_names
6240                    .iter()
6241                    .enumerate()
6242                    .map(|(i, name)| StructMember {
6243                        name: Some((*name).into()),
6244                        ty: u32_ty,
6245                        offset: (i * 4) as u32,
6246                    })
6247                    .collect(),
6248                span: (shape_names.len() * 4) as u32,
6249            },
6250        });
6251
6252        // Input a (binding 0)
6253        module.global_variables.append(GlobalVariable {
6254            name: Some("a".into()),
6255            space: AddressSpace::Storage {
6256                access: StorageAccess::LOAD,
6257            },
6258            binding: Some(ResourceBinding {
6259                group: 0,
6260                binding: 0,
6261            }),
6262            ty: array_f32,
6263            init: None,
6264            layout: None,
6265        });
6266        // Input b (binding 1)
6267        module.global_variables.append(GlobalVariable {
6268            name: Some("b".into()),
6269            space: AddressSpace::Storage {
6270                access: StorageAccess::LOAD,
6271            },
6272            binding: Some(ResourceBinding {
6273                group: 0,
6274                binding: 1,
6275            }),
6276            ty: array_f32,
6277            init: None,
6278            layout: None,
6279        });
6280        // Output (binding 2)
6281        module.global_variables.append(GlobalVariable {
6282            name: Some("result".into()),
6283            space: AddressSpace::Storage {
6284                access: StorageAccess::LOAD | StorageAccess::STORE,
6285            },
6286            binding: Some(ResourceBinding {
6287                group: 0,
6288                binding: 2,
6289            }),
6290            ty: array_f32,
6291            init: None,
6292            layout: None,
6293        });
6294        // Uniform params (binding 3)
6295        module.global_variables.append(GlobalVariable {
6296            name: Some("params".into()),
6297            space: AddressSpace::Uniform,
6298            binding: Some(ResourceBinding {
6299                group: 0,
6300                binding: 3,
6301            }),
6302            ty: params_ty,
6303            init: None,
6304            layout: None,
6305        });
6306
6307        // Build function with If(idx < AccessIndex(params, boundary_index))
6308        let mut func = Function::new("main");
6309        let idx = func
6310            .expressions
6311            .append(Expression::Literal(Literal::U32(0)));
6312        // params GlobalVariable (handle index 3)
6313        let params_gv = {
6314            let mut iter = module.global_variables.iter();
6315            iter.nth(3).unwrap().0
6316        };
6317        let params_expr = func
6318            .expressions
6319            .append(Expression::GlobalVariable(params_gv));
6320        let access = func.expressions.append(Expression::AccessIndex {
6321            base: params_expr,
6322            index: boundary_index,
6323        });
6324        let load = func
6325            .expressions
6326            .append(Expression::Load { pointer: access });
6327        let cmp = func.expressions.append(Expression::Binary {
6328            op: BinaryOp::Less,
6329            left: idx,
6330            right: load,
6331        });
6332
6333        // If body: Store (accept), Store (reject)
6334        let val = func
6335            .expressions
6336            .append(Expression::Literal(Literal::F32(0.0)));
6337        let ptr = func
6338            .expressions
6339            .append(Expression::Literal(Literal::F32(0.0)));
6340        func.body.push(Statement::If {
6341            condition: cmp,
6342            accept: vec![Statement::Store {
6343                pointer: ptr,
6344                value: val,
6345            }],
6346            reject: vec![Statement::Store {
6347                pointer: ptr,
6348                value: val,
6349            }],
6350        });
6351
6352        module.entry_points.push(EntryPoint {
6353            name: "main".into(),
6354            workgroup_size: [256, 1, 1],
6355            function: func,
6356        });
6357
6358        module
6359    }
6360
6361    /// Build a split-like module with 1 input, 2 outputs, and an If condition
6362    /// that compares against `AccessIndex { base: params, index: boundary_index }`.
6363    fn make_split_module(boundary_index: u32, shape_names: &[&str]) -> Module {
6364        let mut module = Module::default();
6365
6366        let f32_ty = module.types.insert(Type {
6367            name: None,
6368            inner: TypeInner::Scalar(Scalar::F32),
6369        });
6370        let u32_ty = module.types.insert(Type {
6371            name: None,
6372            inner: TypeInner::Scalar(Scalar::U32),
6373        });
6374        let array_f32 = module.types.insert(Type {
6375            name: None,
6376            inner: TypeInner::Array {
6377                base: f32_ty,
6378                size: ArraySize::Dynamic,
6379                stride: 4,
6380            },
6381        });
6382        let params_ty = module.types.insert(Type {
6383            name: Some("Params".into()),
6384            inner: TypeInner::Struct {
6385                members: shape_names
6386                    .iter()
6387                    .enumerate()
6388                    .map(|(i, name)| StructMember {
6389                        name: Some((*name).into()),
6390                        ty: u32_ty,
6391                        offset: (i * 4) as u32,
6392                    })
6393                    .collect(),
6394                span: (shape_names.len() * 4) as u32,
6395            },
6396        });
6397
6398        // Input (binding 0) - read-only storage
6399        module.global_variables.append(GlobalVariable {
6400            name: Some("input".into()),
6401            space: AddressSpace::Storage {
6402                access: StorageAccess::LOAD,
6403            },
6404            binding: Some(ResourceBinding {
6405                group: 0,
6406                binding: 0,
6407            }),
6408            ty: array_f32,
6409            init: None,
6410            layout: None,
6411        });
6412        // Output a (binding 1)
6413        module.global_variables.append(GlobalVariable {
6414            name: Some("out_a".into()),
6415            space: AddressSpace::Storage {
6416                access: StorageAccess::LOAD | StorageAccess::STORE,
6417            },
6418            binding: Some(ResourceBinding {
6419                group: 0,
6420                binding: 1,
6421            }),
6422            ty: array_f32,
6423            init: None,
6424            layout: None,
6425        });
6426        // Output b (binding 2)
6427        module.global_variables.append(GlobalVariable {
6428            name: Some("out_b".into()),
6429            space: AddressSpace::Storage {
6430                access: StorageAccess::LOAD | StorageAccess::STORE,
6431            },
6432            binding: Some(ResourceBinding {
6433                group: 0,
6434                binding: 2,
6435            }),
6436            ty: array_f32,
6437            init: None,
6438            layout: None,
6439        });
6440        // Uniform params (binding 3)
6441        module.global_variables.append(GlobalVariable {
6442            name: Some("params".into()),
6443            space: AddressSpace::Uniform,
6444            binding: Some(ResourceBinding {
6445                group: 0,
6446                binding: 3,
6447            }),
6448            ty: params_ty,
6449            init: None,
6450            layout: None,
6451        });
6452
6453        // Build function with If(idx < AccessIndex(params, boundary_index))
6454        let mut func = Function::new("main");
6455        let idx = func
6456            .expressions
6457            .append(Expression::Literal(Literal::U32(0)));
6458        let params_gv = {
6459            let mut iter = module.global_variables.iter();
6460            iter.nth(3).unwrap().0
6461        };
6462        let params_expr = func
6463            .expressions
6464            .append(Expression::GlobalVariable(params_gv));
6465        let access = func.expressions.append(Expression::AccessIndex {
6466            base: params_expr,
6467            index: boundary_index,
6468        });
6469        let load = func
6470            .expressions
6471            .append(Expression::Load { pointer: access });
6472        let cmp = func.expressions.append(Expression::Binary {
6473            op: BinaryOp::Less,
6474            left: idx,
6475            right: load,
6476        });
6477
6478        // `out_a[idx] = input[idx]` on one side of the boundary and
6479        // `out_b[idx] = input[idx]` on the other. Both stores used to be a
6480        // float literal written through a float literal, which addresses
6481        // nothing and copies nothing; the classifier now asks whether the
6482        // outputs are slices of the input, so the module has to be one.
6483        let (input_gv, out_a_gv, out_b_gv) = {
6484            let mut iter = module.global_variables.iter();
6485            let input = iter.next().unwrap().0;
6486            let out_a = iter.next().unwrap().0;
6487            let out_b = iter.next().unwrap().0;
6488            (input, out_a, out_b)
6489        };
6490        let input_expr = func
6491            .expressions
6492            .append(Expression::GlobalVariable(input_gv));
6493        let read = func.expressions.append(Expression::Access {
6494            base: input_expr,
6495            index: idx,
6496        });
6497        let val = func.expressions.append(Expression::Load { pointer: read });
6498
6499        let mut copy_into = |out: Handle<GlobalVariable>| {
6500            let out_expr = func.expressions.append(Expression::GlobalVariable(out));
6501            let ptr = func.expressions.append(Expression::Access {
6502                base: out_expr,
6503                index: idx,
6504            });
6505            Statement::Store {
6506                pointer: ptr,
6507                value: val,
6508            }
6509        };
6510        let store_a = copy_into(out_a_gv);
6511        let store_b = copy_into(out_b_gv);
6512
6513        func.body.push(Statement::If {
6514            condition: cmp,
6515            accept: vec![store_a],
6516            reject: vec![store_b],
6517        });
6518
6519        module.entry_points.push(EntryPoint {
6520            name: "main".into(),
6521            workgroup_size: [256, 1, 1],
6522            function: func,
6523        });
6524
6525        module
6526    }
6527
6528    #[test]
6529    fn classify_concat_axis_0_default() {
6530        // Concat with boundary at index 0 → axis = 0 (same as before).
6531        let module = make_concat_module(0, &["N_a", "N_b"]);
6532        let pattern = classify_entry_point(&module, 0).unwrap();
6533        match &pattern {
6534            KernelPattern::Concat { axis, .. } => {
6535                assert_eq!(*axis, 0, "expected axis 0 for boundary at index 0");
6536            }
6537            _ => panic!("expected Concat pattern, got {pattern:?}"),
6538        }
6539    }
6540
6541    #[test]
6542    fn classify_concat_axis_1_inferred() {
6543        // Concat with boundary at index 1 (params.C1) → axis = 1.
6544        let module = make_concat_module(1, &["N", "C1", "C2"]);
6545        let pattern = classify_entry_point(&module, 0).unwrap();
6546        match &pattern {
6547            KernelPattern::Concat {
6548                axis,
6549                inputs,
6550                output,
6551                ..
6552            } => {
6553                assert_eq!(*axis, 1, "expected axis 1 for boundary at index 1");
6554                assert_eq!(inputs[0].name, "a");
6555                assert_eq!(inputs[1].name, "b");
6556                assert_eq!(output.name, "result");
6557            }
6558            _ => panic!("expected Concat pattern, got {pattern:?}"),
6559        }
6560    }
6561
6562    #[test]
6563    fn classify_concat_axis_2_inferred() {
6564        // Concat with boundary at index 2 → axis = 2.
6565        let module = make_concat_module(2, &["N", "C", "W1", "W2"]);
6566        let pattern = classify_entry_point(&module, 0).unwrap();
6567        match &pattern {
6568            KernelPattern::Concat { axis, .. } => {
6569                assert_eq!(*axis, 2, "expected axis 2 for boundary at index 2");
6570            }
6571            _ => panic!("expected Concat pattern, got {pattern:?}"),
6572        }
6573    }
6574
6575    #[test]
6576    fn classify_concat_axis_3_inferred() {
6577        // Concat with boundary at index 3 → axis = 3 (4D shape: N, C, H, W).
6578        let module = make_concat_module(3, &["N", "C", "H", "W1", "W2"]);
6579        let pattern = classify_entry_point(&module, 0).unwrap();
6580        match &pattern {
6581            KernelPattern::Concat { axis, .. } => {
6582                assert_eq!(*axis, 3, "expected axis 3 for boundary at index 3");
6583            }
6584            _ => panic!("expected Concat pattern, got {pattern:?}"),
6585        }
6586    }
6587
6588    #[test]
6589    fn classify_split_axis_0_default() {
6590        // Split with boundary at index 0 → axis = 0.
6591        let module = make_split_module(0, &["N_a", "N_b"]);
6592        let pattern = classify_entry_point(&module, 0).unwrap();
6593        match &pattern {
6594            KernelPattern::Split { axis, .. } => {
6595                assert_eq!(*axis, 0, "expected axis 0 for boundary at index 0");
6596            }
6597            _ => panic!("expected Split pattern, got {pattern:?}"),
6598        }
6599    }
6600
6601    #[test]
6602    fn classify_split_axis_1_inferred() {
6603        // Split with boundary at index 1 (params.C1) → axis = 1.
6604        let module = make_split_module(1, &["N", "C1", "C2"]);
6605        let pattern = classify_entry_point(&module, 0).unwrap();
6606        match &pattern {
6607            KernelPattern::Split {
6608                axis,
6609                input,
6610                outputs,
6611                ..
6612            } => {
6613                assert_eq!(*axis, 1, "expected axis 1 for boundary at index 1");
6614                assert_eq!(input.name, "input");
6615                assert_eq!(outputs.len(), 2);
6616                assert_eq!(outputs[0].name, "out_a");
6617                assert_eq!(outputs[1].name, "out_b");
6618            }
6619            _ => panic!("expected Split pattern, got {pattern:?}"),
6620        }
6621    }
6622
6623    #[test]
6624    fn classify_split_axis_2_inferred() {
6625        // Split with boundary at index 2 → axis = 2.
6626        let module = make_split_module(2, &["N", "C", "W1", "W2"]);
6627        let pattern = classify_entry_point(&module, 0).unwrap();
6628        match &pattern {
6629            KernelPattern::Split { axis, .. } => {
6630                assert_eq!(*axis, 2, "expected axis 2 for boundary at index 2");
6631            }
6632            _ => panic!("expected Split pattern, got {pattern:?}"),
6633        }
6634    }
6635
6636    #[test]
6637    fn classify_split_axis_3_inferred() {
6638        // Split with boundary at index 3 → axis = 3 (4D shape: N, C, H, W).
6639        let module = make_split_module(3, &["N", "C", "H", "W1", "W2"]);
6640        let pattern = classify_entry_point(&module, 0).unwrap();
6641        match &pattern {
6642            KernelPattern::Split { axis, .. } => {
6643                assert_eq!(*axis, 3, "expected axis 3 for boundary at index 3");
6644            }
6645            _ => panic!("expected Split pattern, got {pattern:?}"),
6646        }
6647    }
6648
6649    #[test]
6650    fn normalize_axis_positive() {
6651        assert_eq!(normalize_axis(0, 3), 0);
6652        assert_eq!(normalize_axis(1, 4), 1);
6653        assert_eq!(normalize_axis(2, 4), 2);
6654    }
6655
6656    #[test]
6657    fn normalize_axis_negative() {
6658        assert_eq!(normalize_axis(-1, 4), 3);
6659        assert_eq!(normalize_axis(-2, 4), 2);
6660        assert_eq!(normalize_axis(-3, 4), 1);
6661        assert_eq!(normalize_axis(-4, 4), 0);
6662    }
6663
6664    #[test]
6665    fn normalize_axis_negative_wraps() {
6666        // -1 with ndim 3 → 2
6667        assert_eq!(normalize_axis(-1, 3), 2);
6668        // -2 with ndim 3 → 1
6669        assert_eq!(normalize_axis(-2, 3), 1);
6670    }
6671
6672    #[test]
6673    fn find_if_comparison_axis_direct_access_index() {
6674        // Test that find_if_comparison_axis works with direct AccessIndex
6675        // (without Load wrapper).
6676        let mut func = Function::new("test");
6677        let idx = func
6678            .expressions
6679            .append(Expression::Literal(Literal::U32(0)));
6680        let base = func
6681            .expressions
6682            .append(Expression::Literal(Literal::U32(0)));
6683        let access = func
6684            .expressions
6685            .append(Expression::AccessIndex { base, index: 2 });
6686        let cmp = func.expressions.append(Expression::Binary {
6687            op: BinaryOp::Less,
6688            left: idx,
6689            right: access,
6690        });
6691
6692        let body = vec![Statement::If {
6693            condition: cmp,
6694            accept: vec![],
6695            reject: vec![],
6696        }];
6697        let names: Vec<String> = vec!["N".into(), "C".into(), "W".into()];
6698        let result = find_if_comparison_axis(&body, &func.expressions, &names);
6699        assert_eq!(result, Some(2));
6700    }
6701
6702    #[test]
6703    fn find_if_comparison_axis_in_loop() {
6704        // Test that the function searches into Loop bodies.
6705        let mut func = Function::new("test");
6706        let idx = func
6707            .expressions
6708            .append(Expression::Literal(Literal::U32(0)));
6709        let base = func
6710            .expressions
6711            .append(Expression::Literal(Literal::U32(0)));
6712        let access = func
6713            .expressions
6714            .append(Expression::AccessIndex { base, index: 1 });
6715        let load = func
6716            .expressions
6717            .append(Expression::Load { pointer: access });
6718        let cmp = func.expressions.append(Expression::Binary {
6719            op: BinaryOp::LessEqual,
6720            left: idx,
6721            right: load,
6722        });
6723
6724        let body = vec![Statement::Loop {
6725            body: vec![Statement::If {
6726                condition: cmp,
6727                accept: vec![],
6728                reject: vec![],
6729            }],
6730            continuing: vec![],
6731            break_if: None,
6732        }];
6733        let names: Vec<String> = vec!["N".into(), "C".into()];
6734        let result = find_if_comparison_axis(&body, &func.expressions, &names);
6735        assert_eq!(result, Some(1));
6736    }
6737
6738    #[test]
6739    fn find_if_comparison_axis_no_if_returns_none() {
6740        let func = Function::new("test");
6741        let body = vec![Statement::Break];
6742        let names: Vec<String> = vec!["N".into()];
6743        let result = find_if_comparison_axis(&body, &func.expressions, &names);
6744        assert_eq!(result, None);
6745    }
6746
6747    /// A params struct that mixes dimensions with a tolerance constant is the
6748    /// normal case, not an edge one: LayerNorm's is (N, D, eps), and counting
6749    /// eps as a third dimension is what made it report as BatchNorm.
6750    #[test]
6751    fn eps_is_not_a_dimension() {
6752        let mut module = Module::default();
6753        let u32_ty = module.types.insert(Type {
6754            name: None,
6755            inner: TypeInner::Scalar(Scalar::U32),
6756        });
6757        let f32_ty = module.types.insert(Type {
6758            name: None,
6759            inner: TypeInner::Scalar(Scalar::F32),
6760        });
6761        let members = [
6762            StructMember {
6763                name: Some("N".into()),
6764                ty: u32_ty,
6765                offset: 0,
6766            },
6767            StructMember {
6768                name: Some("D".into()),
6769                ty: u32_ty,
6770                offset: 4,
6771            },
6772            StructMember {
6773                name: Some("eps".into()),
6774                ty: f32_ty,
6775                offset: 8,
6776            },
6777        ];
6778        let dims: Vec<String> = members
6779            .iter()
6780            .filter(|m| is_integer_member(&module, m))
6781            .filter_map(|m| m.name.clone())
6782            .collect();
6783        assert_eq!(dims, vec!["N".to_string(), "D".to_string()]);
6784    }
6785
6786    // --- Multi-head & causal attention classification tests ---
6787
6788    /// Build a module with 3 inputs (query, key, value), 1 output, a uniform
6789    /// params struct with the given member names, Loop + Exp + Sqrt in the
6790    /// function expressions, and optionally a division-by-literal and/or a
6791    /// causal If-guard inside the loop body.
6792    fn make_attention_module(param_names: &[&str], divide_by: Option<u32>, causal: bool) -> Module {
6793        let mut module = Module::default();
6794
6795        let f32_ty = module.types.insert(Type {
6796            name: None,
6797            inner: TypeInner::Scalar(Scalar::F32),
6798        });
6799        let u32_ty = module.types.insert(Type {
6800            name: None,
6801            inner: TypeInner::Scalar(Scalar::U32),
6802        });
6803        let array_f32 = module.types.insert(Type {
6804            name: None,
6805            inner: TypeInner::Array {
6806                base: f32_ty,
6807                size: ArraySize::Dynamic,
6808                stride: 4,
6809            },
6810        });
6811
6812        // Build params struct from provided member names
6813        let members: Vec<StructMember> = param_names
6814            .iter()
6815            .enumerate()
6816            .map(|(i, name)| StructMember {
6817                name: Some((*name).into()),
6818                ty: u32_ty,
6819                offset: (i * 4) as u32,
6820            })
6821            .collect();
6822        let params_ty = module.types.insert(Type {
6823            name: Some("Params".into()),
6824            inner: TypeInner::Struct {
6825                members,
6826                span: (param_names.len() * 4) as u32,
6827            },
6828        });
6829
6830        // 3 storage-read inputs (query, key, value)
6831        for (i, name) in ["query", "key", "value"].iter().enumerate() {
6832            module.global_variables.append(GlobalVariable {
6833                name: Some((*name).into()),
6834                space: AddressSpace::Storage {
6835                    access: StorageAccess::LOAD,
6836                },
6837                binding: Some(ResourceBinding {
6838                    group: 0,
6839                    binding: i as u32,
6840                }),
6841                ty: array_f32,
6842                init: None,
6843                layout: None,
6844            });
6845        }
6846
6847        // 1 storage-read_write output
6848        module.global_variables.append(GlobalVariable {
6849            name: Some("output".into()),
6850            space: AddressSpace::Storage {
6851                access: StorageAccess::LOAD | StorageAccess::STORE,
6852            },
6853            binding: Some(ResourceBinding {
6854                group: 0,
6855                binding: 3,
6856            }),
6857            ty: array_f32,
6858            init: None,
6859            layout: None,
6860        });
6861
6862        // Uniform params
6863        module.global_variables.append(GlobalVariable {
6864            name: Some("params".into()),
6865            space: AddressSpace::Uniform,
6866            binding: Some(ResourceBinding {
6867                group: 0,
6868                binding: 4,
6869            }),
6870            ty: params_ty,
6871            init: None,
6872            layout: None,
6873        });
6874
6875        // Build function with Exp, Sqrt, and optionally Division + causal If
6876        let mut func = Function::new("main");
6877
6878        // Exp expression
6879        let arg_exp = func
6880            .expressions
6881            .append(Expression::Literal(Literal::F32(0.0)));
6882        func.expressions.append(Expression::Math {
6883            fun: MathFunction::Exp,
6884            arg: arg_exp,
6885            arg1: None,
6886            arg2: None,
6887            arg3: None,
6888        });
6889
6890        // Sqrt expression
6891        let arg_sqrt = func
6892            .expressions
6893            .append(Expression::Literal(Literal::F32(1.0)));
6894        func.expressions.append(Expression::Math {
6895            fun: MathFunction::Sqrt,
6896            arg: arg_sqrt,
6897            arg1: None,
6898            arg2: None,
6899            arg3: None,
6900        });
6901
6902        // Optional: division by literal (e.g. d_model / num_heads)
6903        if let Some(n) = divide_by {
6904            let dividend = func
6905                .expressions
6906                .append(Expression::Literal(Literal::U32(64)));
6907            let divisor = func
6908                .expressions
6909                .append(Expression::Literal(Literal::U32(n)));
6910            func.expressions.append(Expression::Binary {
6911                op: BinaryOp::Divide,
6912                left: dividend,
6913                right: divisor,
6914            });
6915        }
6916
6917        // Build loop body contents
6918        let mut loop_inner: Vec<Statement> = Vec::new();
6919
6920        if causal {
6921            // Causal mask pattern: If(j > i) { score = -1e30; }
6922            // First, add a comparison expression: Greater(j, i)
6923            let j_expr = func
6924                .expressions
6925                .append(Expression::Literal(Literal::U32(0)));
6926            let i_expr = func
6927                .expressions
6928                .append(Expression::Literal(Literal::U32(0)));
6929            let cmp = func.expressions.append(Expression::Binary {
6930                op: BinaryOp::Greater,
6931                left: j_expr,
6932                right: i_expr,
6933            });
6934
6935            // Store of large negative literal
6936            let neg_val = func
6937                .expressions
6938                .append(Expression::Literal(Literal::F32(-1e30)));
6939            let ptr = func
6940                .expressions
6941                .append(Expression::Literal(Literal::F32(0.0)));
6942
6943            // The for-loop break guard (first If in naga loop)
6944            let break_cond = func
6945                .expressions
6946                .append(Expression::Literal(Literal::Bool(true)));
6947            loop_inner.push(Statement::If {
6948                condition: break_cond,
6949                accept: vec![],
6950                reject: vec![Statement::Break],
6951            });
6952
6953            // The causal If guard (second If in the loop)
6954            loop_inner.push(Statement::If {
6955                condition: cmp,
6956                accept: vec![Statement::Store {
6957                    pointer: ptr,
6958                    value: neg_val,
6959                }],
6960                reject: vec![],
6961            });
6962        }
6963
6964        loop_inner.push(Statement::Break);
6965
6966        func.body.push(Statement::Loop {
6967            body: loop_inner,
6968            continuing: vec![],
6969            break_if: None,
6970        });
6971
6972        module.entry_points.push(EntryPoint {
6973            name: "main".into(),
6974            workgroup_size: [16, 16, 1],
6975            function: func,
6976        });
6977
6978        module
6979    }
6980
6981    #[test]
6982    fn classify_multihead_4_heads() {
6983        let module = make_attention_module(&["seq_len", "d_model", "num_heads"], Some(4), false);
6984        let pattern = classify_entry_point(&module, 0).unwrap();
6985        match &pattern {
6986            KernelPattern::Attention {
6987                num_heads,
6988                num_kv_heads,
6989                causal,
6990                seq_len,
6991                ..
6992            } => {
6993                assert_eq!(*num_heads, 4, "expected 4 heads from division literal");
6994                assert_eq!(
6995                    *num_kv_heads, 4,
6996                    "num_kv_heads should equal num_heads for MHA"
6997                );
6998                assert!(!causal, "should not detect causal mask");
6999                assert_eq!(seq_len, "seq_len");
7000            }
7001            _ => panic!("expected Attention pattern, got {pattern:?}"),
7002        }
7003        // Display should include heads=4 and no causal marker
7004        let display = format!("{pattern}");
7005        assert!(
7006            display.contains("heads=4"),
7007            "display should show heads=4: {display}"
7008        );
7009        assert!(
7010            !display.contains("causal"),
7011            "display should not mention causal: {display}"
7012        );
7013    }
7014
7015    #[test]
7016    fn classify_causal_attention() {
7017        let module = make_attention_module(&["seq_len", "d_k"], None, true);
7018        let pattern = classify_entry_point(&module, 0).unwrap();
7019        match &pattern {
7020            KernelPattern::Attention {
7021                num_heads,
7022                causal,
7023                d_k,
7024                ..
7025            } => {
7026                assert_eq!(*num_heads, 1, "no num_heads param → default 1");
7027                assert!(*causal, "should detect causal mask from If + Store(-1e30)");
7028                assert_eq!(d_k, "d_k");
7029            }
7030            _ => panic!("expected Attention pattern, got {pattern:?}"),
7031        }
7032        // Display should include causal marker
7033        let display = format!("{pattern}");
7034        assert!(
7035            display.contains("causal"),
7036            "display should mention causal: {display}"
7037        );
7038        assert!(
7039            display.contains("heads=1"),
7040            "display should show heads=1: {display}"
7041        );
7042    }
7043
7044    #[test]
7045    fn classify_gqa_defaults_to_mha() {
7046        // GQA detection is not yet implemented; num_kv_heads always equals num_heads.
7047        let module = make_attention_module(&["seq_len", "d_model", "num_heads"], Some(4), false);
7048        let pattern = classify_entry_point(&module, 0).unwrap();
7049        match &pattern {
7050            KernelPattern::Attention {
7051                num_heads,
7052                num_kv_heads,
7053                ..
7054            } => {
7055                assert_eq!(*num_heads, 4);
7056                assert_eq!(
7057                    *num_kv_heads, *num_heads,
7058                    "GQA not implemented: kv_heads defaults to num_heads"
7059                );
7060            }
7061            _ => panic!("expected Attention pattern, got {pattern:?}"),
7062        }
7063    }
7064}