Skip to main content

rlx_wgpu/backend/
mod.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! `WgpuExecutable` — compiles an rlx-ir Graph into a sequence of
17//! kernel dispatches against a pre-allocated arena buffer.
18//!
19//! v2 op coverage: MatMul + element-wise families (Binary 7, Unary 12,
20//! Compare 6, Where) + leaves. Anything else panics at compile time.
21
22use std::collections::{HashMap, HashSet};
23use std::num::NonZeroU64;
24
25use rlx_ir::dynamic::{bind_graph, infer_bindings_from_f32_inputs, same_binding};
26use rlx_ir::op::{Activation, BinaryOp, CmpOp, ReduceOp};
27use rlx_ir::shape::DimBinding;
28use rlx_ir::{Graph, NodeId, Op};
29
30use crate::buffer::{Arena, ReadbackStaging, TinyReadbackStaging};
31use crate::device::wgpu_device;
32use crate::kernels::{
33    ArgmaxParams, AttentionBwdParams, AttentionParams, BatchElementwiseRegionParams, BinaryParams,
34    Conv1dParams, Conv2dParams, Conv3dParams, CopyParams, CumsumBwdParams, CumsumParams,
35    DequantMatmulParams, ElementwiseRegionParams, ExpandParams, FmaParams, FusedResidualLnParams,
36    FusedResidualLnTeeParams, FusedResidualRmsNormParams, GatherAxisParams, GatherBwdParams,
37    GatherParams, GroupedMatmulParams, GruParams, Im2Col2dParams, Kernel, LayerNormBwdParams,
38    LayerNormParams, Mamba2Params, MatmulQkvParams, NarrowConcatParams, Pool1dParams, Pool2dParams,
39    Pool3dParams, ReduceParams, RmsNormBwdParams, RnnParams, RopeBwdParams, RopeParams,
40    SampleParams, ScatterAddParams, SceParams, SelectiveScanParams, SoftmaxParams, TopKParams,
41    TransposeParams, UmapKnnParams, UnaryParams, WelchPeaksGpuParams, WhereParams,
42    gather_split_kernel, matmul_coop_f16_vulkan_kernel,
43};
44/// Compute the maximum tail-scratch bytes any single op needs across
45/// the graph. Currently only `Op::LayerNormBackwardGamma` uses scratch
46/// — it stores `num_workgroups * H` f32 partial sums.
47fn compute_scratch_bytes(graph: &rlx_ir::Graph) -> usize {
48    const ROWS_PER_WG: u32 = 16;
49    let mut max_bytes = 0usize;
50    for node in graph.nodes() {
51        // Norm staging: when params live far from activations in the arena,
52        // wgpu's `max_storage_buffer_binding_size` can prevent binding a
53        // single window that covers both. We reserve a small scratch tail
54        // zone so we can copy gamma/beta next to activations via
55        // `copy_buffer_to_buffer` and keep shader bindings local.
56        if matches!(
57            &node.op,
58            rlx_ir::Op::LayerNorm { .. } | rlx_ir::Op::RmsNorm { .. }
59        ) {
60            let x_shape = &graph.node(node.inputs[0]).shape;
61            let h_dim = x_shape.dim(x_shape.rank() - 1);
62            if h_dim.is_static() {
63                let h = h_dim.unwrap_static();
64                // gamma + beta, 256B-aligned for binding offsets.
65                let bytes = ((h * 4).div_ceil(256) * 256) * 2;
66                if bytes > max_bytes {
67                    max_bytes = bytes;
68                }
69            }
70        }
71        if let rlx_ir::Op::LayerNormBackwardGamma { .. } = &node.op {
72            let x_shape = &graph.node(node.inputs[0]).shape;
73            let Some(elems) = x_shape.num_elements() else {
74                continue;
75            };
76            let h_dim = x_shape.dim(x_shape.rank() - 1);
77            if !h_dim.is_static() {
78                continue;
79            }
80            let h = h_dim.unwrap_static();
81            if h == 0 {
82                continue;
83            }
84            let rows = (elems / h) as u32;
85            let num_workgroups = rows.div_ceil(ROWS_PER_WG.max(1));
86            let bytes = (num_workgroups as usize) * h * 4;
87            if bytes > max_bytes {
88                max_bytes = bytes;
89            }
90        }
91    }
92    // Reserve extra scratch for staging small far-apart operands when the
93    // arena exceeds wgpu's binding window. This keeps compile-time simple
94    // and avoids per-op scratch sizing plumbing.
95    max_bytes.max(64 * 1024 * 1024)
96}
97
98/// Default routing thresholds for the im2col+GEMM conv path. im2col only wins
99/// when the GEMM amortizes the `col` materialization: the compute/col-write
100/// ratio is `c_out`, so a large output-channel count (`min_cout`) is the key
101/// gate, plus a real reduction depth (`min_k = c_in*kh*kw`) and a wide enough
102/// spatial extent (`min_spatial`). Small skinny 1D convs (few channels) are
103/// left on the direct `conv2d.wgsl` kernel, which is faster there. All three
104/// are overridable via env for tuning (`RLX_WGPU_IM2COL_MIN_{COUT,K,SPATIAL}`).
105const CONV_IM2COL_MIN_SPATIAL: u64 = 2048;
106const CONV_IM2COL_MIN_K: u64 = 256;
107const CONV_IM2COL_MIN_COUT: u64 = 64;
108
109/// Hard cap on the col scratch matrix (bytes). Convs whose col would exceed
110/// this fall back to the direct conv kernel (keeps the arena well under the
111/// 4 GiB storage-binding limit).
112const CONV_IM2COL_MAX_COL_BYTES: u64 = 1024 * 1024 * 1024;
113
114fn im2col_min_spatial() -> u64 {
115    rlx_ir::env::parse_or("RLX_WGPU_IM2COL_MIN_SPATIAL", CONV_IM2COL_MIN_SPATIAL)
116}
117fn im2col_min_k() -> u64 {
118    rlx_ir::env::parse_or("RLX_WGPU_IM2COL_MIN_K", CONV_IM2COL_MIN_K)
119}
120fn im2col_min_cout() -> u64 {
121    rlx_ir::env::parse_or("RLX_WGPU_IM2COL_MIN_COUT", CONV_IM2COL_MIN_COUT)
122}
123
124/// Minimum output length for routing a `one_d` conv through the register-blocked
125/// `conv1d_tiled` kernel. Short convs stay on the direct kernel (the tile setup
126/// isn't worth it). Overridable via `RLX_WGPU_TILED_MIN_SPATIAL`.
127const CONV_TILED_MIN_SPATIAL: u64 = 256;
128fn conv_tiled_min_spatial() -> u64 {
129    rlx_ir::env::parse_or("RLX_WGPU_TILED_MIN_SPATIAL", CONV_TILED_MIN_SPATIAL)
130}
131
132/// Element count of the im2col `col[c_in*kh*kw, spatial]` matrix if this conv
133/// node qualifies for the im2col+GEMM path (2D / 1D-as-2D NCHW, `N==1`,
134/// `groups==1`, real spatial kernel, spatial ≥ [`CONV_IM2COL_MIN_SPATIAL`]).
135/// Returns `None` otherwise. Mirrors the shape math in the `Op::Conv` compile
136/// arm so scratch sizing and lowering agree on which convs are routed.
137fn conv_im2col_col_elems(
138    op: &Op,
139    in_shape: &rlx_ir::Shape,
140    w_shape: &rlx_ir::Shape,
141    out_shape: &rlx_ir::Shape,
142) -> Option<u64> {
143    let Op::Conv {
144        kernel_size,
145        groups,
146        ..
147    } = op
148    else {
149        return None;
150    };
151    if *groups != 1 {
152        return None;
153    }
154    if !(kernel_size.len() == 2
155        && in_shape.rank() == 4
156        && w_shape.rank() == 4
157        && out_shape.rank() == 4)
158    {
159        return None;
160    }
161    for d in [
162        in_shape.dim(0),
163        in_shape.dim(1),
164        in_shape.dim(2),
165        in_shape.dim(3),
166    ] {
167        if !d.is_static() {
168            return None;
169        }
170    }
171    if !out_shape.dim(2).is_static() || !out_shape.dim(3).is_static() {
172        return None;
173    }
174    if !out_shape.dim(1).is_static() {
175        return None;
176    }
177    if in_shape.dim(0).unwrap_static() != 1 {
178        return None;
179    }
180    let c_in = in_shape.dim(1).unwrap_static() as u64;
181    let c_out = out_shape.dim(1).unwrap_static() as u64;
182    let h_in = in_shape.dim(2).unwrap_static() as u32;
183    let w_in = in_shape.dim(3).unwrap_static() as u32;
184    let one_d = h_in == 1
185        && w_in > 1
186        && kernel_size[0] > 1
187        && kernel_size.get(1).copied().unwrap_or(1) == 1;
188    let (kh, kw, spatial) = if one_d {
189        (
190            kernel_size[0] as u64,
191            1u64,
192            out_shape.dim(3).unwrap_static() as u64,
193        )
194    } else {
195        (
196            kernel_size[0] as u64,
197            kernel_size.get(1).copied().unwrap_or(1) as u64,
198            out_shape.dim(2).unwrap_static() as u64 * out_shape.dim(3).unwrap_static() as u64,
199        )
200    };
201    let k_total = c_in * kh * kw;
202    if kh * kw < 2 {
203        return None;
204    }
205    if spatial < im2col_min_spatial() || k_total < im2col_min_k() || c_out < im2col_min_cout() {
206        return None;
207    }
208    Some(k_total * spatial)
209}
210
211/// Extra arena scratch (bytes, 256-aligned) needed to hold the largest conv
212/// col matrix that will be routed through im2col+GEMM. Capped so the whole
213/// arena still binds in a single storage-binding window. Returns 0 when no
214/// conv qualifies or none fits.
215fn conv_im2col_scratch_bytes(graph: &Graph, planned_arena_size: usize, max_binding: u64) -> usize {
216    let mut max_col_bytes: u64 = 0;
217    for node in graph.nodes() {
218        if node.inputs.len() < 2 {
219            continue;
220        }
221        let in_shape = &graph.node(node.inputs[0]).shape;
222        let w_shape = &graph.node(node.inputs[1]).shape;
223        let Some(elems) = conv_im2col_col_elems(&node.op, in_shape, w_shape, &node.shape) else {
224            continue;
225        };
226        let col_bytes = elems.saturating_mul(4);
227        if col_bytes > CONV_IM2COL_MAX_COL_BYTES {
228            continue;
229        }
230        if (planned_arena_size as u64).saturating_add(col_bytes) > max_binding {
231            continue;
232        }
233        max_col_bytes = max_col_bytes.max(col_bytes);
234    }
235    (max_col_bytes.div_ceil(256) * 256) as usize
236}
237
238/// FNV-1a over f32 payload bytes — skips redundant `queue.write_buffer`
239/// when bench/inference feeds identical input tensors across runs.
240fn hash_f32_input(data: &[f32]) -> u64 {
241    let bytes = bytemuck::cast_slice(data);
242    let mut h: u64 = 0xcbf29ce484222325;
243    h ^= data.len() as u64;
244    h = h.wrapping_mul(0x100000001b3);
245    for chunk in bytes.chunks(8) {
246        let mut arr = [0u8; 8];
247        arr[..chunk.len()].copy_from_slice(chunk);
248        h ^= u64::from_le_bytes(arr);
249        h = h.wrapping_mul(0x100000001b3);
250    }
251    h
252}
253
254/// Inner-FMA precision for matmul.
255///   F32    — full f32 path (matmul.wgsl / matmul_wide.wgsl).
256///   F16    — f16 multiply, f32 acc (matmul_f16_compute.wgsl).
257///   Coop16 — cooperative-matrix 8×8 hardware GEMM
258///            (matmul_coop16.wgsl, simdgroup_multiply_accumulate on
259///             Apple, OpCooperativeMatrixMulAddKHR on Vulkan).
260///            Requires M/N/K multiples of 8, b is a Param, and
261///            both SHADER_F16 + EXPERIMENTAL_COOPERATIVE_MATRIX.
262///            Caller must ensure A is mirrored to arena_f16 first
263///            (the lowering inserts a `Step::CastF32ToF16` pre-pass).
264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
265enum MatmulCompute {
266    F32,
267    F16,
268    Coop16,
269    /// Cooperative-matrix on Apple's `simdgroup_float8x8` — same hardware
270    /// GEMM unit as Coop16 but with f32 operands and f32 accumulator.
271    /// No precision loss vs F32 baseline; no f16 overflow risk in deep
272    /// FFN sums. Used when alignment + features allow but the IR is f32.
273    CoopF32,
274    /// Vulkan/NVIDIA 16×16 f16 tensor-core matmul with K-slab f32
275    /// reduction (avoids Naga mixed f16/f32 coop_mat bugs).
276    CoopF16Vk,
277}
278
279/// Split-write QKV matmul kernel selection.
280#[derive(Debug, Clone, Copy, PartialEq, Eq)]
281enum MatmulQkvKind {
282    F32,
283    CoopF32,
284    CoopF16Vk,
285}
286
287/// f32 → f16 element-wise cast, mirroring an arena region into the
288/// f16 shadow buffer. Used as a pre-pass before `matmul_coop16` so
289/// the matmul's A operand (a runtime activation, not a Param) is
290/// readable as f16.
291///
292/// Currently unused — the matmul_coop16 kernel stages A through
293/// workgroup-shared memory directly from the f32 arena. Kept for
294/// future paths that may want a one-shot cast (e.g. before a chain
295/// of f16-only kernels operating on a fixed activation region).
296#[allow(dead_code)]
297#[derive(Debug, Clone, Copy)]
298struct CastF32ToF16Params {
299    pub src_off: u32, // f32-element offset into arena (also f16-element offset)
300    pub len: u32,
301    pub _p0: u32,
302    pub _p1: u32,
303}
304unsafe impl bytemuck::Pod for CastF32ToF16Params {}
305unsafe impl bytemuck::Zeroable for CastF32ToF16Params {}
306
307/// One dispatch step in the compiled schedule.
308///
309/// `dead_code` is allowed at the enum level: several variants carry
310/// fields (mask_buf, meta_idx, compute_precision discriminants) that
311/// are only consulted at compile time during bind-group construction,
312/// or are kept to extend buffer lifetimes (mask_buf). A few variants
313/// (CastF32ToF16, Copy, the unreachable F16 compute_precision) are
314/// retained for future paths.
315#[allow(dead_code)]
316enum Step {
317    CastF32ToF16 {
318        params: CastF32ToF16Params,
319    },
320    Matmul {
321        m: u32,
322        k: u32,
323        n: u32,
324        a_off_f32: u32,
325        b_off_f32: u32,
326        c_off_f32: u32,
327        batch: u32,
328        a_batch_stride: u32,
329        b_batch_stride: u32,
330        c_batch_stride: u32,
331        has_bias: u32,
332        bias_off_f32: u32,
333        act_id: u32, // 0xFFFF = no activation
334        // True iff input B is a Param node — i.e. a model weight that
335        // doesn't change between `run()` calls. Read from the f16
336        // shadow buffer (half memory bandwidth) when set + the device
337        // exposes SHADER_F16. Set at compile time; consulted only by
338        // the dispatch arm.
339        b_is_param: bool,
340        // Compute precision for the inner FMA. F32 = full precision
341        // (the historical / default path). F16 = mixed-precision
342        // (operands cast to f16, multiply in f16 for 2× ALU on Apple,
343        // accumulator in f32). Set at compile time from the IR's
344        // dtype after AutoMixedPrecision policy.
345        compute_precision: MatmulCompute,
346    },
347    Binary {
348        params: BinaryParams,
349    },
350    Compare {
351        params: BinaryParams,
352    },
353    Unary {
354        params: UnaryParams,
355        f16_mirror: bool,
356    },
357    Where {
358        params: WhereParams,
359    },
360    Fma {
361        params: FmaParams,
362    },
363    Reduce {
364        params: ReduceParams,
365    },
366    Softmax {
367        params: SoftmaxParams,
368    },
369    SoftmaxCrossEntropy {
370        params: SceParams,
371    },
372    LayerNorm {
373        params: LayerNormParams,
374    },
375    Cumsum {
376        params: CumsumParams,
377    },
378    /// Native multi-kernel f32 FFT (gpu-fft dispatch strategy).
379    FftGpu {
380        src_off: u32,
381        dst_off: u32,
382        outer: u32,
383        n: u32,
384        inverse: u32,
385        norm_scale: f32,
386    },
387    /// Explicit host FFT (D2H → rlx-cpu → H2D). Used when the native
388    /// WGSL kernel cannot handle dtype / size / non-pow-2 constraints.
389    FftHost {
390        src_byte_off: u32,
391        dst_byte_off: u32,
392        outer: u32,
393        n_complex: u32,
394        inverse: bool,
395        norm_tag: u32,
396        dtype_tag: u32,
397    },
398    /// General `Op::Scan` recurrence — D2H the input span → run the compiled
399    /// body loop on the CPU → H2D. Enables IIR (`biquad`/`sosfilt`) on wgpu.
400    ScanHost {
401        plan: std::sync::Arc<rlx_cpu::thunk::ScanBodyPlan>,
402        outer_init_off: usize,
403        outer_final_off: usize,
404        length: u32,
405        save_trajectory: bool,
406        xs_outer: Vec<(usize, usize)>,
407        bcast_outer: Vec<(usize, usize)>,
408    },
409    /// Welch PSD top-K — D2H → rlx-cpu → H2D.
410    WelchPeaksHost {
411        spec_byte_off: u32,
412        dst_byte_off: u32,
413        welch_batch: u32,
414        n_fft: u32,
415        n_segments: u32,
416        k: u32,
417    },
418    LogMelHost {
419        spec_byte_off: u32,
420        filt_byte_off: u32,
421        dst_byte_off: u32,
422        outer: u32,
423        n_fft: u32,
424        n_bins: u32,
425        n_mels: u32,
426    },
427    LogMelBackwardHost {
428        spec_byte_off: u32,
429        filt_byte_off: u32,
430        dy_byte_off: u32,
431        dst_byte_off: u32,
432        outer: u32,
433        n_fft: u32,
434        n_bins: u32,
435        n_mels: u32,
436    },
437    /// NCHW im2col host path (D2H → rlx-cpu → H2D).
438    Im2ColHost {
439        x_byte_off: u32,
440        col_byte_off: u32,
441        n: u32,
442        c_in: u32,
443        h: u32,
444        w: u32,
445        h_out: u32,
446        w_out: u32,
447        kh: u32,
448        kw: u32,
449        sh: u32,
450        sw: u32,
451        ph: u32,
452        pw: u32,
453        dh: u32,
454        dw_dil: u32,
455    },
456    /// Host fill for [`Op::RngNormal`] (fill → H2D).
457    RngNormalHost {
458        dst_byte_off: u32,
459        len: u32,
460        mean: f32,
461        scale: f32,
462        key: u64,
463        op_seed: Option<f32>,
464    },
465    /// Host fill for [`Op::RngUniform`] (fill → H2D).
466    RngUniformHost {
467        dst_byte_off: u32,
468        len: u32,
469        low: f32,
470        high: f32,
471        key: u64,
472        op_seed: Option<f32>,
473    },
474    /// Host-side buffer copy (recorded into a command encoder) used to
475    /// stage small param tensors into the tail scratch region so kernels
476    /// can bind a ≤4GiB window of the arena.
477    BufferCopy {
478        // u64: staging copies for >4 GiB GGUF decode arenas address tensors past
479        // the 4 GiB mark; a u32 byte offset truncates and stages garbage.
480        src_byte_off: u64,
481        dst_byte_off: u64,
482        bytes: u32,
483    },
484    Copy {
485        params: CopyParams,
486    },
487    /// PLAN L2 — fused N-ary element-wise region. Lowered from
488    /// `Op::ElementwiseRegion` by `MarkElementwiseRegions`. Kernel
489    /// interprets the chain encoding per-element (saves N kernel
490    /// dispatches + N global-memory round-trips vs the decomposed
491    /// atomic ops).
492    ElementwiseRegion {
493        params: ElementwiseRegionParams,
494    },
495    BatchElementwiseRegion {
496        params: BatchElementwiseRegionParams,
497    },
498    Transpose {
499        params: TransposeParams,
500        meta_idx: usize,
501    },
502    Narrow {
503        params: NarrowConcatParams,
504    },
505    Concat {
506        params: NarrowConcatParams,
507    }, // one Step per input
508    Gather {
509        params: GatherParams,
510    },
511    GatherAxis {
512        params: GatherAxisParams,
513    },
514    Attention {
515        params: AttentionParams,
516        mask_buf: Option<wgpu::Buffer>,
517    },
518    AttentionBackward {
519        params: AttentionBwdParams,
520        mask_buf: Option<wgpu::Buffer>,
521    },
522    Rope {
523        params: RopeParams,
524    },
525    Expand {
526        params: ExpandParams,
527        meta_idx: usize,
528    },
529    Argmax {
530        params: ArgmaxParams,
531    },
532    Pool2d {
533        params: Pool2dParams,
534    },
535    Conv2d {
536        params: Conv2dParams,
537    },
538    /// GPU im2col: gather a conv's receptive fields into a `col` matrix in the
539    /// arena scratch tail. Always immediately followed by a `Step::Matmul`
540    /// (weight @ col → NCHW output) that reuses the tiled f32 GEMM kernels.
541    Im2ColGpu {
542        params: Im2Col2dParams,
543    },
544    /// 2D register-blocked 1D conv (`conv1d_tiled.wgsl`) — same `Conv2dParams`
545    /// as `Conv2d` but a `TCO×TL` output tile per thread that reuses the input
546    /// across output channels. Used for the `one_d` (kw==1) vocoder convs.
547    Conv2dTiled {
548        params: Conv2dParams,
549    },
550    Pool1d {
551        params: Pool1dParams,
552    },
553    Pool3d {
554        params: Pool3dParams,
555    },
556    Conv1d {
557        params: Conv1dParams,
558    },
559    Conv3d {
560        params: Conv3dParams,
561    },
562    ScatterAdd {
563        params: ScatterAddParams,
564    },
565    TopK {
566        params: TopKParams,
567    },
568    WelchPeaksGpu {
569        params: WelchPeaksGpuParams,
570    },
571    GroupedMatmul {
572        params: GroupedMatmulParams,
573    },
574    Sample {
575        params: SampleParams,
576    },
577    SelectiveScan {
578        params: SelectiveScanParams,
579    },
580    /// Native WGSL Mamba-2 SSD scan (state_size ≤ 256).
581    Mamba2 {
582        params: Mamba2Params,
583    },
584    /// Native WGSL GRU (single-layer/unidir/no-carry, hidden ≤ 256).
585    Gru {
586        params: GruParams,
587    },
588    /// Native WGSL Elman RNN (single-layer/unidir/no-carry, hidden ≤ 256).
589    Rnn {
590        params: RnnParams,
591    },
592    /// Host-staged GRU fallback (multi-layer / bidir / carry / hidden > 256).
593    GruHost {
594        x: u32,
595        w_ih: u32,
596        w_hh: u32,
597        b_ih: u32,
598        b_hh: u32,
599        h0: u32,
600        dst: u32,
601        batch: u32,
602        seq: u32,
603        input_size: u32,
604        hidden: u32,
605        num_layers: u32,
606        bidirectional: bool,
607        carry: bool,
608    },
609    /// Host-staged Elman RNN fallback.
610    RnnHost {
611        x: u32,
612        w_ih: u32,
613        w_hh: u32,
614        bias: u32,
615        h0: u32,
616        dst: u32,
617        batch: u32,
618        seq: u32,
619        input_size: u32,
620        hidden: u32,
621        num_layers: u32,
622        bidirectional: bool,
623        carry: bool,
624        relu: bool,
625    },
626    DequantMatmul {
627        params: DequantMatmulParams,
628    },
629    /// Split-binding embedding gather for >4 GiB arenas. The table and the
630    /// idx/output slots are more than one ≤4 GiB binding window apart, so the
631    /// single-arena-binding `Step::Gather` cannot reach the output. Runs as a
632    /// host segment (its own submission + copy-back), like `DequantMatmulGguf`.
633    /// BYTE offsets are u64 (arena exceeds 4 GiB).
634    GatherSplit {
635        n_out: u32,
636        n_idx: u32,
637        dim: u32,
638        vocab: u32,
639        table_byte_off: u64,
640        idx_byte_off: u64,
641        out_byte_off: u64,
642    },
643    /// GGUF K-quant — host fused dequant+matmul between GPU segments.
644    DequantMatmulGguf {
645        m: u32,
646        k: u32,
647        n: u32,
648        scheme_id: u32,
649        // Arena BYTE offsets must be u64: GGUF decode arenas exceed 4 GiB
650        // (Orpheus-3B Q4_K_M is ~10 GiB), so a u32 byte offset truncates for
651        // any tensor past the 4 GiB mark and the host dequant reads garbage.
652        x_byte_off: u64,
653        w_byte_off: u64,
654        out_byte_off: u64,
655    },
656    /// GGUF K-quant — host fused dequant+grouped matmul between GPU segments.
657    DequantGroupedMatmulGguf {
658        m: u32,
659        k: u32,
660        n: u32,
661        num_experts: u32,
662        scheme_id: u32,
663        x_byte_off: u64,
664        w_byte_off: u64,
665        idx_byte_off: u64,
666        out_byte_off: u64,
667    },
668    /// Gated-DeltaNet — host scan between GPU segments (qwen35 linear layers).
669    GatedDeltaNet {
670        q_byte_off: u32,
671        k_byte_off: u32,
672        v_byte_off: u32,
673        g_byte_off: u32,
674        beta_byte_off: u32,
675        state_byte_off: u32,
676        dst_byte_off: u32,
677        batch: u32,
678        seq: u32,
679        heads: u32,
680        state_size: u32,
681        use_carry: bool,
682    },
683    Lstm {
684        x_byte_off: u32,
685        w_ih_byte_off: u32,
686        w_hh_byte_off: u32,
687        bias_byte_off: u32,
688        h0_byte_off: u32,
689        c0_byte_off: u32,
690        dst_byte_off: u32,
691        batch: u32,
692        seq: u32,
693        input_size: u32,
694        hidden: u32,
695        num_layers: u32,
696        bidirectional: bool,
697        carry: bool,
698    },
699    ConvTranspose2d {
700        src_byte_off: u32,
701        weight_byte_off: u32,
702        dst_byte_off: u32,
703        n: u32,
704        c_in: u32,
705        h: u32,
706        w_in: u32,
707        c_out: u32,
708        h_out: u32,
709        w_out: u32,
710        kh: u32,
711        kw: u32,
712        sh: u32,
713        sw: u32,
714        ph: u32,
715        pw: u32,
716        dh: u32,
717        dw: u32,
718        groups: u32,
719    },
720    /// Host-staged NCHW GroupNorm (readback → CPU → writeback).
721    GroupNormHost {
722        src_byte_off: u32,
723        gamma_byte_off: u32,
724        beta_byte_off: u32,
725        dst_byte_off: u32,
726        n: u32,
727        c: u32,
728        h: u32,
729        w: u32,
730        num_groups: u32,
731        eps: f32,
732    },
733    /// Host-staged NCHW LayerNorm2d (readback → CPU → writeback).
734    LayerNorm2dHost {
735        src_byte_off: u32,
736        gamma_byte_off: u32,
737        beta_byte_off: u32,
738        dst_byte_off: u32,
739        n: u32,
740        c: u32,
741        h: u32,
742        w: u32,
743        eps: f32,
744    },
745    /// Host-staged nearest 2× upsample on NCHW (readback → CPU → writeback).
746    ResizeNearest2xHost {
747        src_byte_off: u32,
748        dst_byte_off: u32,
749        n: u32,
750        c: u32,
751        h: u32,
752        w: u32,
753    },
754    /// Host-staged batch-general reverse/flip (readback → CPU → writeback).
755    ReverseHost {
756        src_byte_off: u32,
757        dst_byte_off: u32,
758        dims: Vec<u32>,
759        rev_mask: Vec<bool>,
760        elem_bytes: u32,
761    },
762    /// Host-staged ArgMax/ArgMin (readback → CPU → writeback).
763    ArgReduceHost {
764        src_byte_off: u32,
765        dst_byte_off: u32,
766        outer: u32,
767        reduced: u32,
768        inner: u32,
769        is_max: bool,
770    },
771    Llada2GroupLimitedGate {
772        sig_byte_off: u32,
773        route_byte_off: u32,
774        out_byte_off: u32,
775        n_elems: u32,
776        attrs: [u8; 20],
777    },
778    UmapKnn {
779        params: UmapKnnParams,
780    },
781    /// Small-`n` host k-NN (partial arena read/write; avoids GPU launch overhead).
782    UmapKnnHost {
783        pairwise_byte_off: u32,
784        out_byte_off: u32,
785        n: u32,
786        k: u32,
787    },
788    /// Fused multi-scale deformable attention (host compute over arena buffers).
789    MsDeformAttnHost {
790        in_offs: Vec<(u32, u32)>, // (byte_off, byte_len) per input
791        out_byte_off: u32,
792        out_bytes: u32,
793        attrs: Vec<u8>,
794    },
795    /// 3D Gaussian splat forward (CPU reference between segments).
796    #[cfg(feature = "splat")]
797    GaussianSplatRender {
798        positions_byte_off: u32,
799        positions_len: u32,
800        scales_byte_off: u32,
801        scales_len: u32,
802        rotations_byte_off: u32,
803        rotations_len: u32,
804        opacities_byte_off: u32,
805        opacities_len: u32,
806        colors_byte_off: u32,
807        colors_len: u32,
808        sh_coeffs_byte_off: u32,
809        sh_coeffs_len: u32,
810        meta_byte_off: u32,
811        dst_byte_off: u32,
812        dst_len: u32,
813        width: u32,
814        height: u32,
815        tile_size: u32,
816        radius_scale: f32,
817        alpha_cutoff: f32,
818        max_splat_steps: u32,
819        transmittance_threshold: f32,
820        max_list_entries: u32,
821    },
822    /// Backward splat — host round-trip via rlx-cpu/splat.
823    #[cfg(feature = "splat")]
824    GaussianSplatRenderBackward {
825        positions_byte_off: u32,
826        positions_len: u32,
827        scales_byte_off: u32,
828        scales_len: u32,
829        rotations_byte_off: u32,
830        rotations_len: u32,
831        opacities_byte_off: u32,
832        opacities_len: u32,
833        colors_byte_off: u32,
834        colors_len: u32,
835        sh_coeffs_byte_off: u32,
836        sh_coeffs_len: u32,
837        meta_byte_off: u32,
838        d_loss_byte_off: u32,
839        d_loss_len: u32,
840        packed_byte_off: u32,
841        packed_len: u32,
842        width: u32,
843        height: u32,
844        tile_size: u32,
845        radius_scale: f32,
846        alpha_cutoff: f32,
847        max_splat_steps: u32,
848        transmittance_threshold: f32,
849        max_list_entries: u32,
850        loss_grad_clip: f32,
851        sh_band: u32,
852        max_anisotropy: f32,
853    },
854    #[cfg(feature = "splat")]
855    GaussianSplatPrepare {
856        positions_byte_off: u32,
857        positions_len: u32,
858        scales_byte_off: u32,
859        scales_len: u32,
860        rotations_byte_off: u32,
861        rotations_len: u32,
862        opacities_byte_off: u32,
863        opacities_len: u32,
864        colors_byte_off: u32,
865        colors_len: u32,
866        sh_coeffs_byte_off: u32,
867        sh_coeffs_len: u32,
868        meta_byte_off: u32,
869        meta_len: u32,
870        prep_byte_off: u32,
871        prep_len: u32,
872        width: u32,
873        height: u32,
874        tile_size: u32,
875        radius_scale: f32,
876        alpha_cutoff: f32,
877        max_splat_steps: u32,
878        transmittance_threshold: f32,
879        max_list_entries: u32,
880    },
881    #[cfg(feature = "splat")]
882    GaussianSplatRasterize {
883        prep_byte_off: u32,
884        prep_len: u32,
885        meta_byte_off: u32,
886        meta_len: u32,
887        dst_byte_off: u32,
888        dst_len: u32,
889        count: u32,
890        width: u32,
891        height: u32,
892        tile_size: u32,
893        alpha_cutoff: f32,
894        max_splat_steps: u32,
895        transmittance_threshold: f32,
896        max_list_entries: u32,
897    },
898    RmsNormBackwardInput {
899        params: RmsNormBwdParams,
900    },
901    RmsNormBackwardGamma {
902        params: RmsNormBwdParams,
903    },
904    RmsNormBackwardBeta {
905        params: RmsNormBwdParams,
906    },
907    LayerNormBackwardInput {
908        params: LayerNormBwdParams,
909    },
910    LayerNormBackwardGammaPartial {
911        params: LayerNormBwdParams,
912        num_workgroups: u32,
913    },
914    LayerNormBackwardGammaReduce {
915        params: LayerNormBwdParams,
916    },
917    RopeBackward {
918        params: RopeBwdParams,
919    },
920    CumsumBackward {
921        params: CumsumBwdParams,
922    },
923    GatherBackward {
924        params: GatherBwdParams,
925    },
926    FusedResidualLn {
927        params: FusedResidualLnParams,
928    },
929    /// Split-write QKV matmul. Replaces a (FusedMatMulBiasAct → Narrow×3)
930    /// pattern with one dispatch that writes Q, K, V into separate
931    /// contiguous buffers from a single matmul pass. See
932    /// `kernels/matmul_qkv.wgsl`.
933    MatmulQkv {
934        params: MatmulQkvParams,
935        kind: MatmulQkvKind,
936    },
937    /// `fused_residual_ln_tee` — does (Add → LN) but writes the sum to
938    /// a separate arena slot (the eliminated Add's old slot). Fires
939    /// when the Add has multi-consumer downstream (vision pre-norm).
940    FusedResidualLnTee {
941        params: FusedResidualLnTeeParams,
942    },
943    FusedResidualRmsNorm {
944        params: FusedResidualRmsNormParams,
945    },
946}
947
948pub struct WgpuExecutable {
949    graph: Graph,
950    arena: Arena,
951    /// Byte offset of GGUF dequant scratch slab (0 when host fallback).
952    dequant_scratch_off: usize,
953    schedule: Vec<Step>,
954    input_offsets: HashMap<String, NodeId>,
955    param_offsets: HashMap<String, NodeId>,
956    /// One uniform buffer + bind group per dispatch step. Pre-allocated
957    /// so run() just writes new bytes per step.
958    uniforms: Vec<wgpu::Buffer>,
959    bind_groups: Vec<wgpu::BindGroup>,
960    /// Per-step metadata storage buffers (only Transpose uses them).
961    /// Indexed by `Step::Transpose.meta_idx`.
962    meta_buffers: Vec<wgpu::Buffer>,
963
964    // ── Lazy dynamic-shape state ─────────────────────────────────
965    /// The originally-supplied graph (pre-resolution). Only set when
966    /// the input graph contained `Dim::Dynamic` entries — otherwise
967    /// `None` and the compiled fields above are authoritative. On each
968    /// `run()` we infer a `DimBinding` from the live input data, and
969    /// if it differs from `last_binding` we re-resolve + recompile.
970    unresolved: Option<Graph>,
971    last_binding: Option<DimBinding>,
972    /// Buffered params written via `set_param` / `set_param_bytes`
973    /// before the first `run()`. Replayed against the freshly compiled
974    /// arena once shapes resolve.
975    pending_params: HashMap<String, Vec<f32>>,
976    pending_param_bytes: HashMap<String, Vec<u8>>,
977    /// Active-extent hint (PLAN L1). When set + every Step in the
978    /// safe set, both the uniform write and the dispatch workgroup
979    /// count are scaled by `actual / upper`. Otherwise full-extent.
980    pub(crate) active_extent: Option<(usize, usize)>,
981    /// Skip-redundant-uniform-writes guard. Each `run()` would
982    /// otherwise re-`queue.write_buffer` ~115 per-step uniforms (one
983    /// per dispatched op in BERT) even when their bytes are identical
984    /// to the previous call's. At small batches, that fixed write +
985    /// staging-copy overhead is the dominant cost. We track the last
986    /// active-extent value the uniforms were written for; subsequent
987    /// `run()`s with the same `active_extent` (and `recompile`-clean
988    /// schedule) skip the entire uniform-write loop. `None` ⇒ never
989    /// written; `Some(x)` ⇒ uniforms hold params for active_extent=x.
990    uniforms_active_extent: Option<Option<(usize, usize)>>,
991    /// Last-upload fingerprint per input name; skips staging when unchanged.
992    input_staging_hashes: HashMap<String, u64>,
993    /// True when the schedule contains CoopF16Vk matmul (disables f32-only
994    /// input upload skip — the f16 shadow must stay in sync each run).
995    coop_f16_vk: bool,
996    /// CoopF16Vk Param B offsets (f32 arena / 4) → param name for wide routing.
997    coop_f16_b_param: HashMap<u32, String>,
998    /// Param names flagged by the oscillation probe for wide f32 fallback.
999    coop_f16_vk_wide_b: HashSet<String>,
1000    /// Wide f32 bind groups for CoopF16Vk steps (schedule index → bg).
1001    coop_f16_vk_wide_bind_groups: HashMap<usize, wgpu::BindGroup>,
1002    /// CoopF16Vk activation operands mirrored on the host each `run()` (f32+f16).
1003    coop_f16_host_activations: Vec<(NodeId, Activation, String)>,
1004    /// Last `set_param` f32 payload per name (for host activation mirrors).
1005    stashed_params: HashMap<String, Vec<f32>>,
1006    /// Reused output readback staging (avoids per-run buffer alloc).
1007    readback_staging: Option<ReadbackStaging>,
1008    /// Persistent tiny readback buffer for single scalar outputs.
1009    tiny_readback: Option<TinyReadbackStaging>,
1010    /// When set, `run_inner` dispatches + submits all compute but skips the
1011    /// blocking output readback (results stay in the arena). Used by the wasm
1012    /// `run_async` path, which then reads outputs back asynchronously — the
1013    /// browser event loop cannot be blocked. Always false on native.
1014    dispatch_only: bool,
1015    /// Per-`FftGpu` step: isolated uniform buffers + bind groups (one vec entry per op).
1016    fft_gpu_steps: Vec<crate::fft_dispatch::FftGpuResources>,
1017    /// Persistent KV inputs (host staging uploaded each run).
1018    gpu_handles: HashMap<String, Vec<f32>>,
1019    gpu_handle_feeds: HashMap<String, usize>,
1020    /// Arena input slots authoritative — skip host KV mirror each decode step.
1021    gpu_handle_resident: HashSet<String>,
1022    pending_read_indices: Option<Vec<usize>>,
1023    /// Runtime-mutable RNG policy for [`Step::RngNormalHost`] / [`Step::RngUniformHost`].
1024    rng: std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
1025}
1026
1027impl Step {
1028    /// True when this Step variant honors active-extent dispatch (PLAN L1).
1029    /// Coverage: simple element-wise + reductions + matmul + linalg
1030    /// + reductions/argmax/topk/sample + gather + conv + pool +
1031    /// scatter (zero output + scale num_updates) + macros gated to
1032    /// batch=1 (Attention, SelectiveScan).
1033    pub fn safe_for_active_extent(&self) -> bool {
1034        match self {
1035            Step::Binary { .. }
1036            | Step::Compare { .. }
1037            | Step::Unary { .. }
1038            | Step::Where { .. }
1039            | Step::Fma { .. }
1040            | Step::Reduce { .. }
1041            | Step::Softmax { .. }
1042            | Step::SoftmaxCrossEntropy { .. }
1043            | Step::LayerNorm { .. }
1044            | Step::FusedResidualLn { .. }
1045            | Step::FusedResidualLnTee { .. }
1046            | Step::FusedResidualRmsNorm { .. }
1047            | Step::Cumsum { .. }
1048            | Step::Copy { .. }
1049            | Step::ElementwiseRegion { .. }
1050            | Step::BatchElementwiseRegion { .. }
1051            | Step::Argmax { .. }
1052            | Step::TopK { .. }
1053            | Step::WelchPeaksGpu { .. }
1054            | Step::Sample { .. }
1055            | Step::Gather { .. }
1056            | Step::GatherAxis { .. }
1057            | Step::GatherSplit { .. }
1058            | Step::GroupedMatmul { .. }
1059            | Step::DequantMatmul { .. }
1060            | Step::DequantMatmulGguf { .. }
1061            | Step::DequantGroupedMatmulGguf { .. }
1062            | Step::GatedDeltaNet { .. }
1063            | Step::Lstm { .. }
1064            | Step::ConvTranspose2d { .. }
1065            | Step::GroupNormHost { .. }
1066            | Step::LayerNorm2dHost { .. }
1067            | Step::ResizeNearest2xHost { .. }
1068            | Step::ReverseHost { .. }
1069            | Step::ArgReduceHost { .. }
1070            | Step::GruHost { .. }
1071            | Step::RnnHost { .. }
1072            | Step::Llada2GroupLimitedGate { .. }
1073            | Step::UmapKnn { .. }
1074            | Step::UmapKnnHost { .. }
1075            | Step::MsDeformAttnHost { .. }
1076            | Step::Conv1d { .. }
1077            | Step::Conv2d { .. }
1078            | Step::Conv2dTiled { .. }
1079            | Step::Conv3d { .. }
1080            | Step::Pool1d { .. }
1081            | Step::Pool2d { .. }
1082            | Step::Pool3d { .. }
1083            | Step::ScatterAdd { .. }
1084            | Step::BufferCopy { .. } => true,
1085            // FFT: full-extent transform per row, no active-extent
1086            // scaling. Marking true so a graph that mixes FFT with
1087            // active-extent-safe ops still gets the optimization for
1088            // the rest of the schedule.
1089            Step::FftGpu { .. } | Step::FftHost { .. } | Step::ScanHost { .. } => true,
1090            Step::Im2ColHost { .. }
1091            | Step::RngNormalHost { .. }
1092            | Step::RngUniformHost { .. }
1093            | Step::WelchPeaksHost { .. }
1094            | Step::LogMelHost { .. }
1095            | Step::LogMelBackwardHost { .. } => true,
1096            // Matmul: c_batch_stride is set at compile time at full m,
1097            // independent of params.m. With scaled m, threads with
1098            // global_row >= m early-return; per-batch output offsets
1099            // stay correct. Safe at any batch.
1100            Step::Matmul { .. } => true,
1101            // im2col params (offsets + spatial extents) are baked at compile
1102            // time for a fixed conv shape; they cannot be active-extent
1103            // scaled. Returning false disables the fast path for any graph
1104            // that contains an im2col conv (conv-heavy models don't use it).
1105            Step::Im2ColGpu { .. } => false,
1106            // Same active-extent reasoning as Matmul: per-batch output
1107            // strides are baked at compile time, scaling m only adjusts
1108            // the per-thread bound check.
1109            Step::MatmulQkv { .. } => true,
1110            Step::CastF32ToF16 { .. } => true,
1111            // Attention: WGSL kernel uses `seq_q_stride`/`seq_k_stride`
1112            // (full extent, set at compile time) for per-(batch, head)
1113            // offset math, and `params.seq_q`/`params.seq_k` for loop
1114            // bounds only. Scaling seq_q/seq_k shrinks the iteration
1115            // without corrupting per-head strides. Safe at any batch.
1116            Step::Attention { .. } => true,
1117            Step::AttentionBackward { .. } => true,
1118            // SelectiveScan: WGSL kernel uses `params.seq_stride`
1119            // (full extent, set at compile time) for per-batch stride
1120            // math; `params.seq` is the loop bound only. Safe at any
1121            // batch under active-extent scaling of seq.
1122            Step::SelectiveScan { .. } => true,
1123            // Mamba2: same seq_stride discipline as SelectiveScan.
1124            Step::Mamba2 { .. } => true,
1125            // GRU/RNN: per-batch workgroups; seq_stride is full-extent, seq is
1126            // the loop bound only. Safe under active-extent scaling.
1127            Step::Gru { .. } => true,
1128            Step::Rnn { .. } => true,
1129            // Narrow + Concat: kernel iterates `params.total` in
1130            // row-major order with outer as the leading dim. Scaling
1131            // total by actual/upper effectively scales outer by the
1132            // same factor (since total = outer * axis_size * inner).
1133            // Output positions past scaled_total stay untouched.
1134            // **Conservative assumption**: bucket axis is outer.
1135            // Cases where the bucket axis is the narrow/concat axis
1136            // itself are unsafe — fall back to full extent there.
1137            Step::Narrow { .. } => true,
1138            Step::Concat { .. } => true,
1139            // Rope: WGSL kernel uses `seq_stride` (full extent, set
1140            // at compile time) for per-batch buffer offset math and
1141            // explicit `batch` for index decomposition. `params.seq`
1142            // and `params.n_total` are runtime-scaled iteration
1143            // bounds. Safe at any batch.
1144            Step::Rope { .. } => true,
1145            // Transpose: precomputed `bucket_outermost` flag in
1146            // params (set to 1 at compile time iff `perm[0] == 0`).
1147            // Active path scales `out_total` by `actual / upper`
1148            // proportional to `out_dim_0`. Other transposes (where
1149            // bucket axis moves) fall back to full extent.
1150            Step::Transpose { params, .. } => params.bucket_outermost == 1,
1151            // Expand: same shape as Transpose. `bucket_outermost` is
1152            // 1 iff `in_dims[0] == out_dims[0]` (no broadcast at the
1153            // bucket axis).
1154            Step::Expand { params, .. } => params.bucket_outermost == 1,
1155            // Training backward ops: not used in inference; disable
1156            // active-extent fast path until individually audited.
1157            Step::RmsNormBackwardInput { .. }
1158            | Step::RmsNormBackwardGamma { .. }
1159            | Step::RmsNormBackwardBeta { .. }
1160            | Step::LayerNormBackwardInput { .. }
1161            | Step::LayerNormBackwardGammaPartial { .. }
1162            | Step::LayerNormBackwardGammaReduce { .. }
1163            | Step::RopeBackward { .. }
1164            | Step::CumsumBackward { .. }
1165            | Step::GatherBackward { .. } => false,
1166            #[cfg(feature = "splat")]
1167            Step::GaussianSplatRender { .. }
1168            | Step::GaussianSplatRenderBackward { .. }
1169            | Step::GaussianSplatPrepare { .. }
1170            | Step::GaussianSplatRasterize { .. } => false,
1171        }
1172    }
1173}
1174
1175/// Static-string label for each Step variant — used by the Perfetto
1176/// trace layer (PLAN L3) to mark per-step events without allocating.
1177fn fft_dtype_tag(dtype: rlx_ir::DType) -> u32 {
1178    match dtype {
1179        rlx_ir::DType::F32 => 0,
1180        rlx_ir::DType::F64 => 1,
1181        rlx_ir::DType::C64 => 2,
1182        other => panic!("rlx-wgpu Op::Fft: unsupported dtype {other:?}"),
1183    }
1184}
1185
1186fn fft_dtype_from_tag(tag: u32) -> rlx_ir::DType {
1187    match tag {
1188        0 => rlx_ir::DType::F32,
1189        1 => rlx_ir::DType::F64,
1190        2 => rlx_ir::DType::C64,
1191        other => panic!("rlx-wgpu Op::Fft: bad dtype tag {other}"),
1192    }
1193}
1194
1195fn step_name(step: &Step) -> &'static str {
1196    match step {
1197        Step::CastF32ToF16 { .. } => "cast_f32_to_f16",
1198        Step::Matmul { .. } => "matmul",
1199        Step::Binary { .. } => "binary",
1200        Step::Compare { .. } => "compare",
1201        Step::Unary { .. } => "unary",
1202        Step::Where { .. } => "where",
1203        Step::Fma { .. } => "fma",
1204        Step::Reduce { .. } => "reduce",
1205        Step::Softmax { .. } => "softmax",
1206        Step::SoftmaxCrossEntropy { .. } => "softmax_cross_entropy",
1207        Step::LayerNorm { .. } => "layer_norm",
1208        Step::Cumsum { .. } => "cumsum",
1209        Step::FftGpu { .. } => "fft_gpu",
1210        Step::FftHost { .. } => "fft_host",
1211        Step::WelchPeaksHost { .. } => "welch_peaks_host",
1212        Step::LogMelHost { .. } => "log_mel_host",
1213        Step::LogMelBackwardHost { .. } => "log_mel_backward_host",
1214        Step::Im2ColHost { .. } => "im2col_host",
1215        Step::RngNormalHost { .. } => "rng_normal_host",
1216        Step::RngUniformHost { .. } => "rng_uniform_host",
1217        Step::BufferCopy { .. } => "buffer_copy",
1218        Step::Copy { .. } => "copy",
1219        Step::Transpose { .. } => "transpose",
1220        Step::Narrow { .. } => "narrow",
1221        Step::Concat { .. } => "concat",
1222        Step::Gather { .. } => "gather",
1223        Step::GatherAxis { .. } => "gather_axis",
1224        Step::Attention { .. } => "attention",
1225        Step::AttentionBackward { .. } => "attention_bwd",
1226        Step::Rope { .. } => "rope",
1227        Step::Expand { .. } => "expand",
1228        Step::Argmax { .. } => "argmax",
1229        Step::Pool2d { .. } => "pool2d",
1230        Step::Conv2d { .. } => "conv2d",
1231        Step::Conv2dTiled { .. } => "conv2d_tiled",
1232        Step::Im2ColGpu { .. } => "im2col_gpu",
1233        Step::Pool1d { .. } => "pool1d",
1234        Step::Pool3d { .. } => "pool3d",
1235        Step::Conv1d { .. } => "conv1d",
1236        Step::Conv3d { .. } => "conv3d",
1237        Step::ScatterAdd { .. } => "scatter_add",
1238        Step::TopK { .. } => "topk",
1239        Step::WelchPeaksGpu { .. } => "welch_peaks_gpu",
1240        Step::GroupedMatmul { .. } => "grouped_matmul",
1241        Step::Sample { .. } => "sample",
1242        Step::SelectiveScan { .. } => "selective_scan",
1243        Step::Mamba2 { .. } => "mamba2",
1244        Step::Gru { .. } => "gru",
1245        Step::Rnn { .. } => "rnn",
1246        Step::GruHost { .. } => "gru_host",
1247        Step::RnnHost { .. } => "rnn_host",
1248        Step::DequantMatmul { .. } => "dequant_matmul",
1249        Step::GatherSplit { .. } => "gather_split",
1250        Step::DequantMatmulGguf { .. } => "dequant_matmul_gguf",
1251        Step::DequantGroupedMatmulGguf { .. } => "dequant_grouped_matmul_gguf",
1252        Step::GatedDeltaNet { .. } => "gated_delta_net",
1253        Step::Lstm { .. } => "lstm",
1254        Step::ConvTranspose2d { .. } => "conv_transpose2d",
1255        Step::GroupNormHost { .. } => "group_norm_host",
1256        Step::LayerNorm2dHost { .. } => "layer_norm2d_host",
1257        Step::ResizeNearest2xHost { .. } => "resize_nearest2x_host",
1258        Step::ReverseHost { .. } => "reverse_host",
1259        Step::ArgReduceHost { .. } => "argreduce_host",
1260        Step::Llada2GroupLimitedGate { .. } => "llada2_group_limited_gate",
1261        Step::UmapKnn { .. } => "umap_knn",
1262        Step::UmapKnnHost { .. } => "umap_knn_host",
1263        Step::MsDeformAttnHost { .. } => "ms_deform_attn_host",
1264        Step::ScanHost { .. } => "scan_host",
1265        #[cfg(feature = "splat")]
1266        Step::GaussianSplatRender { .. } => "gaussian_splat_render",
1267        #[cfg(feature = "splat")]
1268        Step::GaussianSplatRenderBackward { .. } => "gaussian_splat_render_backward",
1269        #[cfg(feature = "splat")]
1270        Step::GaussianSplatPrepare { .. } => "gaussian_splat_prepare",
1271        #[cfg(feature = "splat")]
1272        Step::GaussianSplatRasterize { .. } => "gaussian_splat_rasterize",
1273        Step::RmsNormBackwardInput { .. } => "rms_norm_backward_input",
1274        Step::RmsNormBackwardGamma { .. } => "rms_norm_backward_gamma",
1275        Step::RmsNormBackwardBeta { .. } => "rms_norm_backward_beta",
1276        Step::LayerNormBackwardInput { .. } => "layer_norm_backward_input",
1277        Step::LayerNormBackwardGammaPartial { .. } => "layer_norm_backward_gamma_partial",
1278        Step::LayerNormBackwardGammaReduce { .. } => "layer_norm_backward_gamma_reduce",
1279        Step::RopeBackward { .. } => "rope_backward",
1280        Step::CumsumBackward { .. } => "cumsum_backward",
1281        Step::GatherBackward { .. } => "gather_backward",
1282        Step::FusedResidualLn { .. } => "fused_residual_ln",
1283        Step::FusedResidualLnTee { .. } => "fused_residual_ln_tee",
1284        Step::FusedResidualRmsNorm { .. } => "fused_residual_rms_norm",
1285        Step::MatmulQkv { .. } => "matmul_qkv",
1286        Step::ElementwiseRegion { .. } => "elementwise_region",
1287        Step::BatchElementwiseRegion { .. } => "batch_elementwise_region",
1288    }
1289}
1290
1291fn step_is_tail_host(step: &Step) -> bool {
1292    matches!(
1293        step,
1294        Step::WelchPeaksHost { .. } | Step::LogMelHost { .. } | Step::LogMelBackwardHost { .. }
1295    )
1296}
1297
1298fn step_runs_on_host(step: &Step) -> bool {
1299    match step {
1300        Step::GatherSplit { .. }
1301        | Step::DequantMatmulGguf { .. }
1302        | Step::DequantGroupedMatmulGguf { .. }
1303        | Step::GatedDeltaNet { .. }
1304        | Step::Lstm { .. }
1305        | Step::ConvTranspose2d { .. }
1306        | Step::GroupNormHost { .. }
1307        | Step::LayerNorm2dHost { .. }
1308        | Step::ResizeNearest2xHost { .. }
1309        | Step::ReverseHost { .. }
1310        | Step::ArgReduceHost { .. }
1311        | Step::GruHost { .. }
1312        | Step::RnnHost { .. }
1313        | Step::Llada2GroupLimitedGate { .. }
1314        | Step::UmapKnnHost { .. }
1315        | Step::MsDeformAttnHost { .. }
1316        | Step::FftHost { .. }
1317        | Step::ScanHost { .. }
1318        | Step::Im2ColHost { .. }
1319        | Step::RngNormalHost { .. }
1320        | Step::RngUniformHost { .. }
1321        | Step::BufferCopy { .. } => true,
1322        #[cfg(feature = "splat")]
1323        Step::GaussianSplatRender { .. }
1324        | Step::GaussianSplatRenderBackward { .. }
1325        | Step::GaussianSplatPrepare { .. }
1326        | Step::GaussianSplatRasterize { .. } => true,
1327        _ => false,
1328    }
1329}
1330
1331fn binary_op_id(op: BinaryOp) -> u32 {
1332    match op {
1333        BinaryOp::Add => 0,
1334        BinaryOp::Sub => 1,
1335        BinaryOp::Mul => 2,
1336        BinaryOp::Div => 3,
1337        BinaryOp::Max => 4,
1338        BinaryOp::Min => 5,
1339        BinaryOp::Pow => 6,
1340    }
1341}
1342
1343fn compare_op_id(op: CmpOp) -> u32 {
1344    match op {
1345        CmpOp::Eq => 0,
1346        CmpOp::Ne => 1,
1347        CmpOp::Lt => 2,
1348        CmpOp::Le => 3,
1349        CmpOp::Gt => 4,
1350        CmpOp::Ge => 5,
1351    }
1352}
1353
1354fn reduce_op_id(op: ReduceOp) -> u32 {
1355    match op {
1356        ReduceOp::Sum => 0,
1357        ReduceOp::Mean => 1,
1358        ReduceOp::Max => 2,
1359        ReduceOp::Min => 3,
1360        ReduceOp::Prod => 4,
1361    }
1362}
1363
1364fn activation_op_id(act: Activation) -> u32 {
1365    match act {
1366        Activation::Relu => 0,
1367        Activation::Sigmoid => 1,
1368        Activation::Tanh => 2,
1369        Activation::Exp => 3,
1370        Activation::Log => 4,
1371        Activation::Sqrt => 5,
1372        Activation::Rsqrt => 6,
1373        Activation::Neg => 7,
1374        Activation::Abs => 8,
1375        Activation::Gelu => 9,
1376        Activation::Silu => 10,
1377        Activation::GeluApprox => 11,
1378        Activation::Round => 12,
1379        Activation::Sin => 13,
1380        Activation::Cos => 14,
1381        Activation::Tan => 15,
1382        Activation::Atan => 16,
1383    }
1384}
1385
1386mod compile;
1387mod dispatch;
1388mod run;
1389mod set;
1390mod test;
1391
1392impl WgpuExecutable {
1393    /// Resolve the deferred graph against bindings inferred from
1394    /// `inputs`, recompile the inner state if the bindings changed
1395    /// since the last call, and replay any pending params.
1396    pub(crate) fn lazy_compile_for_inputs(&mut self, inputs: &[(&str, &[f32])]) {
1397        let unresolved = self
1398            .unresolved
1399            .as_ref()
1400            .expect("lazy_compile_for_inputs called without an unresolved graph");
1401        let binding = infer_bindings_from_f32_inputs(unresolved, inputs)
1402            .expect("rlx-wgpu lazy compile: could not infer DimBinding from inputs");
1403
1404        // No-op if shapes haven't changed since the last compile.
1405        if let Some(prev) = &self.last_binding
1406            && same_binding(prev, &binding)
1407        {
1408            return;
1409        }
1410
1411        // Resolve and recompile.
1412        let resolved = bind_graph(unresolved, &binding);
1413        let original = self.unresolved.take();
1414        let pending_params = std::mem::take(&mut self.pending_params);
1415        let pending_bytes = std::mem::take(&mut self.pending_param_bytes);
1416
1417        let fresh = Self::compile_static_inner(resolved, self.rng.clone());
1418
1419        // Move the freshly-compiled fields into self, preserve the
1420        // unresolved+binding state for the next round.
1421        self.graph = fresh.graph;
1422        self.arena = fresh.arena;
1423        self.dequant_scratch_off = fresh.dequant_scratch_off;
1424        self.schedule = fresh.schedule;
1425        self.input_offsets = fresh.input_offsets;
1426        self.param_offsets = fresh.param_offsets;
1427        self.uniforms = fresh.uniforms;
1428        self.bind_groups = fresh.bind_groups;
1429        self.meta_buffers = fresh.meta_buffers;
1430        self.unresolved = original;
1431        self.last_binding = Some(binding);
1432        // Recompiled — uniforms are now empty buffers; force re-write
1433        // on next run().
1434        self.uniforms_active_extent = None;
1435        self.input_staging_hashes.clear();
1436        self.coop_f16_vk = fresh.coop_f16_vk;
1437        self.coop_f16_b_param = fresh.coop_f16_b_param;
1438        self.coop_f16_vk_wide_bind_groups = fresh.coop_f16_vk_wide_bind_groups;
1439        self.coop_f16_host_activations = fresh.coop_f16_host_activations;
1440
1441        // Replay pending param uploads against the new arena.
1442        for (name, data) in pending_params {
1443            self.set_param(&name, &data);
1444        }
1445        for (name, data) in pending_bytes {
1446            self.set_param_bytes(&name, &data);
1447        }
1448    }
1449
1450    /// Current RNG compile/execute policy.
1451    pub fn rng(&self) -> rlx_ir::RngOptions {
1452        *self.rng.read().expect("rng lock")
1453    }
1454
1455    /// Compile placeholder for a graph with `Dim::Dynamic` entries.
1456    /// The real compile happens on the first `run()` once input data
1457    /// reveals the symbol → size bindings. Buffered params (set via
1458    /// `set_param` / `set_param_bytes` before run) are replayed.
1459    pub(crate) fn deferred(
1460        graph: Graph,
1461        rng: std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
1462    ) -> Self {
1463        let dev = wgpu_device().expect("rlx-wgpu: no compatible adapter found");
1464        // Minimal valid arena buffer. Replaced on first run().
1465        let placeholder = dev.device.create_buffer(&wgpu::BufferDescriptor {
1466            label: Some("rlx-wgpu deferred placeholder"),
1467            size: 16,
1468            usage: wgpu::BufferUsages::STORAGE
1469                | wgpu::BufferUsages::COPY_DST
1470                | wgpu::BufferUsages::COPY_SRC,
1471            mapped_at_creation: false,
1472        });
1473        let arena = Arena {
1474            buffer: placeholder,
1475            f16_buffer: None,
1476            offsets: HashMap::new(),
1477            lens: HashMap::new(),
1478            size: 0,
1479            scratch_off: 0,
1480            scratch_bytes: 0,
1481        };
1482        Self {
1483            graph: graph.clone(),
1484            arena,
1485            dequant_scratch_off: 0,
1486            schedule: Vec::new(),
1487            input_offsets: HashMap::new(),
1488            param_offsets: HashMap::new(),
1489            uniforms: Vec::new(),
1490            bind_groups: Vec::new(),
1491            meta_buffers: Vec::new(),
1492            unresolved: Some(graph),
1493            last_binding: None,
1494            pending_params: HashMap::new(),
1495            pending_param_bytes: HashMap::new(),
1496            active_extent: None,
1497            uniforms_active_extent: None,
1498            input_staging_hashes: HashMap::new(),
1499            coop_f16_vk: false,
1500            coop_f16_b_param: HashMap::new(),
1501            coop_f16_vk_wide_b: HashSet::new(),
1502            coop_f16_vk_wide_bind_groups: HashMap::new(),
1503            coop_f16_host_activations: Vec::new(),
1504            stashed_params: HashMap::new(),
1505            readback_staging: None,
1506            tiny_readback: None,
1507            dispatch_only: false,
1508            fft_gpu_steps: Vec::new(),
1509            gpu_handles: HashMap::new(),
1510            gpu_handle_feeds: HashMap::new(),
1511            gpu_handle_resident: HashSet::new(),
1512            pending_read_indices: None,
1513            rng,
1514        }
1515    }
1516
1517    pub(crate) fn all_safe_for_active(&self) -> bool {
1518        self.schedule.iter().all(|s| s.safe_for_active_extent())
1519    }
1520
1521    /// Debug helper: run forward, then read every node slot back and
1522    /// report the first node whose output contains a NaN, plus a
1523    /// summary of the *previous* finite node's value range so the
1524    /// caller can see the input that broke. Slow — diagnosis only.
1525    pub fn debug_first_nan_node(
1526        &mut self,
1527        inputs: &[(&str, &[f32])],
1528    ) -> Option<(usize, String, String)> {
1529        let _ = self.run(inputs);
1530        let dev = wgpu_device().expect("rlx-wgpu: device gone");
1531        let mut prev_summary = String::from("(none)");
1532        for (i, node) in self.graph.nodes().iter().enumerate() {
1533            if !self.arena.has(node.id) {
1534                continue;
1535            }
1536            let elems = node.shape.num_elements().unwrap_or(0);
1537            if elems == 0 {
1538                continue;
1539            }
1540            let data = self.arena.read_f32(&dev.device, &dev.queue, node.id);
1541            let nan_count = data.iter().filter(|v| v.is_nan()).count();
1542            let inf_count = data.iter().filter(|v| v.is_infinite()).count();
1543            if nan_count > 0 || inf_count > 0 {
1544                return Some((i, format!("{:?}", node.op), prev_summary));
1545            }
1546            let max = data.iter().copied().fold(f32::NEG_INFINITY, f32::max);
1547            let min = data.iter().copied().fold(f32::INFINITY, f32::min);
1548            let abs_max = data.iter().map(|v| v.abs()).fold(0.0_f32, f32::max);
1549            prev_summary = format!(
1550                "node #{i} {:?} shape={:?}  min={min:.6e} max={max:.6e} |max|={abs_max:.6e}",
1551                node.op,
1552                node.shape
1553                    .dims()
1554                    .iter()
1555                    .map(|d| format!("{d:?}"))
1556                    .collect::<Vec<_>>()
1557            );
1558        }
1559        None
1560    }
1561
1562    /// Declared output dtypes (one per graph output). Used by the
1563    /// runtime wrapper's `run_typed` to narrow F32 results back to
1564    /// F16/BF16 etc. on the way out.
1565    pub fn output_dtypes(&self) -> Vec<rlx_ir::DType> {
1566        self.graph
1567            .outputs
1568            .iter()
1569            .map(|&id| self.graph.node(id).shape.dtype())
1570            .collect()
1571    }
1572
1573    pub(crate) fn dump_node_stats_if_requested(&self, dev: &crate::device::WgpuDevice) {
1574        if !rlx_ir::env::flag("RLX_WGPU_DUMP_NODES") {
1575            return;
1576        }
1577        let flat_probe = rlx_ir::env::parse_or::<usize>("RLX_WGPU_DUMP_FLAT", usize::MAX);
1578        let limit = rlx_ir::env::parse_or("RLX_WGPU_DUMP_NODES_LIMIT", 40usize);
1579        eprintln!(
1580            "[rlx-wgpu-dump] per-node max |x| (topo order, limit={limit}{})",
1581            if flat_probe != usize::MAX {
1582                format!(", flat[{flat_probe}]")
1583            } else {
1584                String::new()
1585            }
1586        );
1587        let mut shown = 0usize;
1588        for (i, node) in self.graph.nodes().iter().enumerate() {
1589            if !self.arena.has(node.id) {
1590                continue;
1591            }
1592            if matches!(
1593                node.op,
1594                rlx_ir::Op::Input { .. }
1595                    | rlx_ir::Op::Param { .. }
1596                    | rlx_ir::Op::Constant { .. }
1597                    | rlx_ir::Op::Reshape { .. }
1598                    | rlx_ir::Op::Cast { .. }
1599            ) {
1600                continue;
1601            }
1602            let data = self.arena.read_f32(&dev.device, &dev.queue, node.id);
1603            let max = data.iter().fold(0.0f32, |m, &v| m.max(v.abs()));
1604            let nz = data.iter().filter(|&&v| v != 0.0).count();
1605            let flat_s = if flat_probe < data.len() {
1606                format!(" flat[{flat_probe}]={:.6}", data[flat_probe])
1607            } else {
1608                String::new()
1609            };
1610            eprintln!(
1611                "  [{i:>3}] {:?} max={max:.6} nonzero={}/{}{flat_s}",
1612                node.op,
1613                nz,
1614                data.len()
1615            );
1616            shown += 1;
1617            if shown >= limit {
1618                break;
1619            }
1620        }
1621    }
1622
1623    pub fn bind_gpu_handle(&mut self, name: &str, data: &[f32]) -> bool {
1624        if !self.input_offsets.contains_key(name) {
1625            return false;
1626        }
1627        self.gpu_handle_resident.remove(name);
1628        self.gpu_handles.insert(name.to_string(), data.to_vec());
1629        true
1630    }
1631
1632    pub fn has_gpu_handle(&self, name: &str) -> bool {
1633        self.gpu_handles.contains_key(name)
1634    }
1635
1636    pub fn read_gpu_handle(&self, name: &str) -> Option<Vec<f32>> {
1637        if let Some(&out_idx) = self.gpu_handle_feeds.get(name) {
1638            if out_idx < self.graph.outputs.len() {
1639                let id = self.graph.outputs[out_idx];
1640                if self.arena.has(id) {
1641                    let dev = wgpu_device().expect("rlx-wgpu: device gone");
1642                    return Some(self.arena.read_f32(&dev.device, &dev.queue, id));
1643                }
1644            }
1645        }
1646        if self.gpu_handle_resident.contains(name) {
1647            if let Some(&id) = self.input_offsets.get(name) {
1648                if self.arena.has(id) {
1649                    let dev = wgpu_device().expect("rlx-wgpu: device gone");
1650                    return Some(self.arena.read_f32(&dev.device, &dev.queue, id));
1651                }
1652            }
1653        }
1654        self.gpu_handles.get(name).cloned()
1655    }
1656
1657    /// Clone into an independent executable (recompiles from the stored graph).
1658    pub fn clone_for_cache(&self) -> Self {
1659        let graph = self
1660            .unresolved
1661            .clone()
1662            .unwrap_or_else(|| self.graph.clone());
1663        let mut exe = Self::compile_rng(graph, self.rng());
1664        for (k, v) in &self.stashed_params {
1665            exe.set_param(k, v);
1666        }
1667        for (k, v) in &self.pending_params {
1668            exe.set_param(k, v);
1669        }
1670        for (k, v) in &self.pending_param_bytes {
1671            exe.set_param_bytes(k, v);
1672        }
1673        for (k, v) in &self.gpu_handles {
1674            exe.bind_gpu_handle(k, v);
1675        }
1676        for (k, &idx) in &self.gpu_handle_feeds {
1677            exe.set_gpu_handle_feed(k, idx);
1678        }
1679        exe.set_active_extent(self.active_extent);
1680        exe.set_rng(self.rng());
1681        exe
1682    }
1683
1684    pub(crate) fn readback_plan(&self) -> Vec<usize> {
1685        let n = self.graph.outputs.len();
1686        if self.pending_read_indices.is_none() && self.gpu_handle_feeds.is_empty() {
1687            return (0..n).collect();
1688        }
1689        if let Some(ref want) = self.pending_read_indices {
1690            let mut v: Vec<_> = want.to_vec();
1691            v.sort_unstable();
1692            return v;
1693        }
1694        (0..n).collect()
1695    }
1696
1697    pub(crate) fn propagate_gpu_handle_feeds_on_gpu(
1698        &mut self,
1699        dev: &crate::device::WgpuDevice,
1700        enc: &mut wgpu::CommandEncoder,
1701    ) {
1702        let extent = self.active_extent;
1703        let feeds: Vec<(String, usize)> = self
1704            .gpu_handle_feeds
1705            .iter()
1706            .map(|(n, &i)| (n.clone(), i))
1707            .collect();
1708        for (name, out_idx) in feeds {
1709            if out_idx >= self.graph.outputs.len() {
1710                continue;
1711            }
1712            let out_id = self.graph.outputs[out_idx];
1713            let Some(&in_id) = self.input_offsets.get(name.as_str()) else {
1714                continue;
1715            };
1716            if in_id != out_id {
1717                let out_bytes = self.arena.len_of(out_id);
1718                let copy_bytes = match extent {
1719                    Some((actual, upper)) if upper > 0 => {
1720                        let stride = (out_bytes / (upper + 1)).max(4);
1721                        (actual * stride).min(out_bytes)
1722                    }
1723                    _ => out_bytes,
1724                };
1725                self.dispatch_arena_copy_bytes(dev, enc, out_id, in_id, copy_bytes);
1726            }
1727            self.gpu_handle_resident.insert(name.clone());
1728            self.gpu_handles.insert(name.clone(), Vec::new());
1729        }
1730    }
1731
1732    pub(crate) fn stage_gpu_handle_inputs(
1733        &mut self,
1734        dev: &crate::device::WgpuDevice,
1735        inputs: &[(&str, &[f32])],
1736    ) {
1737        for (name, data) in &self.gpu_handles {
1738            if self.gpu_handle_resident.contains(name) || inputs.iter().any(|(n, _)| n == name) {
1739                continue;
1740            }
1741            if let Some(&id) = self.input_offsets.get(name.as_str())
1742                && self.arena.has(id)
1743            {
1744                self.arena.write_f32(&dev.queue, id, data);
1745                self.input_staging_hashes.remove(name);
1746            }
1747        }
1748    }
1749
1750    pub(crate) fn pack_readback_outputs(
1751        &mut self,
1752        plan: &[usize],
1753        partial: Vec<Vec<f32>>,
1754    ) -> Vec<Vec<f32>> {
1755        if self.pending_read_indices.is_none() {
1756            for (pos, &out_i) in plan.iter().enumerate() {
1757                if let Some(data) = partial.get(pos) {
1758                    for (name, &feed_i) in &self.gpu_handle_feeds {
1759                        if feed_i == out_i {
1760                            self.gpu_handles.insert(name.clone(), data.clone());
1761                        }
1762                    }
1763                }
1764            }
1765        }
1766        if self.pending_read_indices.is_none() && plan.len() == self.graph.outputs.len() {
1767            return partial;
1768        }
1769        let want = self.pending_read_indices.as_deref().unwrap_or(plan);
1770        let mut by_idx = std::collections::HashMap::new();
1771        for (pos, &i) in plan.iter().enumerate() {
1772            if let Some(d) = partial.get(pos) {
1773                by_idx.insert(i, d.clone());
1774            }
1775        }
1776        want.iter()
1777            .map(|&i| {
1778                by_idx
1779                    .get(&i)
1780                    .cloned()
1781                    .expect("readback plan missing output")
1782            })
1783            .collect()
1784    }
1785}
1786
1787/// Compute a (X, Y, 1) workgroup grid for a 1-D workload.
1788///
1789/// WebGPU caps `dispatch_workgroups` per-dimension at 65535. For
1790/// workloads beyond `65535 × workgroup_size_x` threads we split into
1791/// a 2-D grid; kernels recover the linear thread index via
1792/// `gid.x + gid.y * num_workgroups.x * 64u`.
1793fn dispatch_prologue_nchw(w: u32, h: u32, nc: u32) -> (u32, u32, u32) {
1794    (w.div_ceil(8).max(1), h.div_ceil(8).max(1), nc.max(1))
1795}
1796
1797fn dispatch_dims(threads_total: u32, workgroup_size: u32) -> (u32, u32, u32) {
1798    let groups = threads_total.div_ceil(workgroup_size);
1799    if groups <= 65535 {
1800        (groups, 1, 1)
1801    } else {
1802        let gx = 65535u32;
1803        let gy = groups.div_ceil(gx);
1804        (gx, gy, 1)
1805    }
1806}
1807
1808/// Shape/feature gate for CoopF16Vk (no operand tracing — avoids circular
1809/// dependency with compile-time f16 mirror planning).
1810///
1811/// **Default OFF.** The Vulkan/DX12 cooperative-matrix matmul path
1812/// silently produces wrong output on BERT-family attention chains on at
1813/// least RTX 4090 (verified empirically against Bio_ClinicalBERT:
1814/// encoder cosine collapses from ≈1.0 on the wide-F32 fallback to ≈0.09
1815/// when the coop path runs, regardless of whether the kernel uses
1816/// F16-acc or F32-acc accumulators). The root cause is upstream — likely
1817/// in how wgpu's `coopLoadT` / `coopMultiplyAdd` interact with strided
1818/// arena buffers on non-Apple drivers — and needs a focused
1819/// reproducer before it can be fixed in `rlx-wgpu`. Until then the
1820/// correctness-first default is to route Vulkan/DX12 matmuls through the
1821/// wide-F32 path, even though it's substantially slower (~80× on this
1822/// shape).
1823///
1824/// Opt back in (at the user's risk) with `RLX_WGPU_COOP_F16_VK_ENABLE=1`
1825/// — useful for measuring the perf headroom or for non-BERT models
1826/// where the precision loss may be acceptable. Legacy
1827/// `RLX_WGPU_NO_COOP_F16_VK=1` and explicit
1828/// `RLX_WGPU_COOP_F16_VK_DISABLE=1` are honored for completeness.
1829fn coop_f16_vk_eligible(dev: &wgpu::Device, m: u32, k: u32, n: u32) -> bool {
1830    if rlx_ir::env::flag("RLX_WGPU_NO_COOP_F16_VK")
1831        || rlx_ir::env::flag("RLX_WGPU_COOP_F16_VK_DISABLE")
1832    {
1833        return false;
1834    }
1835    if !rlx_ir::env::flag("RLX_WGPU_COOP_F16_VK_ENABLE") {
1836        return false;
1837    }
1838    m.is_multiple_of(16)
1839        && k.is_multiple_of(16)
1840        && n.is_multiple_of(16)
1841        && dev
1842            .features()
1843            .contains(wgpu::Features::EXPERIMENTAL_COOPERATIVE_MATRIX)
1844        && dev.features().contains(wgpu::Features::SHADER_F16)
1845        && crate::device::coop_discrete_backend()
1846        && crate::device::coop_f16_16x16_supported()
1847}
1848
1849fn step_needs_pass_flush(step: &Step, prev: &Step) -> bool {
1850    match step {
1851        Step::CastF32ToF16 { .. } => matches!(
1852            prev,
1853            Step::Unary {
1854                f16_mirror: false,
1855                ..
1856            }
1857        ),
1858        Step::Matmul {
1859            compute_precision: MatmulCompute::CoopF16Vk,
1860            ..
1861        }
1862        | Step::MatmulQkv {
1863            kind: MatmulQkvKind::CoopF16Vk,
1864            ..
1865        } => matches!(prev, Step::Unary { .. } | Step::CastF32ToF16 { .. }),
1866        _ => false,
1867    }
1868}
1869
1870fn dispatch_wide_f32_matmul(
1871    pass: &mut wgpu::ComputePass<'_>,
1872    mm_w_active: &Kernel,
1873    mm_k: &Kernel,
1874    m_s: u32,
1875    n: u32,
1876    batch: u32,
1877) {
1878    // Tile-size selection differs by GPU backend.
1879    //
1880    // **Vulkan / DX12** (`matmul_wide_nv`, 64×64 tile): when `m_s < 64`
1881    // the bottom rows of every workgroup's M-axis tile contain padded
1882    // zeros that the kernel still computes and writes back — pure
1883    // wasted work on small-M shapes like BERT-base prefill (m=32). The
1884    // regular 32×32-tile kernel sidesteps the M-axis padding and is
1885    // ~8% faster end-to-end on RTX 4090 (verified on Bio_ClinicalBERT:
1886    // encoder forward 58.9 ms → 54.1 ms at cosine 0.9999995 vs HF).
1887    //
1888    // **Metal / other** (`matmul_wide`, 64×64 tile): the wider tile
1889    // wins even on small M — Apple GPUs prefer the larger workgroup
1890    // and amortize the M-padding well. Forcing the 32×32 kernel here
1891    // regresses Mac WGPU encoder time (26.6 → 29.1 ms verified).
1892    let backend = wgpu_device()
1893        .map(|d| d.backend)
1894        .unwrap_or(wgpu::Backend::Noop);
1895    let is_vulkan_dx12 = matches!(backend, wgpu::Backend::Vulkan | wgpu::Backend::Dx12);
1896    let prefer_small_for_m = is_vulkan_dx12 && m_s < 64;
1897    let use_wide = !prefer_small_for_m && m_s >= 32 && n >= 64;
1898    if use_wide {
1899        pass.set_pipeline(&mm_w_active.pipeline);
1900        let (gx, gy) = if is_vulkan_dx12 {
1901            (n.div_ceil(64), m_s.div_ceil(64))
1902        } else {
1903            (n.div_ceil(64), m_s.div_ceil(32))
1904        };
1905        pass.dispatch_workgroups(gx, gy, batch);
1906    } else {
1907        pass.set_pipeline(&mm_k.pipeline);
1908        pass.dispatch_workgroups(n.div_ceil(32), m_s.div_ceil(32), batch);
1909    }
1910}
1911
1912fn coop_f16_vk_bind_group(exe: &WgpuExecutable, gpu_bi: usize, use_wide: bool) -> &wgpu::BindGroup {
1913    if use_wide {
1914        exe.coop_f16_vk_wide_bind_groups
1915            .get(&gpu_bi)
1916            .unwrap_or(&exe.bind_groups[gpu_bi])
1917    } else {
1918        &exe.bind_groups[gpu_bi]
1919    }
1920}
1921
1922fn require_equal_shapes(graph: &Graph, ids: &[NodeId], op_name: &str) {
1923    let s0 = graph.node(ids[0]).shape.num_elements().unwrap_or(0);
1924    for &id in &ids[1..] {
1925        let si = graph.node(id).shape.num_elements().unwrap_or(0);
1926        if si != s0 {
1927            panic!(
1928                "rlx-wgpu {op_name}: broadcasting not yet implemented; \
1929                    inputs must have the same element count (got {s0} vs {si})"
1930            );
1931        }
1932    }
1933}
1934
1935/// Bind the entire arena in one storage buffer range when it fits the device limit.
1936fn arena_whole_arena_bind(arena: &Arena, max_binding: u64) -> Option<(u64, u64)> {
1937    let need = arena.size as u64;
1938    if need > max_binding {
1939        return None;
1940    }
1941    // Bind size must not exceed the allocated buffer (planner may leave a small tail gap).
1942    let buf_bytes = arena.buffer.size();
1943    let size = need.min(buf_bytes).max(256);
1944    Some((0, size))
1945}
1946
1947fn arena_window_for_nodes(dev: &wgpu::Device, arena: &Arena, ids: &[NodeId]) -> (u64, u64) {
1948    // wgpu requires storage buffer binding offsets aligned to 256 bytes.
1949    const ALIGN: u64 = 256;
1950    let max_binding = dev.limits().max_storage_buffer_binding_size;
1951    if let Some(w) = arena_whole_arena_bind(arena, max_binding) {
1952        return w;
1953    }
1954    let mut lo: u64 = u64::MAX;
1955    let mut hi: u64 = 0;
1956    for &id in ids {
1957        let off = arena.offset(id) as u64;
1958        let len = arena.len_of(id) as u64;
1959        lo = lo.min(off);
1960        hi = hi.max(off.saturating_add(len));
1961    }
1962    if lo == u64::MAX {
1963        return (0, max_binding.max(256));
1964    }
1965    let span = hi.saturating_sub(lo).max(1);
1966    if span > max_binding {
1967        let mut details = String::new();
1968        for &id in ids.iter().take(6) {
1969            let off = arena.offset(id);
1970            let len = arena.len_of(id);
1971            details.push_str(&format!(" id={id:?}@{off}+{len};"));
1972        }
1973        panic!(
1974            "rlx-wgpu: op needs {} bytes of arena span (>{});{}",
1975            span, max_binding, details
1976        );
1977    }
1978    let mut base = (lo / ALIGN) * ALIGN;
1979    // Bind only the byte span the op needs (not the full 4 GiB cap) so we
1980    // don't slide the window to the arena tail and drop low-offset tensors.
1981    let mut size = span.div_ceil(ALIGN) * ALIGN;
1982    size = size.max(256).min(max_binding);
1983    if base.saturating_add(size) > arena.size as u64 {
1984        base = (arena.size as u64).saturating_sub(size);
1985        base = (base / ALIGN) * ALIGN;
1986    }
1987    if base > lo || base.saturating_add(size) < hi {
1988        base = (lo / ALIGN) * ALIGN;
1989        size = hi.saturating_sub(base).div_ceil(ALIGN) * ALIGN;
1990        size = size.max(256).min(max_binding);
1991        if base.saturating_add(size) > arena.size as u64 {
1992            base = hi.saturating_sub(size);
1993            base = (base / ALIGN) * ALIGN;
1994        }
1995    }
1996    (base, size)
1997}
1998
1999fn arena_local_off_f32(arena: &Arena, id: NodeId, base: u64) -> u32 {
2000    (((arena.offset(id) as u64).saturating_sub(base)) / 4) as u32
2001}
2002
2003/// Split-binding embedding gather for >4 GiB arenas (see `Step::GatherSplit`).
2004///
2005/// The table and the idx/output slots can be more than one ≤4 GiB binding
2006/// window apart, so they're bound as separate read-only windows of the arena;
2007/// the output goes to a dedicated read-write buffer that is copied back into
2008/// the arena afterwards (the arena cannot also be bound read-write in the same
2009/// dispatch — wgpu treats STORAGE_READ_WRITE as exclusive per buffer). Mirrors
2010/// [`crate::gguf_gpu::run_dequant_matmul_gguf_gemv`].
2011#[allow(clippy::too_many_arguments)]
2012fn run_gather_split(
2013    arena: &Arena,
2014    device: &wgpu::Device,
2015    queue: &wgpu::Queue,
2016    n_out: u32,
2017    n_idx: u32,
2018    dim: u32,
2019    vocab: u32,
2020    table_byte_off: usize,
2021    idx_byte_off: usize,
2022    out_byte_off: usize,
2023) {
2024    const ALIGN: u64 = 256;
2025    let arena_size = arena.size as u64;
2026    let max_bind = device.limits().max_storage_buffer_binding_size;
2027
2028    // Table window: cover [table_byte_off, + vocab*dim*4).
2029    let t0 = table_byte_off as u64;
2030    let t_bytes = (vocab as u64) * (dim as u64) * 4;
2031    let t_base = (t0 / ALIGN) * ALIGN;
2032    let t_size = ((t0 + t_bytes - t_base).div_ceil(16) * 16).min(arena_size - t_base);
2033
2034    // Index window: cover [idx_byte_off, + n_idx*4).
2035    let i0 = idx_byte_off as u64;
2036    let i_bytes = ((n_idx as u64) * 4).max(4);
2037    let i_base = (i0 / ALIGN) * ALIGN;
2038    let i_size = ((i0 + i_bytes - i_base).div_ceil(16) * 16).min(arena_size - i_base);
2039
2040    assert!(
2041        t_size <= max_bind && i_size <= max_bind,
2042        "rlx-wgpu gather_split: window too large (table={t_size}, idx={i_size}, max={max_bind})"
2043    );
2044
2045    // Separate output buffer (rw) — copied into the arena after the dispatch.
2046    let out_bytes = ((n_out as u64) * 4).max(4);
2047    let out_buf = device.create_buffer(&wgpu::BufferDescriptor {
2048        label: Some("rlx-wgpu gather_split out"),
2049        size: out_bytes.div_ceil(16) * 16,
2050        usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
2051        mapped_at_creation: false,
2052    });
2053
2054    let p = GatherParams {
2055        n_out,
2056        n_idx,
2057        dim,
2058        vocab,
2059        in_off: ((t0 - t_base) / 4) as u32,
2060        idx_off: ((i0 - i_base) / 4) as u32,
2061        out_off: 0,
2062        _p0: 0,
2063    };
2064    let u = device.create_buffer(&wgpu::BufferDescriptor {
2065        label: Some("rlx-wgpu gather_split uniform"),
2066        size: std::mem::size_of::<GatherParams>() as u64,
2067        usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
2068        mapped_at_creation: false,
2069    });
2070    queue.write_buffer(&u, 0, bytemuck::bytes_of(&p));
2071
2072    let gk = gather_split_kernel(device);
2073    let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
2074        label: Some("rlx-wgpu gather_split bg"),
2075        layout: &gk.bgl,
2076        entries: &[
2077            wgpu::BindGroupEntry {
2078                binding: 0,
2079                resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
2080                    buffer: &arena.buffer,
2081                    offset: t_base,
2082                    size: wgpu::BufferSize::new(t_size),
2083                }),
2084            },
2085            wgpu::BindGroupEntry {
2086                binding: 1,
2087                resource: u.as_entire_binding(),
2088            },
2089            wgpu::BindGroupEntry {
2090                binding: 2,
2091                resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
2092                    buffer: &arena.buffer,
2093                    offset: i_base,
2094                    size: wgpu::BufferSize::new(i_size),
2095                }),
2096            },
2097            wgpu::BindGroupEntry {
2098                binding: 3,
2099                resource: out_buf.as_entire_binding(),
2100            },
2101        ],
2102    });
2103
2104    let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
2105        label: Some("rlx-wgpu gather_split"),
2106    });
2107    {
2108        let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
2109            label: Some("rlx-wgpu gather_split pass"),
2110            ..Default::default()
2111        });
2112        pass.set_pipeline(&gk.pipeline);
2113        pass.set_bind_group(0, &bg, &[]);
2114        let (gx, gy, gz) = dispatch_dims(n_out, 64);
2115        pass.dispatch_workgroups(gx, gy, gz);
2116    }
2117    // Copy the embedding back into the arena (distinct buffers → legal).
2118    enc.copy_buffer_to_buffer(&out_buf, 0, &arena.buffer, out_byte_off as u64, out_bytes);
2119    queue.submit(std::iter::once(enc.finish()));
2120}
2121
2122fn arena_tensor_in_window(arena: &Arena, id: NodeId, base: u64, size: u64) -> bool {
2123    let src = arena.offset(id) as u64;
2124    let len = arena.len_of(id) as u64;
2125    src >= base && src.saturating_add(len) <= base.saturating_add(size)
2126}
2127
2128/// True when two planned arena slots share any byte (memory planner reuse).
2129fn arena_tensors_overlap(arena: &Arena, a: NodeId, b: NodeId) -> bool {
2130    if a == b {
2131        return true;
2132    }
2133    let (a0, al) = (arena.offset(a) as u64, arena.len_of(a) as u64);
2134    let (b0, bl) = (arena.offset(b) as u64, arena.len_of(b) as u64);
2135    if al == 0 || bl == 0 {
2136        return false;
2137    }
2138    let a1 = a0.saturating_add(al);
2139    let b1 = b0.saturating_add(bl);
2140    a0 < b1 && b0 < a1
2141}
2142
2143/// True when a matmul reads its weight `B` from the separate f16 shadow buffer
2144/// (so `B` is NOT bound through the arena binding). For these precisions the
2145/// arena window must cover only the activation + output, never the weight.
2146fn matmul_b_from_f16(precision: MatmulCompute, b_is_param: bool) -> bool {
2147    b_is_param
2148        && matches!(
2149            precision,
2150            MatmulCompute::F16 | MatmulCompute::Coop16 | MatmulCompute::CoopF16Vk
2151        )
2152}
2153
2154/// Arena bind window for matmul: when the weight alone fits the bind limit but
2155/// activations + weight do not, anchor on the param tensor (e.g. tied `LmHead`).
2156fn arena_matmul_bind_window(
2157    device: &wgpu::Device,
2158    arena: &Arena,
2159    graph: &Graph,
2160    param_offsets: &HashMap<String, NodeId>,
2161    out_id: NodeId,
2162    a_id: NodeId,
2163    b_id: NodeId,
2164    b_in_arena: bool,
2165) -> (u64, u64, bool) {
2166    let max_binding = device.limits().max_storage_buffer_binding_size;
2167    if let Some((base, size)) = arena_whole_arena_bind(arena, max_binding) {
2168        return (base, size, false);
2169    }
2170    if !b_in_arena {
2171        // B is read from the separate f16 shadow buffer (F16 / Coop16 /
2172        // CoopF16Vk), so the arena binding only carries the activation A and
2173        // the output C. Anchor on [out, a] — NOT on B. Anchoring on a >4 GiB
2174        // param B (e.g. the tied F32 lm_head/embedding) would push the window
2175        // onto the weight and leave C outside it, dropping the output write
2176        // (logits stayed mostly zero → argmax = STOP).
2177        let (base, size) = arena_window_for_nodes(device, arena, &[out_id, a_id]);
2178        return (base, size, false);
2179    }
2180    let ids = [out_id, a_id, b_id];
2181    let all_fits = arena_span_bytes(arena, &ids) <= max_binding;
2182    let b_bytes = arena.len_of(b_id) as u64;
2183    let b_is_param = tensor_is_graph_param(graph, param_offsets, b_id);
2184    let param_anchor =
2185        b_is_param && b_bytes <= max_binding && (!all_fits || b_bytes > ARENA_STAGE_CAP);
2186    let (mut base, mut size) = if param_anchor {
2187        arena_window_for_nodes(device, arena, &[b_id])
2188    } else if all_fits {
2189        arena_window_for_nodes(device, arena, &ids)
2190    } else {
2191        arena_window_for_nodes(device, arena, &[out_id])
2192    };
2193    let param_anchor = param_anchor
2194        || (b_is_param
2195            && b_bytes <= max_binding
2196            && !arena_tensor_in_window(arena, b_id, base, size));
2197    if param_anchor && !arena_tensor_in_window(arena, b_id, base, size) {
2198        (base, size) = arena_window_for_nodes(device, arena, &[b_id]);
2199    }
2200    (base, size, param_anchor)
2201}
2202
2203/// Grow `[base, base+size)` to cover all listed tensors when the span still
2204/// fits `max_storage_buffer_binding_size` (avoids spurious staging copies).
2205fn arena_expand_bind_window(
2206    arena: &Arena,
2207    ids: &[NodeId],
2208    base: &mut u64,
2209    size: &mut u64,
2210    max_binding: u64,
2211) {
2212    const ALIGN: u64 = 256;
2213    let mut lo = *base;
2214    let mut hi = base.saturating_add(*size);
2215    for &id in ids {
2216        let off = arena.offset(id) as u64;
2217        let len = arena.len_of(id) as u64;
2218        lo = lo.min(off);
2219        hi = hi.max(off.saturating_add(len));
2220    }
2221    let span = hi.saturating_sub(lo).max(1);
2222    if span > max_binding {
2223        return;
2224    }
2225    *base = (lo / ALIGN) * ALIGN;
2226    *size = span.div_ceil(ALIGN) * ALIGN;
2227    *size = (*size).max(256).min(max_binding);
2228    if (*base).saturating_add(*size) > arena.size as u64 {
2229        *base = (arena.size as u64).saturating_sub(*size);
2230        *base = (*base / ALIGN) * ALIGN;
2231    }
2232}
2233
2234fn arena_off_in_bind_window(
2235    graph: &Graph,
2236    param_offsets: &HashMap<String, NodeId>,
2237    device: &wgpu::Device,
2238    arena: &Arena,
2239    schedule: &mut Vec<Step>,
2240    scratch: &mut u64,
2241    id: NodeId,
2242    base: &mut u64,
2243    size: &mut u64,
2244) -> u32 {
2245    let max_binding = device.limits().max_storage_buffer_binding_size;
2246    if let Some((b, s)) = arena_whole_arena_bind(arena, max_binding) {
2247        *base = b;
2248        *size = s;
2249        return arena_local_off_f32(arena, id, b);
2250    }
2251    if arena_tensor_in_window(arena, id, *base, *size) {
2252        arena_local_off_f32(arena, id, *base)
2253    } else {
2254        let len = arena.len_of(id) as u64;
2255        if tensor_is_graph_param(graph, param_offsets, id) && len > max_binding {
2256            panic!(
2257                "rlx-wgpu: param node {:?} ({} bytes) exceeds max_storage_buffer_binding_size \
2258                 ({max_binding}); split weights or use f16 shadow binds",
2259                id, len
2260            );
2261        }
2262        if len > ARENA_STAGE_CAP {
2263            let op = &graph.node(id).op;
2264            panic!(
2265                "rlx-wgpu: bind_window would stage {} bytes for {:?} op={op:?} \
2266                 (off={}, base={}, bind_size={})",
2267                len,
2268                id,
2269                arena.offset(id),
2270                *base,
2271                *size,
2272            );
2273        }
2274        arena_off_in_window_or_stage(arena, schedule, scratch, base, size, max_binding, id)
2275    }
2276}
2277
2278/// Bind window for ops that read/write multiple arena tensors (conv, concat, …).
2279/// Returns `(base, size)` and rebased f32 offsets; stages operands that fall outside
2280/// the window when the full span exceeds `max_storage_buffer_binding_size`.
2281fn arena_multi_op_window(
2282    dev: &wgpu::Device,
2283    arena: &Arena,
2284    graph: &Graph,
2285    param_offsets: &HashMap<String, NodeId>,
2286    _schedule: &mut Vec<Step>,
2287    scratch: &mut u64,
2288    ids: &[NodeId],
2289) -> (u64, u64, bool) {
2290    let max_binding = dev.limits().max_storage_buffer_binding_size;
2291    if let Some((base, size)) = arena_whole_arena_bind(arena, max_binding) {
2292        *scratch = arena.scratch_off as u64;
2293        return (base, size, false);
2294    }
2295    let param_anchor = if arena_span_bytes(arena, ids) > max_binding {
2296        ids.iter()
2297            .find(|&&id| {
2298                let nbytes = arena.len_of(id) as u64;
2299                tensor_is_graph_param(graph, param_offsets, id) && nbytes <= max_binding
2300            })
2301            .copied()
2302    } else {
2303        None
2304    };
2305    let mut param_anchored = param_anchor.is_some();
2306    let (mut base, mut size) = if arena_span_bytes(arena, ids) <= max_binding {
2307        arena_window_for_nodes(dev, arena, ids)
2308    } else if let Some(id) = param_anchor {
2309        arena_window_for_nodes(dev, arena, &[id])
2310    } else {
2311        arena_window_for_nodes(dev, arena, &[ids[0]])
2312    };
2313    if let Some(id) = param_anchor {
2314        if !arena_tensor_in_window(arena, id, base, size) {
2315            (base, size) = arena_window_for_nodes(dev, arena, &[id]);
2316        }
2317        param_anchored = true;
2318    } else {
2319        for &id in ids {
2320            let nbytes = arena.len_of(id) as u64;
2321            if tensor_is_graph_param(graph, param_offsets, id)
2322                && nbytes <= max_binding
2323                && !arena_tensor_in_window(arena, id, base, size)
2324            {
2325                (base, size) = arena_window_for_nodes(dev, arena, &[id]);
2326                param_anchored = true;
2327                break;
2328            }
2329        }
2330    }
2331    *scratch = arena.scratch_off as u64;
2332    if param_anchored {
2333        arena_ensure_scratch_in_window(scratch, base, size);
2334    }
2335    (base, size, param_anchored)
2336}
2337
2338fn arena_bind_window_covering_scratch_if_needed(
2339    arena: &Arena,
2340    base: u64,
2341    size: u64,
2342    scratch: u64,
2343) -> u64 {
2344    // Planner places scratch at the arena tail; do not relocate the bind
2345    // window until this op has actually started staging into scratch.
2346    if scratch <= arena.scratch_off as u64 {
2347        return base;
2348    }
2349    if scratch >= base && scratch.saturating_add(ARENA_STAGE_CAP) <= base.saturating_add(size) {
2350        return base;
2351    }
2352    arena_window_covering_scratch(arena, base, size)
2353}
2354
2355/// Keep staging writes inside `[base, base+size)` when the bind window is anchored on a
2356/// param far from the arena tail scratch zone.
2357fn arena_ensure_scratch_in_window(scratch: &mut u64, base: u64, size: u64) {
2358    let cap = ARENA_STAGE_CAP.min(size);
2359    let end = base.saturating_add(size);
2360    if *scratch < base || scratch.saturating_add(cap) > end {
2361        *scratch = end.saturating_sub(cap);
2362        *scratch = (*scratch / 256) * 256;
2363    }
2364}
2365
2366#[allow(dead_code)]
2367fn arena_off_for_window(
2368    arena: &Arena,
2369    schedule: &mut Vec<Step>,
2370    scratch: &mut u64,
2371    id: NodeId,
2372    _window_ids: &[NodeId],
2373    mut base: u64,
2374    mut size: u64,
2375    max_binding: u64,
2376    _fits_in_one_binding: bool,
2377) -> u32 {
2378    let src = arena.offset(id) as u64;
2379    let len = arena.len_of(id) as u64;
2380    if src >= base && src.saturating_add(len) <= base.saturating_add(size) {
2381        arena_local_off_f32(arena, id, base)
2382    } else {
2383        arena_off_in_window_or_stage(
2384            arena,
2385            schedule,
2386            scratch,
2387            &mut base,
2388            &mut size,
2389            max_binding,
2390            id,
2391        )
2392    }
2393}
2394
2395/// f16 shadow buffer window matching an f32 arena bind `[arena_base, arena_base+arena_size)`.
2396fn f16_shadow_bind_range(arena_base: u64, arena_size: u64, f16_buf_bytes: u64) -> (u64, u64) {
2397    const ALIGN: u64 = 256;
2398    let mut base = (arena_base / 2 / ALIGN) * ALIGN;
2399    let mut size = (arena_size / 2).div_ceil(ALIGN) * ALIGN;
2400    size = size.max(256).min(f16_buf_bytes);
2401    if base.saturating_add(size) > f16_buf_bytes {
2402        base = f16_buf_bytes.saturating_sub(size);
2403        base = (base / ALIGN) * ALIGN;
2404    }
2405    (base, size)
2406}
2407
2408/// Window into `f16_buffer` for matmul weight reads (`params.b_off` is in
2409/// f16-element indices, matching the f32 arena word index).
2410fn f16_weight_bind_range(
2411    dev: &wgpu::Device,
2412    f16_buf_bytes: u64,
2413    b_off: u32,
2414    k: u32,
2415    n: u32,
2416    batch: u32,
2417    b_batch_stride: u32,
2418) -> (u64, u64, u32) {
2419    const ALIGN: u64 = 256;
2420    let max_binding = dev.limits().max_storage_buffer_binding_size;
2421    let b0 = b_off as u64;
2422    let span = (k as u64).saturating_mul(n as u64);
2423    let batch_n = batch.max(1) as u64;
2424    let stride = if batch_n > 1 {
2425        b_batch_stride as u64
2426    } else {
2427        span
2428    };
2429    let hi_elems = b0
2430        .saturating_add((batch_n - 1).saturating_mul(stride))
2431        .saturating_add(span);
2432    let lo_byte = b0.saturating_mul(2);
2433    let hi_byte = hi_elems.saturating_mul(2).saturating_add(8);
2434    let need = hi_byte.saturating_sub(lo_byte).max(1);
2435    if need > max_binding {
2436        panic!(
2437            "rlx-wgpu: f16 weight region needs {need} bytes (> {max_binding}); \
2438             matmul k={k} n={n} batch={batch}"
2439        );
2440    }
2441    let mut base = (lo_byte / ALIGN) * ALIGN;
2442    let mut size = need.div_ceil(ALIGN) * ALIGN;
2443    size = size.max(256).min(max_binding).min(f16_buf_bytes);
2444    if base.saturating_add(size) < hi_byte {
2445        base = hi_byte.saturating_sub(size);
2446        base = (base / ALIGN) * ALIGN;
2447    }
2448    if base.saturating_add(size) > f16_buf_bytes {
2449        base = f16_buf_bytes.saturating_sub(size);
2450        base = (base / ALIGN) * ALIGN;
2451    }
2452    let rebased = b_off.saturating_sub((base / 2) as u32);
2453    (base, size, rebased)
2454}
2455
2456const ARENA_STAGE_CAP: u64 = 256 * 1024 * 1024;
2457
2458/// Output spatial positions computed per thread by `conv2d.wgsl` (register
2459/// tiling for weight reuse). MUST equal `TILE` in that kernel.
2460const CONV2D_TILE: u32 = 4;
2461
2462/// Return a window-local f32 offset, staging into scratch when the tensor lies
2463/// outside the bind window (via `copy_buffer_to_buffer`).
2464fn arena_off_in_window_or_stage(
2465    arena: &Arena,
2466    schedule: &mut Vec<Step>,
2467    scratch: &mut u64,
2468    base: &mut u64,
2469    size: &mut u64,
2470    max_binding: u64,
2471    id: NodeId,
2472) -> u32 {
2473    let src = arena.offset(id) as u64;
2474    let len = arena.len_of(id) as u64;
2475    if src >= *base && src.saturating_add(len) <= (*base).saturating_add(*size) {
2476        return arena_local_off_f32(arena, id, *base);
2477    }
2478    if len > ARENA_STAGE_CAP {
2479        panic!(
2480            "rlx-wgpu: cannot stage {} bytes for node {:?} (cap {ARENA_STAGE_CAP})",
2481            len, id
2482        );
2483    }
2484    let aligned = len.div_ceil(256) * 256;
2485    let dst = *scratch;
2486    *scratch = scratch.saturating_add(aligned);
2487    schedule.push(Step::BufferCopy {
2488        src_byte_off: src,
2489        dst_byte_off: dst,
2490        bytes: len as u32,
2491    });
2492    let lo = (*base).min(dst);
2493    let hi = (*base)
2494        .saturating_add(*size)
2495        .max(dst.saturating_add(aligned));
2496    let span = hi.saturating_sub(lo).max(1);
2497    if span <= max_binding {
2498        const ALIGN: u64 = 256;
2499        *base = (lo / ALIGN) * ALIGN;
2500        *size = span.div_ceil(ALIGN) * ALIGN;
2501        *size = (*size).max(256).min(max_binding);
2502        if (*base).saturating_add(*size) > arena.size as u64 {
2503            *base = (arena.size as u64).saturating_sub(*size);
2504            *base = (*base / ALIGN) * ALIGN;
2505        }
2506    }
2507    if arena_tensor_in_window(arena, id, *base, *size) {
2508        arena_local_off_f32(arena, id, *base)
2509    } else {
2510        ((dst.saturating_sub(*base)) / 4) as u32
2511    }
2512}
2513
2514/// If scratch does not fall inside `[base, base+size)`, slide the window to the tail.
2515fn arena_window_covering_scratch(arena: &Arena, base: u64, size: u64) -> u64 {
2516    let scratch = arena.scratch_off as u64;
2517    if scratch >= base && scratch.saturating_add(ARENA_STAGE_CAP) <= base.saturating_add(size) {
2518        return base;
2519    }
2520    let new_base = (arena.size as u64).saturating_sub(size);
2521    (new_base / 256) * 256
2522}
2523
2524fn arena_span_bytes(arena: &Arena, ids: &[NodeId]) -> u64 {
2525    let mut lo: u64 = u64::MAX;
2526    let mut hi: u64 = 0;
2527    for &id in ids {
2528        let off = arena.offset(id) as u64;
2529        let len = arena.len_of(id) as u64;
2530        lo = lo.min(off);
2531        hi = hi.max(off.saturating_add(len));
2532    }
2533    if lo == u64::MAX {
2534        0
2535    } else {
2536        hi.saturating_sub(lo)
2537    }
2538}
2539
2540#[allow(dead_code)]
2541fn bind_two(
2542    device: &wgpu::Device,
2543    kernel: &Kernel,
2544    buf0: &wgpu::Buffer,
2545    buf1: &wgpu::Buffer,
2546) -> wgpu::BindGroup {
2547    let max_binding = device.limits().max_storage_buffer_binding_size;
2548    if buf0.size() > max_binding {
2549        panic!(
2550            "rlx-wgpu: bind_two buffer {} bytes exceeds max_storage_buffer_binding_size {}; \
2551             use bind_two_buf0_window or bind_op_output_window",
2552            buf0.size(),
2553            max_binding
2554        );
2555    }
2556    device.create_bind_group(&wgpu::BindGroupDescriptor {
2557        label: Some("rlx-wgpu bg"),
2558        layout: &kernel.bgl,
2559        entries: &[
2560            wgpu::BindGroupEntry {
2561                binding: 0,
2562                resource: buf0.as_entire_binding(),
2563            },
2564            wgpu::BindGroupEntry {
2565                binding: 1,
2566                resource: buf1.as_entire_binding(),
2567            },
2568        ],
2569    })
2570}
2571
2572/// Windowed arena bind. When `operand_ids` is non-empty and their span with
2573/// `out_id` exceeds the binding limit, falls back to output-only window
2574/// (callers should stage operands and rebase offsets).
2575fn bind_op_output_window(
2576    device: &wgpu::Device,
2577    kernel: &Kernel,
2578    arena: &Arena,
2579    out_id: NodeId,
2580    params: &wgpu::Buffer,
2581) -> wgpu::BindGroup {
2582    bind_op_window(device, kernel, arena, &[out_id], params)
2583}
2584
2585fn bind_op_window(
2586    device: &wgpu::Device,
2587    kernel: &Kernel,
2588    arena: &Arena,
2589    ids: &[NodeId],
2590    params: &wgpu::Buffer,
2591) -> wgpu::BindGroup {
2592    let max_binding = device.limits().max_storage_buffer_binding_size;
2593    let (base, size) = if arena_span_bytes(arena, ids) <= max_binding {
2594        arena_window_for_nodes(device, arena, ids)
2595    } else {
2596        arena_window_for_nodes(device, arena, &[ids[0]])
2597    };
2598    bind_two_buf0_window(device, kernel, &arena.buffer, base, size, params)
2599}
2600
2601/// Storage-buffer binding size for an arena window. wgpu 30 validates that
2602/// storage-buffer binding sizes are a multiple of 4; arena windows can end on
2603/// a non-4 byte (u8-packed GGUF / f16 buffers), so round up — clamped to the
2604/// buffer's tail so the binding never runs past the buffer end.
2605pub(crate) fn aligned_bind_size(size: u64, base: u64, buffer_size: u64) -> Option<NonZeroU64> {
2606    // Round the window up to a multiple of 4, clamped to the buffer's 4-aligned
2607    // capacity (the arena buffer may itself end on a non-4 byte, so `& !3`).
2608    let cap = (buffer_size & !3).saturating_sub(base);
2609    NonZeroU64::new(size.next_multiple_of(4).min(cap))
2610}
2611
2612fn bind_two_buf0_window(
2613    device: &wgpu::Device,
2614    kernel: &Kernel,
2615    buf0: &wgpu::Buffer,
2616    buf0_base: u64,
2617    buf0_size: u64,
2618    buf1: &wgpu::Buffer,
2619) -> wgpu::BindGroup {
2620    device.create_bind_group(&wgpu::BindGroupDescriptor {
2621        label: Some("rlx-wgpu bg window"),
2622        layout: &kernel.bgl,
2623        entries: &[
2624            wgpu::BindGroupEntry {
2625                binding: 0,
2626                resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
2627                    buffer: buf0,
2628                    offset: buf0_base,
2629                    size: aligned_bind_size(buf0_size, buf0_base, buf0.size()),
2630                }),
2631            },
2632            wgpu::BindGroupEntry {
2633                binding: 1,
2634                resource: buf1.as_entire_binding(),
2635            },
2636        ],
2637    })
2638}
2639
2640/// Compute precision selector: derive from IR dtypes of A and B and
2641/// the device features.
2642///
2643/// Priority:
2644///   1. Coop16 — if EXPERIMENTAL_COOPERATIVE_MATRIX + SHADER_F16 +
2645///      F16 IR tag + b traces to a Param + M/K/N are 32/8/32 aligned.
2646///      Unlocks Apple's `simdgroup_matrix` / Vulkan's KHR_cooperative
2647///      hardware GEMM units (~18× faster than f32 ALU on Apple M-series).
2648///   2. F32 — every other case, *including* when AutoMixedPrecision
2649///      tagged the matmul as F16 but it failed Coop16's alignment
2650///      check. The non-coop F16 path (`matmul_f16_compute.wgsl`) was
2651///      empirically measured 4-5× SLOWER than the f32 baseline on
2652///      Apple via wgpu/naga 29 — the WGSL→MSL emit doesn't unlock
2653///      Apple's f16 ALU through portable WGSL ALU. So at small /
2654///      unaligned shapes we lose nothing by ignoring the IR's f16
2655///      tag and using f32 — precision improves AND speed wins.
2656///
2657/// (The F16 variant of `MatmulCompute` and `matmul_f16_compute.wgsl`
2658/// remain for future use — e.g. when naga gains a portable subgroup-
2659/// matrix surface that lowers efficiently without needing the full
2660/// coop-matrix dance, or when bf16 hardware lands. Today no path
2661/// dispatches them.)
2662fn derive_matmul_compute(
2663    dev: &wgpu::Device,
2664    graph: &Graph,
2665    mirror_acts: &HashSet<NodeId>,
2666    a_id: NodeId,
2667    b_id: NodeId,
2668    m: u32,
2669    k: u32,
2670    n: u32,
2671) -> MatmulCompute {
2672    if rlx_ir::env::flag("RLX_WGPU_MATMUL_F32_ONLY") {
2673        return MatmulCompute::F32;
2674    }
2675    use rlx_ir::DType;
2676    let a_dt = graph.node(a_id).shape.dtype();
2677    let b_dt = graph.node(b_id).shape.dtype();
2678    let any_low =
2679        matches!(a_dt, DType::F16 | DType::BF16) || matches!(b_dt, DType::F16 | DType::BF16);
2680    // CoopF32 (`simdgroup_float8x8`) needs K and N aligned to 8 and 32
2681    // (one micro-tile per K-iter, one 32-col workgroup per N-tile).
2682    // M can be arbitrary — the kernel pads to the next multiple of 32
2683    // and bounds-checks the output writes so out-of-range rows stay
2684    // untouched. (The Coop16 / matmul_qkv paths still require m%32==0;
2685    // their kernels don't have the same bounds check.)
2686    //
2687    // Vulkan uses `matmul_coop_f32_portable` (8×8 tiles, coopLoadT) which
2688    // only requires k%8 and n%8.
2689    let coop16_aligned = m.is_multiple_of(32) && k.is_multiple_of(8) && n.is_multiple_of(32);
2690    let coop_f32_metal_aligned = k.is_multiple_of(8) && n.is_multiple_of(32);
2691    let coop_f32_portable_aligned = k.is_multiple_of(8) && n.is_multiple_of(8);
2692    let has_coop = dev
2693        .features()
2694        .contains(wgpu::Features::EXPERIMENTAL_COOPERATIVE_MATRIX);
2695    let backend = crate::device::wgpu_device().map(|d| d.backend);
2696    // Coop16 has an f16 accumulator (Naga 29 can't compile the mixed
2697    // f32-acc / f16-operand form). Sums of 3072 BERT-FFN activations
2698    // overflow f16, so we only enter on F16/BF16 IR tags — AutoMixed
2699    // users have already opted into the precision tradeoff.
2700    if any_low
2701        && has_coop
2702        && dev.features().contains(wgpu::Features::SHADER_F16)
2703        && traces_to_param(graph, b_id)
2704        && coop16_aligned
2705    {
2706        return MatmulCompute::Coop16;
2707    }
2708    if !any_low && coop_f16_vk_eligible(dev, m, k, n) {
2709        if traces_to_param(graph, b_id)
2710            && !mirror_acts.contains(&a_id)
2711            && !mirror_acts.contains(&b_id)
2712        {
2713            return MatmulCompute::CoopF16Vk;
2714        }
2715    }
2716    // CoopF32 (`simdgroup_float8x8` on Apple): the f32 hardware-GEMM
2717    // path. Used whenever cooperative-matrix is available, B is a
2718    // Param, and shapes align — gives ~5-10× speedup over the
2719    // tiled `matmul_wide` path with no precision loss vs the f32
2720    // baseline (BERT max|Δ| stays at 2.3e-3 vs CPU on Apple).
2721    //
2722    // CoopF32: Metal-only by default. Vulkan portable 8×8 is opt-in via
2723    // RLX_WGPU_FORCE_COOP_F32 (RTX lacks 8×8 f32 coop; output is unreliable).
2724    let disabled = rlx_ir::env::flag("RLX_WGPU_NO_COOP_F32");
2725    let forced = rlx_ir::env::flag("RLX_WGPU_FORCE_COOP_F32");
2726    // Metal `simdgroup_float8x8` CoopF32 produced ORTHOGONAL GARBAGE (not mere
2727    // imprecision) on Gemma-4 vision/audio f32 param-weight matmuls — e.g.
2728    // scaled[1,64,768] @ input_proj[768,768] (all axes aligned) gave cos 0.016
2729    // vs CPU; forcing the plain F32 kernel restores cos 1.0. GGUF text models
2730    // dodged this via the DequantMatMul path. Until the kernel is root-caused,
2731    // Metal CoopF32 is opt-in via RLX_WGPU_FORCE_COOP_F32.
2732    let metal_coop =
2733        !disabled && has_coop && coop_f32_metal_aligned && traces_to_param(graph, b_id) && forced;
2734    let _ = backend;
2735    let vulkan_coop = !disabled
2736        && has_coop
2737        && coop_f32_portable_aligned
2738        && traces_to_param(graph, b_id)
2739        && crate::device::coop_discrete_backend()
2740        && crate::device::coop_f32_8x8_supported();
2741    if metal_coop
2742        || vulkan_coop
2743        || (forced
2744            && has_coop
2745            && traces_to_param(graph, b_id)
2746            && (coop_f32_metal_aligned || coop_f32_portable_aligned))
2747    {
2748        return MatmulCompute::CoopF32;
2749    }
2750    MatmulCompute::F32
2751}
2752
2753/// Detects the BERT-style fused-QKV-then-narrow-then-attention
2754/// pattern. When all three of an attention's Q/K/V inputs are
2755/// `Op::Narrow` of a single source tensor on the last axis with
2756/// sequential offsets `(0, H·D, 2·H·D)` and equal lengths `H·D`,
2757/// returns `Some((qkv_source_node, h_d))` — naming the source
2758/// tensor and per-slice width.
2759///
2760/// EMPIRICAL FINDING: the obvious "skip the narrow + read attention
2761/// directly from QKV with stride 3·H·D" optimization REGRESSED end-
2762/// to-end perf 7-15× on Apple M4 Pro. The narrow's apparent overhead
2763/// (~3 dispatches per attention block, ~150µs at small batch) is
2764/// dwarfed by the cost of strided attention reads — stepping by
2765/// 3·H·D = 4.6 KB between sequence positions defeats the hardware
2766/// prefetcher (prefetch distance maxes around 1-2 KB on M-series).
2767/// Cosine stayed 0.9999+ (output is correct, just slow).
2768///
2769/// Kept as a helper for future smarter fusions — e.g. a coop kernel
2770/// that reads Q/K/V cooperatively from QKV in a single pass over
2771/// the sequence dim, avoiding the random-access stride pattern.
2772#[allow(dead_code)]
2773fn detect_qkv_narrow_pattern(
2774    graph: &Graph,
2775    q_id: NodeId,
2776    k_id: NodeId,
2777    v_id: NodeId,
2778) -> Option<(NodeId, u32)> {
2779    let unwrap_narrow = |id: NodeId| -> Option<(NodeId, usize, usize, usize)> {
2780        let node = graph.node(id);
2781        match &node.op {
2782            Op::Narrow { axis, start, len } => Some((node.inputs[0], *axis, *start, *len)),
2783            _ => None,
2784        }
2785    };
2786    let (q_src, q_axis, q_start, q_len) = unwrap_narrow(q_id)?;
2787    let (k_src, k_axis, k_start, k_len) = unwrap_narrow(k_id)?;
2788    let (v_src, v_axis, v_start, v_len) = unwrap_narrow(v_id)?;
2789    // Same source tensor.
2790    if q_src != k_src || k_src != v_src {
2791        return None;
2792    }
2793    // Equal slice widths (= H · D).
2794    if q_len != k_len || k_len != v_len {
2795        return None;
2796    }
2797    // Sequential offsets 0, H·D, 2·H·D.
2798    if q_start != 0 || k_start != q_len || v_start != q_len * 2 {
2799        return None;
2800    }
2801    // All on the LAST axis of the source.
2802    let src_rank = graph.node(q_src).shape.dims().len();
2803    if q_axis + 1 != src_rank || k_axis + 1 != src_rank || v_axis + 1 != src_rank {
2804        return None;
2805    }
2806    Some((q_src, q_len as u32))
2807}
2808
2809/// Detects the (FusedMatMulBiasAct → Narrow×3) split-QKV pattern that
2810/// shows up at the start of every BERT-style attention block. Returns
2811/// a map `parent_fmb_id → (q_narrow_id, k_narrow_id, v_narrow_id)`
2812/// for every site where the pattern can be replaced by one
2813/// `Step::MatmulQkv` dispatch.
2814///
2815/// Pattern requirements:
2816///   - Parent is `Op::FusedMatMulBiasAct { activation: None }` with
2817///     output shape `[..., 3·head_width]`.
2818///   - The parent's *only* consumers are exactly 3 `Op::Narrow` nodes,
2819///     all on the last axis, with offsets `(0, head_width, 2·head_width)`
2820///     and equal `len = head_width`.
2821///
2822/// The win is purely structural: same FMA work, but the 3 narrow
2823/// dispatches (and their full-tensor read+write of the QKV intermediate)
2824/// disappear. Different from the reverted "skip narrow + read attention
2825/// strided" approach because reads from each Q/K/V buffer remain
2826/// sequential — the prefetcher stays happy.
2827/// Detects (`Op::Binary(Add) → Op::LayerNorm`) where the Add has more
2828/// than one consumer in the graph — the case `FuseResidualLN` declines
2829/// because its single-consumer guard would force materializing the sum.
2830///
2831/// Returns:
2832///   - `ln_to_tee`: `ln_id → (h, delta, gamma, beta, sum_id)` so the
2833///     wgpu LayerNorm lowering can emit `Step::FusedResidualLnTee`
2834///     using the existing arena slot for the sum (= the Add's slot).
2835///   - `skip_adds`: the set of Add `NodeId`s whose normal Step emission
2836///     should be suppressed; their output value is written by the tee
2837///     step instead.
2838fn detect_residual_ln_tee_pattern(
2839    graph: &Graph,
2840) -> (
2841    HashMap<NodeId, (NodeId, NodeId, NodeId, NodeId, NodeId)>,
2842    HashSet<NodeId>,
2843) {
2844    use rlx_ir::op::BinaryOp;
2845    // Consumer counts (output references count once each).
2846    let mut consumers: HashMap<NodeId, usize> = HashMap::new();
2847    for node in graph.nodes() {
2848        for &input in &node.inputs {
2849            *consumers.entry(input).or_insert(0) += 1;
2850        }
2851    }
2852    for &out in &graph.outputs {
2853        *consumers.entry(out).or_insert(0) += 1;
2854    }
2855
2856    let mut ln_to_tee = HashMap::new();
2857    let mut skip_adds = HashSet::new();
2858    for node in graph.nodes() {
2859        let Op::LayerNorm { axis: _, eps: _ } = &node.op else {
2860            continue;
2861        };
2862        if node.inputs.len() < 3 {
2863            continue;
2864        } // need [in, gamma, beta]
2865        let in_id = node.inputs[0];
2866        let in_node = graph.node(in_id);
2867        if !matches!(in_node.op, Op::Binary(BinaryOp::Add)) {
2868            continue;
2869        }
2870        // Only fire when Add has >= 2 consumers (otherwise `FuseResidualLN`
2871        // already collapses it into Op::FusedResidualLN upstream).
2872        if consumers.get(&in_id).copied().unwrap_or(0) < 2 {
2873            continue;
2874        }
2875        // Add must be plain — both operands shape-equal to LN's input
2876        // and to each other.
2877        if in_node.inputs.len() != 2 {
2878            continue;
2879        }
2880        let h_id = in_node.inputs[0];
2881        let delta_id = in_node.inputs[1];
2882        if graph.node(h_id).shape.dims() != node.shape.dims() {
2883            continue;
2884        }
2885        if graph.node(delta_id).shape.dims() != node.shape.dims() {
2886            continue;
2887        }
2888        let gamma_id = node.inputs[1];
2889        let beta_id = node.inputs[2];
2890        ln_to_tee.insert(node.id, (h_id, delta_id, gamma_id, beta_id, in_id));
2891        skip_adds.insert(in_id);
2892    }
2893    (ln_to_tee, skip_adds)
2894}
2895
2896fn detect_split_qkv_pattern(graph: &Graph) -> HashMap<NodeId, (NodeId, NodeId, NodeId)> {
2897    // consumers[parent] = list of node ids that read parent
2898    let mut consumers: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
2899    for node in graph.nodes() {
2900        for &input in &node.inputs {
2901            consumers.entry(input).or_default().push(node.id);
2902        }
2903    }
2904    // Output nodes also count as consumers — would prevent QKV elision
2905    // if the matmul output is ever read externally.
2906    for &out_id in &graph.outputs {
2907        consumers.entry(out_id).or_default().push(NodeId(u32::MAX));
2908    }
2909
2910    let mut result = HashMap::new();
2911    for node in graph.nodes() {
2912        if !matches!(node.op, Op::FusedMatMulBiasAct { activation: None }) {
2913            continue;
2914        }
2915        let cs = match consumers.get(&node.id) {
2916            Some(c) if c.len() == 3 => c,
2917            _ => continue,
2918        };
2919        let dims = node.shape.dims();
2920        if dims.is_empty() {
2921            continue;
2922        }
2923        let last_axis = dims.len() - 1;
2924        let n = dims[last_axis].unwrap_static();
2925        if n % 3 != 0 {
2926            continue;
2927        }
2928        let head_width = n / 3;
2929
2930        // Each consumer must be a Narrow on the last axis, len = head_width.
2931        let mut narrows: Vec<(usize, NodeId)> = Vec::with_capacity(3);
2932        let mut all_match = true;
2933        for &c in cs {
2934            let cn = graph.node(c);
2935            match cn.op {
2936                Op::Narrow { axis, start, len }
2937                    if axis == last_axis && len == head_width && cn.inputs[0] == node.id =>
2938                {
2939                    narrows.push((start, c));
2940                }
2941                _ => {
2942                    all_match = false;
2943                    break;
2944                }
2945            }
2946        }
2947        if !all_match {
2948            continue;
2949        }
2950        narrows.sort_by_key(|&(start, _)| start);
2951        if narrows[0].0 != 0 || narrows[1].0 != head_width || narrows[2].0 != 2 * head_width {
2952            continue;
2953        }
2954        result.insert(node.id, (narrows[0].1, narrows[1].1, narrows[2].1));
2955    }
2956    result
2957}
2958
2959/// Walk through Cast/Reshape nodes (which alias the underlying arena
2960/// slot, per `plan_f32_uniform`) to find whether `id` ultimately
2961/// refers to an `Op::Param`. AutoMixedPrecision wraps params in
2962/// Cast(F32→F16) nodes, so a literal `matches!(node.op, Op::Param)`
2963/// check on the matmul's `b_id` would miss the Cast(Param) case.
2964fn node_is_arena_param(param_offsets: &HashMap<String, NodeId>, id: NodeId) -> bool {
2965    param_offsets.values().any(|&nid| nid == id)
2966}
2967
2968fn traces_to_param(graph: &Graph, mut id: NodeId) -> bool {
2969    loop {
2970        let node = graph.node(id);
2971        match &node.op {
2972            Op::Param { .. } => return true,
2973            Op::Cast { .. } | Op::Reshape { .. } | Op::Transpose { .. } => {
2974                if node.inputs.is_empty() {
2975                    return false;
2976                }
2977                id = node.inputs[0];
2978            }
2979            _ => return false,
2980        }
2981    }
2982}
2983
2984fn tensor_is_graph_param(
2985    graph: &Graph,
2986    param_offsets: &HashMap<String, NodeId>,
2987    id: NodeId,
2988) -> bool {
2989    node_is_arena_param(param_offsets, id) || traces_to_param(graph, id)
2990}
2991
2992fn traces_to_input(graph: &Graph, mut id: NodeId) -> bool {
2993    loop {
2994        let node = graph.node(id);
2995        match &node.op {
2996            Op::Input { .. } => return true,
2997            Op::Cast { .. } | Op::Reshape { .. } => {
2998                if node.inputs.is_empty() {
2999                    return false;
3000                }
3001                id = node.inputs[0];
3002            }
3003            _ => return false,
3004        }
3005    }
3006}
3007
3008/// Mirror A/B into the f16 shadow buffer before CoopF16Vk when the operand
3009/// is not already mirrored (Inputs/Params are written via `write_f32`).
3010fn schedule_uses_coop_f16_vk(schedule: &[Step]) -> bool {
3011    schedule.iter().any(|s| {
3012        matches!(
3013            s,
3014            Step::Matmul {
3015                compute_precision: MatmulCompute::CoopF16Vk,
3016                ..
3017            } | Step::MatmulQkv {
3018                kind: MatmulQkvKind::CoopF16Vk,
3019                ..
3020            }
3021        )
3022    })
3023}
3024
3025fn register_coop_f16_vk_b_param(
3026    map: &mut HashMap<u32, String>,
3027    param_offsets: &HashMap<String, NodeId>,
3028    b_id: NodeId,
3029    b_off_f32: u32,
3030    compute: MatmulCompute,
3031) {
3032    if compute != MatmulCompute::CoopF16Vk {
3033        return;
3034    }
3035    for (name, &id) in param_offsets {
3036        if id == b_id {
3037            map.insert(b_off_f32, name.clone());
3038            return;
3039        }
3040    }
3041}
3042
3043fn tensor_host_name(
3044    input_offsets: &HashMap<String, NodeId>,
3045    param_offsets: &HashMap<String, NodeId>,
3046    id: NodeId,
3047) -> String {
3048    for (name, &nid) in input_offsets {
3049        if nid == id {
3050            return name.clone();
3051        }
3052    }
3053    for (name, &nid) in param_offsets {
3054        if nid == id {
3055            return name.clone();
3056        }
3057    }
3058    panic!("rlx-wgpu: CoopF16Vk host activation source {id} is not an input or param");
3059}
3060
3061fn host_tensor_f32<'a>(
3062    name: &str,
3063    inputs: &'a [(&str, &[f32])],
3064    stashed_params: &'a HashMap<String, Vec<f32>>,
3065) -> Option<&'a [f32]> {
3066    inputs
3067        .iter()
3068        .find(|(n, _)| *n == name)
3069        .map(|(_, d)| *d)
3070        .or_else(|| stashed_params.get(name).map(|v| v.as_slice()))
3071}
3072
3073fn apply_activation_host(act: Activation, data: &[f32]) -> Vec<f32> {
3074    data.iter()
3075        .map(|&x| match act {
3076            Activation::Relu => x.max(0.0),
3077            Activation::Sigmoid => 1.0 / (1.0 + (-x).exp()),
3078            Activation::Tanh => x.tanh(),
3079            Activation::Exp => x.exp(),
3080            Activation::Log => x.ln(),
3081            Activation::Sqrt => x.sqrt(),
3082            Activation::Rsqrt => 1.0 / x.sqrt(),
3083            Activation::Neg => -x,
3084            Activation::Abs => x.abs(),
3085            Activation::Gelu | Activation::GeluApprox => {
3086                let c = 0.797_884_6_f32;
3087                let x3 = x * x * x;
3088                let inner = (c * (x + 0.044_715 * x3)).clamp(-15.0, 15.0);
3089                0.5 * x * (1.0 + inner.tanh())
3090            }
3091            Activation::Silu => {
3092                let nx = (-x).clamp(-88.0, 88.0);
3093                x / (1.0 + nx.exp())
3094            }
3095            Activation::Round => x.round(),
3096            Activation::Sin => x.sin(),
3097            Activation::Cos => x.cos(),
3098            Activation::Tan => x.tan(),
3099            Activation::Atan => x.atan(),
3100        })
3101        .collect()
3102}
3103
3104/// Activation node ids consumed as CoopF16Vk matmul A/B operands.
3105fn collect_coop_f16_vk_mirror_activations(graph: &Graph, dev: &wgpu::Device) -> HashSet<NodeId> {
3106    let mut acts = HashSet::new();
3107    for node in graph.nodes() {
3108        if !matches!(node.op, Op::MatMul) {
3109            continue;
3110        }
3111        let a_id = node.inputs[0];
3112        let b_id = node.inputs[1];
3113        let a_shape = graph.node(a_id).shape.dims();
3114        let b_shape = graph.node(b_id).shape.dims();
3115        if a_shape.len() != 2 || b_shape.len() != 2 {
3116            continue;
3117        }
3118        let m = a_shape[0].unwrap_static() as u32;
3119        let k = a_shape[1].unwrap_static() as u32;
3120        let n = b_shape[1].unwrap_static() as u32;
3121        if !coop_f16_vk_eligible(dev, m, k, n) || !traces_to_param(graph, b_id) {
3122            continue;
3123        }
3124        if matches!(graph.node(a_id).op, Op::Activation(_)) {
3125            acts.insert(a_id);
3126        }
3127        if matches!(graph.node(b_id).op, Op::Activation(_)) {
3128            acts.insert(b_id);
3129        }
3130    }
3131    acts
3132}
3133
3134/// When A/B are computed (not Input/Param), mirror f32 arena into f16 shadow
3135/// via `cast_f32_to_f16` before CoopF16Vk matmul (non-activation intermediates).
3136fn maybe_push_coop_f16_vk_casts(
3137    graph: &Graph,
3138    a_id: NodeId,
3139    b_id: NodeId,
3140    mirror_acts: &HashSet<NodeId>,
3141    device: &wgpu::Device,
3142    arena: &Arena,
3143    schedule: &mut Vec<Step>,
3144    uniforms: &mut Vec<wgpu::Buffer>,
3145    bind_groups: &mut Vec<wgpu::BindGroup>,
3146    mm_cast: &Option<&'static Kernel>,
3147    compute_precision: MatmulCompute,
3148    a_off_f32: u32,
3149    m: u32,
3150    k: u32,
3151    batch: u32,
3152    b_off_f32: u32,
3153    n: u32,
3154) {
3155    if compute_precision != MatmulCompute::CoopF16Vk {
3156        return;
3157    }
3158    let batch_n = batch.max(1);
3159    if !traces_to_input(graph, a_id)
3160        && !traces_to_param(graph, a_id)
3161        && !mirror_acts.contains(&a_id)
3162    {
3163        let a_elems = m.saturating_mul(k).saturating_mul(batch_n);
3164        let (base, size) = arena_window_for_nodes(device, arena, &[a_id]);
3165        push_cast_f32_to_f16_step(
3166            device,
3167            arena,
3168            base,
3169            size,
3170            schedule,
3171            uniforms,
3172            bind_groups,
3173            mm_cast,
3174            a_off_f32,
3175            a_elems,
3176        );
3177    }
3178    if !traces_to_input(graph, b_id)
3179        && !traces_to_param(graph, b_id)
3180        && !mirror_acts.contains(&b_id)
3181    {
3182        let b_elems = k.saturating_mul(n).saturating_mul(batch_n);
3183        let (base, size) = arena_window_for_nodes(device, arena, &[b_id]);
3184        push_cast_f32_to_f16_step(
3185            device,
3186            arena,
3187            base,
3188            size,
3189            schedule,
3190            uniforms,
3191            bind_groups,
3192            mm_cast,
3193            b_off_f32,
3194            b_elems,
3195        );
3196    }
3197}
3198
3199fn build_matmul_qkv_coop_f16_vk_bind_group(
3200    device: &wgpu::Device,
3201    mqk: &Kernel,
3202    arena: &Arena,
3203    arena_base: u64,
3204    arena_size: u64,
3205    params: &wgpu::Buffer,
3206    k: u32,
3207    n: u32,
3208    b_off: u32,
3209) -> (wgpu::BindGroup, u32) {
3210    let f16_buf = arena
3211        .f16_buffer
3212        .as_ref()
3213        .expect("CoopF16Vk QKV requires SHADER_F16 f16 shadow arena");
3214    let (f16_res, rebased_b) = {
3215        let (base, size, rebased) =
3216            f16_weight_bind_range(device, f16_buf.size(), b_off, k, n, 1, 0);
3217        (
3218            wgpu::BindingResource::Buffer(wgpu::BufferBinding {
3219                buffer: f16_buf,
3220                offset: base,
3221                size: NonZeroU64::new(size),
3222            }),
3223            rebased,
3224        )
3225    };
3226    (
3227        device.create_bind_group(&wgpu::BindGroupDescriptor {
3228            label: Some("rlx-wgpu matmul_qkv_coop_f16_vk bg"),
3229            layout: &mqk.bgl,
3230            entries: &[
3231                wgpu::BindGroupEntry {
3232                    binding: 0,
3233                    resource: f16_res,
3234                },
3235                wgpu::BindGroupEntry {
3236                    binding: 1,
3237                    resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
3238                        buffer: &arena.buffer,
3239                        offset: arena_base,
3240                        size: NonZeroU64::new(arena_size),
3241                    }),
3242                },
3243                wgpu::BindGroupEntry {
3244                    binding: 2,
3245                    resource: params.as_entire_binding(),
3246                },
3247            ],
3248        }),
3249        rebased_b,
3250    )
3251}
3252/// Append a CastF32ToF16 pre-pass: mirrors `arena[off..off+len]` (f32) into
3253/// `arena_f16[off..off+len]` (f16) so coop matmul kernels can read operands
3254/// as f16. Used before CoopF16Vk when A/B are computed activations.
3255fn push_cast_f32_to_f16_step(
3256    device: &wgpu::Device,
3257    arena: &Arena,
3258    arena_base: u64,
3259    arena_size: u64,
3260    schedule: &mut Vec<Step>,
3261    uniforms: &mut Vec<wgpu::Buffer>,
3262    bind_groups: &mut Vec<wgpu::BindGroup>,
3263    mm_cast: &Option<&'static Kernel>,
3264    src_off: u32,
3265    len: u32,
3266) {
3267    let kernel = match mm_cast {
3268        Some(k) => *k,
3269        None => return, // device lacks SHADER_F16; fall through, dispatch will skip
3270    };
3271    let f16_buf = match &arena.f16_buffer {
3272        Some(b) => b,
3273        None => return,
3274    };
3275    let p = CastF32ToF16Params {
3276        src_off: src_off.saturating_sub((arena_base / 4) as u32),
3277        len,
3278        _p0: 0,
3279        _p1: 0,
3280    };
3281    let u = device.create_buffer(&wgpu::BufferDescriptor {
3282        label: Some("rlx-wgpu cast_f32_to_f16 uniform"),
3283        size: std::mem::size_of::<CastF32ToF16Params>() as u64,
3284        usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
3285        mapped_at_creation: false,
3286    });
3287    // Write params at compile (kernel doesn't depend on active extent).
3288    let dev = wgpu_device().expect("rlx-wgpu: device gone");
3289    dev.queue.write_buffer(&u, 0, bytemuck::bytes_of(&p));
3290    let (f16_base, f16_size) = f16_shadow_bind_range(arena_base, arena_size, f16_buf.size());
3291    let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
3292        label: Some("rlx-wgpu cast_f32_to_f16 bg"),
3293        layout: &kernel.bgl,
3294        entries: &[
3295            wgpu::BindGroupEntry {
3296                binding: 0,
3297                resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
3298                    buffer: f16_buf,
3299                    offset: f16_base,
3300                    size: NonZeroU64::new(f16_size),
3301                }),
3302            },
3303            wgpu::BindGroupEntry {
3304                binding: 1,
3305                resource: u.as_entire_binding(),
3306            },
3307            wgpu::BindGroupEntry {
3308                binding: 2,
3309                resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
3310                    buffer: &arena.buffer,
3311                    offset: arena_base,
3312                    size: NonZeroU64::new(arena_size),
3313                }),
3314            },
3315        ],
3316    });
3317    schedule.push(Step::CastF32ToF16 { params: p });
3318    uniforms.push(u);
3319    bind_groups.push(bg);
3320}
3321
3322/// Per-Matmul-step bind group builder. Returns `(bind_group, rebased_b_off)`;
3323/// `rebased_b_off` adjusts `MatmulParams.b_off` when the f16 weight buffer is
3324/// window-bound.
3325fn build_matmul_bind_group(
3326    device: &wgpu::Device,
3327    mm_k: &Kernel,
3328    _mm_w: &Kernel,
3329    mm_f16w: &Option<&'static Kernel>,
3330    mm_f16c: &Option<&'static Kernel>,
3331    mm_coop: &Option<&'static Kernel>,
3332    mm_coop_f32: &Option<&'static Kernel>,
3333    arena: &Arena,
3334    arena_base: u64,
3335    arena_size: u64,
3336    params: &wgpu::Buffer,
3337    b_is_param: bool,
3338    compute_precision: MatmulCompute,
3339    k: u32,
3340    n: u32,
3341    batch: u32,
3342    b_off: u32,
3343    b_batch_stride: u32,
3344) -> (wgpu::BindGroup, u32) {
3345    let f16_bind = |b_off: u32| -> (wgpu::BindingResource<'_>, u32) {
3346        let f16_buf = arena
3347            .f16_buffer
3348            .as_ref()
3349            .expect("f16 weight bind without f16_buffer");
3350        let (base, size, rebased) =
3351            f16_weight_bind_range(device, f16_buf.size(), b_off, k, n, batch, b_batch_stride);
3352        (
3353            wgpu::BindingResource::Buffer(wgpu::BufferBinding {
3354                buffer: f16_buf,
3355                offset: base,
3356                size: NonZeroU64::new(size),
3357            }),
3358            rebased,
3359        )
3360    };
3361    if compute_precision == MatmulCompute::CoopF16Vk
3362        && let (Some(coop_vk), Some(_f16_buf)) =
3363            (matmul_coop_f16_vulkan_kernel(device), &arena.f16_buffer)
3364    {
3365        let (f16_res, rebased_b) = f16_bind(b_off);
3366        return (
3367            device.create_bind_group(&wgpu::BindGroupDescriptor {
3368                label: Some("rlx-wgpu matmul_coop_f16_vulkan bg"),
3369                layout: &coop_vk.bgl,
3370                entries: &[
3371                    wgpu::BindGroupEntry {
3372                        binding: 0,
3373                        resource: f16_res,
3374                    },
3375                    wgpu::BindGroupEntry {
3376                        binding: 1,
3377                        resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
3378                            buffer: &arena.buffer,
3379                            offset: arena_base,
3380                            size: NonZeroU64::new(arena_size),
3381                        }),
3382                    },
3383                    wgpu::BindGroupEntry {
3384                        binding: 2,
3385                        resource: params.as_entire_binding(),
3386                    },
3387                ],
3388            }),
3389            rebased_b,
3390        );
3391    }
3392    if b_is_param
3393        && compute_precision == MatmulCompute::CoopF32
3394        && let Some(coop_f32) = mm_coop_f32
3395    {
3396        // 2-binding layout — both A and B come from the f32 arena
3397        // (no f16 shadow buffer needed for the pure-f32 path).
3398        return (
3399            device.create_bind_group(&wgpu::BindGroupDescriptor {
3400                label: Some("rlx-wgpu matmul_coop_f32 bg"),
3401                layout: &coop_f32.bgl,
3402                entries: &[
3403                    wgpu::BindGroupEntry {
3404                        binding: 0,
3405                        resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
3406                            buffer: &arena.buffer,
3407                            offset: arena_base,
3408                            size: NonZeroU64::new(arena_size),
3409                        }),
3410                    },
3411                    wgpu::BindGroupEntry {
3412                        binding: 1,
3413                        resource: params.as_entire_binding(),
3414                    },
3415                ],
3416            }),
3417            b_off,
3418        );
3419    }
3420    if b_is_param
3421        && compute_precision == MatmulCompute::Coop16
3422        && let (Some(_f16_buf), Some(coop)) = (&arena.f16_buffer, mm_coop)
3423    {
3424        let (f16_res, rebased_b) = f16_bind(b_off);
3425        // 3-binding layout — A is staged from arena (f32) through
3426        // workgroup-shared memory inside the kernel, no separate
3427        // f16 binding for A.
3428        return (
3429            device.create_bind_group(&wgpu::BindGroupDescriptor {
3430                label: Some("rlx-wgpu matmul_coop16 bg"),
3431                layout: &coop.bgl,
3432                entries: &[
3433                    wgpu::BindGroupEntry {
3434                        binding: 0,
3435                        resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
3436                            buffer: &arena.buffer,
3437                            offset: arena_base,
3438                            size: NonZeroU64::new(arena_size),
3439                        }),
3440                    },
3441                    wgpu::BindGroupEntry {
3442                        binding: 1,
3443                        resource: params.as_entire_binding(),
3444                    },
3445                    wgpu::BindGroupEntry {
3446                        binding: 2,
3447                        resource: f16_res,
3448                    }, // weights
3449                ],
3450            }),
3451            rebased_b,
3452        );
3453    }
3454    if b_is_param
3455        && compute_precision == MatmulCompute::F16
3456        && let (Some(_f16_buf), Some(f16c)) = (&arena.f16_buffer, mm_f16c)
3457    {
3458        let (f16_res, rebased_b) = f16_bind(b_off);
3459        return (
3460            device.create_bind_group(&wgpu::BindGroupDescriptor {
3461                label: Some("rlx-wgpu matmul_f16_compute bg"),
3462                layout: &f16c.bgl,
3463                entries: &[
3464                    wgpu::BindGroupEntry {
3465                        binding: 0,
3466                        resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
3467                            buffer: &arena.buffer,
3468                            offset: arena_base,
3469                            size: NonZeroU64::new(arena_size),
3470                        }),
3471                    },
3472                    wgpu::BindGroupEntry {
3473                        binding: 1,
3474                        resource: params.as_entire_binding(),
3475                    },
3476                    wgpu::BindGroupEntry {
3477                        binding: 2,
3478                        resource: f16_res,
3479                    },
3480                ],
3481            }),
3482            rebased_b,
3483        );
3484    }
3485    let f16w_opt_in = rlx_ir::env::flag("RLX_WGPU_F16_WEIGHTS");
3486    if b_is_param
3487        && f16w_opt_in
3488        && let (Some(_f16_buf), Some(f16w)) = (&arena.f16_buffer, mm_f16w)
3489    {
3490        let (f16_res, rebased_b) = f16_bind(b_off);
3491        return (
3492            device.create_bind_group(&wgpu::BindGroupDescriptor {
3493                label: Some("rlx-wgpu matmul_f16w bg"),
3494                layout: &f16w.bgl,
3495                entries: &[
3496                    wgpu::BindGroupEntry {
3497                        binding: 0,
3498                        resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
3499                            buffer: &arena.buffer,
3500                            offset: arena_base,
3501                            size: NonZeroU64::new(arena_size),
3502                        }),
3503                    },
3504                    wgpu::BindGroupEntry {
3505                        binding: 1,
3506                        resource: params.as_entire_binding(),
3507                    },
3508                    wgpu::BindGroupEntry {
3509                        binding: 2,
3510                        resource: f16_res,
3511                    },
3512                ],
3513            }),
3514            rebased_b,
3515        );
3516    }
3517    (
3518        bind_two_buf0_window(device, mm_k, &arena.buffer, arena_base, arena_size, params),
3519        b_off,
3520    )
3521}