Skip to main content

rlx_wgpu/
backend.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, has_dynamic_dims, infer_bindings_from_f32_inputs, same_binding};
26use rlx_ir::op::{Activation, BinaryOp, CmpOp, MaskKind, ReduceOp};
27use rlx_ir::shape::DimBinding;
28use rlx_ir::{Graph, NodeId, Op};
29
30use crate::buffer::{
31    Arena, ReadbackLayout, ReadbackStaging, TinyReadbackStaging, decode_mapped_readback_f32,
32    decode_tiny_mapped_f32, encode_readback_copies, plan_f32_uniform, read_f32_many_pooled,
33    schedule_readback_map, use_tiny_readback, wait_readback_map,
34};
35use crate::device::wgpu_device;
36use crate::kernels::{
37    ArgmaxParams, AttentionBwdParams, AttentionParams, BatchElementwiseRegionParams, BinaryParams,
38    Conv1dParams, Conv2dParams, Conv3dParams, CopyParams, CumsumBwdParams, CumsumParams,
39    DequantMatmulParams, ElementwiseRegionParams, ExpandParams, FmaParams, FusedResidualLnParams,
40    FusedResidualLnTeeParams, FusedResidualRmsNormParams, GatherAxisParams, GatherBwdParams,
41    GatherParams, GroupedMatmulParams, GruParams, Kernel, LayerNormBwdParams, LayerNormParams,
42    Mamba2Params, MatmulParams, MatmulQkvParams, NarrowConcatParams, Pool1dParams, Pool2dParams,
43    Pool3dParams, ReduceParams, RmsNormBwdParams, RnnParams, RopeBwdParams, RopeParams,
44    SampleParams, ScatterAddParams, SceParams, SelectiveScanParams, SoftmaxParams, TopKParams,
45    TransposeParams, UmapKnnParams, UnaryParams, WelchPeaksGpuParams, WhereParams, argmax_kernel,
46    attention_bwd_kernel, attention_kernel, batch_elementwise_region_kernel, binary_kernel,
47    cast_f32_to_f16_kernel, compare_kernel, concat_kernel, conv1d_kernel, conv2d_kernel,
48    conv3d_kernel, copy_kernel, cumsum_backward_kernel, cumsum_kernel, dequant_matmul_kernel,
49    elementwise_region_kernel, elementwise_region_spatial_kernel, expand_kernel, fma_kernel,
50    fused_residual_ln_kernel, fused_residual_ln_tee_kernel, fused_residual_rms_norm_kernel,
51    gather_axis_kernel, gather_backward_acc_kernel, gather_backward_zero_kernel, gather_kernel,
52    gather_split_kernel, grouped_matmul_kernel, gru_kernel,
53    layer_norm_backward_gamma_partial_kernel, layer_norm_backward_gamma_reduce_kernel,
54    layer_norm_backward_input_kernel, layernorm_kernel, mamba2_kernel,
55    matmul_coop_f16_vulkan_active_kernel, matmul_coop_f16_vulkan_kernel,
56    matmul_coop_f32_active_kernel, matmul_coop16_kernel, matmul_f16_compute_kernel,
57    matmul_f16w_kernel, matmul_kernel, matmul_qkv_coop_f16_vk_active_kernel,
58    matmul_qkv_coop_f16_vk_kernel, matmul_qkv_coop_f32_kernel, matmul_qkv_kernel,
59    matmul_wide_active_kernel, matmul_wide_kernel, narrow_kernel, pool1d_kernel, pool2d_kernel,
60    pool3d_kernel, reduce_kernel, rms_norm_backward_kernel, rms_norm_backward_param_kernel,
61    rnn_kernel, rope_backward_kernel, rope_kernel, sample_kernel, scatter_add_kernel,
62    selective_scan_kernel, softmax_cross_entropy_kernel, softmax_kernel, topk_kernel,
63    transpose_kernel, umap_knn_kernel, unary_f16_mirror_kernel, unary_kernel,
64    welch_peaks_gpu_kernel, where_kernel,
65};
66/// Compute the maximum tail-scratch bytes any single op needs across
67/// the graph. Currently only `Op::LayerNormBackwardGamma` uses scratch
68/// — it stores `num_workgroups * H` f32 partial sums.
69fn compute_scratch_bytes(graph: &rlx_ir::Graph) -> usize {
70    const ROWS_PER_WG: u32 = 16;
71    let mut max_bytes = 0usize;
72    for node in graph.nodes() {
73        // Norm staging: when params live far from activations in the arena,
74        // wgpu's `max_storage_buffer_binding_size` can prevent binding a
75        // single window that covers both. We reserve a small scratch tail
76        // zone so we can copy gamma/beta next to activations via
77        // `copy_buffer_to_buffer` and keep shader bindings local.
78        if matches!(
79            &node.op,
80            rlx_ir::Op::LayerNorm { .. } | rlx_ir::Op::RmsNorm { .. }
81        ) {
82            let x_shape = &graph.node(node.inputs[0]).shape;
83            let h_dim = x_shape.dim(x_shape.rank() - 1);
84            if h_dim.is_static() {
85                let h = h_dim.unwrap_static();
86                // gamma + beta, 256B-aligned for binding offsets.
87                let bytes = ((h * 4).div_ceil(256) * 256) * 2;
88                if bytes > max_bytes {
89                    max_bytes = bytes;
90                }
91            }
92        }
93        if let rlx_ir::Op::LayerNormBackwardGamma { .. } = &node.op {
94            let x_shape = &graph.node(node.inputs[0]).shape;
95            let Some(elems) = x_shape.num_elements() else {
96                continue;
97            };
98            let h_dim = x_shape.dim(x_shape.rank() - 1);
99            if !h_dim.is_static() {
100                continue;
101            }
102            let h = h_dim.unwrap_static();
103            if h == 0 {
104                continue;
105            }
106            let rows = (elems / h) as u32;
107            let num_workgroups = rows.div_ceil(ROWS_PER_WG.max(1));
108            let bytes = (num_workgroups as usize) * h * 4;
109            if bytes > max_bytes {
110                max_bytes = bytes;
111            }
112        }
113    }
114    // Reserve extra scratch for staging small far-apart operands when the
115    // arena exceeds wgpu's binding window. This keeps compile-time simple
116    // and avoids per-op scratch sizing plumbing.
117    max_bytes.max(64 * 1024 * 1024)
118}
119
120/// FNV-1a over f32 payload bytes — skips redundant `queue.write_buffer`
121/// when bench/inference feeds identical input tensors across runs.
122fn hash_f32_input(data: &[f32]) -> u64 {
123    let bytes = bytemuck::cast_slice(data);
124    let mut h: u64 = 0xcbf29ce484222325;
125    h ^= data.len() as u64;
126    h = h.wrapping_mul(0x100000001b3);
127    for chunk in bytes.chunks(8) {
128        let mut arr = [0u8; 8];
129        arr[..chunk.len()].copy_from_slice(chunk);
130        h ^= u64::from_le_bytes(arr);
131        h = h.wrapping_mul(0x100000001b3);
132    }
133    h
134}
135
136/// Inner-FMA precision for matmul.
137///   F32    — full f32 path (matmul.wgsl / matmul_wide.wgsl).
138///   F16    — f16 multiply, f32 acc (matmul_f16_compute.wgsl).
139///   Coop16 — cooperative-matrix 8×8 hardware GEMM
140///            (matmul_coop16.wgsl, simdgroup_multiply_accumulate on
141///             Apple, OpCooperativeMatrixMulAddKHR on Vulkan).
142///            Requires M/N/K multiples of 8, b is a Param, and
143///            both SHADER_F16 + EXPERIMENTAL_COOPERATIVE_MATRIX.
144///            Caller must ensure A is mirrored to arena_f16 first
145///            (the lowering inserts a `Step::CastF32ToF16` pre-pass).
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147enum MatmulCompute {
148    F32,
149    F16,
150    Coop16,
151    /// Cooperative-matrix on Apple's `simdgroup_float8x8` — same hardware
152    /// GEMM unit as Coop16 but with f32 operands and f32 accumulator.
153    /// No precision loss vs F32 baseline; no f16 overflow risk in deep
154    /// FFN sums. Used when alignment + features allow but the IR is f32.
155    CoopF32,
156    /// Vulkan/NVIDIA 16×16 f16 tensor-core matmul with K-slab f32
157    /// reduction (avoids Naga mixed f16/f32 coop_mat bugs).
158    CoopF16Vk,
159}
160
161/// Split-write QKV matmul kernel selection.
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163enum MatmulQkvKind {
164    F32,
165    CoopF32,
166    CoopF16Vk,
167}
168
169/// f32 → f16 element-wise cast, mirroring an arena region into the
170/// f16 shadow buffer. Used as a pre-pass before `matmul_coop16` so
171/// the matmul's A operand (a runtime activation, not a Param) is
172/// readable as f16.
173///
174/// Currently unused — the matmul_coop16 kernel stages A through
175/// workgroup-shared memory directly from the f32 arena. Kept for
176/// future paths that may want a one-shot cast (e.g. before a chain
177/// of f16-only kernels operating on a fixed activation region).
178#[allow(dead_code)]
179#[derive(Debug, Clone, Copy)]
180struct CastF32ToF16Params {
181    pub src_off: u32, // f32-element offset into arena (also f16-element offset)
182    pub len: u32,
183    pub _p0: u32,
184    pub _p1: u32,
185}
186unsafe impl bytemuck::Pod for CastF32ToF16Params {}
187unsafe impl bytemuck::Zeroable for CastF32ToF16Params {}
188
189/// One dispatch step in the compiled schedule.
190///
191/// `dead_code` is allowed at the enum level: several variants carry
192/// fields (mask_buf, meta_idx, compute_precision discriminants) that
193/// are only consulted at compile time during bind-group construction,
194/// or are kept to extend buffer lifetimes (mask_buf). A few variants
195/// (CastF32ToF16, Copy, the unreachable F16 compute_precision) are
196/// retained for future paths.
197#[allow(dead_code)]
198enum Step {
199    CastF32ToF16 {
200        params: CastF32ToF16Params,
201    },
202    Matmul {
203        m: u32,
204        k: u32,
205        n: u32,
206        a_off_f32: u32,
207        b_off_f32: u32,
208        c_off_f32: u32,
209        batch: u32,
210        a_batch_stride: u32,
211        b_batch_stride: u32,
212        c_batch_stride: u32,
213        has_bias: u32,
214        bias_off_f32: u32,
215        act_id: u32, // 0xFFFF = no activation
216        // True iff input B is a Param node — i.e. a model weight that
217        // doesn't change between `run()` calls. Read from the f16
218        // shadow buffer (half memory bandwidth) when set + the device
219        // exposes SHADER_F16. Set at compile time; consulted only by
220        // the dispatch arm.
221        b_is_param: bool,
222        // Compute precision for the inner FMA. F32 = full precision
223        // (the historical / default path). F16 = mixed-precision
224        // (operands cast to f16, multiply in f16 for 2× ALU on Apple,
225        // accumulator in f32). Set at compile time from the IR's
226        // dtype after AutoMixedPrecision policy.
227        compute_precision: MatmulCompute,
228    },
229    Binary {
230        params: BinaryParams,
231    },
232    Compare {
233        params: BinaryParams,
234    },
235    Unary {
236        params: UnaryParams,
237        f16_mirror: bool,
238    },
239    Where {
240        params: WhereParams,
241    },
242    Fma {
243        params: FmaParams,
244    },
245    Reduce {
246        params: ReduceParams,
247    },
248    Softmax {
249        params: SoftmaxParams,
250    },
251    SoftmaxCrossEntropy {
252        params: SceParams,
253    },
254    LayerNorm {
255        params: LayerNormParams,
256    },
257    Cumsum {
258        params: CumsumParams,
259    },
260    /// Native multi-kernel f32 FFT (gpu-fft dispatch strategy).
261    FftGpu {
262        src_off: u32,
263        dst_off: u32,
264        outer: u32,
265        n: u32,
266        inverse: u32,
267        norm_scale: f32,
268    },
269    /// Explicit host FFT (D2H → rlx-cpu → H2D). Used when the native
270    /// WGSL kernel cannot handle dtype / size / non-pow-2 constraints.
271    FftHost {
272        src_byte_off: u32,
273        dst_byte_off: u32,
274        outer: u32,
275        n_complex: u32,
276        inverse: bool,
277        norm_tag: u32,
278        dtype_tag: u32,
279    },
280    /// Welch PSD top-K — D2H → rlx-cpu → H2D.
281    WelchPeaksHost {
282        spec_byte_off: u32,
283        dst_byte_off: u32,
284        welch_batch: u32,
285        n_fft: u32,
286        n_segments: u32,
287        k: u32,
288    },
289    LogMelHost {
290        spec_byte_off: u32,
291        filt_byte_off: u32,
292        dst_byte_off: u32,
293        outer: u32,
294        n_fft: u32,
295        n_bins: u32,
296        n_mels: u32,
297    },
298    LogMelBackwardHost {
299        spec_byte_off: u32,
300        filt_byte_off: u32,
301        dy_byte_off: u32,
302        dst_byte_off: u32,
303        outer: u32,
304        n_fft: u32,
305        n_bins: u32,
306        n_mels: u32,
307    },
308    /// NCHW im2col host path (D2H → rlx-cpu → H2D).
309    Im2ColHost {
310        x_byte_off: u32,
311        col_byte_off: u32,
312        n: u32,
313        c_in: u32,
314        h: u32,
315        w: u32,
316        h_out: u32,
317        w_out: u32,
318        kh: u32,
319        kw: u32,
320        sh: u32,
321        sw: u32,
322        ph: u32,
323        pw: u32,
324        dh: u32,
325        dw_dil: u32,
326    },
327    /// Host fill for [`Op::RngNormal`] (fill → H2D).
328    RngNormalHost {
329        dst_byte_off: u32,
330        len: u32,
331        mean: f32,
332        scale: f32,
333        key: u64,
334        op_seed: Option<f32>,
335    },
336    /// Host fill for [`Op::RngUniform`] (fill → H2D).
337    RngUniformHost {
338        dst_byte_off: u32,
339        len: u32,
340        low: f32,
341        high: f32,
342        key: u64,
343        op_seed: Option<f32>,
344    },
345    /// Host-side buffer copy (recorded into a command encoder) used to
346    /// stage small param tensors into the tail scratch region so kernels
347    /// can bind a ≤4GiB window of the arena.
348    BufferCopy {
349        // u64: staging copies for >4 GiB GGUF decode arenas address tensors past
350        // the 4 GiB mark; a u32 byte offset truncates and stages garbage.
351        src_byte_off: u64,
352        dst_byte_off: u64,
353        bytes: u32,
354    },
355    Copy {
356        params: CopyParams,
357    },
358    /// PLAN L2 — fused N-ary element-wise region. Lowered from
359    /// `Op::ElementwiseRegion` by `MarkElementwiseRegions`. Kernel
360    /// interprets the chain encoding per-element (saves N kernel
361    /// dispatches + N global-memory round-trips vs the decomposed
362    /// atomic ops).
363    ElementwiseRegion {
364        params: ElementwiseRegionParams,
365    },
366    BatchElementwiseRegion {
367        params: BatchElementwiseRegionParams,
368    },
369    Transpose {
370        params: TransposeParams,
371        meta_idx: usize,
372    },
373    Narrow {
374        params: NarrowConcatParams,
375    },
376    Concat {
377        params: NarrowConcatParams,
378    }, // one Step per input
379    Gather {
380        params: GatherParams,
381    },
382    GatherAxis {
383        params: GatherAxisParams,
384    },
385    Attention {
386        params: AttentionParams,
387        mask_buf: Option<wgpu::Buffer>,
388    },
389    AttentionBackward {
390        params: AttentionBwdParams,
391        mask_buf: Option<wgpu::Buffer>,
392    },
393    Rope {
394        params: RopeParams,
395    },
396    Expand {
397        params: ExpandParams,
398        meta_idx: usize,
399    },
400    Argmax {
401        params: ArgmaxParams,
402    },
403    Pool2d {
404        params: Pool2dParams,
405    },
406    Conv2d {
407        params: Conv2dParams,
408    },
409    Pool1d {
410        params: Pool1dParams,
411    },
412    Pool3d {
413        params: Pool3dParams,
414    },
415    Conv1d {
416        params: Conv1dParams,
417    },
418    Conv3d {
419        params: Conv3dParams,
420    },
421    ScatterAdd {
422        params: ScatterAddParams,
423    },
424    TopK {
425        params: TopKParams,
426    },
427    WelchPeaksGpu {
428        params: WelchPeaksGpuParams,
429    },
430    GroupedMatmul {
431        params: GroupedMatmulParams,
432    },
433    Sample {
434        params: SampleParams,
435    },
436    SelectiveScan {
437        params: SelectiveScanParams,
438    },
439    /// Native WGSL Mamba-2 SSD scan (state_size ≤ 256).
440    Mamba2 {
441        params: Mamba2Params,
442    },
443    /// Native WGSL GRU (single-layer/unidir/no-carry, hidden ≤ 256).
444    Gru {
445        params: GruParams,
446    },
447    /// Native WGSL Elman RNN (single-layer/unidir/no-carry, hidden ≤ 256).
448    Rnn {
449        params: RnnParams,
450    },
451    /// Host-staged GRU fallback (multi-layer / bidir / carry / hidden > 256).
452    GruHost {
453        x: u32,
454        w_ih: u32,
455        w_hh: u32,
456        b_ih: u32,
457        b_hh: u32,
458        h0: u32,
459        dst: u32,
460        batch: u32,
461        seq: u32,
462        input_size: u32,
463        hidden: u32,
464        num_layers: u32,
465        bidirectional: bool,
466        carry: bool,
467    },
468    /// Host-staged Elman RNN fallback.
469    RnnHost {
470        x: u32,
471        w_ih: u32,
472        w_hh: u32,
473        bias: u32,
474        h0: u32,
475        dst: u32,
476        batch: u32,
477        seq: u32,
478        input_size: u32,
479        hidden: u32,
480        num_layers: u32,
481        bidirectional: bool,
482        carry: bool,
483        relu: bool,
484    },
485    DequantMatmul {
486        params: DequantMatmulParams,
487    },
488    /// Split-binding embedding gather for >4 GiB arenas. The table and the
489    /// idx/output slots are more than one ≤4 GiB binding window apart, so the
490    /// single-arena-binding `Step::Gather` cannot reach the output. Runs as a
491    /// host segment (its own submission + copy-back), like `DequantMatmulGguf`.
492    /// BYTE offsets are u64 (arena exceeds 4 GiB).
493    GatherSplit {
494        n_out: u32,
495        n_idx: u32,
496        dim: u32,
497        vocab: u32,
498        table_byte_off: u64,
499        idx_byte_off: u64,
500        out_byte_off: u64,
501    },
502    /// GGUF K-quant — host fused dequant+matmul between GPU segments.
503    DequantMatmulGguf {
504        m: u32,
505        k: u32,
506        n: u32,
507        scheme_id: u32,
508        // Arena BYTE offsets must be u64: GGUF decode arenas exceed 4 GiB
509        // (Orpheus-3B Q4_K_M is ~10 GiB), so a u32 byte offset truncates for
510        // any tensor past the 4 GiB mark and the host dequant reads garbage.
511        x_byte_off: u64,
512        w_byte_off: u64,
513        out_byte_off: u64,
514    },
515    /// GGUF K-quant — host fused dequant+grouped matmul between GPU segments.
516    DequantGroupedMatmulGguf {
517        m: u32,
518        k: u32,
519        n: u32,
520        num_experts: u32,
521        scheme_id: u32,
522        x_byte_off: u64,
523        w_byte_off: u64,
524        idx_byte_off: u64,
525        out_byte_off: u64,
526    },
527    /// Gated-DeltaNet — host scan between GPU segments (qwen35 linear layers).
528    GatedDeltaNet {
529        q_byte_off: u32,
530        k_byte_off: u32,
531        v_byte_off: u32,
532        g_byte_off: u32,
533        beta_byte_off: u32,
534        state_byte_off: u32,
535        dst_byte_off: u32,
536        batch: u32,
537        seq: u32,
538        heads: u32,
539        state_size: u32,
540        use_carry: bool,
541    },
542    Lstm {
543        x_byte_off: u32,
544        w_ih_byte_off: u32,
545        w_hh_byte_off: u32,
546        bias_byte_off: u32,
547        h0_byte_off: u32,
548        c0_byte_off: u32,
549        dst_byte_off: u32,
550        batch: u32,
551        seq: u32,
552        input_size: u32,
553        hidden: u32,
554        num_layers: u32,
555        bidirectional: bool,
556        carry: bool,
557    },
558    ConvTranspose2d {
559        src_byte_off: u32,
560        weight_byte_off: u32,
561        dst_byte_off: u32,
562        n: u32,
563        c_in: u32,
564        h: u32,
565        w_in: u32,
566        c_out: u32,
567        h_out: u32,
568        w_out: u32,
569        kh: u32,
570        kw: u32,
571        sh: u32,
572        sw: u32,
573        ph: u32,
574        pw: u32,
575        dh: u32,
576        dw: u32,
577        groups: u32,
578    },
579    /// Host-staged NCHW GroupNorm (readback → CPU → writeback).
580    GroupNormHost {
581        src_byte_off: u32,
582        gamma_byte_off: u32,
583        beta_byte_off: u32,
584        dst_byte_off: u32,
585        n: u32,
586        c: u32,
587        h: u32,
588        w: u32,
589        num_groups: u32,
590        eps: f32,
591    },
592    /// Host-staged NCHW LayerNorm2d (readback → CPU → writeback).
593    LayerNorm2dHost {
594        src_byte_off: u32,
595        gamma_byte_off: u32,
596        beta_byte_off: u32,
597        dst_byte_off: u32,
598        n: u32,
599        c: u32,
600        h: u32,
601        w: u32,
602        eps: f32,
603    },
604    /// Host-staged nearest 2× upsample on NCHW (readback → CPU → writeback).
605    ResizeNearest2xHost {
606        src_byte_off: u32,
607        dst_byte_off: u32,
608        n: u32,
609        c: u32,
610        h: u32,
611        w: u32,
612    },
613    /// Host-staged batch-general reverse/flip (readback → CPU → writeback).
614    ReverseHost {
615        src_byte_off: u32,
616        dst_byte_off: u32,
617        dims: Vec<u32>,
618        rev_mask: Vec<bool>,
619        elem_bytes: u32,
620    },
621    /// Host-staged ArgMax/ArgMin (readback → CPU → writeback).
622    ArgReduceHost {
623        src_byte_off: u32,
624        dst_byte_off: u32,
625        outer: u32,
626        reduced: u32,
627        inner: u32,
628        is_max: bool,
629    },
630    Llada2GroupLimitedGate {
631        sig_byte_off: u32,
632        route_byte_off: u32,
633        out_byte_off: u32,
634        n_elems: u32,
635        attrs: [u8; 20],
636    },
637    UmapKnn {
638        params: UmapKnnParams,
639    },
640    /// Small-`n` host k-NN (partial arena read/write; avoids GPU launch overhead).
641    UmapKnnHost {
642        pairwise_byte_off: u32,
643        out_byte_off: u32,
644        n: u32,
645        k: u32,
646    },
647    /// Fused multi-scale deformable attention (host compute over arena buffers).
648    MsDeformAttnHost {
649        in_offs: Vec<(u32, u32)>, // (byte_off, byte_len) per input
650        out_byte_off: u32,
651        out_bytes: u32,
652        attrs: Vec<u8>,
653    },
654    /// 3D Gaussian splat forward (CPU reference between segments).
655    #[cfg(feature = "splat")]
656    GaussianSplatRender {
657        positions_byte_off: u32,
658        positions_len: u32,
659        scales_byte_off: u32,
660        scales_len: u32,
661        rotations_byte_off: u32,
662        rotations_len: u32,
663        opacities_byte_off: u32,
664        opacities_len: u32,
665        colors_byte_off: u32,
666        colors_len: u32,
667        sh_coeffs_byte_off: u32,
668        sh_coeffs_len: u32,
669        meta_byte_off: u32,
670        dst_byte_off: u32,
671        dst_len: u32,
672        width: u32,
673        height: u32,
674        tile_size: u32,
675        radius_scale: f32,
676        alpha_cutoff: f32,
677        max_splat_steps: u32,
678        transmittance_threshold: f32,
679        max_list_entries: u32,
680    },
681    /// Backward splat — host round-trip via rlx-cpu/splat.
682    #[cfg(feature = "splat")]
683    GaussianSplatRenderBackward {
684        positions_byte_off: u32,
685        positions_len: u32,
686        scales_byte_off: u32,
687        scales_len: u32,
688        rotations_byte_off: u32,
689        rotations_len: u32,
690        opacities_byte_off: u32,
691        opacities_len: u32,
692        colors_byte_off: u32,
693        colors_len: u32,
694        sh_coeffs_byte_off: u32,
695        sh_coeffs_len: u32,
696        meta_byte_off: u32,
697        d_loss_byte_off: u32,
698        d_loss_len: u32,
699        packed_byte_off: u32,
700        packed_len: u32,
701        width: u32,
702        height: u32,
703        tile_size: u32,
704        radius_scale: f32,
705        alpha_cutoff: f32,
706        max_splat_steps: u32,
707        transmittance_threshold: f32,
708        max_list_entries: u32,
709        loss_grad_clip: f32,
710        sh_band: u32,
711        max_anisotropy: f32,
712    },
713    #[cfg(feature = "splat")]
714    GaussianSplatPrepare {
715        positions_byte_off: u32,
716        positions_len: u32,
717        scales_byte_off: u32,
718        scales_len: u32,
719        rotations_byte_off: u32,
720        rotations_len: u32,
721        opacities_byte_off: u32,
722        opacities_len: u32,
723        colors_byte_off: u32,
724        colors_len: u32,
725        sh_coeffs_byte_off: u32,
726        sh_coeffs_len: u32,
727        meta_byte_off: u32,
728        meta_len: u32,
729        prep_byte_off: u32,
730        prep_len: u32,
731        width: u32,
732        height: u32,
733        tile_size: u32,
734        radius_scale: f32,
735        alpha_cutoff: f32,
736        max_splat_steps: u32,
737        transmittance_threshold: f32,
738        max_list_entries: u32,
739    },
740    #[cfg(feature = "splat")]
741    GaussianSplatRasterize {
742        prep_byte_off: u32,
743        prep_len: u32,
744        meta_byte_off: u32,
745        meta_len: u32,
746        dst_byte_off: u32,
747        dst_len: u32,
748        count: u32,
749        width: u32,
750        height: u32,
751        tile_size: u32,
752        alpha_cutoff: f32,
753        max_splat_steps: u32,
754        transmittance_threshold: f32,
755        max_list_entries: u32,
756    },
757    RmsNormBackwardInput {
758        params: RmsNormBwdParams,
759    },
760    RmsNormBackwardGamma {
761        params: RmsNormBwdParams,
762    },
763    RmsNormBackwardBeta {
764        params: RmsNormBwdParams,
765    },
766    LayerNormBackwardInput {
767        params: LayerNormBwdParams,
768    },
769    LayerNormBackwardGammaPartial {
770        params: LayerNormBwdParams,
771        num_workgroups: u32,
772    },
773    LayerNormBackwardGammaReduce {
774        params: LayerNormBwdParams,
775    },
776    RopeBackward {
777        params: RopeBwdParams,
778    },
779    CumsumBackward {
780        params: CumsumBwdParams,
781    },
782    GatherBackward {
783        params: GatherBwdParams,
784    },
785    FusedResidualLn {
786        params: FusedResidualLnParams,
787    },
788    /// Split-write QKV matmul. Replaces a (FusedMatMulBiasAct → Narrow×3)
789    /// pattern with one dispatch that writes Q, K, V into separate
790    /// contiguous buffers from a single matmul pass. See
791    /// `kernels/matmul_qkv.wgsl`.
792    MatmulQkv {
793        params: MatmulQkvParams,
794        kind: MatmulQkvKind,
795    },
796    /// `fused_residual_ln_tee` — does (Add → LN) but writes the sum to
797    /// a separate arena slot (the eliminated Add's old slot). Fires
798    /// when the Add has multi-consumer downstream (vision pre-norm).
799    FusedResidualLnTee {
800        params: FusedResidualLnTeeParams,
801    },
802    FusedResidualRmsNorm {
803        params: FusedResidualRmsNormParams,
804    },
805}
806
807pub struct WgpuExecutable {
808    graph: Graph,
809    arena: Arena,
810    /// Byte offset of GGUF dequant scratch slab (0 when host fallback).
811    dequant_scratch_off: usize,
812    schedule: Vec<Step>,
813    input_offsets: HashMap<String, NodeId>,
814    param_offsets: HashMap<String, NodeId>,
815    /// One uniform buffer + bind group per dispatch step. Pre-allocated
816    /// so run() just writes new bytes per step.
817    uniforms: Vec<wgpu::Buffer>,
818    bind_groups: Vec<wgpu::BindGroup>,
819    /// Per-step metadata storage buffers (only Transpose uses them).
820    /// Indexed by `Step::Transpose.meta_idx`.
821    meta_buffers: Vec<wgpu::Buffer>,
822
823    // ── Lazy dynamic-shape state ─────────────────────────────────
824    /// The originally-supplied graph (pre-resolution). Only set when
825    /// the input graph contained `Dim::Dynamic` entries — otherwise
826    /// `None` and the compiled fields above are authoritative. On each
827    /// `run()` we infer a `DimBinding` from the live input data, and
828    /// if it differs from `last_binding` we re-resolve + recompile.
829    unresolved: Option<Graph>,
830    last_binding: Option<DimBinding>,
831    /// Buffered params written via `set_param` / `set_param_bytes`
832    /// before the first `run()`. Replayed against the freshly compiled
833    /// arena once shapes resolve.
834    pending_params: HashMap<String, Vec<f32>>,
835    pending_param_bytes: HashMap<String, Vec<u8>>,
836    /// Active-extent hint (PLAN L1). When set + every Step in the
837    /// safe set, both the uniform write and the dispatch workgroup
838    /// count are scaled by `actual / upper`. Otherwise full-extent.
839    pub(crate) active_extent: Option<(usize, usize)>,
840    /// Skip-redundant-uniform-writes guard. Each `run()` would
841    /// otherwise re-`queue.write_buffer` ~115 per-step uniforms (one
842    /// per dispatched op in BERT) even when their bytes are identical
843    /// to the previous call's. At small batches, that fixed write +
844    /// staging-copy overhead is the dominant cost. We track the last
845    /// active-extent value the uniforms were written for; subsequent
846    /// `run()`s with the same `active_extent` (and `recompile`-clean
847    /// schedule) skip the entire uniform-write loop. `None` ⇒ never
848    /// written; `Some(x)` ⇒ uniforms hold params for active_extent=x.
849    uniforms_active_extent: Option<Option<(usize, usize)>>,
850    /// Last-upload fingerprint per input name; skips staging when unchanged.
851    input_staging_hashes: HashMap<String, u64>,
852    /// True when the schedule contains CoopF16Vk matmul (disables f32-only
853    /// input upload skip — the f16 shadow must stay in sync each run).
854    coop_f16_vk: bool,
855    /// CoopF16Vk Param B offsets (f32 arena / 4) → param name for wide routing.
856    coop_f16_b_param: HashMap<u32, String>,
857    /// Param names flagged by the oscillation probe for wide f32 fallback.
858    coop_f16_vk_wide_b: HashSet<String>,
859    /// Wide f32 bind groups for CoopF16Vk steps (schedule index → bg).
860    coop_f16_vk_wide_bind_groups: HashMap<usize, wgpu::BindGroup>,
861    /// CoopF16Vk activation operands mirrored on the host each `run()` (f32+f16).
862    coop_f16_host_activations: Vec<(NodeId, Activation, String)>,
863    /// Last `set_param` f32 payload per name (for host activation mirrors).
864    stashed_params: HashMap<String, Vec<f32>>,
865    /// Reused output readback staging (avoids per-run buffer alloc).
866    readback_staging: Option<ReadbackStaging>,
867    /// Persistent tiny readback buffer for single scalar outputs.
868    tiny_readback: Option<TinyReadbackStaging>,
869    /// When set, `run_inner` dispatches + submits all compute but skips the
870    /// blocking output readback (results stay in the arena). Used by the wasm
871    /// `run_async` path, which then reads outputs back asynchronously — the
872    /// browser event loop cannot be blocked. Always false on native.
873    dispatch_only: bool,
874    /// Per-`FftGpu` step: isolated uniform buffers + bind groups (one vec entry per op).
875    fft_gpu_steps: Vec<crate::fft_dispatch::FftGpuResources>,
876    /// Persistent KV inputs (host staging uploaded each run).
877    gpu_handles: HashMap<String, Vec<f32>>,
878    gpu_handle_feeds: HashMap<String, usize>,
879    /// Arena input slots authoritative — skip host KV mirror each decode step.
880    gpu_handle_resident: HashSet<String>,
881    pending_read_indices: Option<Vec<usize>>,
882    /// Runtime-mutable RNG policy for [`Step::RngNormalHost`] / [`Step::RngUniformHost`].
883    rng: std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
884}
885
886impl Step {
887    /// True when this Step variant honors active-extent dispatch (PLAN L1).
888    /// Coverage: simple element-wise + reductions + matmul + linalg
889    /// + reductions/argmax/topk/sample + gather + conv + pool +
890    /// scatter (zero output + scale num_updates) + macros gated to
891    /// batch=1 (Attention, SelectiveScan).
892    pub fn safe_for_active_extent(&self) -> bool {
893        match self {
894            Step::Binary { .. }
895            | Step::Compare { .. }
896            | Step::Unary { .. }
897            | Step::Where { .. }
898            | Step::Fma { .. }
899            | Step::Reduce { .. }
900            | Step::Softmax { .. }
901            | Step::SoftmaxCrossEntropy { .. }
902            | Step::LayerNorm { .. }
903            | Step::FusedResidualLn { .. }
904            | Step::FusedResidualLnTee { .. }
905            | Step::FusedResidualRmsNorm { .. }
906            | Step::Cumsum { .. }
907            | Step::Copy { .. }
908            | Step::ElementwiseRegion { .. }
909            | Step::BatchElementwiseRegion { .. }
910            | Step::Argmax { .. }
911            | Step::TopK { .. }
912            | Step::WelchPeaksGpu { .. }
913            | Step::Sample { .. }
914            | Step::Gather { .. }
915            | Step::GatherAxis { .. }
916            | Step::GatherSplit { .. }
917            | Step::GroupedMatmul { .. }
918            | Step::DequantMatmul { .. }
919            | Step::DequantMatmulGguf { .. }
920            | Step::DequantGroupedMatmulGguf { .. }
921            | Step::GatedDeltaNet { .. }
922            | Step::Lstm { .. }
923            | Step::ConvTranspose2d { .. }
924            | Step::GroupNormHost { .. }
925            | Step::LayerNorm2dHost { .. }
926            | Step::ResizeNearest2xHost { .. }
927            | Step::ReverseHost { .. }
928            | Step::ArgReduceHost { .. }
929            | Step::GruHost { .. }
930            | Step::RnnHost { .. }
931            | Step::Llada2GroupLimitedGate { .. }
932            | Step::UmapKnn { .. }
933            | Step::UmapKnnHost { .. }
934            | Step::MsDeformAttnHost { .. }
935            | Step::Conv1d { .. }
936            | Step::Conv2d { .. }
937            | Step::Conv3d { .. }
938            | Step::Pool1d { .. }
939            | Step::Pool2d { .. }
940            | Step::Pool3d { .. }
941            | Step::ScatterAdd { .. }
942            | Step::BufferCopy { .. } => true,
943            // FFT: full-extent transform per row, no active-extent
944            // scaling. Marking true so a graph that mixes FFT with
945            // active-extent-safe ops still gets the optimization for
946            // the rest of the schedule.
947            Step::FftGpu { .. } | Step::FftHost { .. } => true,
948            Step::Im2ColHost { .. }
949            | Step::RngNormalHost { .. }
950            | Step::RngUniformHost { .. }
951            | Step::WelchPeaksHost { .. }
952            | Step::LogMelHost { .. }
953            | Step::LogMelBackwardHost { .. } => true,
954            // Matmul: c_batch_stride is set at compile time at full m,
955            // independent of params.m. With scaled m, threads with
956            // global_row >= m early-return; per-batch output offsets
957            // stay correct. Safe at any batch.
958            Step::Matmul { .. } => true,
959            // Same active-extent reasoning as Matmul: per-batch output
960            // strides are baked at compile time, scaling m only adjusts
961            // the per-thread bound check.
962            Step::MatmulQkv { .. } => true,
963            Step::CastF32ToF16 { .. } => true,
964            // Attention: WGSL kernel uses `seq_q_stride`/`seq_k_stride`
965            // (full extent, set at compile time) for per-(batch, head)
966            // offset math, and `params.seq_q`/`params.seq_k` for loop
967            // bounds only. Scaling seq_q/seq_k shrinks the iteration
968            // without corrupting per-head strides. Safe at any batch.
969            Step::Attention { .. } => true,
970            Step::AttentionBackward { .. } => true,
971            // SelectiveScan: WGSL kernel uses `params.seq_stride`
972            // (full extent, set at compile time) for per-batch stride
973            // math; `params.seq` is the loop bound only. Safe at any
974            // batch under active-extent scaling of seq.
975            Step::SelectiveScan { .. } => true,
976            // Mamba2: same seq_stride discipline as SelectiveScan.
977            Step::Mamba2 { .. } => true,
978            // GRU/RNN: per-batch workgroups; seq_stride is full-extent, seq is
979            // the loop bound only. Safe under active-extent scaling.
980            Step::Gru { .. } => true,
981            Step::Rnn { .. } => true,
982            // Narrow + Concat: kernel iterates `params.total` in
983            // row-major order with outer as the leading dim. Scaling
984            // total by actual/upper effectively scales outer by the
985            // same factor (since total = outer * axis_size * inner).
986            // Output positions past scaled_total stay untouched.
987            // **Conservative assumption**: bucket axis is outer.
988            // Cases where the bucket axis is the narrow/concat axis
989            // itself are unsafe — fall back to full extent there.
990            Step::Narrow { .. } => true,
991            Step::Concat { .. } => true,
992            // Rope: WGSL kernel uses `seq_stride` (full extent, set
993            // at compile time) for per-batch buffer offset math and
994            // explicit `batch` for index decomposition. `params.seq`
995            // and `params.n_total` are runtime-scaled iteration
996            // bounds. Safe at any batch.
997            Step::Rope { .. } => true,
998            // Transpose: precomputed `bucket_outermost` flag in
999            // params (set to 1 at compile time iff `perm[0] == 0`).
1000            // Active path scales `out_total` by `actual / upper`
1001            // proportional to `out_dim_0`. Other transposes (where
1002            // bucket axis moves) fall back to full extent.
1003            Step::Transpose { params, .. } => params.bucket_outermost == 1,
1004            // Expand: same shape as Transpose. `bucket_outermost` is
1005            // 1 iff `in_dims[0] == out_dims[0]` (no broadcast at the
1006            // bucket axis).
1007            Step::Expand { params, .. } => params.bucket_outermost == 1,
1008            // Training backward ops: not used in inference; disable
1009            // active-extent fast path until individually audited.
1010            Step::RmsNormBackwardInput { .. }
1011            | Step::RmsNormBackwardGamma { .. }
1012            | Step::RmsNormBackwardBeta { .. }
1013            | Step::LayerNormBackwardInput { .. }
1014            | Step::LayerNormBackwardGammaPartial { .. }
1015            | Step::LayerNormBackwardGammaReduce { .. }
1016            | Step::RopeBackward { .. }
1017            | Step::CumsumBackward { .. }
1018            | Step::GatherBackward { .. } => false,
1019            #[cfg(feature = "splat")]
1020            Step::GaussianSplatRender { .. }
1021            | Step::GaussianSplatRenderBackward { .. }
1022            | Step::GaussianSplatPrepare { .. }
1023            | Step::GaussianSplatRasterize { .. } => false,
1024        }
1025    }
1026}
1027
1028/// Static-string label for each Step variant — used by the Perfetto
1029/// trace layer (PLAN L3) to mark per-step events without allocating.
1030fn fft_dtype_tag(dtype: rlx_ir::DType) -> u32 {
1031    match dtype {
1032        rlx_ir::DType::F32 => 0,
1033        rlx_ir::DType::F64 => 1,
1034        rlx_ir::DType::C64 => 2,
1035        other => panic!("rlx-wgpu Op::Fft: unsupported dtype {other:?}"),
1036    }
1037}
1038
1039fn fft_dtype_from_tag(tag: u32) -> rlx_ir::DType {
1040    match tag {
1041        0 => rlx_ir::DType::F32,
1042        1 => rlx_ir::DType::F64,
1043        2 => rlx_ir::DType::C64,
1044        other => panic!("rlx-wgpu Op::Fft: bad dtype tag {other}"),
1045    }
1046}
1047
1048fn step_name(step: &Step) -> &'static str {
1049    match step {
1050        Step::CastF32ToF16 { .. } => "cast_f32_to_f16",
1051        Step::Matmul { .. } => "matmul",
1052        Step::Binary { .. } => "binary",
1053        Step::Compare { .. } => "compare",
1054        Step::Unary { .. } => "unary",
1055        Step::Where { .. } => "where",
1056        Step::Fma { .. } => "fma",
1057        Step::Reduce { .. } => "reduce",
1058        Step::Softmax { .. } => "softmax",
1059        Step::SoftmaxCrossEntropy { .. } => "softmax_cross_entropy",
1060        Step::LayerNorm { .. } => "layer_norm",
1061        Step::Cumsum { .. } => "cumsum",
1062        Step::FftGpu { .. } => "fft_gpu",
1063        Step::FftHost { .. } => "fft_host",
1064        Step::WelchPeaksHost { .. } => "welch_peaks_host",
1065        Step::LogMelHost { .. } => "log_mel_host",
1066        Step::LogMelBackwardHost { .. } => "log_mel_backward_host",
1067        Step::Im2ColHost { .. } => "im2col_host",
1068        Step::RngNormalHost { .. } => "rng_normal_host",
1069        Step::RngUniformHost { .. } => "rng_uniform_host",
1070        Step::BufferCopy { .. } => "buffer_copy",
1071        Step::Copy { .. } => "copy",
1072        Step::Transpose { .. } => "transpose",
1073        Step::Narrow { .. } => "narrow",
1074        Step::Concat { .. } => "concat",
1075        Step::Gather { .. } => "gather",
1076        Step::GatherAxis { .. } => "gather_axis",
1077        Step::Attention { .. } => "attention",
1078        Step::AttentionBackward { .. } => "attention_bwd",
1079        Step::Rope { .. } => "rope",
1080        Step::Expand { .. } => "expand",
1081        Step::Argmax { .. } => "argmax",
1082        Step::Pool2d { .. } => "pool2d",
1083        Step::Conv2d { .. } => "conv2d",
1084        Step::Pool1d { .. } => "pool1d",
1085        Step::Pool3d { .. } => "pool3d",
1086        Step::Conv1d { .. } => "conv1d",
1087        Step::Conv3d { .. } => "conv3d",
1088        Step::ScatterAdd { .. } => "scatter_add",
1089        Step::TopK { .. } => "topk",
1090        Step::WelchPeaksGpu { .. } => "welch_peaks_gpu",
1091        Step::GroupedMatmul { .. } => "grouped_matmul",
1092        Step::Sample { .. } => "sample",
1093        Step::SelectiveScan { .. } => "selective_scan",
1094        Step::Mamba2 { .. } => "mamba2",
1095        Step::Gru { .. } => "gru",
1096        Step::Rnn { .. } => "rnn",
1097        Step::GruHost { .. } => "gru_host",
1098        Step::RnnHost { .. } => "rnn_host",
1099        Step::DequantMatmul { .. } => "dequant_matmul",
1100        Step::GatherSplit { .. } => "gather_split",
1101        Step::DequantMatmulGguf { .. } => "dequant_matmul_gguf",
1102        Step::DequantGroupedMatmulGguf { .. } => "dequant_grouped_matmul_gguf",
1103        Step::GatedDeltaNet { .. } => "gated_delta_net",
1104        Step::Lstm { .. } => "lstm",
1105        Step::ConvTranspose2d { .. } => "conv_transpose2d",
1106        Step::GroupNormHost { .. } => "group_norm_host",
1107        Step::LayerNorm2dHost { .. } => "layer_norm2d_host",
1108        Step::ResizeNearest2xHost { .. } => "resize_nearest2x_host",
1109        Step::ReverseHost { .. } => "reverse_host",
1110        Step::ArgReduceHost { .. } => "argreduce_host",
1111        Step::Llada2GroupLimitedGate { .. } => "llada2_group_limited_gate",
1112        Step::UmapKnn { .. } => "umap_knn",
1113        Step::UmapKnnHost { .. } => "umap_knn_host",
1114        Step::MsDeformAttnHost { .. } => "ms_deform_attn_host",
1115        #[cfg(feature = "splat")]
1116        Step::GaussianSplatRender { .. } => "gaussian_splat_render",
1117        #[cfg(feature = "splat")]
1118        Step::GaussianSplatRenderBackward { .. } => "gaussian_splat_render_backward",
1119        #[cfg(feature = "splat")]
1120        Step::GaussianSplatPrepare { .. } => "gaussian_splat_prepare",
1121        #[cfg(feature = "splat")]
1122        Step::GaussianSplatRasterize { .. } => "gaussian_splat_rasterize",
1123        Step::RmsNormBackwardInput { .. } => "rms_norm_backward_input",
1124        Step::RmsNormBackwardGamma { .. } => "rms_norm_backward_gamma",
1125        Step::RmsNormBackwardBeta { .. } => "rms_norm_backward_beta",
1126        Step::LayerNormBackwardInput { .. } => "layer_norm_backward_input",
1127        Step::LayerNormBackwardGammaPartial { .. } => "layer_norm_backward_gamma_partial",
1128        Step::LayerNormBackwardGammaReduce { .. } => "layer_norm_backward_gamma_reduce",
1129        Step::RopeBackward { .. } => "rope_backward",
1130        Step::CumsumBackward { .. } => "cumsum_backward",
1131        Step::GatherBackward { .. } => "gather_backward",
1132        Step::FusedResidualLn { .. } => "fused_residual_ln",
1133        Step::FusedResidualLnTee { .. } => "fused_residual_ln_tee",
1134        Step::FusedResidualRmsNorm { .. } => "fused_residual_rms_norm",
1135        Step::MatmulQkv { .. } => "matmul_qkv",
1136        Step::ElementwiseRegion { .. } => "elementwise_region",
1137        Step::BatchElementwiseRegion { .. } => "batch_elementwise_region",
1138    }
1139}
1140
1141fn step_is_tail_host(step: &Step) -> bool {
1142    matches!(
1143        step,
1144        Step::WelchPeaksHost { .. } | Step::LogMelHost { .. } | Step::LogMelBackwardHost { .. }
1145    )
1146}
1147
1148fn step_runs_on_host(step: &Step) -> bool {
1149    match step {
1150        Step::GatherSplit { .. }
1151        | Step::DequantMatmulGguf { .. }
1152        | Step::DequantGroupedMatmulGguf { .. }
1153        | Step::GatedDeltaNet { .. }
1154        | Step::Lstm { .. }
1155        | Step::ConvTranspose2d { .. }
1156        | Step::GroupNormHost { .. }
1157        | Step::LayerNorm2dHost { .. }
1158        | Step::ResizeNearest2xHost { .. }
1159        | Step::ReverseHost { .. }
1160        | Step::ArgReduceHost { .. }
1161        | Step::GruHost { .. }
1162        | Step::RnnHost { .. }
1163        | Step::Llada2GroupLimitedGate { .. }
1164        | Step::UmapKnnHost { .. }
1165        | Step::MsDeformAttnHost { .. }
1166        | Step::FftHost { .. }
1167        | Step::Im2ColHost { .. }
1168        | Step::RngNormalHost { .. }
1169        | Step::RngUniformHost { .. }
1170        | Step::BufferCopy { .. } => true,
1171        #[cfg(feature = "splat")]
1172        Step::GaussianSplatRender { .. }
1173        | Step::GaussianSplatRenderBackward { .. }
1174        | Step::GaussianSplatPrepare { .. }
1175        | Step::GaussianSplatRasterize { .. } => true,
1176        _ => false,
1177    }
1178}
1179
1180fn binary_op_id(op: BinaryOp) -> u32 {
1181    match op {
1182        BinaryOp::Add => 0,
1183        BinaryOp::Sub => 1,
1184        BinaryOp::Mul => 2,
1185        BinaryOp::Div => 3,
1186        BinaryOp::Max => 4,
1187        BinaryOp::Min => 5,
1188        BinaryOp::Pow => 6,
1189    }
1190}
1191
1192fn compare_op_id(op: CmpOp) -> u32 {
1193    match op {
1194        CmpOp::Eq => 0,
1195        CmpOp::Ne => 1,
1196        CmpOp::Lt => 2,
1197        CmpOp::Le => 3,
1198        CmpOp::Gt => 4,
1199        CmpOp::Ge => 5,
1200    }
1201}
1202
1203fn reduce_op_id(op: ReduceOp) -> u32 {
1204    match op {
1205        ReduceOp::Sum => 0,
1206        ReduceOp::Mean => 1,
1207        ReduceOp::Max => 2,
1208        ReduceOp::Min => 3,
1209        ReduceOp::Prod => 4,
1210    }
1211}
1212
1213fn activation_op_id(act: Activation) -> u32 {
1214    match act {
1215        Activation::Relu => 0,
1216        Activation::Sigmoid => 1,
1217        Activation::Tanh => 2,
1218        Activation::Exp => 3,
1219        Activation::Log => 4,
1220        Activation::Sqrt => 5,
1221        Activation::Rsqrt => 6,
1222        Activation::Neg => 7,
1223        Activation::Abs => 8,
1224        Activation::Gelu => 9,
1225        Activation::Silu => 10,
1226        Activation::GeluApprox => 11,
1227        Activation::Round => 12,
1228        Activation::Sin => 13,
1229        Activation::Cos => 14,
1230        Activation::Tan => 15,
1231        Activation::Atan => 16,
1232    }
1233}
1234
1235impl WgpuExecutable {
1236    /// Resolve the deferred graph against bindings inferred from
1237    /// `inputs`, recompile the inner state if the bindings changed
1238    /// since the last call, and replay any pending params.
1239    fn lazy_compile_for_inputs(&mut self, inputs: &[(&str, &[f32])]) {
1240        let unresolved = self
1241            .unresolved
1242            .as_ref()
1243            .expect("lazy_compile_for_inputs called without an unresolved graph");
1244        let binding = infer_bindings_from_f32_inputs(unresolved, inputs)
1245            .expect("rlx-wgpu lazy compile: could not infer DimBinding from inputs");
1246
1247        // No-op if shapes haven't changed since the last compile.
1248        if let Some(prev) = &self.last_binding
1249            && same_binding(prev, &binding)
1250        {
1251            return;
1252        }
1253
1254        // Resolve and recompile.
1255        let resolved = bind_graph(unresolved, &binding);
1256        let original = self.unresolved.take();
1257        let pending_params = std::mem::take(&mut self.pending_params);
1258        let pending_bytes = std::mem::take(&mut self.pending_param_bytes);
1259
1260        let fresh = Self::compile_static_inner(resolved, self.rng.clone());
1261
1262        // Move the freshly-compiled fields into self, preserve the
1263        // unresolved+binding state for the next round.
1264        self.graph = fresh.graph;
1265        self.arena = fresh.arena;
1266        self.dequant_scratch_off = fresh.dequant_scratch_off;
1267        self.schedule = fresh.schedule;
1268        self.input_offsets = fresh.input_offsets;
1269        self.param_offsets = fresh.param_offsets;
1270        self.uniforms = fresh.uniforms;
1271        self.bind_groups = fresh.bind_groups;
1272        self.meta_buffers = fresh.meta_buffers;
1273        self.unresolved = original;
1274        self.last_binding = Some(binding);
1275        // Recompiled — uniforms are now empty buffers; force re-write
1276        // on next run().
1277        self.uniforms_active_extent = None;
1278        self.input_staging_hashes.clear();
1279        self.coop_f16_vk = fresh.coop_f16_vk;
1280        self.coop_f16_b_param = fresh.coop_f16_b_param;
1281        self.coop_f16_vk_wide_bind_groups = fresh.coop_f16_vk_wide_bind_groups;
1282        self.coop_f16_host_activations = fresh.coop_f16_host_activations;
1283
1284        // Replay pending param uploads against the new arena.
1285        for (name, data) in pending_params {
1286            self.set_param(&name, &data);
1287        }
1288        for (name, data) in pending_bytes {
1289            self.set_param_bytes(&name, &data);
1290        }
1291    }
1292
1293    /// Compile against an explicit `DimBinding`. Each `Dim::Dynamic`
1294    /// in the graph that maps to a symbol in `bindings` is replaced
1295    /// with `Dim::Static(size)` before the standard compile runs.
1296    /// Symbols not in the binding stay dynamic — and then `compile`
1297    /// will panic with the usual diagnostic.
1298    pub fn compile_with_bindings(graph: Graph, bindings: &DimBinding) -> Self {
1299        if bindings.is_empty() {
1300            return Self::compile(graph);
1301        }
1302        // Walk the graph and bind every node's shape.
1303        let mut fresh = Graph::new(&graph.name);
1304        for node in graph.nodes() {
1305            let bound = node.shape.bind(bindings);
1306            fresh.add_node(node.op.clone(), node.inputs.clone(), bound);
1307        }
1308        fresh.set_outputs(graph.outputs.clone());
1309        Self::compile(fresh)
1310    }
1311
1312    pub fn compile(graph: Graph) -> Self {
1313        Self::compile_rng(graph, rlx_ir::RngOptions::default())
1314    }
1315
1316    pub fn compile_rng(graph: Graph, rng: rlx_ir::RngOptions) -> Self {
1317        let rng = std::sync::Arc::new(std::sync::RwLock::new(rng));
1318        if has_dynamic_dims(&graph) {
1319            return Self::deferred(graph, rng);
1320        }
1321        Self::compile_static_inner(graph, rng)
1322    }
1323
1324    /// Override RNG policy for in-graph random ops without recompiling.
1325    pub fn set_rng(&mut self, rng: rlx_ir::RngOptions) {
1326        *self.rng.write().expect("rng lock") = rng;
1327    }
1328
1329    /// Current RNG compile/execute policy.
1330    pub fn rng(&self) -> rlx_ir::RngOptions {
1331        *self.rng.read().expect("rng lock")
1332    }
1333
1334    /// Test hook: first `Step::Attention` Q sequence stride (600 = packed QKV).
1335    #[doc(hidden)]
1336    pub fn test_attn_q_seq_stride(&self) -> Option<u32> {
1337        self.schedule.iter().find_map(|s| {
1338            if let Step::Attention { params, .. } = s {
1339                Some(params.q_seq_stride)
1340            } else {
1341                None
1342            }
1343        })
1344    }
1345
1346    /// Test hook: `(q_off, k_off, v_off, q_seq_stride)` for the first attention step.
1347    #[doc(hidden)]
1348    pub fn test_attn_offsets_and_stride(&self) -> Option<(u32, u32, u32, u32)> {
1349        self.schedule.iter().find_map(|s| {
1350            if let Step::Attention { params, .. } = s {
1351                Some((
1352                    params.q_off,
1353                    params.k_off,
1354                    params.v_off,
1355                    params.q_seq_stride,
1356                ))
1357            } else {
1358                None
1359            }
1360        })
1361    }
1362
1363    /// Global arena offset in f32 elements (not bind-window-local).
1364    #[doc(hidden)]
1365    pub fn test_arena_offset_elems(&self, id: NodeId) -> u32 {
1366        (self.arena.offset(id) / 4) as u32
1367    }
1368
1369    /// Compile placeholder for a graph with `Dim::Dynamic` entries.
1370    /// The real compile happens on the first `run()` once input data
1371    /// reveals the symbol → size bindings. Buffered params (set via
1372    /// `set_param` / `set_param_bytes` before run) are replayed.
1373    fn deferred(graph: Graph, rng: std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>) -> Self {
1374        let dev = wgpu_device().expect("rlx-wgpu: no compatible adapter found");
1375        // Minimal valid arena buffer. Replaced on first run().
1376        let placeholder = dev.device.create_buffer(&wgpu::BufferDescriptor {
1377            label: Some("rlx-wgpu deferred placeholder"),
1378            size: 16,
1379            usage: wgpu::BufferUsages::STORAGE
1380                | wgpu::BufferUsages::COPY_DST
1381                | wgpu::BufferUsages::COPY_SRC,
1382            mapped_at_creation: false,
1383        });
1384        let arena = Arena {
1385            buffer: placeholder,
1386            f16_buffer: None,
1387            offsets: HashMap::new(),
1388            lens: HashMap::new(),
1389            size: 0,
1390            scratch_off: 0,
1391            scratch_bytes: 0,
1392        };
1393        Self {
1394            graph: graph.clone(),
1395            arena,
1396            dequant_scratch_off: 0,
1397            schedule: Vec::new(),
1398            input_offsets: HashMap::new(),
1399            param_offsets: HashMap::new(),
1400            uniforms: Vec::new(),
1401            bind_groups: Vec::new(),
1402            meta_buffers: Vec::new(),
1403            unresolved: Some(graph),
1404            last_binding: None,
1405            pending_params: HashMap::new(),
1406            pending_param_bytes: HashMap::new(),
1407            active_extent: None,
1408            uniforms_active_extent: None,
1409            input_staging_hashes: HashMap::new(),
1410            coop_f16_vk: false,
1411            coop_f16_b_param: HashMap::new(),
1412            coop_f16_vk_wide_b: HashSet::new(),
1413            coop_f16_vk_wide_bind_groups: HashMap::new(),
1414            coop_f16_host_activations: Vec::new(),
1415            stashed_params: HashMap::new(),
1416            readback_staging: None,
1417            tiny_readback: None,
1418            dispatch_only: false,
1419            fft_gpu_steps: Vec::new(),
1420            gpu_handles: HashMap::new(),
1421            gpu_handle_feeds: HashMap::new(),
1422            gpu_handle_resident: HashSet::new(),
1423            pending_read_indices: None,
1424            rng,
1425        }
1426    }
1427
1428    /// Hint the next `run` to process only the first `actual` rows
1429    /// along the bucket axis (out of `upper`, the compile extent).
1430    /// Honored when every Step is in the safe set. See PLAN L1.
1431    pub fn set_active_extent(&mut self, extent: Option<(usize, usize)>) {
1432        self.active_extent = extent;
1433    }
1434
1435    fn all_safe_for_active(&self) -> bool {
1436        self.schedule.iter().all(|s| s.safe_for_active_extent())
1437    }
1438
1439    fn compile_static_inner(
1440        graph: Graph,
1441        rng: std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
1442    ) -> Self {
1443        let dev = wgpu_device().expect("rlx-wgpu: no compatible adapter found");
1444
1445        // Decompose composed/fused ops (FusedMatMulBiasAct, LoraMatMul,
1446        // FusedAttentionBlock, FusedTransformerLayer, ...) into primitive
1447        // sequences before memory planning so every intermediate gets a
1448        // regular arena slot. CPU/Metal/MLX lower the fused variants
1449        // directly with bespoke kernels; we choose simplicity over peak
1450        // throughput here.
1451        let graph = crate::unfuse::unfuse(graph);
1452
1453        // f32-uniform slots + liveness reuse (pairwise `[n,n]` graphs).
1454        let mut plan = plan_f32_uniform(&graph, 16);
1455        let dequant_scratch = crate::gguf_gpu::dequant_gguf_scratch_bytes(&graph);
1456        let dequant_scratch_off = if dequant_scratch > 0 {
1457            let aligned = plan.arena_size.div_ceil(16) * 16;
1458            let new_size = aligned + dequant_scratch.div_ceil(16) * 16;
1459            if (new_size as u64) <= dev.device.limits().max_buffer_size {
1460                plan.arena_size = new_size;
1461                aligned
1462            } else {
1463                0
1464            }
1465        } else {
1466            0
1467        };
1468        // Pre-walk to compute the max scratch any single op needs.
1469        // Currently only `Op::LayerNormBackwardGamma` uses scratch
1470        // (`num_workgroups * H * 4` bytes for the partial-sums buffer).
1471        let scratch_bytes = compute_scratch_bytes(&graph);
1472        let mut arena = Arena::from_plan_with_scratch(&dev.device, &plan, scratch_bytes);
1473        // Override slot lengths with the actual elem*4 byte counts so
1474        // readback returns the right element count (slots may be
1475        // padded for alignment).
1476        for node in graph.nodes() {
1477            let elems = node.shape.num_elements().unwrap_or(0);
1478            arena.set_actual_len(node.id, elems * 4);
1479        }
1480
1481        // Initialize Constants directly into the arena. The wgpu arena is f32-
1482        // uniform (4 bytes/elem; integer inputs are widened on upload), so integer
1483        // and bool constants must be converted to f32 here too — writing their raw
1484        // bytes would corrupt them (e.g. the VITS sequence-mask `arange` i64 const,
1485        // 8 bytes/elem, read back as f32 garbage → all-zero mask → dead encoder).
1486        for node in graph.nodes() {
1487            if let Op::Constant { data } = &node.op
1488                && arena.has(node.id)
1489                && !data.is_empty()
1490            {
1491                let widened: Option<Vec<u8>> = match node.shape.dtype() {
1492                    rlx_ir::DType::I64 => Some(
1493                        data.chunks_exact(8)
1494                            .flat_map(|c| {
1495                                (i64::from_le_bytes(c.try_into().unwrap()) as f32).to_le_bytes()
1496                            })
1497                            .collect(),
1498                    ),
1499                    rlx_ir::DType::I32 => Some(
1500                        data.chunks_exact(4)
1501                            .flat_map(|c| {
1502                                (i32::from_le_bytes(c.try_into().unwrap()) as f32).to_le_bytes()
1503                            })
1504                            .collect(),
1505                    ),
1506                    rlx_ir::DType::U32 => Some(
1507                        data.chunks_exact(4)
1508                            .flat_map(|c| {
1509                                (u32::from_le_bytes(c.try_into().unwrap()) as f32).to_le_bytes()
1510                            })
1511                            .collect(),
1512                    ),
1513                    rlx_ir::DType::Bool | rlx_ir::DType::U8 => Some(
1514                        data.iter()
1515                            .flat_map(|&b| (b as f32).to_le_bytes())
1516                            .collect(),
1517                    ),
1518                    rlx_ir::DType::I8 => Some(
1519                        data.iter()
1520                            .flat_map(|&b| ((b as i8) as f32).to_le_bytes())
1521                            .collect(),
1522                    ),
1523                    _ => None,
1524                };
1525                let bytes: &[u8] = widened.as_deref().unwrap_or(data);
1526                let bytes_to_write = bytes.len().min(arena.len_of(node.id));
1527                dev.queue.write_buffer(
1528                    &arena.buffer,
1529                    arena.offset(node.id) as u64,
1530                    &bytes[..bytes_to_write],
1531                );
1532            }
1533        }
1534
1535        let mut input_offsets = HashMap::new();
1536        let mut param_offsets = HashMap::new();
1537        for node in graph.nodes() {
1538            match &node.op {
1539                Op::Input { name } => {
1540                    input_offsets.insert(name.clone(), node.id);
1541                }
1542                Op::Param { name } => {
1543                    param_offsets.insert(name.clone(), node.id);
1544                }
1545                _ => {}
1546            }
1547        }
1548
1549        let mm_k = matmul_kernel(&dev.device);
1550        let mm_w = matmul_wide_kernel(&dev.device);
1551        let _mm_w_active = matmul_wide_active_kernel(&dev.device);
1552        let mm_f16w = matmul_f16w_kernel(&dev.device);
1553        let mm_f16c = matmul_f16_compute_kernel(&dev.device);
1554        let mm_coop = matmul_coop16_kernel(&dev.device);
1555        let mm_coop_f32 = matmul_coop_f32_active_kernel(&dev.device);
1556        let mm_cast = cast_f32_to_f16_kernel(&dev.device);
1557        let bk = binary_kernel(&dev.device);
1558        let uk = unary_kernel(&dev.device);
1559        let ck = compare_kernel(&dev.device);
1560        let wk = where_kernel(&dev.device);
1561        let fk = fma_kernel(&dev.device);
1562
1563        let mut schedule = Vec::new();
1564        let mut uniforms = Vec::new();
1565        let mut bind_groups = Vec::new();
1566        let mut fft_gpu_steps: Vec<crate::fft_dispatch::FftGpuResources> = Vec::new();
1567        let mut gguf_host_pad: Option<(wgpu::Buffer, wgpu::BindGroup)> = None;
1568        let mut meta_buffers: Vec<wgpu::Buffer> = Vec::new();
1569        let mut coop_f16_b_param: HashMap<u32, String> = HashMap::new();
1570        let mut coop_f16_vk_wide_bind_groups: HashMap<usize, wgpu::BindGroup> = HashMap::new();
1571        let mm_w_active_compile = matmul_wide_active_kernel(&dev.device);
1572
1573        let coop_f16_vk_mirror_acts = collect_coop_f16_vk_mirror_activations(&graph, &dev.device);
1574
1575        // Detect (FusedMatMulBiasAct → Narrow×3) split-QKV pattern. Returns
1576        // a map parent_node_id → (q_narrow_id, k_narrow_id, v_narrow_id).
1577        // The matmul_qkv kernel collapses the matmul + 3 narrows into one
1578        // dispatch by routing each output column to the right Q/K/V sink.
1579        //
1580        // CRITICAL: only mark a pattern site for elision when the parent
1581        // FMB will actually take the MatmulQkv path (which only fires
1582        // for F32 compute precision). For Coop16/CoopF32-eligible FMBs,
1583        // those kernels write to the FMB's *own* output slot, NOT the
1584        // 3 narrow slots — skipping the narrows would leave Q/K/V
1585        // uninitialized and attention would read garbage. Predict the
1586        // compute precision the FMB will receive; only skip when F32.
1587        let mut qkv_split: HashMap<NodeId, (NodeId, NodeId, NodeId)> = HashMap::new();
1588        for (parent_id, qkv) in detect_split_qkv_pattern(&graph) {
1589            let parent = graph.node(parent_id);
1590            // Mirror the lowering's precision derivation. FMB inputs:
1591            // [a, w, bias]; we need (m, k, n) to query.
1592            let a_id = parent.inputs[0];
1593            let b_id = parent.inputs[1];
1594            let a_dims = graph.node(a_id).shape.dims();
1595            let b_dims = graph.node(b_id).shape.dims();
1596            let out_dims = parent.shape.dims();
1597            let (m, k, n) =
1598                if a_dims.len() >= 2 && b_dims.len() == 2 && out_dims.len() == a_dims.len() {
1599                    let leading: usize = a_dims[..a_dims.len() - 2]
1600                        .iter()
1601                        .map(|d| d.unwrap_static())
1602                        .product();
1603                    let m_inner = a_dims[a_dims.len() - 2].unwrap_static();
1604                    let k_inner = a_dims[a_dims.len() - 1].unwrap_static();
1605                    let n_inner = b_dims[1].unwrap_static();
1606                    ((leading * m_inner) as u32, k_inner as u32, n_inner as u32)
1607                } else if a_dims.len() == 2 && b_dims.len() == 2 {
1608                    (
1609                        a_dims[0].unwrap_static() as u32,
1610                        a_dims[1].unwrap_static() as u32,
1611                        b_dims[1].unwrap_static() as u32,
1612                    )
1613                } else {
1614                    continue; // unusual shape — let the regular FMB path handle
1615                };
1616            let cp = derive_matmul_compute(
1617                &dev.device,
1618                &graph,
1619                &coop_f16_vk_mirror_acts,
1620                a_id,
1621                b_id,
1622                m,
1623                k,
1624                n,
1625            );
1626            // F32 → matmul_qkv. CoopF32 → matmul_qkv_coop_f32. Both write
1627            // Q/K/V into the narrow output slots, so the narrows can be
1628            // elided. Coop16 still falls back to FMB+narrows (kernel
1629            // would need an f16-acc variant; deferred).
1630            if cp == MatmulCompute::F32 || cp == MatmulCompute::CoopF32 {
1631                qkv_split.insert(parent_id, qkv);
1632            }
1633        }
1634        let qkv_skip_narrows: HashSet<NodeId> = qkv_split
1635            .values()
1636            .flat_map(|&(q, k, v)| [q, k, v])
1637            .collect();
1638
1639        // EEG-DINO / packed QKV: FMB → [B,S,3,H,D] → Narrow×3 (axis 2) → Attention.
1640        // Match CPU `compile_thunks` fused_strided_attn: read Q/K/V from the
1641        // packed parent with seq stride 3·H·D instead of materializing narrows.
1642        let mut packed_bshd_attn: HashMap<NodeId, (NodeId, u32)> = HashMap::new();
1643        let mut packed_bshd_skip_narrows: HashSet<NodeId> = HashSet::new();
1644        if !rlx_ir::env::flag("RLX_WGPU_NO_PACKED_BSHD_ATTN") {
1645            for node in graph.nodes() {
1646                let Op::Attention { .. } = &node.op else {
1647                    continue;
1648                };
1649                if node.inputs.len() < 3 {
1650                    continue;
1651                }
1652                if let Some((parent, head_width, narrows)) =
1653                    rlx_ir::detect_packed_bshd_qkv_attention(
1654                        &graph,
1655                        node.inputs[0],
1656                        node.inputs[1],
1657                        node.inputs[2],
1658                    )
1659                {
1660                    packed_bshd_attn.insert(node.id, (parent, head_width as u32));
1661                    for narrow in narrows {
1662                        if rlx_ir::packed_bshd_narrow_elidable(&graph, narrow, node.id) {
1663                            packed_bshd_skip_narrows.insert(narrow);
1664                        }
1665                    }
1666                }
1667            }
1668        }
1669
1670        // Detect (Add → LayerNorm) where Add has multi-consumer downstream.
1671        // The standard `FuseResidualLN` pass declines to fuse these (its
1672        // single-consumer guard forces materializing the sum); we collapse
1673        // them here at the wgpu lowering level via `Step::FusedResidualLnTee`.
1674        // Returns:
1675        //   ln_to_tee: ln_id  → (h, delta, gamma, beta, sum_arena_id)
1676        //   skip_adds: { add_id }  — these Add nodes are computed by the
1677        //                            tee step; their normal Step emission
1678        //                            is suppressed.
1679        let (ln_to_tee, skip_adds) = detect_residual_ln_tee_pattern(&graph);
1680
1681        let mut coop_f16_host_activations: Vec<(NodeId, Activation, String)> = Vec::new();
1682
1683        let emit_uniform = |size: usize| -> wgpu::Buffer {
1684            dev.device.create_buffer(&wgpu::BufferDescriptor {
1685                label: Some("rlx-wgpu uniform"),
1686                size: size as u64,
1687                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1688                mapped_at_creation: false,
1689            })
1690        };
1691
1692        for node in graph.nodes() {
1693            // Helpers — capture device + arena into closures isn't
1694            // ergonomic in the loop, so inline the bind-group build
1695            // when each step is emitted below.
1696            let elems = node.shape.num_elements().unwrap_or(0) as u32;
1697            match &node.op {
1698                Op::Input { .. } | Op::Param { .. } | Op::Constant { .. } => continue,
1699                Op::MatMul => {
1700                    let a_id = node.inputs[0];
1701                    let b_id = node.inputs[1];
1702                    let a_shape = graph.node(a_id).shape.dims();
1703                    let b_shape = graph.node(b_id).shape.dims();
1704                    let out_shape = node.shape.dims();
1705                    // Three patterns:
1706                    //   • 2D×2D                              → batch=1
1707                    //   • [..,M,K] × [K,N]  (broadcast rhs)  → batch=1, flatten leading into M
1708                    //   • [..,M,K] × [..,K,N] (matched batch)→ batch=prod(leading), per-batch strides
1709                    let (m, k, n, batch, a_bs, b_bs, c_bs) = if a_shape.len() == 2
1710                        && b_shape.len() == 2
1711                        && out_shape.len() == 2
1712                    {
1713                        (
1714                            a_shape[0].unwrap_static() as u32,
1715                            a_shape[1].unwrap_static() as u32,
1716                            b_shape[1].unwrap_static() as u32,
1717                            1u32,
1718                            0u32,
1719                            0u32,
1720                            0u32,
1721                        )
1722                    } else if a_shape.len() >= 2
1723                        && b_shape.len() == 2
1724                        && out_shape.len() == a_shape.len()
1725                    {
1726                        let leading: usize = a_shape[..a_shape.len() - 2]
1727                            .iter()
1728                            .map(|d| d.unwrap_static())
1729                            .product();
1730                        let m_inner = a_shape[a_shape.len() - 2].unwrap_static();
1731                        let k_inner = a_shape[a_shape.len() - 1].unwrap_static();
1732                        let n_inner = b_shape[1].unwrap_static();
1733                        (
1734                            (leading * m_inner) as u32,
1735                            k_inner as u32,
1736                            n_inner as u32,
1737                            1u32,
1738                            0u32,
1739                            0u32,
1740                            0u32,
1741                        )
1742                    } else if a_shape.len() == b_shape.len()
1743                        && a_shape.len() >= 3
1744                        && out_shape.len() == a_shape.len()
1745                    {
1746                        // True batched: leading dims must match.
1747                        let leading_a: Vec<usize> = a_shape[..a_shape.len() - 2]
1748                            .iter()
1749                            .map(|d| d.unwrap_static())
1750                            .collect();
1751                        let leading_b: Vec<usize> = b_shape[..b_shape.len() - 2]
1752                            .iter()
1753                            .map(|d| d.unwrap_static())
1754                            .collect();
1755                        if leading_a != leading_b {
1756                            panic!(
1757                                "rlx-wgpu MatMul: batched shape mismatch \
1758                                    a_leading={leading_a:?} b_leading={leading_b:?}"
1759                            );
1760                        }
1761                        let b_count: usize = leading_a.iter().product();
1762                        let m_inner = a_shape[a_shape.len() - 2].unwrap_static();
1763                        let k_inner = a_shape[a_shape.len() - 1].unwrap_static();
1764                        let n_inner = b_shape[b_shape.len() - 1].unwrap_static();
1765                        (
1766                            m_inner as u32,
1767                            k_inner as u32,
1768                            n_inner as u32,
1769                            b_count as u32,
1770                            (m_inner * k_inner) as u32,
1771                            (k_inner * n_inner) as u32,
1772                            (m_inner * n_inner) as u32,
1773                        )
1774                    } else {
1775                        panic!(
1776                            "rlx-wgpu MatMul: unsupported shapes a={a_shape:?} b={b_shape:?} \
1777                                out={out_shape:?} (supported: 2D×2D, [..,M,K]×[K,N], [..,M,K]×[..,K,N])"
1778                        );
1779                    };
1780                    let b_is_param = tensor_is_graph_param(&graph, &param_offsets, b_id);
1781                    let b_bytes = arena.len_of(b_id) as u64;
1782                    let mut compute_precision = derive_matmul_compute(
1783                        &dev.device,
1784                        &graph,
1785                        &coop_f16_vk_mirror_acts,
1786                        a_id,
1787                        b_id,
1788                        m,
1789                        k,
1790                        n,
1791                    );
1792                    if b_is_param && b_bytes > ARENA_STAGE_CAP && arena.param_fits_f16_mirror(b_id)
1793                    {
1794                        compute_precision = MatmulCompute::F16;
1795                    }
1796                    let b_in_arena = !matmul_b_from_f16(compute_precision, b_is_param);
1797                    let (mut base, mut size, param_anchor) = arena_matmul_bind_window(
1798                        &dev.device,
1799                        &arena,
1800                        &graph,
1801                        &param_offsets,
1802                        node.id,
1803                        a_id,
1804                        b_id,
1805                        b_in_arena,
1806                    );
1807                    let max_binding = dev.device.limits().max_storage_buffer_binding_size;
1808                    // Only grow to cover B when B is actually bound through the arena.
1809                    let expand_ids: &[NodeId] = if b_in_arena {
1810                        &[node.id, a_id, b_id]
1811                    } else {
1812                        &[node.id, a_id]
1813                    };
1814                    arena_expand_bind_window(&arena, expand_ids, &mut base, &mut size, max_binding);
1815                    let mut scratch = arena.scratch_off as u64;
1816                    if param_anchor {
1817                        arena_ensure_scratch_in_window(&mut scratch, base, size);
1818                    }
1819                    if b_is_param && b_bytes > ARENA_STAGE_CAP && b_in_arena {
1820                        // The invariant we actually need is that the large param
1821                        // B is addressable in the bound window. That holds either
1822                        // via an explicit param anchor OR when the whole arena is
1823                        // bound (`arena_whole_arena_bind`), in which case
1824                        // `arena_matmul_bind_window` returns `param_anchor=false`
1825                        // but B is trivially in `[0, arena.size)`. Keying the
1826                        // assert on `param_anchor` alone spuriously panicked for
1827                        // models whose entire arena fits `max_binding`.
1828                        assert!(
1829                            arena_tensor_in_window(&arena, b_id, base, size),
1830                            "rlx-wgpu matmul: large param B {:?} off={} not in window base={base} size={size}",
1831                            b_id,
1832                            arena.offset(b_id),
1833                        );
1834                    }
1835                    let a_off_f32 = arena_off_in_bind_window(
1836                        &graph,
1837                        &param_offsets,
1838                        &dev.device,
1839                        &arena,
1840                        &mut schedule,
1841                        &mut scratch,
1842                        a_id,
1843                        &mut base,
1844                        &mut size,
1845                    );
1846                    let b_off_f32 = if !b_in_arena {
1847                        // B is read from the f16 shadow buffer (separate binding);
1848                        // never bind/stage it through the arena window. Use the
1849                        // global arena word index — build_matmul_bind_group rebases
1850                        // it into the f16 weight window.
1851                        (arena.offset(b_id) / 4) as u32
1852                    } else if b_is_param
1853                        && b_bytes > ARENA_STAGE_CAP
1854                        && arena_tensor_in_window(&arena, b_id, base, size)
1855                    {
1856                        arena_local_off_f32(&arena, b_id, base)
1857                    } else {
1858                        arena_off_in_bind_window(
1859                            &graph,
1860                            &param_offsets,
1861                            &dev.device,
1862                            &arena,
1863                            &mut schedule,
1864                            &mut scratch,
1865                            b_id,
1866                            &mut base,
1867                            &mut size,
1868                        )
1869                    };
1870                    maybe_push_coop_f16_vk_casts(
1871                        &graph,
1872                        a_id,
1873                        b_id,
1874                        &coop_f16_vk_mirror_acts,
1875                        &dev.device,
1876                        &arena,
1877                        &mut schedule,
1878                        &mut uniforms,
1879                        &mut bind_groups,
1880                        &mm_cast,
1881                        compute_precision,
1882                        a_off_f32,
1883                        m,
1884                        k,
1885                        batch,
1886                        b_off_f32,
1887                        n,
1888                    );
1889                    schedule.push(Step::Matmul {
1890                        m,
1891                        k,
1892                        n,
1893                        batch,
1894                        a_batch_stride: a_bs,
1895                        b_batch_stride: b_bs,
1896                        c_batch_stride: c_bs,
1897                        a_off_f32,
1898                        b_off_f32,
1899                        c_off_f32: arena_local_off_f32(&arena, node.id, base),
1900                        has_bias: 0,
1901                        bias_off_f32: 0,
1902                        act_id: 0xFFFF,
1903                        b_is_param,
1904                        compute_precision,
1905                    });
1906                    let b_off_global = (arena.offset(b_id) / 4) as u32;
1907                    let b_off_bind = if b_is_param
1908                        && matches!(
1909                            compute_precision,
1910                            MatmulCompute::Coop16 | MatmulCompute::CoopF16Vk | MatmulCompute::F16
1911                        ) {
1912                        b_off_global
1913                    } else {
1914                        b_off_f32
1915                    };
1916                    register_coop_f16_vk_b_param(
1917                        &mut coop_f16_b_param,
1918                        &param_offsets,
1919                        b_id,
1920                        b_off_bind,
1921                        compute_precision,
1922                    );
1923                    let u = emit_uniform(std::mem::size_of::<MatmulParams>());
1924                    let (bg, b_off_adj) = build_matmul_bind_group(
1925                        &dev.device,
1926                        mm_k,
1927                        mm_w,
1928                        &mm_f16w,
1929                        &mm_f16c,
1930                        &mm_coop,
1931                        &mm_coop_f32,
1932                        &arena,
1933                        base,
1934                        size,
1935                        &u,
1936                        b_is_param,
1937                        compute_precision,
1938                        k,
1939                        n,
1940                        batch,
1941                        b_off_bind,
1942                        b_bs,
1943                    );
1944                    if let Some(Step::Matmul { b_off_f32, .. }) = schedule.last_mut() {
1945                        *b_off_f32 = b_off_adj;
1946                    }
1947                    uniforms.push(u);
1948                    bind_groups.push(bg);
1949                    if compute_precision == MatmulCompute::CoopF16Vk {
1950                        coop_f16_vk_wide_bind_groups.insert(
1951                            schedule.len() - 1,
1952                            bind_two_buf0_window(
1953                                &dev.device,
1954                                mm_w_active_compile,
1955                                &arena.buffer,
1956                                base,
1957                                size,
1958                                &uniforms[uniforms.len() - 1],
1959                            ),
1960                        );
1961                    }
1962                }
1963                Op::Binary(bop) => {
1964                    // Skip emit when this Add is consumed by a downstream
1965                    // FRLTee — the tee step writes the sum to this node's
1966                    // arena slot directly. Subsequent consumers read the
1967                    // same slot and find correct data.
1968                    if skip_adds.contains(&node.id) {
1969                        continue;
1970                    }
1971                    require_equal_shapes(&graph, &node.inputs, "Binary");
1972                    let a_id = node.inputs[0];
1973                    let b_id = node.inputs[1];
1974                    let win_ids = [node.id, a_id, b_id];
1975                    let max_binding = dev.device.limits().max_storage_buffer_binding_size;
1976                    let fits = arena_span_bytes(&arena, &win_ids) <= max_binding;
1977                    let mut scratch = arena.scratch_off as u64;
1978                    let (mut base, mut size, param_anchor) = arena_multi_op_window(
1979                        &dev.device,
1980                        &arena,
1981                        &graph,
1982                        &param_offsets,
1983                        &mut schedule,
1984                        &mut scratch,
1985                        &win_ids,
1986                    );
1987                    if !fits && !param_anchor {
1988                        base = arena_bind_window_covering_scratch_if_needed(
1989                            &arena, base, size, scratch,
1990                        );
1991                    }
1992                    let a_off = arena_off_in_bind_window(
1993                        &graph,
1994                        &param_offsets,
1995                        &dev.device,
1996                        &arena,
1997                        &mut schedule,
1998                        &mut scratch,
1999                        a_id,
2000                        &mut base,
2001                        &mut size,
2002                    );
2003                    let b_off = arena_off_in_bind_window(
2004                        &graph,
2005                        &param_offsets,
2006                        &dev.device,
2007                        &arena,
2008                        &mut schedule,
2009                        &mut scratch,
2010                        b_id,
2011                        &mut base,
2012                        &mut size,
2013                    );
2014                    let p = BinaryParams {
2015                        n: elems,
2016                        a_off,
2017                        b_off,
2018                        c_off: arena_local_off_f32(&arena, node.id, base),
2019                        op: binary_op_id(*bop),
2020                        _p0: 0,
2021                        _p1: 0,
2022                        _p2: 0,
2023                    };
2024                    schedule.push(Step::Binary { params: p });
2025                    let u = emit_uniform(std::mem::size_of::<BinaryParams>());
2026                    let bg = bind_two_buf0_window(&dev.device, bk, &arena.buffer, base, size, &u);
2027                    uniforms.push(u);
2028                    bind_groups.push(bg);
2029                }
2030                Op::Compare(cop) => {
2031                    require_equal_shapes(&graph, &node.inputs, "Compare");
2032                    let (mut base, size) = arena_window_for_nodes(&dev.device, &arena, &[node.id]);
2033                    let a_id = node.inputs[0];
2034                    let b_id = node.inputs[1];
2035                    let a_src = arena.offset(a_id) as u64;
2036                    let b_src = arena.offset(b_id) as u64;
2037                    let a_len = arena.len_of(a_id) as u64;
2038                    let b_len = arena.len_of(b_id) as u64;
2039                    let a_in = a_src >= base && a_src + a_len <= base + size;
2040                    let b_in = b_src >= base && b_src + b_len <= base + size;
2041                    let a_dst = arena.scratch_off as u64;
2042                    let a_aligned = a_len.div_ceil(256) * 256;
2043                    let b_dst = a_dst + a_aligned;
2044                    if a_dst < base || b_dst + b_len > base + size {
2045                        base = (arena.size as u64).saturating_sub(size);
2046                        base = (base / 256) * 256;
2047                    }
2048                    let a_off = if a_in {
2049                        arena_local_off_f32(&arena, a_id, base)
2050                    } else {
2051                        if a_len > 64 * 1024 * 1024 {
2052                            panic!("rlx-wgpu: Compare staging operand A too large ({a_len} bytes)");
2053                        }
2054                        schedule.push(Step::BufferCopy {
2055                            src_byte_off: a_src,
2056                            dst_byte_off: a_dst,
2057                            bytes: a_len as u32,
2058                        });
2059                        ((a_dst.saturating_sub(base)) / 4) as u32
2060                    };
2061                    let b_off = if b_in {
2062                        arena_local_off_f32(&arena, b_id, base)
2063                    } else {
2064                        if b_len > 64 * 1024 * 1024 {
2065                            panic!("rlx-wgpu: Compare staging operand B too large ({b_len} bytes)");
2066                        }
2067                        schedule.push(Step::BufferCopy {
2068                            src_byte_off: b_src,
2069                            dst_byte_off: b_dst,
2070                            bytes: b_len as u32,
2071                        });
2072                        ((b_dst.saturating_sub(base)) / 4) as u32
2073                    };
2074                    let p = BinaryParams {
2075                        n: elems,
2076                        a_off,
2077                        b_off,
2078                        c_off: arena_local_off_f32(&arena, node.id, base),
2079                        op: compare_op_id(*cop),
2080                        _p0: 0,
2081                        _p1: 0,
2082                        _p2: 0,
2083                    };
2084                    schedule.push(Step::Compare { params: p });
2085                    let u = emit_uniform(std::mem::size_of::<BinaryParams>());
2086                    let bg = bind_two_buf0_window(&dev.device, ck, &arena.buffer, base, size, &u);
2087                    uniforms.push(u);
2088                    bind_groups.push(bg);
2089                }
2090                Op::Activation(act) => {
2091                    if coop_f16_vk_mirror_acts.contains(&node.id) {
2092                        let src_name =
2093                            tensor_host_name(&input_offsets, &param_offsets, node.inputs[0]);
2094                        coop_f16_host_activations.push((node.id, *act, src_name));
2095                        continue;
2096                    }
2097                    let in_id = node.inputs[0];
2098                    let win_ids = [node.id, in_id];
2099                    let max_binding = dev.device.limits().max_storage_buffer_binding_size;
2100                    let fits = arena_span_bytes(&arena, &win_ids) <= max_binding;
2101                    let mut scratch = arena.scratch_off as u64;
2102                    let (mut base, mut size, param_anchor) = arena_multi_op_window(
2103                        &dev.device,
2104                        &arena,
2105                        &graph,
2106                        &param_offsets,
2107                        &mut schedule,
2108                        &mut scratch,
2109                        &win_ids,
2110                    );
2111                    if !fits && !param_anchor {
2112                        base = arena_bind_window_covering_scratch_if_needed(
2113                            &arena, base, size, scratch,
2114                        );
2115                    }
2116                    let in_off = arena_off_in_bind_window(
2117                        &graph,
2118                        &param_offsets,
2119                        &dev.device,
2120                        &arena,
2121                        &mut schedule,
2122                        &mut scratch,
2123                        in_id,
2124                        &mut base,
2125                        &mut size,
2126                    );
2127                    let p = UnaryParams {
2128                        n: elems,
2129                        in_off,
2130                        out_off: arena_local_off_f32(&arena, node.id, base),
2131                        op: activation_op_id(*act),
2132                        _p0: 0,
2133                        _p1: 0,
2134                        _p2: 0,
2135                        _p3: 0,
2136                    };
2137                    schedule.push(Step::Unary {
2138                        params: p,
2139                        f16_mirror: false,
2140                    });
2141                    let u = emit_uniform(std::mem::size_of::<UnaryParams>());
2142                    let bg = bind_two_buf0_window(&dev.device, uk, &arena.buffer, base, size, &u);
2143                    uniforms.push(u);
2144                    bind_groups.push(bg);
2145                }
2146                Op::Where => {
2147                    let (mut base, size) = arena_window_for_nodes(&dev.device, &arena, &[node.id]);
2148                    let cond_id = node.inputs[0];
2149                    let x_id = node.inputs[1];
2150                    let y_id = node.inputs[2];
2151                    let cond_src = arena.offset(cond_id) as u64;
2152                    let x_src = arena.offset(x_id) as u64;
2153                    let y_src = arena.offset(y_id) as u64;
2154                    let cond_len = arena.len_of(cond_id) as u64;
2155                    let x_len = arena.len_of(x_id) as u64;
2156                    let y_len = arena.len_of(y_id) as u64;
2157                    let cond_in = cond_src >= base && cond_src + cond_len <= base + size;
2158                    let x_in = x_src >= base && x_src + x_len <= base + size;
2159                    let y_in = y_src >= base && y_src + y_len <= base + size;
2160                    let cond_dst = arena.scratch_off as u64;
2161                    let cond_aligned = cond_len.div_ceil(256) * 256;
2162                    let x_dst = cond_dst + cond_aligned;
2163                    let x_aligned = x_len.div_ceil(256) * 256;
2164                    let y_dst = x_dst + x_aligned;
2165                    if cond_dst < base || y_dst + y_len > base + size {
2166                        base = (arena.size as u64).saturating_sub(size);
2167                        base = (base / 256) * 256;
2168                    }
2169                    let cond_off = if cond_in {
2170                        arena_local_off_f32(&arena, cond_id, base)
2171                    } else {
2172                        if cond_len > 64 * 1024 * 1024 {
2173                            panic!("rlx-wgpu: Where staging cond too large ({cond_len} bytes)");
2174                        }
2175                        schedule.push(Step::BufferCopy {
2176                            src_byte_off: cond_src,
2177                            dst_byte_off: cond_dst,
2178                            bytes: cond_len as u32,
2179                        });
2180                        ((cond_dst.saturating_sub(base)) / 4) as u32
2181                    };
2182                    let x_off = if x_in {
2183                        arena_local_off_f32(&arena, x_id, base)
2184                    } else {
2185                        if x_len > 64 * 1024 * 1024 {
2186                            panic!("rlx-wgpu: Where staging x too large ({x_len} bytes)");
2187                        }
2188                        schedule.push(Step::BufferCopy {
2189                            src_byte_off: x_src,
2190                            dst_byte_off: x_dst,
2191                            bytes: x_len as u32,
2192                        });
2193                        ((x_dst.saturating_sub(base)) / 4) as u32
2194                    };
2195                    let y_off = if y_in {
2196                        arena_local_off_f32(&arena, y_id, base)
2197                    } else {
2198                        if y_len > 64 * 1024 * 1024 {
2199                            panic!("rlx-wgpu: Where staging y too large ({y_len} bytes)");
2200                        }
2201                        schedule.push(Step::BufferCopy {
2202                            src_byte_off: y_src,
2203                            dst_byte_off: y_dst,
2204                            bytes: y_len as u32,
2205                        });
2206                        ((y_dst.saturating_sub(base)) / 4) as u32
2207                    };
2208                    let p = WhereParams {
2209                        n: elems,
2210                        cond_off,
2211                        x_off,
2212                        y_off,
2213                        out_off: arena_local_off_f32(&arena, node.id, base),
2214                        _p0: 0,
2215                        _p1: 0,
2216                        _p2: 0,
2217                    };
2218                    schedule.push(Step::Where { params: p });
2219                    let u = emit_uniform(std::mem::size_of::<WhereParams>());
2220                    let bg = bind_two_buf0_window(&dev.device, wk, &arena.buffer, base, size, &u);
2221                    uniforms.push(u);
2222                    bind_groups.push(bg);
2223                }
2224
2225                Op::Fma => {
2226                    let (mut base, size) = arena_window_for_nodes(&dev.device, &arena, &[node.id]);
2227                    let a_id = node.inputs[0];
2228                    let b_id = node.inputs[1];
2229                    let c_id = node.inputs[2];
2230                    let a_src = arena.offset(a_id) as u64;
2231                    let b_src = arena.offset(b_id) as u64;
2232                    let c_src = arena.offset(c_id) as u64;
2233                    let a_len = arena.len_of(a_id) as u64;
2234                    let b_len = arena.len_of(b_id) as u64;
2235                    let c_len = arena.len_of(c_id) as u64;
2236                    let a_in = a_src >= base && a_src + a_len <= base + size;
2237                    let b_in = b_src >= base && b_src + b_len <= base + size;
2238                    let c_in = c_src >= base && c_src + c_len <= base + size;
2239                    let a_dst = arena.scratch_off as u64;
2240                    let a_aligned = a_len.div_ceil(256) * 256;
2241                    let b_dst = a_dst + a_aligned;
2242                    let b_aligned = b_len.div_ceil(256) * 256;
2243                    let c_dst = b_dst + b_aligned;
2244                    if a_dst < base || c_dst + c_len > base + size {
2245                        base = (arena.size as u64).saturating_sub(size);
2246                        base = (base / 256) * 256;
2247                    }
2248                    let a_off = if a_in {
2249                        arena_local_off_f32(&arena, a_id, base)
2250                    } else {
2251                        if a_len > 64 * 1024 * 1024 {
2252                            panic!("rlx-wgpu: Fma staging a too large ({a_len} bytes)");
2253                        }
2254                        schedule.push(Step::BufferCopy {
2255                            src_byte_off: a_src,
2256                            dst_byte_off: a_dst,
2257                            bytes: a_len as u32,
2258                        });
2259                        ((a_dst.saturating_sub(base)) / 4) as u32
2260                    };
2261                    let b_off = if b_in {
2262                        arena_local_off_f32(&arena, b_id, base)
2263                    } else {
2264                        if b_len > 64 * 1024 * 1024 {
2265                            panic!("rlx-wgpu: Fma staging b too large ({b_len} bytes)");
2266                        }
2267                        schedule.push(Step::BufferCopy {
2268                            src_byte_off: b_src,
2269                            dst_byte_off: b_dst,
2270                            bytes: b_len as u32,
2271                        });
2272                        ((b_dst.saturating_sub(base)) / 4) as u32
2273                    };
2274                    let c_off = if c_in {
2275                        arena_local_off_f32(&arena, c_id, base)
2276                    } else {
2277                        if c_len > 64 * 1024 * 1024 {
2278                            panic!("rlx-wgpu: Fma staging c too large ({c_len} bytes)");
2279                        }
2280                        schedule.push(Step::BufferCopy {
2281                            src_byte_off: c_src,
2282                            dst_byte_off: c_dst,
2283                            bytes: c_len as u32,
2284                        });
2285                        ((c_dst.saturating_sub(base)) / 4) as u32
2286                    };
2287                    let p = FmaParams {
2288                        n: elems,
2289                        a_off,
2290                        b_off,
2291                        c_off,
2292                        out_off: arena_local_off_f32(&arena, node.id, base),
2293                        _p0: 0,
2294                        _p1: 0,
2295                        _p2: 0,
2296                    };
2297                    schedule.push(Step::Fma { params: p });
2298                    let u = emit_uniform(std::mem::size_of::<FmaParams>());
2299                    let bg = bind_two_buf0_window(&dev.device, fk, &arena.buffer, base, size, &u);
2300                    uniforms.push(u);
2301                    bind_groups.push(bg);
2302                }
2303
2304                Op::BatchElementwiseRegion {
2305                    chain,
2306                    num_batch_inputs,
2307                    scalar_input_mask,
2308                    input_modulus,
2309                    prologue,
2310                    prologue_input,
2311                } => {
2312                    let n = *num_batch_inputs as usize;
2313                    if n == 0 || chain.len() > 32 {
2314                        panic!(
2315                            "rlx-wgpu BatchElementwiseRegion: num_batch_inputs={n} steps={}",
2316                            chain.len()
2317                        );
2318                    }
2319                    let slice_shape = rlx_ir::batch_region_slice_shape(&node.shape);
2320                    let slice_elems = rlx_ir::batch_region_slice_elems(&node.shape, n)
2321                        .expect("batch region static shape");
2322                    let mut win_ids: Vec<NodeId> = vec![node.id];
2323                    win_ids.extend(node.inputs.iter().copied());
2324                    let max_binding = dev.device.limits().max_storage_buffer_binding_size;
2325                    let fits = arena_span_bytes(&arena, &win_ids) <= max_binding;
2326                    let mut scratch = arena.scratch_off as u64;
2327                    let (mut base, mut size, param_anchor) = arena_multi_op_window(
2328                        &dev.device,
2329                        &arena,
2330                        &graph,
2331                        &param_offsets,
2332                        &mut schedule,
2333                        &mut scratch,
2334                        &win_ids,
2335                    );
2336                    if !fits && !param_anchor {
2337                        base = arena_bind_window_covering_scratch_if_needed(
2338                            &arena, base, size, scratch,
2339                        );
2340                    }
2341                    let chain_enc = rlx_ir::encode_chain_steps(chain);
2342                    let tail =
2343                        rlx_ir::encode_prologue_tail(*prologue, &slice_shape, *prologue_input);
2344                    let base_dst = arena_local_off_f32(&arena, node.id, base);
2345                    let use_single = rlx_ir::fk_batch_use_single_launch(n, *prologue);
2346                    if use_single {
2347                        let mut batch_input_offs = [0u32; 64];
2348                        for i in 0..n {
2349                            batch_input_offs[i] = arena_off_in_bind_window(
2350                                &graph,
2351                                &param_offsets,
2352                                &dev.device,
2353                                &arena,
2354                                &mut schedule,
2355                                &mut scratch,
2356                                node.inputs[i],
2357                                &mut base,
2358                                &mut size,
2359                            );
2360                        }
2361                        let p = BatchElementwiseRegionParams {
2362                            slice_len: slice_elems,
2363                            num_batch: n as u32,
2364                            num_steps: chain.len() as u32,
2365                            base_dst_off: base_dst,
2366                            slice_elems,
2367                            batch_input_offs,
2368                            chain: chain_enc,
2369                            scalar_input_mask: *scalar_input_mask,
2370                            input_modulus: *input_modulus,
2371                        };
2372                        schedule.push(Step::BatchElementwiseRegion { params: p });
2373                        let ek = batch_elementwise_region_kernel(&dev.device);
2374                        let u = dev.device.create_buffer(&wgpu::BufferDescriptor {
2375                            label: Some("rlx-wgpu batch region params"),
2376                            size: std::mem::size_of::<BatchElementwiseRegionParams>() as u64,
2377                            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
2378                            mapped_at_creation: false,
2379                        });
2380                        let bg =
2381                            bind_two_buf0_window(&dev.device, ek, &arena.buffer, base, size, &u);
2382                        uniforms.push(u);
2383                        bind_groups.push(bg);
2384                    } else {
2385                        let spatial = tail[0] == rlx_ir::REGION_PROLOGUE_RESIZE_NEAREST_2X_NCHW;
2386                        let ek = if spatial {
2387                            elementwise_region_spatial_kernel(&dev.device)
2388                        } else {
2389                            elementwise_region_kernel(&dev.device)
2390                        };
2391                        for i in 0..n {
2392                            let mut input_offs = [0u32; 16];
2393                            input_offs[0] = arena_off_in_bind_window(
2394                                &graph,
2395                                &param_offsets,
2396                                &dev.device,
2397                                &arena,
2398                                &mut schedule,
2399                                &mut scratch,
2400                                node.inputs[i],
2401                                &mut base,
2402                                &mut size,
2403                            );
2404                            let p = ElementwiseRegionParams {
2405                                len: slice_elems,
2406                                num_inputs: 1,
2407                                num_steps: chain.len() as u32,
2408                                dst_off: rlx_ir::batch_region_slice_dst_off_f32(
2409                                    base_dst,
2410                                    slice_elems,
2411                                    i,
2412                                ),
2413                                input_offs,
2414                                chain: chain_enc,
2415                                scalar_input_mask: *scalar_input_mask,
2416                                prologue: tail[0],
2417                                out_n: tail[1],
2418                                out_c: tail[2],
2419                                out_h: tail[3],
2420                                out_w: tail[4],
2421                                prologue_input: tail[5],
2422                                input_modulus: *input_modulus,
2423                            };
2424                            schedule.push(Step::ElementwiseRegion { params: p });
2425                            let u = dev.device.create_buffer(&wgpu::BufferDescriptor {
2426                                label: Some("rlx-wgpu batch region params"),
2427                                size: std::mem::size_of::<ElementwiseRegionParams>() as u64,
2428                                usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
2429                                mapped_at_creation: false,
2430                            });
2431                            let bg = bind_two_buf0_window(
2432                                &dev.device,
2433                                ek,
2434                                &arena.buffer,
2435                                base,
2436                                size,
2437                                &u,
2438                            );
2439                            uniforms.push(u);
2440                            bind_groups.push(bg);
2441                        }
2442                    }
2443                }
2444                Op::ElementwiseRegion {
2445                    chain,
2446                    num_inputs,
2447                    scalar_input_mask,
2448                    input_modulus,
2449                    prologue,
2450                    prologue_input,
2451                } => {
2452                    // PLAN L2 native lowering. Encode the chain into a
2453                    // fixed-size u32 buffer; one uniform per region.
2454                    let n = *num_inputs as usize;
2455                    if n > 16 || chain.len() > 32 {
2456                        panic!(
2457                            "rlx-wgpu ElementwiseRegion: chain too large \
2458                                (inputs={n}, steps={}). Caps: 16 / 32. \
2459                                Use UnfuseElementwiseRegions to fall back.",
2460                            chain.len()
2461                        );
2462                    }
2463                    let mut win_ids: Vec<NodeId> = vec![node.id];
2464                    win_ids.extend(node.inputs.iter().copied());
2465                    let max_binding = dev.device.limits().max_storage_buffer_binding_size;
2466                    let fits = arena_span_bytes(&arena, &win_ids) <= max_binding;
2467                    let mut scratch = arena.scratch_off as u64;
2468                    let (mut base, mut size, param_anchor) = arena_multi_op_window(
2469                        &dev.device,
2470                        &arena,
2471                        &graph,
2472                        &param_offsets,
2473                        &mut schedule,
2474                        &mut scratch,
2475                        &win_ids,
2476                    );
2477                    if !fits && !param_anchor {
2478                        base = arena_bind_window_covering_scratch_if_needed(
2479                            &arena, base, size, scratch,
2480                        );
2481                    }
2482                    let mut input_offs = [0u32; 16];
2483                    for (i, &id) in node.inputs.iter().enumerate() {
2484                        input_offs[i] = arena_off_in_bind_window(
2485                            &graph,
2486                            &param_offsets,
2487                            &dev.device,
2488                            &arena,
2489                            &mut schedule,
2490                            &mut scratch,
2491                            id,
2492                            &mut base,
2493                            &mut size,
2494                        );
2495                    }
2496                    let chain_enc = rlx_ir::encode_chain_steps(chain);
2497                    let tail =
2498                        rlx_ir::encode_prologue_tail(*prologue, &node.shape, *prologue_input);
2499                    let p = ElementwiseRegionParams {
2500                        len: elems,
2501                        num_inputs: *num_inputs,
2502                        num_steps: chain.len() as u32,
2503                        dst_off: arena_local_off_f32(&arena, node.id, base),
2504                        input_offs,
2505                        chain: chain_enc,
2506                        scalar_input_mask: *scalar_input_mask,
2507                        prologue: tail[0],
2508                        out_n: tail[1],
2509                        out_c: tail[2],
2510                        out_h: tail[3],
2511                        out_w: tail[4],
2512                        prologue_input: tail[5],
2513                        input_modulus: *input_modulus,
2514                    };
2515                    schedule.push(Step::ElementwiseRegion { params: p });
2516                    let ek = if p.prologue == rlx_ir::REGION_PROLOGUE_RESIZE_NEAREST_2X_NCHW {
2517                        elementwise_region_spatial_kernel(&dev.device)
2518                    } else {
2519                        elementwise_region_kernel(&dev.device)
2520                    };
2521                    // STORAGE (not UNIFORM) — the WGSL params struct
2522                    // contains `array<u32, N>` arrays whose 4-byte
2523                    // stride violates uniform's 16-byte stride rule.
2524                    let u = dev.device.create_buffer(&wgpu::BufferDescriptor {
2525                        label: Some("rlx-wgpu region params"),
2526                        size: std::mem::size_of::<ElementwiseRegionParams>() as u64,
2527                        usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
2528                        mapped_at_creation: false,
2529                    });
2530                    let bg = bind_two_buf0_window(&dev.device, ek, &arena.buffer, base, size, &u);
2531                    uniforms.push(u);
2532                    bind_groups.push(bg);
2533                }
2534
2535                Op::Reduce {
2536                    op: rop,
2537                    axes,
2538                    keep_dim: _,
2539                } => {
2540                    // Single-axis reduce OR contiguous multi-axis reduce.
2541                    // The kernel walks the input as `[outer, reduce_dim,
2542                    // inner]` — for contiguous axes [k..k+m], we set
2543                    // `reduce_dim = product(dims[k..k+m])`.
2544                    // Non-contiguous reductions are not yet wired (no
2545                    // model has hit them); transposing into contiguous
2546                    // form first is the future fix.
2547                    let in_id = node.inputs[0];
2548                    let in_shape = graph.node(in_id).shape.dims();
2549                    let mut sorted = axes.clone();
2550                    sorted.sort_unstable();
2551                    let contiguous = sorted.windows(2).all(|w| w[1] == w[0] + 1);
2552                    if !contiguous {
2553                        panic!(
2554                            "rlx-wgpu Reduce: non-contiguous axes not yet wired \
2555                             (got axes={axes:?}, rank={})",
2556                            in_shape.len()
2557                        );
2558                    }
2559                    let ax_first = sorted[0];
2560                    let ax_last = *sorted.last().unwrap();
2561                    let dims_u32: Vec<u32> =
2562                        in_shape.iter().map(|d| d.unwrap_static() as u32).collect();
2563                    let outer: u32 = dims_u32[..ax_first].iter().product();
2564                    let reduce_dim: u32 = dims_u32[ax_first..=ax_last].iter().product();
2565                    let inner: u32 = dims_u32[ax_last + 1..].iter().product();
2566                    let red_ids = [node.id, in_id];
2567                    let max_binding = dev.device.limits().max_storage_buffer_binding_size;
2568                    let red_fits = arena_span_bytes(&arena, &red_ids) <= max_binding;
2569                    let mut scratch = arena.scratch_off as u64;
2570                    let (mut base, mut size, param_anchor) = arena_multi_op_window(
2571                        &dev.device,
2572                        &arena,
2573                        &graph,
2574                        &param_offsets,
2575                        &mut schedule,
2576                        &mut scratch,
2577                        &red_ids,
2578                    );
2579                    if !red_fits && !param_anchor {
2580                        base = arena_bind_window_covering_scratch_if_needed(
2581                            &arena, base, size, scratch,
2582                        );
2583                    }
2584                    let in_off = arena_off_in_bind_window(
2585                        &graph,
2586                        &param_offsets,
2587                        &dev.device,
2588                        &arena,
2589                        &mut schedule,
2590                        &mut scratch,
2591                        in_id,
2592                        &mut base,
2593                        &mut size,
2594                    );
2595                    let p = ReduceParams {
2596                        outer,
2597                        reduce_dim,
2598                        inner,
2599                        in_off,
2600                        out_off: arena_local_off_f32(&arena, node.id, base),
2601                        op: reduce_op_id(*rop),
2602                        _p0: 0,
2603                        _p1: 0,
2604                    };
2605                    schedule.push(Step::Reduce { params: p });
2606                    let rk = reduce_kernel(&dev.device);
2607                    let u = emit_uniform(std::mem::size_of::<ReduceParams>());
2608                    let bg = bind_two_buf0_window(&dev.device, rk, &arena.buffer, base, size, &u);
2609                    uniforms.push(u);
2610                    bind_groups.push(bg);
2611                }
2612
2613                Op::Softmax { axis } => {
2614                    let in_id = node.inputs[0];
2615                    let in_shape = graph.node(in_id).shape.dims();
2616                    let last = (in_shape.len() - 1) as i32;
2617                    if *axis != -1 && *axis != last {
2618                        panic!("rlx-wgpu Softmax: only last-axis wired (got axis={axis})");
2619                    }
2620                    let inner = in_shape[in_shape.len() - 1].unwrap_static() as u32;
2621                    let total: u32 = in_shape.iter().map(|d| d.unwrap_static() as u32).product();
2622                    let outer = total / inner.max(1);
2623                    let sm_ids = [node.id, in_id];
2624                    let max_binding = dev.device.limits().max_storage_buffer_binding_size;
2625                    let sm_fits = arena_span_bytes(&arena, &sm_ids) <= max_binding;
2626                    let mut scratch = arena.scratch_off as u64;
2627                    let (mut base, mut size, param_anchor) = arena_multi_op_window(
2628                        &dev.device,
2629                        &arena,
2630                        &graph,
2631                        &param_offsets,
2632                        &mut schedule,
2633                        &mut scratch,
2634                        &sm_ids,
2635                    );
2636                    if !sm_fits && !param_anchor {
2637                        base = arena_bind_window_covering_scratch_if_needed(
2638                            &arena, base, size, scratch,
2639                        );
2640                    }
2641                    let in_off = arena_off_in_bind_window(
2642                        &graph,
2643                        &param_offsets,
2644                        &dev.device,
2645                        &arena,
2646                        &mut schedule,
2647                        &mut scratch,
2648                        in_id,
2649                        &mut base,
2650                        &mut size,
2651                    );
2652                    let p = SoftmaxParams {
2653                        outer,
2654                        inner,
2655                        in_off,
2656                        out_off: arena_local_off_f32(&arena, node.id, base),
2657                        _p0: 0,
2658                        _p1: 0,
2659                        _p2: 0,
2660                        _p3: 0,
2661                    };
2662                    schedule.push(Step::Softmax { params: p });
2663                    let sk = softmax_kernel(&dev.device);
2664                    let u = emit_uniform(std::mem::size_of::<SoftmaxParams>());
2665                    let bg = bind_two_buf0_window(&dev.device, sk, &arena.buffer, base, size, &u);
2666                    uniforms.push(u);
2667                    bind_groups.push(bg);
2668                }
2669
2670                Op::SoftmaxCrossEntropy => {
2671                    // Dense / soft-label cross-entropy: logits [N,C], targets
2672                    // [N,C] → loss [N]. One thread per row (outer=N, inner=C).
2673                    // Window must cover both inputs + the [N] output slot;
2674                    // mirrors the LayerNorm multi-input arena-window dance.
2675                    let logits_id = node.inputs[0];
2676                    let targets_id = node.inputs[1];
2677                    let logits_shape = graph.node(logits_id).shape.dims();
2678                    let inner = logits_shape[logits_shape.len() - 1].unwrap_static() as u32;
2679                    let total: u32 = logits_shape
2680                        .iter()
2681                        .map(|d| d.unwrap_static() as u32)
2682                        .product();
2683                    let outer = total / inner.max(1);
2684
2685                    let sce_win = vec![node.id, logits_id, targets_id];
2686                    let max_binding = dev.device.limits().max_storage_buffer_binding_size;
2687                    let sce_fits = arena_span_bytes(&arena, &sce_win) <= max_binding;
2688                    let mut scratch = arena.scratch_off as u64;
2689                    let (mut base, mut size, param_anchor) = arena_multi_op_window(
2690                        &dev.device,
2691                        &arena,
2692                        &graph,
2693                        &param_offsets,
2694                        &mut schedule,
2695                        &mut scratch,
2696                        &sce_win,
2697                    );
2698                    if !sce_fits && !param_anchor {
2699                        base = arena_bind_window_covering_scratch_if_needed(
2700                            &arena, base, size, scratch,
2701                        );
2702                    }
2703                    let logits_off = arena_off_in_bind_window(
2704                        &graph,
2705                        &param_offsets,
2706                        &dev.device,
2707                        &arena,
2708                        &mut schedule,
2709                        &mut scratch,
2710                        logits_id,
2711                        &mut base,
2712                        &mut size,
2713                    );
2714                    let targets_off = arena_off_in_bind_window(
2715                        &graph,
2716                        &param_offsets,
2717                        &dev.device,
2718                        &arena,
2719                        &mut schedule,
2720                        &mut scratch,
2721                        targets_id,
2722                        &mut base,
2723                        &mut size,
2724                    );
2725                    let p = SceParams {
2726                        outer,
2727                        inner,
2728                        logits_off,
2729                        targets_off,
2730                        out_off: arena_local_off_f32(&arena, node.id, base),
2731                        _p0: 0,
2732                        _p1: 0,
2733                        _p2: 0,
2734                    };
2735                    schedule.push(Step::SoftmaxCrossEntropy { params: p });
2736                    let sk = softmax_cross_entropy_kernel(&dev.device);
2737                    let u = emit_uniform(std::mem::size_of::<SceParams>());
2738                    let bg = bind_two_buf0_window(&dev.device, sk, &arena.buffer, base, size, &u);
2739                    uniforms.push(u);
2740                    bind_groups.push(bg);
2741                }
2742
2743                Op::LayerNorm { axis: _, eps } | Op::RmsNorm { axis: _, eps } => {
2744                    let in_id = node.inputs[0];
2745                    let in_shape = graph.node(in_id).shape.dims();
2746                    let inner = in_shape[in_shape.len() - 1].unwrap_static() as u32;
2747                    let total: u32 = in_shape.iter().map(|d| d.unwrap_static() as u32).product();
2748                    let outer = total / inner.max(1);
2749                    let is_layer_norm = matches!(&node.op, Op::LayerNorm { .. });
2750
2751                    // FRLTee fast path: if this LN is the head of a
2752                    // (multi-consumer Add → LN) pattern, emit one
2753                    // `Step::FusedResidualLnTee` that writes the sum to
2754                    // the eliminated Add's arena slot AND the LN result
2755                    // to this LN's slot. The Add itself is skipped
2756                    // upstream (`skip_adds`).
2757                    if is_layer_norm
2758                        && let Some(&(h_id, delta_id, gamma_id, beta_id, sum_id)) =
2759                            ln_to_tee.get(&node.id)
2760                    {
2761                        let gamma_is_param =
2762                            tensor_is_graph_param(&graph, &param_offsets, gamma_id);
2763                        let gamma_bytes = arena.len_of(gamma_id) as u64;
2764                        let frlt_win: Vec<NodeId> =
2765                            if gamma_is_param && gamma_bytes > ARENA_STAGE_CAP {
2766                                vec![gamma_id, node.id, h_id, delta_id, beta_id, sum_id]
2767                            } else {
2768                                vec![node.id, h_id, delta_id, gamma_id, beta_id, sum_id]
2769                            };
2770                        let mut scratch = arena.scratch_off as u64;
2771                        let (mut base, mut size, param_anchor) = arena_multi_op_window(
2772                            &dev.device,
2773                            &arena,
2774                            &graph,
2775                            &param_offsets,
2776                            &mut schedule,
2777                            &mut scratch,
2778                            &frlt_win,
2779                        );
2780                        if !param_anchor {
2781                            base = arena_bind_window_covering_scratch_if_needed(
2782                                &arena, base, size, scratch,
2783                            );
2784                        }
2785                        let in_off = arena_off_in_bind_window(
2786                            &graph,
2787                            &param_offsets,
2788                            &dev.device,
2789                            &arena,
2790                            &mut schedule,
2791                            &mut scratch,
2792                            h_id,
2793                            &mut base,
2794                            &mut size,
2795                        );
2796                        let residual_off = arena_off_in_bind_window(
2797                            &graph,
2798                            &param_offsets,
2799                            &dev.device,
2800                            &arena,
2801                            &mut schedule,
2802                            &mut scratch,
2803                            delta_id,
2804                            &mut base,
2805                            &mut size,
2806                        );
2807                        let sum_off = arena_off_in_bind_window(
2808                            &graph,
2809                            &param_offsets,
2810                            &dev.device,
2811                            &arena,
2812                            &mut schedule,
2813                            &mut scratch,
2814                            sum_id,
2815                            &mut base,
2816                            &mut size,
2817                        );
2818                        let gamma_off = arena_off_in_bind_window(
2819                            &graph,
2820                            &param_offsets,
2821                            &dev.device,
2822                            &arena,
2823                            &mut schedule,
2824                            &mut scratch,
2825                            gamma_id,
2826                            &mut base,
2827                            &mut size,
2828                        );
2829                        let beta_off = arena_off_in_bind_window(
2830                            &graph,
2831                            &param_offsets,
2832                            &dev.device,
2833                            &arena,
2834                            &mut schedule,
2835                            &mut scratch,
2836                            beta_id,
2837                            &mut base,
2838                            &mut size,
2839                        );
2840                        let p = FusedResidualLnTeeParams {
2841                            outer,
2842                            inner,
2843                            in_off,
2844                            residual_off,
2845                            bias_off: 0, // FRLTee currently no-bias only
2846                            gamma_off,
2847                            beta_off,
2848                            sum_off,
2849                            ln_out_off: arena_local_off_f32(&arena, node.id, base),
2850                            eps_bits: eps.to_bits(),
2851                            has_bias: 0,
2852                            _p0: 0,
2853                        };
2854                        schedule.push(Step::FusedResidualLnTee { params: p });
2855                        let frtk = fused_residual_ln_tee_kernel(&dev.device);
2856                        let u = emit_uniform(std::mem::size_of::<FusedResidualLnTeeParams>());
2857                        let bg =
2858                            bind_two_buf0_window(&dev.device, frtk, &arena.buffer, base, size, &u);
2859                        uniforms.push(u);
2860                        bind_groups.push(bg);
2861                        continue;
2862                    }
2863
2864                    let gamma_id = node.inputs[1];
2865                    // beta is the third input for LayerNorm; RmsNorm
2866                    // ignores it (kernel branch on `op` skips the read).
2867                    let beta_id = if is_layer_norm && node.inputs.len() >= 3 {
2868                        node.inputs[2]
2869                    } else {
2870                        // Use gamma's offset as a benign placeholder;
2871                        // the RmsNorm kernel branch never reads it.
2872                        gamma_id
2873                    };
2874                    let gamma_is_param = tensor_is_graph_param(&graph, &param_offsets, gamma_id);
2875                    let gamma_bytes = arena.len_of(gamma_id) as u64;
2876                    let ln_win: Vec<NodeId> = if gamma_is_param && gamma_bytes > ARENA_STAGE_CAP {
2877                        vec![gamma_id, node.id, in_id]
2878                    } else {
2879                        let mut v = vec![node.id, in_id];
2880                        if gamma_is_param {
2881                            v.push(gamma_id);
2882                        }
2883                        if is_layer_norm {
2884                            v.push(beta_id);
2885                        }
2886                        v
2887                    };
2888                    let max_binding = dev.device.limits().max_storage_buffer_binding_size;
2889                    let ln_fits = arena_span_bytes(&arena, &ln_win) <= max_binding;
2890                    let mut scratch = arena.scratch_off as u64;
2891                    let (mut base, mut size, param_anchor) = arena_multi_op_window(
2892                        &dev.device,
2893                        &arena,
2894                        &graph,
2895                        &param_offsets,
2896                        &mut schedule,
2897                        &mut scratch,
2898                        &ln_win,
2899                    );
2900                    if !ln_fits && !param_anchor {
2901                        base = arena_bind_window_covering_scratch_if_needed(
2902                            &arena, base, size, scratch,
2903                        );
2904                    }
2905                    let in_off = arena_off_in_bind_window(
2906                        &graph,
2907                        &param_offsets,
2908                        &dev.device,
2909                        &arena,
2910                        &mut schedule,
2911                        &mut scratch,
2912                        in_id,
2913                        &mut base,
2914                        &mut size,
2915                    );
2916                    let gamma_off = arena_off_in_bind_window(
2917                        &graph,
2918                        &param_offsets,
2919                        &dev.device,
2920                        &arena,
2921                        &mut schedule,
2922                        &mut scratch,
2923                        gamma_id,
2924                        &mut base,
2925                        &mut size,
2926                    );
2927                    let beta_off = arena_off_in_bind_window(
2928                        &graph,
2929                        &param_offsets,
2930                        &dev.device,
2931                        &arena,
2932                        &mut schedule,
2933                        &mut scratch,
2934                        beta_id,
2935                        &mut base,
2936                        &mut size,
2937                    );
2938                    let p = LayerNormParams {
2939                        outer,
2940                        inner,
2941                        in_off,
2942                        out_off: arena_local_off_f32(&arena, node.id, base),
2943                        gamma_off,
2944                        beta_off,
2945                        eps_bits: eps.to_bits(),
2946                        op: if is_layer_norm { 0 } else { 1 },
2947                    };
2948                    schedule.push(Step::LayerNorm { params: p });
2949                    let lk = layernorm_kernel(&dev.device);
2950                    let u = emit_uniform(std::mem::size_of::<LayerNormParams>());
2951                    let bg = bind_two_buf0_window(&dev.device, lk, &arena.buffer, base, size, &u);
2952                    uniforms.push(u);
2953                    bind_groups.push(bg);
2954                }
2955
2956                Op::Reshape { .. } => {
2957                    // No-op: memory planner view-aliased this slot.
2958                }
2959
2960                Op::Cast { .. } => {
2961                    // A same-dtype Cast is view-aliased by the planner (src==dst) →
2962                    // no-op. A dtype-changing Cast (e.g. Bool→F32 for VITS sequence
2963                    // masks) gets its own slot; on the f32-uniform arena every value
2964                    // is already f32-encoded, so the cast is a value-preserving copy.
2965                    // Without this the output slot keeps its (zero) init — a Bool→F32
2966                    // mask Cast silently produced all-zeros, killing the encoder.
2967                    let in_id = node.inputs[0];
2968                    let src = arena.offset(in_id);
2969                    let dst = arena.offset(node.id);
2970                    if src != dst {
2971                        let bytes = arena.len_of(in_id).min(arena.len_of(node.id));
2972                        schedule.push(Step::BufferCopy {
2973                            src_byte_off: src as u64,
2974                            dst_byte_off: dst as u64,
2975                            bytes: bytes as u32,
2976                        });
2977                    }
2978                }
2979
2980                Op::Transpose { perm } => {
2981                    let in_id = node.inputs[0];
2982                    let in_shape = graph.node(in_id).shape.dims();
2983                    let out_shape = node.shape.dims();
2984                    let rank = perm.len();
2985                    if rank != in_shape.len() || rank != out_shape.len() {
2986                        panic!("rlx-wgpu Transpose: rank mismatch");
2987                    }
2988                    let in_dims: Vec<u32> =
2989                        in_shape.iter().map(|d| d.unwrap_static() as u32).collect();
2990                    let out_dims: Vec<u32> =
2991                        out_shape.iter().map(|d| d.unwrap_static() as u32).collect();
2992                    // Input cumulative strides (row-major).
2993                    let mut in_strides = vec![1u32; rank];
2994                    for i in (0..rank.saturating_sub(1)).rev() {
2995                        in_strides[i] = in_strides[i + 1] * in_dims[i + 1];
2996                    }
2997                    // For each *output* axis i, the corresponding input
2998                    // axis is perm[i] — its stride is in_strides[perm[i]].
2999                    let strides_for_out: Vec<u32> =
3000                        (0..rank).map(|i| in_strides[perm[i]]).collect();
3001
3002                    // Build meta buffer: dims (rank u32s) + strides (rank u32s).
3003                    let mut meta_data: Vec<u32> = Vec::with_capacity(rank * 2);
3004                    meta_data.extend_from_slice(&out_dims);
3005                    meta_data.extend_from_slice(&strides_for_out);
3006                    let meta_buf = dev.device.create_buffer(&wgpu::BufferDescriptor {
3007                        label: Some("rlx-wgpu transpose meta"),
3008                        size: (meta_data.len() * 4).max(4) as u64,
3009                        usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
3010                        mapped_at_creation: false,
3011                    });
3012                    dev.queue
3013                        .write_buffer(&meta_buf, 0, bytemuck::cast_slice(&meta_data));
3014                    let meta_idx = meta_buffers.len();
3015                    meta_buffers.push(meta_buf);
3016
3017                    // PLAN L1: precompute "bucket axis stays at out
3018                    // axis 0" flag from perm. When `perm[0] == 0`,
3019                    // active-extent scaling of `out_total` is safe.
3020                    let bucket_outermost = if perm[0] == 0 { 1u32 } else { 0u32 };
3021                    let tr_ids = [node.id, in_id];
3022                    let max_binding = dev.device.limits().max_storage_buffer_binding_size;
3023                    let in_is_param = tensor_is_graph_param(&graph, &param_offsets, in_id);
3024                    let in_bytes = arena.len_of(in_id) as u64;
3025                    let (mut base, mut size) = if in_is_param && in_bytes <= max_binding {
3026                        arena_window_for_nodes(&dev.device, &arena, &[in_id])
3027                    } else if arena_span_bytes(&arena, &tr_ids) <= max_binding {
3028                        arena_window_for_nodes(&dev.device, &arena, &tr_ids)
3029                    } else {
3030                        arena_window_for_nodes(&dev.device, &arena, &[node.id])
3031                    };
3032                    let mut scratch = arena.scratch_off as u64;
3033                    let in_off = arena_off_in_bind_window(
3034                        &graph,
3035                        &param_offsets,
3036                        &dev.device,
3037                        &arena,
3038                        &mut schedule,
3039                        &mut scratch,
3040                        in_id,
3041                        &mut base,
3042                        &mut size,
3043                    );
3044                    let out_off = arena_off_in_bind_window(
3045                        &graph,
3046                        &param_offsets,
3047                        &dev.device,
3048                        &arena,
3049                        &mut schedule,
3050                        &mut scratch,
3051                        node.id,
3052                        &mut base,
3053                        &mut size,
3054                    );
3055                    let p = TransposeParams {
3056                        rank: rank as u32,
3057                        out_total: elems,
3058                        in_off,
3059                        out_off,
3060                        bucket_outermost,
3061                        out_dim_0: out_dims[0],
3062                        _p2: 0,
3063                        _p3: 0,
3064                    };
3065                    schedule.push(Step::Transpose {
3066                        params: p,
3067                        meta_idx,
3068                    });
3069                    let tk = transpose_kernel(&dev.device);
3070                    let u = emit_uniform(std::mem::size_of::<TransposeParams>());
3071                    let bg = dev.device.create_bind_group(&wgpu::BindGroupDescriptor {
3072                        label: Some("rlx-wgpu transpose bg"),
3073                        layout: &tk.bgl,
3074                        entries: &[
3075                            wgpu::BindGroupEntry {
3076                                binding: 0,
3077                                resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
3078                                    buffer: &arena.buffer,
3079                                    offset: base,
3080                                    size: NonZeroU64::new(size),
3081                                }),
3082                            },
3083                            wgpu::BindGroupEntry {
3084                                binding: 1,
3085                                resource: u.as_entire_binding(),
3086                            },
3087                            wgpu::BindGroupEntry {
3088                                binding: 2,
3089                                resource: meta_buffers[meta_idx].as_entire_binding(),
3090                            },
3091                        ],
3092                    });
3093                    uniforms.push(u);
3094                    bind_groups.push(bg);
3095                }
3096
3097                Op::Narrow { axis, start, len } => {
3098                    // Part of a split-QKV pattern: the parent FMB has been
3099                    // (or will be) replaced by Step::MatmulQkv that writes
3100                    // directly into this narrow's arena slot. Skip the
3101                    // narrow's own dispatch.
3102                    if qkv_skip_narrows.contains(&node.id)
3103                        || packed_bshd_skip_narrows.contains(&node.id)
3104                    {
3105                        continue;
3106                    }
3107                    let in_id = node.inputs[0];
3108                    let in_shape = graph.node(in_id).shape.dims();
3109                    let outer: u32 = in_shape[..*axis]
3110                        .iter()
3111                        .map(|d| d.unwrap_static() as u32)
3112                        .product::<u32>()
3113                        .max(1);
3114                    let inner: u32 = in_shape[*axis + 1..]
3115                        .iter()
3116                        .map(|d| d.unwrap_static() as u32)
3117                        .product::<u32>()
3118                        .max(1);
3119                    let axis_in = in_shape[*axis].unwrap_static() as u32;
3120                    let win_ids = [node.id, in_id];
3121                    let max_binding = dev.device.limits().max_storage_buffer_binding_size;
3122                    let fits = arena_span_bytes(&arena, &win_ids) <= max_binding;
3123                    let mut scratch = arena.scratch_off as u64;
3124                    let (mut base, mut size, param_anchor) = arena_multi_op_window(
3125                        &dev.device,
3126                        &arena,
3127                        &graph,
3128                        &param_offsets,
3129                        &mut schedule,
3130                        &mut scratch,
3131                        &win_ids,
3132                    );
3133                    if !fits && !param_anchor {
3134                        base = arena_bind_window_covering_scratch_if_needed(
3135                            &arena, base, size, scratch,
3136                        );
3137                    }
3138                    let in_off = arena_off_in_bind_window(
3139                        &graph,
3140                        &param_offsets,
3141                        &dev.device,
3142                        &arena,
3143                        &mut schedule,
3144                        &mut scratch,
3145                        in_id,
3146                        &mut base,
3147                        &mut size,
3148                    );
3149                    let out_off = arena_off_in_bind_window(
3150                        &graph,
3151                        &param_offsets,
3152                        &dev.device,
3153                        &arena,
3154                        &mut schedule,
3155                        &mut scratch,
3156                        node.id,
3157                        &mut base,
3158                        &mut size,
3159                    );
3160                    let p = NarrowConcatParams {
3161                        total: elems,
3162                        outer,
3163                        inner,
3164                        axis_in_size: axis_in,
3165                        axis_out_size: *len as u32,
3166                        start: *start as u32,
3167                        in_off,
3168                        out_off,
3169                    };
3170                    schedule.push(Step::Narrow { params: p });
3171                    let nk = narrow_kernel(&dev.device);
3172                    let u = emit_uniform(std::mem::size_of::<NarrowConcatParams>());
3173                    let bg = bind_two_buf0_window(&dev.device, nk, &arena.buffer, base, size, &u);
3174                    uniforms.push(u);
3175                    bind_groups.push(bg);
3176                }
3177
3178                Op::Concat { axis } => {
3179                    let out_shape = node.shape.dims();
3180                    let outer: u32 = out_shape[..*axis]
3181                        .iter()
3182                        .map(|d| d.unwrap_static() as u32)
3183                        .product::<u32>()
3184                        .max(1);
3185                    let inner: u32 = out_shape[*axis + 1..]
3186                        .iter()
3187                        .map(|d| d.unwrap_static() as u32)
3188                        .product::<u32>()
3189                        .max(1);
3190                    let axis_out = out_shape[*axis].unwrap_static() as u32;
3191
3192                    let all_ids: Vec<NodeId> = std::iter::once(node.id)
3193                        .chain(node.inputs.iter().copied())
3194                        .collect();
3195                    let max_binding = dev.device.limits().max_storage_buffer_binding_size;
3196                    let fits_all = arena_span_bytes(&arena, &all_ids) <= max_binding;
3197                    let mut scratch = arena.scratch_off as u64;
3198                    let (mut base, mut size, param_anchor) = arena_multi_op_window(
3199                        &dev.device,
3200                        &arena,
3201                        &graph,
3202                        &param_offsets,
3203                        &mut schedule,
3204                        &mut scratch,
3205                        &all_ids,
3206                    );
3207                    arena_expand_bind_window(&arena, &all_ids, &mut base, &mut size, max_binding);
3208                    if !fits_all && !param_anchor {
3209                        base = arena_bind_window_covering_scratch_if_needed(
3210                            &arena, base, size, scratch,
3211                        );
3212                    }
3213                    let mut start_pos: u32 = 0;
3214                    for &in_id in &node.inputs {
3215                        let in_shape = graph.node(in_id).shape.dims();
3216                        let axis_in = in_shape[*axis].unwrap_static() as u32;
3217                        let in_total: u32 =
3218                            in_shape.iter().map(|d| d.unwrap_static() as u32).product();
3219                        let _win_ids = [node.id, in_id];
3220                        let in_off = arena_off_in_bind_window(
3221                            &graph,
3222                            &param_offsets,
3223                            &dev.device,
3224                            &arena,
3225                            &mut schedule,
3226                            &mut scratch,
3227                            in_id,
3228                            &mut base,
3229                            &mut size,
3230                        );
3231                        // Recompute the output offset against the *current* base:
3232                        // `arena_off_in_bind_window` above may have shifted `base`
3233                        // to cover this input (large decoder arenas), which would
3234                        // otherwise leave a pre-loop `out_off` stale → writes land
3235                        // outside the bound window and the output stays zero.
3236                        let out_off = arena_local_off_f32(&arena, node.id, base);
3237                        let p = NarrowConcatParams {
3238                            total: in_total,
3239                            outer,
3240                            inner,
3241                            axis_in_size: axis_in,
3242                            axis_out_size: axis_out,
3243                            start: start_pos,
3244                            in_off,
3245                            out_off,
3246                        };
3247                        schedule.push(Step::Concat { params: p });
3248                        let cck = concat_kernel(&dev.device);
3249                        let u = emit_uniform(std::mem::size_of::<NarrowConcatParams>());
3250                        let bg =
3251                            bind_two_buf0_window(&dev.device, cck, &arena.buffer, base, size, &u);
3252                        uniforms.push(u);
3253                        bind_groups.push(bg);
3254                        start_pos += axis_in;
3255                    }
3256                }
3257
3258                Op::Attention {
3259                    num_heads,
3260                    head_dim,
3261                    mask_kind,
3262                    score_scale: _,
3263                    attn_logit_softcap: _,
3264                } => {
3265                    // v5: rank-4 [B, H, S, D] inputs only. SlidingWindow
3266                    // synthesizes a Custom mask host-side.
3267                    let q_id = node.inputs[0];
3268                    let k_id = node.inputs[1];
3269                    let v_id = node.inputs[2];
3270                    let q_shape = graph.node(q_id).shape.dims();
3271                    let k_shape = graph.node(k_id).shape.dims();
3272                    // Accept either rank-4 [B, H, S, D] or rank-3 [B*H, S, D]
3273                    // (the latter is what BERT-flavored builders emit). For
3274                    // rank-3 we treat the leading dim as `batch * heads`,
3275                    // setting heads = num_heads from the Op so the kernel's
3276                    // (b, h) indexing folds back to the right offset.
3277                    let h = *num_heads as u32;
3278                    let hd = *head_dim as u32;
3279                    let q_ir = graph.node(q_id).shape.clone();
3280                    let k_ir = graph.node(k_id).shape.clone();
3281                    let geom = rlx_ir::attention_geom(&q_ir, &k_ir, *num_heads, *head_dim);
3282                    let bhsd = geom.bhsd;
3283                    let (batch, heads, seq_q, seq_k) = match q_shape.len() {
3284                        4 => (
3285                            geom.batch as u32,
3286                            geom.heads as u32,
3287                            geom.seq_q as u32,
3288                            geom.seq_k as u32,
3289                        ),
3290                        3 => {
3291                            // Two rank-3 layouts coexist:
3292                            //   [B, S, H·D] — transpose-elided layout
3293                            //   [B·H, S, D] — canonical compacted layout
3294                            // Distinguish by last-dim: if it equals H·D
3295                            // (the per-token feature width) it's [B, S, H·D];
3296                            // otherwise it's [B·H, S, D].
3297                            let last = q_shape[2].unwrap_static() as u32;
3298                            if last == h * hd {
3299                                // [B, S, H·D]: leading = B, seq = S
3300                                (
3301                                    q_shape[0].unwrap_static() as u32,
3302                                    h,
3303                                    q_shape[1].unwrap_static() as u32,
3304                                    k_shape[1].unwrap_static() as u32,
3305                                )
3306                            } else {
3307                                // [B·H, S, D]: leading must be divisible by H
3308                                let leading = q_shape[0].unwrap_static() as u32;
3309                                if !leading.is_multiple_of(h) {
3310                                    panic!(
3311                                        "rlx-wgpu Attention: rank-3 leading dim {leading} \
3312                                            not divisible by num_heads {h} (and last dim \
3313                                            {last} ≠ H·D = {})",
3314                                        h * hd
3315                                    );
3316                                }
3317                                (
3318                                    leading / h,
3319                                    h,
3320                                    q_shape[1].unwrap_static() as u32,
3321                                    k_shape[1].unwrap_static() as u32,
3322                                )
3323                            }
3324                        }
3325                        other => panic!(
3326                            "rlx-wgpu Attention: only rank-3 / rank-4 Q,K,V \
3327                                         inputs supported (got rank {other})"
3328                        ),
3329                    };
3330                    let scale = 1.0_f32 / (hd as f32).sqrt();
3331
3332                    let (mask_kind_id, mask_buf, window) = match mask_kind {
3333                        MaskKind::None => (0u32, None, 0u32),
3334                        MaskKind::Causal => (1u32, None, 0u32),
3335                        // 2 = binary key-padding mask (Custom: <0.5 → -inf);
3336                        // 4 = additive bias mask (Bias: score += mask). These
3337                        // are NOT interchangeable — the encoder's block-diagonal
3338                        // winmask is additive, so folding it into the binary
3339                        // path silently corrupts attention.
3340                        MaskKind::Custom => (2u32, None, 0u32),
3341                        MaskKind::Bias => (4u32, None, 0u32),
3342                        MaskKind::SlidingWindow(w) => (3u32, None, *w as u32),
3343                    };
3344
3345                    // Mask address strides. For Custom masks, derive from
3346                    // the mask's IR shape so the kernel can broadcast a
3347                    // [B, S] padding mask without materializing the full
3348                    // [B, H, S_q, S_k] expansion. Other mask kinds use
3349                    // canonical [B, H, S_q, S_k] strides (the kernel's
3350                    // mask_partial computation is harmless when not read).
3351                    struct MStrides {
3352                        b: u32,
3353                        h: u32,
3354                        q: u32,
3355                        k: u32,
3356                    }
3357                    let mask_strides = if mask_kind_id == 2u32 || mask_kind_id == 4u32 {
3358                        let m_dims = graph.node(node.inputs[3]).shape.dims();
3359                        let dim = |i: usize| m_dims[i].unwrap_static() as u32;
3360                        match m_dims.len() {
3361                            2 => MStrides {
3362                                b: dim(1),
3363                                h: 0,
3364                                q: 0,
3365                                k: 1,
3366                            },
3367                            3 => MStrides {
3368                                b: dim(1) * dim(2),
3369                                h: 0,
3370                                q: dim(2),
3371                                k: 1,
3372                            },
3373                            4 => MStrides {
3374                                b: dim(1) * dim(2) * dim(3),
3375                                h: dim(2) * dim(3),
3376                                q: dim(3),
3377                                k: 1,
3378                            },
3379                            _ => MStrides {
3380                                b: heads * seq_q * seq_k,
3381                                h: seq_q * seq_k,
3382                                q: seq_k,
3383                                k: 1,
3384                            },
3385                        }
3386                    } else {
3387                        MStrides {
3388                            b: heads * seq_q * seq_k,
3389                            h: seq_q * seq_k,
3390                            q: seq_k,
3391                            k: 1,
3392                        }
3393                    };
3394
3395                    let stride = |shape: &[rlx_ir::shape::Dim], seq_extent: u32| {
3396                        rlx_ir::strides_for_shape(shape, heads, hd, seq_extent, bhsd)
3397                    };
3398                    let packed_parent = packed_bshd_attn.get(&node.id).copied();
3399                    let (q_b, q_h, q_s, k_b, k_h, k_s, v_b, v_h, v_s) =
3400                        if let Some((_parent, head_width)) = packed_parent {
3401                            let (batch_stride, head_stride, pack_seq) =
3402                                rlx_ir::packed_bshd_qkv_strides(head_width as usize, hd, seq_q);
3403                            (
3404                                batch_stride,
3405                                head_stride,
3406                                pack_seq,
3407                                batch_stride,
3408                                head_stride,
3409                                pack_seq,
3410                                batch_stride,
3411                                head_stride,
3412                                pack_seq,
3413                            )
3414                        } else {
3415                            let (qb, qh, qs) = stride(q_shape, seq_q);
3416                            let (kb, kh, ks) = stride(k_shape, seq_k);
3417                            let v_shape = graph.node(v_id).shape.dims();
3418                            let (vb, vh, vs) = stride(v_shape, seq_k);
3419                            (qb, qh, qs, kb, kh, ks, vb, vh, vs)
3420                        };
3421                    let out_shape = node.shape.dims();
3422                    let (o_b, o_h, o_s) = stride(out_shape, seq_q);
3423                    let mut attn_ids = if let Some((parent, _)) = packed_parent {
3424                        vec![node.id, parent]
3425                    } else {
3426                        vec![node.id, q_id, k_id, v_id]
3427                    };
3428                    if mask_kind_id == 2 {
3429                        attn_ids.push(node.inputs[3]);
3430                    }
3431                    let mut scratch = arena.scratch_off as u64;
3432                    let (mut base, mut size, param_anchor) = arena_multi_op_window(
3433                        &dev.device,
3434                        &arena,
3435                        &graph,
3436                        &param_offsets,
3437                        &mut schedule,
3438                        &mut scratch,
3439                        &attn_ids,
3440                    );
3441                    if !param_anchor {
3442                        base = arena_bind_window_covering_scratch_if_needed(
3443                            &arena, base, size, scratch,
3444                        );
3445                    }
3446                    let (q_off, k_off, v_off) = if let Some((parent, head_width)) = packed_parent {
3447                        let parent_off = arena_off_in_bind_window(
3448                            &graph,
3449                            &param_offsets,
3450                            &dev.device,
3451                            &arena,
3452                            &mut schedule,
3453                            &mut scratch,
3454                            parent,
3455                            &mut base,
3456                            &mut size,
3457                        );
3458                        (
3459                            parent_off,
3460                            parent_off.saturating_add(head_width),
3461                            parent_off.saturating_add(head_width * 2),
3462                        )
3463                    } else {
3464                        let q_off = arena_off_in_bind_window(
3465                            &graph,
3466                            &param_offsets,
3467                            &dev.device,
3468                            &arena,
3469                            &mut schedule,
3470                            &mut scratch,
3471                            q_id,
3472                            &mut base,
3473                            &mut size,
3474                        );
3475                        let k_off = arena_off_in_bind_window(
3476                            &graph,
3477                            &param_offsets,
3478                            &dev.device,
3479                            &arena,
3480                            &mut schedule,
3481                            &mut scratch,
3482                            k_id,
3483                            &mut base,
3484                            &mut size,
3485                        );
3486                        let v_off = arena_off_in_bind_window(
3487                            &graph,
3488                            &param_offsets,
3489                            &dev.device,
3490                            &arena,
3491                            &mut schedule,
3492                            &mut scratch,
3493                            v_id,
3494                            &mut base,
3495                            &mut size,
3496                        );
3497                        (q_off, k_off, v_off)
3498                    };
3499                    let out_byte = arena.offset(node.id) as u64;
3500                    let out_len = arena.len_of(node.id) as u64;
3501                    let out_aliases_qkv = arena_tensors_overlap(&arena, node.id, q_id)
3502                        || arena_tensors_overlap(&arena, node.id, k_id)
3503                        || arena_tensors_overlap(&arena, node.id, v_id)
3504                        || packed_parent.is_some_and(|(parent, _)| {
3505                            arena_tensors_overlap(&arena, node.id, parent)
3506                        });
3507                    let mut kernel_out_off = arena_off_in_bind_window(
3508                        &graph,
3509                        &param_offsets,
3510                        &dev.device,
3511                        &arena,
3512                        &mut schedule,
3513                        &mut scratch,
3514                        node.id,
3515                        &mut base,
3516                        &mut size,
3517                    );
3518                    let mut attn_scratch_copy: Option<(u64, u32)> = None;
3519                    if out_aliases_qkv && rlx_ir::env::flag("RLX_WGPU_DEBUG_ATTN_ALIAS") {
3520                        eprintln!(
3521                            "rlx-wgpu Attention alias: out={:?}@{}+{} q={:?}@{} k={:?}@{} v={:?}@{}",
3522                            node.id,
3523                            out_byte,
3524                            out_len,
3525                            q_id,
3526                            arena.offset(q_id),
3527                            k_id,
3528                            arena.offset(k_id),
3529                            v_id,
3530                            arena.offset(v_id),
3531                        );
3532                    }
3533                    if out_aliases_qkv {
3534                        let tmp_byte = scratch;
3535                        let tmp_aligned = out_len.div_ceil(256) * 256;
3536                        scratch = scratch.saturating_add(tmp_aligned);
3537                        if param_anchor {
3538                            arena_ensure_scratch_in_window(&mut scratch, base, size);
3539                        } else {
3540                            base = arena_bind_window_covering_scratch_if_needed(
3541                                &arena, base, size, scratch,
3542                            );
3543                        }
3544                        kernel_out_off = ((tmp_byte.saturating_sub(base)) / 4) as u32;
3545                        attn_scratch_copy = Some((tmp_byte, out_len as u32));
3546                    }
3547                    let mask_off = if mask_kind_id == 2 {
3548                        arena_off_in_bind_window(
3549                            &graph,
3550                            &param_offsets,
3551                            &dev.device,
3552                            &arena,
3553                            &mut schedule,
3554                            &mut scratch,
3555                            node.inputs[3],
3556                            &mut base,
3557                            &mut size,
3558                        )
3559                    } else {
3560                        0
3561                    };
3562                    let p = AttentionParams {
3563                        batch,
3564                        heads,
3565                        seq_q,
3566                        seq_k,
3567                        head_dim: hd,
3568                        q_off,
3569                        k_off,
3570                        v_off,
3571                        out_off: kernel_out_off,
3572                        mask_off,
3573                        mask_kind: mask_kind_id,
3574                        scale_bits: scale.to_bits(),
3575                        window,
3576                        // Mask strides — derive from the mask's IR shape:
3577                        //   [B, S]:           (mb=S,        mh=0,    mq=0,   mk=1)
3578                        //   [B, S_q, S_k]:    (mb=S_q·S_k,  mh=0,    mq=S_k, mk=1)
3579                        //   [B, H, S_q, S_k]: (mb=H·S_q·S_k mh=S_q·S_k mq=S_k mk=1)
3580                        // Stride 0 means the kernel broadcasts across that
3581                        // axis (reads the same element for every value of
3582                        // the index). Lets us skip the Expand pre-pass that
3583                        // unfuse used to emit per attention block.
3584                        seq_q_stride: mask_strides.q,
3585                        seq_k_stride: mask_strides.k,
3586                        mask_batch_stride: mask_strides.b,
3587                        mask_head_stride: mask_strides.h,
3588                        _pad_mask_0: 0,
3589                        _pad_mask_1: 0,
3590                        _pad_mask_2: 0,
3591                        q_batch_stride: q_b,
3592                        q_head_stride: q_h,
3593                        q_seq_stride: q_s,
3594                        _pad_q: 0,
3595                        k_batch_stride: k_b,
3596                        k_head_stride: k_h,
3597                        k_seq_stride: k_s,
3598                        _pad_k: 0,
3599                        v_batch_stride: v_b,
3600                        v_head_stride: v_h,
3601                        v_seq_stride: v_s,
3602                        _pad_v: 0,
3603                        o_batch_stride: o_b,
3604                        o_head_stride: o_h,
3605                        o_seq_stride: o_s,
3606                        _pad_o: 0,
3607                    };
3608                    let _ = num_heads;
3609                    schedule.push(Step::Attention {
3610                        params: p,
3611                        mask_buf,
3612                    });
3613                    if let Some((tmp_byte, bytes)) = attn_scratch_copy {
3614                        schedule.push(Step::BufferCopy {
3615                            src_byte_off: tmp_byte,
3616                            dst_byte_off: out_byte,
3617                            bytes,
3618                        });
3619                    }
3620                    let ak = attention_kernel(&dev.device);
3621                    let u = emit_uniform(std::mem::size_of::<AttentionParams>());
3622                    let bg = bind_two_buf0_window(&dev.device, ak, &arena.buffer, base, size, &u);
3623                    uniforms.push(u);
3624                    bind_groups.push(bg);
3625                }
3626
3627                Op::AttentionBackward {
3628                    num_heads,
3629                    head_dim,
3630                    mask_kind,
3631                    wrt,
3632                } => {
3633                    use rlx_ir::op::AttentionBwdWrt;
3634                    let q_id = node.inputs[0];
3635                    let k_id = node.inputs[1];
3636                    let v_id = node.inputs[2];
3637                    let dy_id = node.inputs[3];
3638                    let q_shape = graph.node(q_id).shape.dims();
3639                    let k_shape = graph.node(k_id).shape.dims();
3640                    let hd = *head_dim as u32;
3641                    let q_ir = graph.node(q_id).shape.clone();
3642                    let k_ir = graph.node(k_id).shape.clone();
3643                    let geom = rlx_ir::attention_geom(&q_ir, &k_ir, *num_heads, *head_dim);
3644                    let bhsd = geom.bhsd;
3645                    let (batch, heads, seq_q, seq_k) = match q_shape.len() {
3646                        4 => (
3647                            geom.batch as u32,
3648                            geom.heads as u32,
3649                            geom.seq_q as u32,
3650                            geom.seq_k as u32,
3651                        ),
3652                        3 => {
3653                            let h = q_shape[2].unwrap_static() as u32 / hd;
3654                            (
3655                                q_shape[0].unwrap_static() as u32 / h,
3656                                h,
3657                                q_shape[1].unwrap_static() as u32,
3658                                k_shape[1].unwrap_static() as u32,
3659                            )
3660                        }
3661                        other => panic!(
3662                            "rlx-wgpu AttentionBackward: only rank-3/4 Q,K,V (got rank {other})"
3663                        ),
3664                    };
3665                    let scale = 1.0_f32 / (hd as f32).sqrt();
3666                    let (mask_kind_id, mask_off, mask_buf, window) = match mask_kind {
3667                        MaskKind::None => (0u32, 0u32, None, 0u32),
3668                        MaskKind::Causal => (1u32, 0u32, None, 0u32),
3669                        MaskKind::Custom => {
3670                            (2u32, (arena.offset(node.inputs[4]) / 4) as u32, None, 0u32)
3671                        }
3672                        MaskKind::Bias => {
3673                            (4u32, (arena.offset(node.inputs[4]) / 4) as u32, None, 0u32)
3674                        }
3675                        MaskKind::SlidingWindow(w) => (3u32, 0u32, None, *w as u32),
3676                    };
3677                    struct MStrides {
3678                        b: u32,
3679                        h: u32,
3680                        q: u32,
3681                        k: u32,
3682                    }
3683                    let mask_strides = if mask_kind_id == 2 || mask_kind_id == 4 {
3684                        let m_dims = graph.node(node.inputs[4]).shape.dims();
3685                        let dim = |i: usize| m_dims[i].unwrap_static() as u32;
3686                        match m_dims.len() {
3687                            2 => MStrides {
3688                                b: dim(1),
3689                                h: 0,
3690                                q: 0,
3691                                k: 1,
3692                            },
3693                            3 => MStrides {
3694                                b: dim(1) * dim(2),
3695                                h: 0,
3696                                q: dim(2),
3697                                k: 1,
3698                            },
3699                            4 => MStrides {
3700                                b: dim(1) * dim(2) * dim(3),
3701                                h: dim(2) * dim(3),
3702                                q: dim(3),
3703                                k: 1,
3704                            },
3705                            _ => MStrides {
3706                                b: heads * seq_q * seq_k,
3707                                h: seq_q * seq_k,
3708                                q: seq_k,
3709                                k: 1,
3710                            },
3711                        }
3712                    } else {
3713                        MStrides {
3714                            b: heads * seq_q * seq_k,
3715                            h: seq_q * seq_k,
3716                            q: seq_k,
3717                            k: 1,
3718                        }
3719                    };
3720                    let stride = |shape: &[rlx_ir::shape::Dim], seq_extent: u32| {
3721                        rlx_ir::strides_for_shape(shape, heads, hd, seq_extent, bhsd)
3722                    };
3723                    let (q_b, q_h, q_s) = stride(q_shape, seq_q);
3724                    let (k_b, k_h, k_s) = stride(k_shape, seq_k);
3725                    let v_shape = graph.node(v_id).shape.dims();
3726                    let (v_b, v_h, v_s) = stride(v_shape, seq_k);
3727                    let out_shape = node.shape.dims();
3728                    let out_seq = match wrt {
3729                        AttentionBwdWrt::Query => seq_q,
3730                        AttentionBwdWrt::Key | AttentionBwdWrt::Value => seq_k,
3731                    };
3732                    let (o_b, o_h, o_s) = stride(out_shape, out_seq);
3733                    let wrt_id = match wrt {
3734                        AttentionBwdWrt::Query => 0u32,
3735                        AttentionBwdWrt::Key => 1u32,
3736                        AttentionBwdWrt::Value => 2u32,
3737                    };
3738                    let p = AttentionBwdParams {
3739                        batch,
3740                        heads,
3741                        seq_q,
3742                        seq_k,
3743                        head_dim: hd,
3744                        q_off: (arena.offset(q_id) / 4) as u32,
3745                        k_off: (arena.offset(k_id) / 4) as u32,
3746                        v_off: (arena.offset(v_id) / 4) as u32,
3747                        dy_off: (arena.offset(dy_id) / 4) as u32,
3748                        out_off: (arena.offset(node.id) / 4) as u32,
3749                        mask_off,
3750                        mask_kind: mask_kind_id,
3751                        scale_bits: scale.to_bits(),
3752                        window,
3753                        wrt: wrt_id,
3754                        seq_q_stride: mask_strides.q,
3755                        seq_k_stride: mask_strides.k,
3756                        mask_batch_stride: mask_strides.b,
3757                        mask_head_stride: mask_strides.h,
3758                        _pad_mask_0: 0,
3759                        _pad_mask_1: 0,
3760                        _pad_mask_2: 0,
3761                        q_batch_stride: q_b,
3762                        q_head_stride: q_h,
3763                        q_seq_stride: q_s,
3764                        _pad_q: 0,
3765                        k_batch_stride: k_b,
3766                        k_head_stride: k_h,
3767                        k_seq_stride: k_s,
3768                        _pad_k: 0,
3769                        v_batch_stride: v_b,
3770                        v_head_stride: v_h,
3771                        v_seq_stride: v_s,
3772                        _pad_v: 0,
3773                        o_batch_stride: o_b,
3774                        o_head_stride: o_h,
3775                        o_seq_stride: o_s,
3776                        _pad_o: 0,
3777                    };
3778                    schedule.push(Step::AttentionBackward {
3779                        params: p,
3780                        mask_buf,
3781                    });
3782                    let ak = attention_bwd_kernel(&dev.device);
3783                    let u = emit_uniform(std::mem::size_of::<AttentionBwdParams>());
3784                    let bg = bind_op_output_window(&dev.device, ak, &arena, node.id, &u);
3785                    uniforms.push(u);
3786                    bind_groups.push(bg);
3787                }
3788
3789                Op::Rope {
3790                    head_dim,
3791                    n_rot: _,
3792                    style,
3793                } => {
3794                    let x_id = node.inputs[0];
3795                    let cos_id = node.inputs[1];
3796                    let sin_id = node.inputs[2];
3797                    let x_shape = graph.node(x_id).shape.dims();
3798                    let last = x_shape.last().map(|d| d.unwrap_static()).unwrap_or(0);
3799                    if !last.is_multiple_of(*head_dim) {
3800                        panic!(
3801                            "rlx-wgpu Rope: last_dim ({last}) must be a multiple \
3802                                of head_dim ({head_dim})"
3803                        );
3804                    }
3805                    if head_dim % 2 != 0 {
3806                        panic!("rlx-wgpu Rope: head_dim must be even");
3807                    }
3808                    let total: u32 = x_shape.iter().map(|d| d.unwrap_static() as u32).product();
3809                    let seq = x_shape[x_shape.len() - 2].unwrap_static() as u32;
3810                    // PLAN L1: derive batch from total / seq / last_dim
3811                    // (= product of leading dims). `seq_stride` stays at
3812                    // full seq for buffer offset math; `seq` becomes the
3813                    // runtime-scaled loop bound.
3814                    let batch = total / (seq * last as u32).max(1);
3815                    let cos_is_param = tensor_is_graph_param(&graph, &param_offsets, cos_id);
3816                    let cos_bytes = arena.len_of(cos_id) as u64;
3817                    let rope_win: Vec<NodeId> = if cos_is_param && cos_bytes > ARENA_STAGE_CAP {
3818                        vec![cos_id, sin_id, node.id, x_id]
3819                    } else {
3820                        vec![node.id, x_id, cos_id, sin_id]
3821                    };
3822                    let mut scratch = arena.scratch_off as u64;
3823                    let (mut base, mut size, param_anchor) = arena_multi_op_window(
3824                        &dev.device,
3825                        &arena,
3826                        &graph,
3827                        &param_offsets,
3828                        &mut schedule,
3829                        &mut scratch,
3830                        &rope_win,
3831                    );
3832                    if !param_anchor {
3833                        base = arena_bind_window_covering_scratch_if_needed(
3834                            &arena, base, size, scratch,
3835                        );
3836                    }
3837                    let in_off = arena_off_in_bind_window(
3838                        &graph,
3839                        &param_offsets,
3840                        &dev.device,
3841                        &arena,
3842                        &mut schedule,
3843                        &mut scratch,
3844                        x_id,
3845                        &mut base,
3846                        &mut size,
3847                    );
3848                    let cos_off = arena_off_in_bind_window(
3849                        &graph,
3850                        &param_offsets,
3851                        &dev.device,
3852                        &arena,
3853                        &mut schedule,
3854                        &mut scratch,
3855                        cos_id,
3856                        &mut base,
3857                        &mut size,
3858                    );
3859                    let sin_off = arena_off_in_bind_window(
3860                        &graph,
3861                        &param_offsets,
3862                        &dev.device,
3863                        &arena,
3864                        &mut schedule,
3865                        &mut scratch,
3866                        sin_id,
3867                        &mut base,
3868                        &mut size,
3869                    );
3870                    let p = RopeParams {
3871                        n_total: total,
3872                        seq,
3873                        head_dim: *head_dim as u32,
3874                        half: (*head_dim / 2) as u32,
3875                        in_off,
3876                        cos_off,
3877                        sin_off,
3878                        out_off: arena_local_off_f32(&arena, node.id, base),
3879                        last_dim: last as u32,
3880                        batch,
3881                        seq_stride: seq,
3882                        style: match style {
3883                            rlx_ir::op::RopeStyle::NeoX => 0,
3884                            rlx_ir::op::RopeStyle::GptJ => 1,
3885                        },
3886                    };
3887                    schedule.push(Step::Rope { params: p });
3888                    let rk = rope_kernel(&dev.device);
3889                    let u = emit_uniform(std::mem::size_of::<RopeParams>());
3890                    let bg = bind_two_buf0_window(&dev.device, rk, &arena.buffer, base, size, &u);
3891                    uniforms.push(u);
3892                    bind_groups.push(bg);
3893                }
3894
3895                Op::Expand { target_shape } => {
3896                    let in_id = node.inputs[0];
3897                    let in_shape = graph.node(in_id).shape.dims();
3898                    let in_rank = in_shape.len();
3899                    let rank = target_shape.len();
3900                    if in_rank > rank {
3901                        panic!(
3902                            "rlx-wgpu Expand: rank mismatch \
3903                                (in_rank={in_rank}, target_rank={rank})"
3904                        );
3905                    }
3906                    // Implicit leading 1s when input rank < target rank (e.g.
3907                    // scalar → vector from `LegalizeBroadcast`).
3908                    let pad = rank.saturating_sub(in_rank);
3909                    let out_dims: Vec<u32> = target_shape.iter().map(|&d| d as u32).collect();
3910                    let in_dims: Vec<u32> = (0..rank)
3911                        .map(|i| {
3912                            if i < pad {
3913                                1
3914                            } else {
3915                                in_shape[i - pad].unwrap_static() as u32
3916                            }
3917                        })
3918                        .collect();
3919                    // Cumulative input strides (row-major). When the
3920                    // input dim is 1 but target dim > 1, that axis
3921                    // broadcasts → stride = 0.
3922                    let mut in_strides_row = vec![1u32; rank];
3923                    for i in (0..rank.saturating_sub(1)).rev() {
3924                        in_strides_row[i] = in_strides_row[i + 1] * in_dims[i + 1];
3925                    }
3926                    let strides_for_out: Vec<u32> = (0..rank)
3927                        .map(|i| {
3928                            if in_dims[i] == 1 && out_dims[i] != 1 {
3929                                0
3930                            } else {
3931                                in_strides_row[i]
3932                            }
3933                        })
3934                        .collect();
3935
3936                    let mut meta_data: Vec<u32> = Vec::with_capacity(rank * 2);
3937                    meta_data.extend_from_slice(&out_dims);
3938                    meta_data.extend_from_slice(&strides_for_out);
3939                    let meta_buf = dev.device.create_buffer(&wgpu::BufferDescriptor {
3940                        label: Some("rlx-wgpu expand meta"),
3941                        size: (meta_data.len() * 4).max(4) as u64,
3942                        usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
3943                        mapped_at_creation: false,
3944                    });
3945                    dev.queue
3946                        .write_buffer(&meta_buf, 0, bytemuck::cast_slice(&meta_data));
3947                    let meta_idx = meta_buffers.len();
3948                    meta_buffers.push(meta_buf);
3949
3950                    // PLAN L1: bucket axis stays at out axis 0 iff the
3951                    // expand at axis 0 isn't a broadcast (in_dims[0]
3952                    // matches out_dims[0]). When broadcast at axis 0
3953                    // (in_dims[0]==1, out_dims[0]>1), the bucket-axis
3954                    // contract doesn't apply — fall back to full extent.
3955                    let bucket_outermost = if in_dims[0] == out_dims[0] {
3956                        1u32
3957                    } else {
3958                        0u32
3959                    };
3960                    let exp_ids = [node.id, in_id];
3961                    let max_binding = dev.device.limits().max_storage_buffer_binding_size;
3962                    let exp_fits = arena_span_bytes(&arena, &exp_ids) <= max_binding;
3963                    let mut scratch = arena.scratch_off as u64;
3964                    let (mut base, mut size, param_anchor) = arena_multi_op_window(
3965                        &dev.device,
3966                        &arena,
3967                        &graph,
3968                        &param_offsets,
3969                        &mut schedule,
3970                        &mut scratch,
3971                        &exp_ids,
3972                    );
3973                    if !exp_fits && !param_anchor {
3974                        base = arena_bind_window_covering_scratch_if_needed(
3975                            &arena, base, size, scratch,
3976                        );
3977                    }
3978                    let in_off = arena_off_in_bind_window(
3979                        &graph,
3980                        &param_offsets,
3981                        &dev.device,
3982                        &arena,
3983                        &mut schedule,
3984                        &mut scratch,
3985                        in_id,
3986                        &mut base,
3987                        &mut size,
3988                    );
3989                    let out_off = arena_off_in_bind_window(
3990                        &graph,
3991                        &param_offsets,
3992                        &dev.device,
3993                        &arena,
3994                        &mut schedule,
3995                        &mut scratch,
3996                        node.id,
3997                        &mut base,
3998                        &mut size,
3999                    );
4000                    let p = ExpandParams {
4001                        rank: rank as u32,
4002                        out_total: elems,
4003                        in_off,
4004                        out_off,
4005                        bucket_outermost,
4006                        out_dim_0: out_dims[0],
4007                        _p2: 0,
4008                        _p3: 0,
4009                    };
4010                    schedule.push(Step::Expand {
4011                        params: p,
4012                        meta_idx,
4013                    });
4014                    let ek = expand_kernel(&dev.device);
4015                    let u = emit_uniform(std::mem::size_of::<ExpandParams>());
4016                    let bg = dev.device.create_bind_group(&wgpu::BindGroupDescriptor {
4017                        label: Some("rlx-wgpu expand bg"),
4018                        layout: &ek.bgl,
4019                        entries: &[
4020                            wgpu::BindGroupEntry {
4021                                binding: 0,
4022                                resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
4023                                    buffer: &arena.buffer,
4024                                    offset: base,
4025                                    size: NonZeroU64::new(size),
4026                                }),
4027                            },
4028                            wgpu::BindGroupEntry {
4029                                binding: 1,
4030                                resource: u.as_entire_binding(),
4031                            },
4032                            wgpu::BindGroupEntry {
4033                                binding: 2,
4034                                resource: meta_buffers[meta_idx].as_entire_binding(),
4035                            },
4036                        ],
4037                    });
4038                    uniforms.push(u);
4039                    bind_groups.push(bg);
4040                }
4041
4042                Op::Gather { axis } => {
4043                    let table_id = node.inputs[0];
4044                    let idx_id = node.inputs[1];
4045                    let table_is_param = tensor_is_graph_param(&graph, &param_offsets, table_id);
4046                    let table_bytes = arena.len_of(table_id) as u64;
4047                    // Split-binding path: on >4 GiB arenas the embedding output and
4048                    // its index can lie more than one ≤4 GiB binding window away
4049                    // from the (multi-GiB) table. A single arena binding then can't
4050                    // cover both — the kernel writes the output OUTSIDE the bound
4051                    // window (the write is dropped/clamped) and idx-staging would
4052                    // even overwrite part of the table. Route axis-0 embedding
4053                    // gathers through a host segment with separate table / idx
4054                    // windows and a dedicated output buffer (copied back).
4055                    let max_binding = dev.device.limits().max_storage_buffer_binding_size;
4056                    let gather_needs_split = *axis == 0
4057                        && arena_whole_arena_bind(&arena, max_binding).is_none()
4058                        && arena_span_bytes(&arena, &[table_id, idx_id, node.id]) > max_binding;
4059                    if gather_needs_split {
4060                        let table_shape = graph.node(table_id).shape.dims();
4061                        let idx_shape = graph.node(idx_id).shape.dims();
4062                        let vocab = table_shape[0].unwrap_static() as u32;
4063                        let dim: u32 = table_shape[1..]
4064                            .iter()
4065                            .map(|d| d.unwrap_static() as u32)
4066                            .product::<u32>()
4067                            .max(1);
4068                        let n_idx: u32 =
4069                            idx_shape.iter().map(|d| d.unwrap_static() as u32).product();
4070                        let table_w = arena.len_of(table_id) as u64;
4071                        assert!(
4072                            table_w <= max_binding,
4073                            "rlx-wgpu gather_split: embedding table {table_w} bytes exceeds \
4074                             max_storage_buffer_binding_size {max_binding}"
4075                        );
4076                        schedule.push(Step::GatherSplit {
4077                            n_out: elems,
4078                            n_idx,
4079                            dim,
4080                            vocab,
4081                            table_byte_off: arena.offset(table_id) as u64,
4082                            idx_byte_off: arena.offset(idx_id) as u64,
4083                            out_byte_off: arena.offset(node.id) as u64,
4084                        });
4085                        // Host segment: like DequantMatmulGguf, it builds its own
4086                        // bind group at exec time, so do NOT push a uniform/bind
4087                        // group lane (those are indexed by GPU-step count).
4088                        continue;
4089                    }
4090                    let gather_win: Vec<NodeId> = if table_is_param && table_bytes > ARENA_STAGE_CAP
4091                    {
4092                        vec![table_id, node.id, idx_id]
4093                    } else {
4094                        vec![node.id, idx_id, table_id]
4095                    };
4096                    let mut scratch = arena.scratch_off as u64;
4097                    let (mut base, mut size, table_anchor) = arena_multi_op_window(
4098                        &dev.device,
4099                        &arena,
4100                        &graph,
4101                        &param_offsets,
4102                        &mut schedule,
4103                        &mut scratch,
4104                        &gather_win,
4105                    );
4106                    if !table_anchor {
4107                        base = arena_bind_window_covering_scratch_if_needed(
4108                            &arena, base, size, scratch,
4109                        );
4110                    }
4111                    let in_off =
4112                        if table_anchor && arena_tensor_in_window(&arena, table_id, base, size) {
4113                            arena_local_off_f32(&arena, table_id, base)
4114                        } else {
4115                            arena_off_in_bind_window(
4116                                &graph,
4117                                &param_offsets,
4118                                &dev.device,
4119                                &arena,
4120                                &mut schedule,
4121                                &mut scratch,
4122                                table_id,
4123                                &mut base,
4124                                &mut size,
4125                            )
4126                        };
4127                    let idx_off = arena_off_in_bind_window(
4128                        &graph,
4129                        &param_offsets,
4130                        &dev.device,
4131                        &arena,
4132                        &mut schedule,
4133                        &mut scratch,
4134                        idx_id,
4135                        &mut base,
4136                        &mut size,
4137                    );
4138                    let out_off = arena_local_off_f32(&arena, node.id, base);
4139                    if *axis == 0 {
4140                        let table_shape = graph.node(table_id).shape.dims();
4141                        let idx_shape = graph.node(idx_id).shape.dims();
4142                        let vocab = table_shape[0].unwrap_static() as u32;
4143                        let dim: u32 = table_shape[1..]
4144                            .iter()
4145                            .map(|d| d.unwrap_static() as u32)
4146                            .product::<u32>()
4147                            .max(1);
4148                        let n_idx: u32 =
4149                            idx_shape.iter().map(|d| d.unwrap_static() as u32).product();
4150                        let p = GatherParams {
4151                            n_out: elems,
4152                            n_idx,
4153                            dim,
4154                            vocab,
4155                            in_off,
4156                            idx_off,
4157                            out_off,
4158                            _p0: 0,
4159                        };
4160                        schedule.push(Step::Gather { params: p });
4161                        let gk = gather_kernel(&dev.device);
4162                        let u = emit_uniform(std::mem::size_of::<GatherParams>());
4163                        let bg =
4164                            bind_two_buf0_window(&dev.device, gk, &arena.buffer, base, size, &u);
4165                        uniforms.push(u);
4166                        bind_groups.push(bg);
4167                    } else {
4168                        let table_shape = graph.node(table_id).shape.dims();
4169                        let idx_shape = graph.node(idx_id).shape.dims();
4170                        let outer: u32 = table_shape[..*axis]
4171                            .iter()
4172                            .map(|d| d.unwrap_static() as u32)
4173                            .product::<u32>()
4174                            .max(1);
4175                        let trailing: u32 = table_shape[*axis + 1..]
4176                            .iter()
4177                            .map(|d| d.unwrap_static() as u32)
4178                            .product::<u32>()
4179                            .max(1);
4180                        let axis_dim = table_shape[*axis].unwrap_static() as u32;
4181                        let num_idx: u32 =
4182                            idx_shape.iter().map(|d| d.unwrap_static() as u32).product();
4183                        let total = outer * num_idx * trailing;
4184                        let p = GatherAxisParams {
4185                            total,
4186                            outer,
4187                            axis_dim,
4188                            num_idx,
4189                            trailing,
4190                            table_off: in_off,
4191                            idx_off,
4192                            out_off,
4193                        };
4194                        schedule.push(Step::GatherAxis { params: p });
4195                        let gk = gather_axis_kernel(&dev.device);
4196                        let u = emit_uniform(std::mem::size_of::<GatherAxisParams>());
4197                        let bg =
4198                            bind_two_buf0_window(&dev.device, gk, &arena.buffer, base, size, &u);
4199                        uniforms.push(u);
4200                        bind_groups.push(bg);
4201                    }
4202                }
4203
4204                Op::FusedMatMulBiasAct { activation } => {
4205                    // Inputs: [x, w, bias]. We require 2D × 2D or
4206                    // [..,M,K] × [K,N] (broadcast bias). Bias is shape [N].
4207                    let a_id = node.inputs[0];
4208                    let b_id = node.inputs[1];
4209                    let bias_id = node.inputs[2];
4210                    let a_shape = graph.node(a_id).shape.dims();
4211                    let b_shape = graph.node(b_id).shape.dims();
4212                    let out_shape = node.shape.dims();
4213                    let (m, k, n) =
4214                        if a_shape.len() == 2 && b_shape.len() == 2 && out_shape.len() == 2 {
4215                            (
4216                                a_shape[0].unwrap_static() as u32,
4217                                a_shape[1].unwrap_static() as u32,
4218                                b_shape[1].unwrap_static() as u32,
4219                            )
4220                        } else if a_shape.len() >= 2
4221                            && b_shape.len() == 2
4222                            && out_shape.len() == a_shape.len()
4223                        {
4224                            let leading: usize = a_shape[..a_shape.len() - 2]
4225                                .iter()
4226                                .map(|d| d.unwrap_static())
4227                                .product();
4228                            let m_inner = a_shape[a_shape.len() - 2].unwrap_static();
4229                            let k_inner = a_shape[a_shape.len() - 1].unwrap_static();
4230                            let n_inner = b_shape[1].unwrap_static();
4231                            ((leading * m_inner) as u32, k_inner as u32, n_inner as u32)
4232                        } else {
4233                            panic!(
4234                                "rlx-wgpu FusedMatMulBiasAct: unsupported shapes \
4235                                a={a_shape:?} b={b_shape:?}"
4236                            );
4237                        };
4238                    let act_id = match activation {
4239                        None => 0xFFFFu32,
4240                        Some(a) => activation_op_id(*a),
4241                    };
4242                    let b_is_param = tensor_is_graph_param(&graph, &param_offsets, b_id);
4243                    let b_bytes = arena.len_of(b_id) as u64;
4244                    let mut compute_precision = derive_matmul_compute(
4245                        &dev.device,
4246                        &graph,
4247                        &coop_f16_vk_mirror_acts,
4248                        a_id,
4249                        b_id,
4250                        m,
4251                        k,
4252                        n,
4253                    );
4254                    if b_is_param && b_bytes > ARENA_STAGE_CAP && arena.param_fits_f16_mirror(b_id)
4255                    {
4256                        compute_precision = MatmulCompute::F16;
4257                    }
4258
4259                    // Split-QKV pattern: matmul writes Q/K/V directly into
4260                    // 3 separate output buffers, eliminating the 3 Narrow
4261                    // dispatches that would otherwise follow.
4262                    let mqk_eligible = act_id == 0xFFFFu32
4263                        && matches!(
4264                            compute_precision,
4265                            MatmulCompute::F32 | MatmulCompute::CoopF32 | MatmulCompute::CoopF16Vk
4266                        );
4267                    if mqk_eligible && let Some(&(q_id, k_id_n, v_id)) = qkv_split.get(&node.id) {
4268                        let head_width = n / 3;
4269                        let qkv_kind = match compute_precision {
4270                            MatmulCompute::CoopF16Vk => MatmulQkvKind::CoopF16Vk,
4271                            MatmulCompute::CoopF32 => MatmulQkvKind::CoopF32,
4272                            _ => MatmulQkvKind::F32,
4273                        };
4274                        let b_in_arena = !matmul_b_from_f16(compute_precision, b_is_param);
4275                        let (mut base, mut size, param_anchor) = arena_matmul_bind_window(
4276                            &dev.device,
4277                            &arena,
4278                            &graph,
4279                            &param_offsets,
4280                            q_id,
4281                            a_id,
4282                            b_id,
4283                            b_in_arena,
4284                        );
4285                        let mut scratch = arena.scratch_off as u64;
4286                        if param_anchor {
4287                            arena_ensure_scratch_in_window(&mut scratch, base, size);
4288                        }
4289                        if b_is_param && b_bytes > ARENA_STAGE_CAP && b_in_arena {
4290                            assert!(
4291                                param_anchor && arena_tensor_in_window(&arena, b_id, base, size),
4292                                "rlx-wgpu FusedMatMul QKV: large param B {:?} not in bind window",
4293                                b_id,
4294                            );
4295                        }
4296                        let a_off = arena_off_in_bind_window(
4297                            &graph,
4298                            &param_offsets,
4299                            &dev.device,
4300                            &arena,
4301                            &mut schedule,
4302                            &mut scratch,
4303                            a_id,
4304                            &mut base,
4305                            &mut size,
4306                        );
4307                        let q_off = arena_off_in_bind_window(
4308                            &graph,
4309                            &param_offsets,
4310                            &dev.device,
4311                            &arena,
4312                            &mut schedule,
4313                            &mut scratch,
4314                            q_id,
4315                            &mut base,
4316                            &mut size,
4317                        );
4318                        let k_off = arena_off_in_bind_window(
4319                            &graph,
4320                            &param_offsets,
4321                            &dev.device,
4322                            &arena,
4323                            &mut schedule,
4324                            &mut scratch,
4325                            k_id_n,
4326                            &mut base,
4327                            &mut size,
4328                        );
4329                        let v_off = arena_off_in_bind_window(
4330                            &graph,
4331                            &param_offsets,
4332                            &dev.device,
4333                            &arena,
4334                            &mut schedule,
4335                            &mut scratch,
4336                            v_id,
4337                            &mut base,
4338                            &mut size,
4339                        );
4340                        let bias_off = arena_off_in_bind_window(
4341                            &graph,
4342                            &param_offsets,
4343                            &dev.device,
4344                            &arena,
4345                            &mut schedule,
4346                            &mut scratch,
4347                            bias_id,
4348                            &mut base,
4349                            &mut size,
4350                        );
4351                        let b_off_f32 = if !b_in_arena {
4352                            (arena.offset(b_id) / 4) as u32
4353                        } else if b_is_param
4354                            && b_bytes > ARENA_STAGE_CAP
4355                            && arena_tensor_in_window(&arena, b_id, base, size)
4356                        {
4357                            arena_local_off_f32(&arena, b_id, base)
4358                        } else {
4359                            arena_off_in_bind_window(
4360                                &graph,
4361                                &param_offsets,
4362                                &dev.device,
4363                                &arena,
4364                                &mut schedule,
4365                                &mut scratch,
4366                                b_id,
4367                                &mut base,
4368                                &mut size,
4369                            )
4370                        };
4371                        let b_off_global = (arena.offset(b_id) / 4) as u32;
4372                        maybe_push_coop_f16_vk_casts(
4373                            &graph,
4374                            a_id,
4375                            b_id,
4376                            &coop_f16_vk_mirror_acts,
4377                            &dev.device,
4378                            &arena,
4379                            &mut schedule,
4380                            &mut uniforms,
4381                            &mut bind_groups,
4382                            &mm_cast,
4383                            compute_precision,
4384                            a_off,
4385                            m,
4386                            k,
4387                            1,
4388                            if qkv_kind == MatmulQkvKind::CoopF16Vk {
4389                                b_off_global
4390                            } else {
4391                                b_off_f32
4392                            },
4393                            n,
4394                        );
4395                        let p = MatmulQkvParams {
4396                            m,
4397                            k,
4398                            n,
4399                            a_off,
4400                            b_off: if qkv_kind == MatmulQkvKind::CoopF16Vk {
4401                                b_off_global
4402                            } else {
4403                                b_off_f32
4404                            },
4405                            q_off,
4406                            k_off,
4407                            v_off,
4408                            head_width,
4409                            has_bias: 1,
4410                            bias_off,
4411                            _p0: 0,
4412                            _p1: 0,
4413                            _p2: 0,
4414                            _p3: 0,
4415                            _p4: 0,
4416                        };
4417                        schedule.push(Step::MatmulQkv {
4418                            params: p,
4419                            kind: qkv_kind,
4420                        });
4421                        register_coop_f16_vk_b_param(
4422                            &mut coop_f16_b_param,
4423                            &param_offsets,
4424                            b_id,
4425                            p.b_off,
4426                            match qkv_kind {
4427                                MatmulQkvKind::CoopF16Vk => MatmulCompute::CoopF16Vk,
4428                                MatmulQkvKind::CoopF32 => MatmulCompute::CoopF32,
4429                                MatmulQkvKind::F32 => MatmulCompute::F32,
4430                            },
4431                        );
4432                        let u = emit_uniform(std::mem::size_of::<MatmulQkvParams>());
4433                        let bg = match qkv_kind {
4434                            MatmulQkvKind::CoopF16Vk => {
4435                                let mqk = matmul_qkv_coop_f16_vk_kernel(&dev.device).expect(
4436                                    "coop f16 matmul_qkv kernel: feature was checked but missing",
4437                                );
4438                                let (bg, b_off_adj) = build_matmul_qkv_coop_f16_vk_bind_group(
4439                                    &dev.device,
4440                                    mqk,
4441                                    &arena,
4442                                    base,
4443                                    size,
4444                                    &u,
4445                                    k,
4446                                    n,
4447                                    p.b_off,
4448                                );
4449                                if let Some(Step::MatmulQkv { params, .. }) = schedule.last_mut() {
4450                                    params.b_off = b_off_adj;
4451                                }
4452                                bg
4453                            }
4454                            MatmulQkvKind::CoopF32 => bind_two_buf0_window(
4455                                &dev.device,
4456                                matmul_qkv_coop_f32_kernel(&dev.device).expect(
4457                                    "coop matmul_qkv kernel: hardware feature was checked but kernel missing",
4458                                ),
4459                                &arena.buffer,
4460                                base,
4461                                size,
4462                                &u,
4463                            ),
4464                            MatmulQkvKind::F32 => bind_two_buf0_window(
4465                                &dev.device,
4466                                matmul_qkv_kernel(&dev.device),
4467                                &arena.buffer,
4468                                base,
4469                                size,
4470                                &u,
4471                            ),
4472                        };
4473                        uniforms.push(u);
4474                        bind_groups.push(bg);
4475                        if qkv_kind == MatmulQkvKind::CoopF16Vk {
4476                            coop_f16_vk_wide_bind_groups.insert(
4477                                schedule.len() - 1,
4478                                bind_two_buf0_window(
4479                                    &dev.device,
4480                                    matmul_qkv_kernel(&dev.device),
4481                                    &arena.buffer,
4482                                    base,
4483                                    size,
4484                                    &uniforms[uniforms.len() - 1],
4485                                ),
4486                            );
4487                        }
4488                    } else {
4489                        let b_in_arena = !matmul_b_from_f16(compute_precision, b_is_param);
4490                        let (mut base, mut size, param_anchor) = arena_matmul_bind_window(
4491                            &dev.device,
4492                            &arena,
4493                            &graph,
4494                            &param_offsets,
4495                            node.id,
4496                            a_id,
4497                            b_id,
4498                            b_in_arena,
4499                        );
4500                        let mut scratch = arena.scratch_off as u64;
4501                        if param_anchor {
4502                            arena_ensure_scratch_in_window(&mut scratch, base, size);
4503                        }
4504                        if b_is_param && b_bytes > ARENA_STAGE_CAP && b_in_arena {
4505                            assert!(
4506                                param_anchor && arena_tensor_in_window(&arena, b_id, base, size),
4507                                "rlx-wgpu FusedMatMul: large param B {:?} not in bind window",
4508                                b_id,
4509                            );
4510                        }
4511                        let a_off_f32 = arena_off_in_bind_window(
4512                            &graph,
4513                            &param_offsets,
4514                            &dev.device,
4515                            &arena,
4516                            &mut schedule,
4517                            &mut scratch,
4518                            a_id,
4519                            &mut base,
4520                            &mut size,
4521                        );
4522                        let b_off_f32 = if !b_in_arena {
4523                            (arena.offset(b_id) / 4) as u32
4524                        } else if b_is_param
4525                            && b_bytes > ARENA_STAGE_CAP
4526                            && arena_tensor_in_window(&arena, b_id, base, size)
4527                        {
4528                            arena_local_off_f32(&arena, b_id, base)
4529                        } else {
4530                            arena_off_in_bind_window(
4531                                &graph,
4532                                &param_offsets,
4533                                &dev.device,
4534                                &arena,
4535                                &mut schedule,
4536                                &mut scratch,
4537                                b_id,
4538                                &mut base,
4539                                &mut size,
4540                            )
4541                        };
4542                        let bias_off_f32 = arena_off_in_bind_window(
4543                            &graph,
4544                            &param_offsets,
4545                            &dev.device,
4546                            &arena,
4547                            &mut schedule,
4548                            &mut scratch,
4549                            bias_id,
4550                            &mut base,
4551                            &mut size,
4552                        );
4553                        let b_off_global = (arena.offset(b_id) / 4) as u32;
4554                        let b_off_bind = if b_is_param
4555                            && matches!(
4556                                compute_precision,
4557                                MatmulCompute::Coop16
4558                                    | MatmulCompute::CoopF16Vk
4559                                    | MatmulCompute::F16
4560                            ) {
4561                            b_off_global
4562                        } else {
4563                            b_off_f32
4564                        };
4565                        maybe_push_coop_f16_vk_casts(
4566                            &graph,
4567                            a_id,
4568                            b_id,
4569                            &coop_f16_vk_mirror_acts,
4570                            &dev.device,
4571                            &arena,
4572                            &mut schedule,
4573                            &mut uniforms,
4574                            &mut bind_groups,
4575                            &mm_cast,
4576                            compute_precision,
4577                            a_off_f32,
4578                            m,
4579                            k,
4580                            1,
4581                            b_off_bind,
4582                            n,
4583                        );
4584                        schedule.push(Step::Matmul {
4585                            m,
4586                            k,
4587                            n,
4588                            batch: 1,
4589                            a_batch_stride: 0,
4590                            b_batch_stride: 0,
4591                            c_batch_stride: 0,
4592                            a_off_f32,
4593                            b_off_f32,
4594                            c_off_f32: arena_local_off_f32(&arena, node.id, base),
4595                            has_bias: 1,
4596                            bias_off_f32,
4597                            act_id,
4598                            b_is_param,
4599                            compute_precision,
4600                        });
4601                        register_coop_f16_vk_b_param(
4602                            &mut coop_f16_b_param,
4603                            &param_offsets,
4604                            b_id,
4605                            b_off_bind,
4606                            compute_precision,
4607                        );
4608                        let u = emit_uniform(std::mem::size_of::<MatmulParams>());
4609                        let (bg, b_off_adj) = build_matmul_bind_group(
4610                            &dev.device,
4611                            mm_k,
4612                            mm_w,
4613                            &mm_f16w,
4614                            &mm_f16c,
4615                            &mm_coop,
4616                            &mm_coop_f32,
4617                            &arena,
4618                            base,
4619                            size,
4620                            &u,
4621                            b_is_param,
4622                            compute_precision,
4623                            k,
4624                            n,
4625                            1,
4626                            b_off_bind,
4627                            0,
4628                        );
4629                        if let Some(Step::Matmul { b_off_f32, .. }) = schedule.last_mut() {
4630                            *b_off_f32 = b_off_adj;
4631                        }
4632                        uniforms.push(u);
4633                        bind_groups.push(bg);
4634                        if compute_precision == MatmulCompute::CoopF16Vk {
4635                            coop_f16_vk_wide_bind_groups.insert(
4636                                schedule.len() - 1,
4637                                bind_two_buf0_window(
4638                                    &dev.device,
4639                                    mm_w_active_compile,
4640                                    &arena.buffer,
4641                                    base,
4642                                    size,
4643                                    &uniforms[uniforms.len() - 1],
4644                                ),
4645                            );
4646                        }
4647                    }
4648                }
4649
4650                Op::DotGeneral { .. } => {
4651                    // Should be unreachable: DotGeneral is decomposed into
4652                    // MatMul + Transpose + Reshape by the unfusion pass
4653                    // before memory planning. If we hit this arm, the
4654                    // unfusion pass has a gap.
4655                    panic!(
4656                        "rlx-wgpu DotGeneral: leaked past unfusion pass — \
4657                            check unfuse.rs::expand_dot_general for missing patterns"
4658                    );
4659                }
4660
4661                Op::Sample {
4662                    top_k,
4663                    top_p,
4664                    temperature,
4665                    seed,
4666                } => {
4667                    let in_id = node.inputs[0];
4668                    let in_shape = graph.node(in_id).shape.dims();
4669                    let inner = in_shape[in_shape.len() - 1].unwrap_static() as u32;
4670                    let total: u32 = in_shape.iter().map(|d| d.unwrap_static() as u32).product();
4671                    let outer = total / inner.max(1);
4672                    // Greedy fast-path: temperature == 1.0 with no top_k/top_p
4673                    // is an argmax — same numeric result, much cheaper kernel.
4674                    let is_greedy = *top_k == 0
4675                        && (*top_p - 1.0).abs() < 1e-6
4676                        && (*temperature - 1.0).abs() < 1e-6;
4677                    if is_greedy {
4678                        let p = ArgmaxParams {
4679                            outer,
4680                            inner,
4681                            in_off: (arena.offset(in_id) / 4) as u32,
4682                            out_off: (arena.offset(node.id) / 4) as u32,
4683                            _p0: 0,
4684                            _p1: 0,
4685                            _p2: 0,
4686                            _p3: 0,
4687                        };
4688                        schedule.push(Step::Argmax { params: p });
4689                        let amk = argmax_kernel(&dev.device);
4690                        let u = emit_uniform(std::mem::size_of::<ArgmaxParams>());
4691                        let bg = bind_op_output_window(&dev.device, amk, &arena, node.id, &u);
4692                        uniforms.push(u);
4693                        bind_groups.push(bg);
4694                    } else {
4695                        let p = SampleParams {
4696                            outer,
4697                            inner,
4698                            in_off: (arena.offset(in_id) / 4) as u32,
4699                            out_off: (arena.offset(node.id) / 4) as u32,
4700                            top_k: *top_k as u32,
4701                            top_p_bits: top_p.to_bits(),
4702                            temp_bits: temperature.to_bits(),
4703                            seed_lo: *seed as u32,
4704                            seed_hi: (*seed >> 32) as u32,
4705                            _p0: 0,
4706                            _p1: 0,
4707                            _p2: 0,
4708                        };
4709                        schedule.push(Step::Sample { params: p });
4710                        let sk = sample_kernel(&dev.device);
4711                        let u = emit_uniform(std::mem::size_of::<SampleParams>());
4712                        let bg = bind_op_output_window(&dev.device, sk, &arena, node.id, &u);
4713                        uniforms.push(u);
4714                        bind_groups.push(bg);
4715                    }
4716                }
4717
4718                Op::Pool {
4719                    kind,
4720                    kernel_size,
4721                    stride,
4722                    padding,
4723                } => {
4724                    let in_shape = graph.node(node.inputs[0]).shape.dims();
4725                    let out_shape = node.shape.dims();
4726                    let op_id: u32 = match kind {
4727                        ReduceOp::Sum => 0,
4728                        ReduceOp::Mean => 1,
4729                        ReduceOp::Max => 2,
4730                        ReduceOp::Min => 3,
4731                        ReduceOp::Prod => 4,
4732                    };
4733                    match (kernel_size.len(), in_shape.len(), out_shape.len()) {
4734                        (1, 3, 3) => {
4735                            let p = Pool1dParams {
4736                                n: in_shape[0].unwrap_static() as u32,
4737                                c: in_shape[1].unwrap_static() as u32,
4738                                l: in_shape[2].unwrap_static() as u32,
4739                                l_out: out_shape[2].unwrap_static() as u32,
4740                                kl: kernel_size[0] as u32,
4741                                sl: stride.first().copied().unwrap_or(1) as u32,
4742                                pl: padding.first().copied().unwrap_or(0) as u32,
4743                                op: op_id,
4744                                in_off: (arena.offset(node.inputs[0]) / 4) as u32,
4745                                out_off: (arena.offset(node.id) / 4) as u32,
4746                                _p0: 0,
4747                                _p1: 0,
4748                                _p2: 0,
4749                                _p3: 0,
4750                                _p4: 0,
4751                                _p5: 0,
4752                            };
4753                            schedule.push(Step::Pool1d { params: p });
4754                            let pk = pool1d_kernel(&dev.device);
4755                            let u = emit_uniform(std::mem::size_of::<Pool1dParams>());
4756                            let bg = bind_op_output_window(&dev.device, pk, &arena, node.id, &u);
4757                            uniforms.push(u);
4758                            bind_groups.push(bg);
4759                        }
4760                        (2, 4, 4) => {
4761                            let p = Pool2dParams {
4762                                n: in_shape[0].unwrap_static() as u32,
4763                                c: in_shape[1].unwrap_static() as u32,
4764                                h: in_shape[2].unwrap_static() as u32,
4765                                w: in_shape[3].unwrap_static() as u32,
4766                                h_out: out_shape[2].unwrap_static() as u32,
4767                                w_out: out_shape[3].unwrap_static() as u32,
4768                                kh: kernel_size[0] as u32,
4769                                kw: kernel_size[1] as u32,
4770                                sh: stride.first().copied().unwrap_or(1) as u32,
4771                                sw: stride.get(1).copied().unwrap_or(1) as u32,
4772                                ph: padding.first().copied().unwrap_or(0) as u32,
4773                                pw: padding.get(1).copied().unwrap_or(0) as u32,
4774                                op: op_id,
4775                                in_off: (arena.offset(node.inputs[0]) / 4) as u32,
4776                                out_off: (arena.offset(node.id) / 4) as u32,
4777                                _p0: 0,
4778                                _p1: 0,
4779                                _p2: 0,
4780                            };
4781                            schedule.push(Step::Pool2d { params: p });
4782                            let pk = pool2d_kernel(&dev.device);
4783                            let u = emit_uniform(std::mem::size_of::<Pool2dParams>());
4784                            let bg = bind_op_output_window(&dev.device, pk, &arena, node.id, &u);
4785                            uniforms.push(u);
4786                            bind_groups.push(bg);
4787                        }
4788                        (3, 5, 5) => {
4789                            let p = Pool3dParams {
4790                                n: in_shape[0].unwrap_static() as u32,
4791                                c: in_shape[1].unwrap_static() as u32,
4792                                d: in_shape[2].unwrap_static() as u32,
4793                                h: in_shape[3].unwrap_static() as u32,
4794                                w: in_shape[4].unwrap_static() as u32,
4795                                d_out: out_shape[2].unwrap_static() as u32,
4796                                h_out: out_shape[3].unwrap_static() as u32,
4797                                w_out: out_shape[4].unwrap_static() as u32,
4798                                kd: kernel_size[0] as u32,
4799                                kh: kernel_size[1] as u32,
4800                                kw: kernel_size[2] as u32,
4801                                sd: stride.first().copied().unwrap_or(1) as u32,
4802                                sh: stride.get(1).copied().unwrap_or(1) as u32,
4803                                sw: stride.get(2).copied().unwrap_or(1) as u32,
4804                                pd: padding.first().copied().unwrap_or(0) as u32,
4805                                ph: padding.get(1).copied().unwrap_or(0) as u32,
4806                                pw: padding.get(2).copied().unwrap_or(0) as u32,
4807                                op: op_id,
4808                                in_off: (arena.offset(node.inputs[0]) / 4) as u32,
4809                                out_off: (arena.offset(node.id) / 4) as u32,
4810                                _p0: 0,
4811                                _p1: 0,
4812                            };
4813                            schedule.push(Step::Pool3d { params: p });
4814                            let pk = pool3d_kernel(&dev.device);
4815                            let u = emit_uniform(std::mem::size_of::<Pool3dParams>());
4816                            let bg = bind_op_output_window(&dev.device, pk, &arena, node.id, &u);
4817                            uniforms.push(u);
4818                            bind_groups.push(bg);
4819                        }
4820                        (k, n, m) => panic!(
4821                            "rlx-wgpu Pool: kernel-rank {k} with input rank {n} / \
4822                             output rank {m} not supported (use 1D/2D/3D NCHW)"
4823                        ),
4824                    }
4825                }
4826
4827                Op::Conv {
4828                    kernel_size,
4829                    stride,
4830                    padding,
4831                    dilation,
4832                    groups,
4833                } => {
4834                    let in_id = node.inputs[0];
4835                    let w_id = node.inputs[1];
4836                    let win_ids = [node.id, in_id, w_id];
4837                    let max_binding = dev.device.limits().max_storage_buffer_binding_size;
4838                    let fits = arena_span_bytes(&arena, &win_ids) <= max_binding;
4839                    let mut scratch = arena.scratch_off as u64;
4840                    let (mut base, mut size, param_anchor) = arena_multi_op_window(
4841                        &dev.device,
4842                        &arena,
4843                        &graph,
4844                        &param_offsets,
4845                        &mut schedule,
4846                        &mut scratch,
4847                        &win_ids,
4848                    );
4849                    arena_expand_bind_window(&arena, &win_ids, &mut base, &mut size, max_binding);
4850                    if !fits && !param_anchor {
4851                        base = arena_bind_window_covering_scratch_if_needed(
4852                            &arena, base, size, scratch,
4853                        );
4854                    }
4855                    let in_off = arena_off_in_bind_window(
4856                        &graph,
4857                        &param_offsets,
4858                        &dev.device,
4859                        &arena,
4860                        &mut schedule,
4861                        &mut scratch,
4862                        in_id,
4863                        &mut base,
4864                        &mut size,
4865                    );
4866                    let w_off = arena_off_in_bind_window(
4867                        &graph,
4868                        &param_offsets,
4869                        &dev.device,
4870                        &arena,
4871                        &mut schedule,
4872                        &mut scratch,
4873                        w_id,
4874                        &mut base,
4875                        &mut size,
4876                    );
4877                    let out_off = arena_off_in_bind_window(
4878                        &graph,
4879                        &param_offsets,
4880                        &dev.device,
4881                        &arena,
4882                        &mut schedule,
4883                        &mut scratch,
4884                        node.id,
4885                        &mut base,
4886                        &mut size,
4887                    );
4888                    let in_shape = graph.node(in_id).shape.dims();
4889                    let w_shape = graph.node(w_id).shape.dims();
4890                    let out_shape = node.shape.dims();
4891                    let s = |i: usize| stride.get(i).copied().unwrap_or(1) as u32;
4892                    let p = |i: usize| padding.get(i).copied().unwrap_or(0) as u32;
4893                    let d = |i: usize| dilation.get(i).copied().unwrap_or(1) as u32;
4894                    match (
4895                        kernel_size.len(),
4896                        in_shape.len(),
4897                        w_shape.len(),
4898                        out_shape.len(),
4899                    ) {
4900                        (1, 3, 3, 3) => {
4901                            let p1 = Conv1dParams {
4902                                n: in_shape[0].unwrap_static() as u32,
4903                                c_in: in_shape[1].unwrap_static() as u32,
4904                                c_out: out_shape[1].unwrap_static() as u32,
4905                                l: in_shape[2].unwrap_static() as u32,
4906                                l_out: out_shape[2].unwrap_static() as u32,
4907                                kl: kernel_size[0] as u32,
4908                                sl: s(0),
4909                                pl: p(0),
4910                                dl: d(0),
4911                                groups: *groups as u32,
4912                                in_off,
4913                                w_off,
4914                                out_off,
4915                                _p0: 0,
4916                                _p1: 0,
4917                                _p2: 0,
4918                            };
4919                            schedule.push(Step::Conv1d { params: p1 });
4920                            let ck = conv1d_kernel(&dev.device);
4921                            let u = emit_uniform(std::mem::size_of::<Conv1dParams>());
4922                            let bg = bind_two_buf0_window(
4923                                &dev.device,
4924                                ck,
4925                                &arena.buffer,
4926                                base,
4927                                size,
4928                                &u,
4929                            );
4930                            uniforms.push(u);
4931                            bind_groups.push(bg);
4932                        }
4933                        (2, 4, 4, 4) => {
4934                            let h_in = in_shape[2].unwrap_static() as u32;
4935                            let w_in = in_shape[3].unwrap_static() as u32;
4936                            // rlx lowers ONNX 1D convs as 2D NCHW with a unit H axis
4937                            // and the length in W (`[N,C,1,L]`, kernel `[k,1]`). The 2D
4938                            // conv kernel would run the k-tap kernel over the singleton
4939                            // H axis. `[N,C,1,L]` and `[N,C,L,1]` share row-major layout,
4940                            // so relabel the length onto H (no data copy) — matching the
4941                            // CPU/MLX 1D paths and onnxruntime.
4942                            let one_d = h_in == 1
4943                                && w_in > 1
4944                                && kernel_size[0] > 1
4945                                && kernel_size.get(1).copied().unwrap_or(1) == 1;
4946                            let (h, w, h_out, w_out, kh, kw, sh, sw, ph, pw, dh, dw) = if one_d {
4947                                (
4948                                    w_in,
4949                                    1,
4950                                    out_shape[3].unwrap_static() as u32,
4951                                    1,
4952                                    kernel_size[0] as u32,
4953                                    1,
4954                                    s(0),
4955                                    1,
4956                                    p(0),
4957                                    0,
4958                                    d(0),
4959                                    1,
4960                                )
4961                            } else {
4962                                (
4963                                    h_in,
4964                                    w_in,
4965                                    out_shape[2].unwrap_static() as u32,
4966                                    out_shape[3].unwrap_static() as u32,
4967                                    kernel_size[0] as u32,
4968                                    kernel_size[1] as u32,
4969                                    s(0),
4970                                    s(1),
4971                                    p(0),
4972                                    p(1),
4973                                    d(0),
4974                                    d(1),
4975                                )
4976                            };
4977                            let p2 = Conv2dParams {
4978                                n: in_shape[0].unwrap_static() as u32,
4979                                c_in: in_shape[1].unwrap_static() as u32,
4980                                c_out: out_shape[1].unwrap_static() as u32,
4981                                h,
4982                                w,
4983                                h_out,
4984                                w_out,
4985                                kh,
4986                                kw,
4987                                sh,
4988                                sw,
4989                                ph,
4990                                pw,
4991                                dh,
4992                                dw,
4993                                groups: *groups as u32,
4994                                in_off,
4995                                w_off,
4996                                out_off,
4997                            };
4998                            schedule.push(Step::Conv2d { params: p2 });
4999                            let ck = conv2d_kernel(&dev.device);
5000                            let u = emit_uniform(std::mem::size_of::<Conv2dParams>());
5001                            let bg = bind_two_buf0_window(
5002                                &dev.device,
5003                                ck,
5004                                &arena.buffer,
5005                                base,
5006                                size,
5007                                &u,
5008                            );
5009                            uniforms.push(u);
5010                            bind_groups.push(bg);
5011                        }
5012                        (3, 5, 5, 5) => {
5013                            let p3 = Conv3dParams {
5014                                n: in_shape[0].unwrap_static() as u32,
5015                                c_in: in_shape[1].unwrap_static() as u32,
5016                                c_out: out_shape[1].unwrap_static() as u32,
5017                                d: in_shape[2].unwrap_static() as u32,
5018                                h: in_shape[3].unwrap_static() as u32,
5019                                w: in_shape[4].unwrap_static() as u32,
5020                                d_out: out_shape[2].unwrap_static() as u32,
5021                                h_out: out_shape[3].unwrap_static() as u32,
5022                                w_out: out_shape[4].unwrap_static() as u32,
5023                                kd: kernel_size[0] as u32,
5024                                kh: kernel_size[1] as u32,
5025                                kw: kernel_size[2] as u32,
5026                                sd: s(0),
5027                                sh: s(1),
5028                                sw: s(2),
5029                                pd: p(0),
5030                                ph: p(1),
5031                                pw: p(2),
5032                                dd: d(0),
5033                                dh: d(1),
5034                                dw: d(2),
5035                                groups: *groups as u32,
5036                                in_off,
5037                                w_off,
5038                                out_off,
5039                                _p0: 0,
5040                            };
5041                            schedule.push(Step::Conv3d { params: p3 });
5042                            let ck = conv3d_kernel(&dev.device);
5043                            let u = emit_uniform(std::mem::size_of::<Conv3dParams>());
5044                            let bg = bind_two_buf0_window(
5045                                &dev.device,
5046                                ck,
5047                                &arena.buffer,
5048                                base,
5049                                size,
5050                                &u,
5051                            );
5052                            uniforms.push(u);
5053                            bind_groups.push(bg);
5054                        }
5055                        (k, ni, wi, mi) => panic!(
5056                            "rlx-wgpu Conv: rank kernel={k} in={ni} weight={wi} out={mi} \
5057                             not supported (use 1D/2D/3D NCHW)"
5058                        ),
5059                    }
5060                }
5061
5062                Op::Im2Col {
5063                    kernel_size,
5064                    stride,
5065                    padding,
5066                    dilation,
5067                } => {
5068                    let x_shape = &graph.node(node.inputs[0]).shape;
5069                    if kernel_size.len() != 2 || x_shape.rank() != 4 {
5070                        panic!("rlx-wgpu Im2Col: 2D NCHW only");
5071                    }
5072                    let n = match x_shape.dim(0) {
5073                        rlx_ir::shape::Dim::Static(v) => v as u32,
5074                        _ => 0,
5075                    };
5076                    let c_in = x_shape.dim(1).unwrap_static() as u32;
5077                    let h = x_shape.dim(2).unwrap_static() as u32;
5078                    let w = x_shape.dim(3).unwrap_static() as u32;
5079                    let kh = kernel_size[0] as u32;
5080                    let kw = kernel_size[1] as u32;
5081                    let sh = stride.first().copied().unwrap_or(1) as u32;
5082                    let sw = stride.get(1).copied().unwrap_or(1) as u32;
5083                    let ph = padding.first().copied().unwrap_or(0) as u32;
5084                    let pw = padding.get(1).copied().unwrap_or(0) as u32;
5085                    let dh = dilation.first().copied().unwrap_or(1) as u32;
5086                    let dw_dil = dilation.get(1).copied().unwrap_or(1) as u32;
5087                    let h_out = rlx_ir::shape::conv2d_spatial_output(
5088                        h as usize,
5089                        kh as usize,
5090                        sh as usize,
5091                        ph as usize,
5092                        dh as usize,
5093                    ) as u32;
5094                    let w_out = rlx_ir::shape::conv2d_spatial_output(
5095                        w as usize,
5096                        kw as usize,
5097                        sw as usize,
5098                        pw as usize,
5099                        dw_dil as usize,
5100                    ) as u32;
5101                    schedule.push(Step::Im2ColHost {
5102                        x_byte_off: arena.offset(node.inputs[0]) as u32,
5103                        col_byte_off: arena.offset(node.id) as u32,
5104                        n,
5105                        c_in,
5106                        h,
5107                        w,
5108                        h_out,
5109                        w_out,
5110                        kh,
5111                        kw,
5112                        sh,
5113                        sw,
5114                        ph,
5115                        pw,
5116                        dh,
5117                        dw_dil,
5118                    });
5119                }
5120
5121                Op::Cumsum { axis, exclusive } => {
5122                    let in_id = node.inputs[0];
5123                    let in_shape = graph.node(in_id).shape.dims();
5124                    let last = (in_shape.len() - 1) as i32;
5125                    if *axis != -1 && *axis != last {
5126                        panic!("rlx-wgpu Cumsum: only last-axis wired (got axis={axis})");
5127                    }
5128                    let inner = in_shape[in_shape.len() - 1].unwrap_static() as u32;
5129                    let total: u32 = in_shape.iter().map(|d| d.unwrap_static() as u32).product();
5130                    let outer = total / inner.max(1);
5131                    let p = CumsumParams {
5132                        outer,
5133                        inner,
5134                        in_off: (arena.offset(in_id) / 4) as u32,
5135                        out_off: (arena.offset(node.id) / 4) as u32,
5136                        exclusive: if *exclusive { 1 } else { 0 },
5137                        _p0: 0,
5138                        _p1: 0,
5139                        _p2: 0,
5140                    };
5141                    schedule.push(Step::Cumsum { params: p });
5142                    let ck2 = cumsum_kernel(&dev.device);
5143                    let u = emit_uniform(std::mem::size_of::<CumsumParams>());
5144                    let bg = bind_op_output_window(&dev.device, ck2, &arena, node.id, &u);
5145                    uniforms.push(u);
5146                    bind_groups.push(bg);
5147                }
5148                Op::Fft { inverse, norm } => {
5149                    let in_id = node.inputs[0];
5150                    let in_shape = graph.node(in_id).shape.clone();
5151                    let meta = rlx_ir::fft::fft_meta(&in_shape);
5152                    let dtype = in_shape.dtype();
5153                    let use_gpu = rlx_ir::fft::gpu_fft_native_eligible(dtype, meta.n_complex)
5154                        && meta.n_complex >= 2;
5155                    let scale = norm.output_scale(meta.n_complex, *inverse) as f32;
5156                    if use_gpu {
5157                        schedule.push(Step::FftGpu {
5158                            src_off: (arena.offset(in_id) / 4) as u32,
5159                            dst_off: (arena.offset(node.id) / 4) as u32,
5160                            outer: meta.outer as u32,
5161                            n: meta.n_complex as u32,
5162                            inverse: if *inverse { 1 } else { 0 },
5163                            norm_scale: scale,
5164                        });
5165                        fft_gpu_steps.push(crate::fft_dispatch::FftGpuResources::new(
5166                            &dev.device,
5167                            &arena.buffer,
5168                        ));
5169                    } else {
5170                        schedule.push(Step::FftHost {
5171                            src_byte_off: arena.offset(in_id) as u32,
5172                            dst_byte_off: arena.offset(node.id) as u32,
5173                            outer: meta.outer as u32,
5174                            n_complex: meta.n_complex as u32,
5175                            inverse: *inverse,
5176                            norm_tag: norm.tag(),
5177                            dtype_tag: fft_dtype_tag(dtype),
5178                        });
5179                    }
5180                }
5181                Op::WelchPeaks { k, n_segments } => {
5182                    let spec_shape = graph.node(node.inputs[0]).shape.clone();
5183                    let meta = rlx_ir::audio::welch_peaks_meta(&spec_shape, *k, *n_segments)
5184                        .unwrap_or_else(|e| panic!("Op::WelchPeaks: {e}"));
5185                    let use_gpu = rlx_ir::audio::welch_peaks_gpu_native_eligible(
5186                        &spec_shape,
5187                        *k,
5188                        *n_segments,
5189                    )
5190                    .unwrap_or(false);
5191                    if use_gpu {
5192                        let p = WelchPeaksGpuParams {
5193                            spec_off: (arena.offset(node.inputs[0]) / 4) as u32,
5194                            dst_off: (arena.offset(node.id) / 4) as u32,
5195                            welch_batch: meta.welch_batch as u32,
5196                            n_fft: meta.n_fft as u32,
5197                            n_segments: meta.n_segments as u32,
5198                            k: meta.k as u32,
5199                            n_bins: meta.n_bins as u32,
5200                            _p0: 0,
5201                            _p1: 0,
5202                        };
5203                        schedule.push(Step::WelchPeaksGpu { params: p });
5204                        let wk = welch_peaks_gpu_kernel(&dev.device);
5205                        let u = emit_uniform(std::mem::size_of::<WelchPeaksGpuParams>());
5206                        let bg = bind_op_output_window(&dev.device, wk, &arena, node.id, &u);
5207                        uniforms.push(u);
5208                        bind_groups.push(bg);
5209                    } else {
5210                        schedule.push(Step::WelchPeaksHost {
5211                            spec_byte_off: arena.offset(node.inputs[0]) as u32,
5212                            dst_byte_off: arena.offset(node.id) as u32,
5213                            welch_batch: meta.welch_batch as u32,
5214                            n_fft: meta.n_fft as u32,
5215                            n_segments: meta.n_segments as u32,
5216                            k: meta.k as u32,
5217                        });
5218                    }
5219                }
5220                Op::LogMel => {
5221                    let spec_shape = graph.node(node.inputs[0]).shape.clone();
5222                    let filt_shape = graph.node(node.inputs[1]).shape.clone();
5223                    let meta = rlx_ir::audio::log_mel_meta(&spec_shape, &filt_shape)
5224                        .unwrap_or_else(|e| panic!("Op::LogMel: {e}"));
5225                    schedule.push(Step::LogMelHost {
5226                        spec_byte_off: arena.offset(node.inputs[0]) as u32,
5227                        filt_byte_off: arena.offset(node.inputs[1]) as u32,
5228                        dst_byte_off: arena.offset(node.id) as u32,
5229                        outer: meta.outer as u32,
5230                        n_fft: meta.n_fft as u32,
5231                        n_bins: meta.n_bins as u32,
5232                        n_mels: meta.n_mels as u32,
5233                    });
5234                }
5235                Op::LogMelBackward => {
5236                    let spec_shape = graph.node(node.inputs[0]).shape.clone();
5237                    let filt_shape = graph.node(node.inputs[1]).shape.clone();
5238                    let meta = rlx_ir::audio::log_mel_meta(&spec_shape, &filt_shape)
5239                        .unwrap_or_else(|e| panic!("Op::LogMelBackward: {e}"));
5240                    schedule.push(Step::LogMelBackwardHost {
5241                        spec_byte_off: arena.offset(node.inputs[0]) as u32,
5242                        filt_byte_off: arena.offset(node.inputs[1]) as u32,
5243                        dy_byte_off: arena.offset(node.inputs[2]) as u32,
5244                        dst_byte_off: arena.offset(node.id) as u32,
5245                        outer: meta.outer as u32,
5246                        n_fft: meta.n_fft as u32,
5247                        n_bins: meta.n_bins as u32,
5248                        n_mels: meta.n_mels as u32,
5249                    });
5250                }
5251                Op::SelectiveScan { state_size } => {
5252                    if *state_size > 256 {
5253                        panic!(
5254                            "rlx-wgpu SelectiveScan: state_size {} exceeds compile-time \
5255                                cap of 256 (kernel uses fixed-size private array)",
5256                            state_size
5257                        );
5258                    }
5259                    let x_id = node.inputs[0];
5260                    let dt_id = node.inputs[1];
5261                    let a_id = node.inputs[2];
5262                    let b_id = node.inputs[3];
5263                    let c_id = node.inputs[4];
5264                    let in_dims = graph.node(x_id).shape.dims();
5265                    let seq = in_dims[1].unwrap_static() as u32;
5266                    let p = SelectiveScanParams {
5267                        batch: in_dims[0].unwrap_static() as u32,
5268                        seq,
5269                        hidden: in_dims[2].unwrap_static() as u32,
5270                        state_size: *state_size as u32,
5271                        x_off: (arena.offset(x_id) / 4) as u32,
5272                        delta_off: (arena.offset(dt_id) / 4) as u32,
5273                        a_off: (arena.offset(a_id) / 4) as u32,
5274                        b_off: (arena.offset(b_id) / 4) as u32,
5275                        c_off: (arena.offset(c_id) / 4) as u32,
5276                        out_off: (arena.offset(node.id) / 4) as u32,
5277                        // PLAN L1: full-extent stride; safe under
5278                        // active-extent scaling of params.seq.
5279                        seq_stride: seq,
5280                        _p1: 0,
5281                        _p2: 0,
5282                        _p3: 0,
5283                        _p4: 0,
5284                        _p5: 0,
5285                    };
5286                    schedule.push(Step::SelectiveScan { params: p });
5287                    let ssk = selective_scan_kernel(&dev.device);
5288                    let u = emit_uniform(std::mem::size_of::<SelectiveScanParams>());
5289                    let bg = bind_op_output_window(&dev.device, ssk, &arena, node.id, &u);
5290                    uniforms.push(u);
5291                    bind_groups.push(bg);
5292                }
5293                Op::Mamba2 {
5294                    head_dim,
5295                    state_size,
5296                } => {
5297                    if *state_size > 256 {
5298                        panic!(
5299                            "rlx-wgpu Mamba2: state_size {} exceeds compile-time cap of 256",
5300                            state_size
5301                        );
5302                    }
5303                    let x_id = node.inputs[0];
5304                    let in_dims = graph.node(x_id).shape.dims(); // [B,S,H,P]
5305                    let seq = in_dims[1].unwrap_static() as u32;
5306                    let p = Mamba2Params {
5307                        batch: in_dims[0].unwrap_static() as u32,
5308                        seq,
5309                        heads: in_dims[2].unwrap_static() as u32,
5310                        head_dim: *head_dim as u32,
5311                        state_size: *state_size as u32,
5312                        x_off: (arena.offset(x_id) / 4) as u32,
5313                        dt_off: (arena.offset(node.inputs[1]) / 4) as u32,
5314                        a_off: (arena.offset(node.inputs[2]) / 4) as u32,
5315                        b_off: (arena.offset(node.inputs[3]) / 4) as u32,
5316                        c_off: (arena.offset(node.inputs[4]) / 4) as u32,
5317                        out_off: (arena.offset(node.id) / 4) as u32,
5318                        seq_stride: seq,
5319                        _p1: 0,
5320                        _p2: 0,
5321                        _p3: 0,
5322                        _p4: 0,
5323                    };
5324                    schedule.push(Step::Mamba2 { params: p });
5325                    let mk = mamba2_kernel(&dev.device);
5326                    let u = emit_uniform(std::mem::size_of::<Mamba2Params>());
5327                    let bg = bind_op_output_window(&dev.device, mk, &arena, node.id, &u);
5328                    uniforms.push(u);
5329                    bind_groups.push(bg);
5330                }
5331                Op::Gru {
5332                    hidden_size,
5333                    num_layers,
5334                    bidirectional,
5335                    carry,
5336                } => {
5337                    let x_id = node.inputs[0];
5338                    let in_dims = graph.node(x_id).shape.dims(); // [B,S,In]
5339                    let batch = in_dims[0].unwrap_static() as u32;
5340                    let seq = in_dims[1].unwrap_static() as u32;
5341                    let input_size = in_dims[2].unwrap_static() as u32;
5342                    let hidden = *hidden_size as u32;
5343                    let simple = *num_layers == 1 && !*bidirectional && !*carry;
5344                    if simple && hidden <= 256 {
5345                        let p = GruParams {
5346                            batch,
5347                            seq,
5348                            input_size,
5349                            hidden,
5350                            x_off: (arena.offset(x_id) / 4) as u32,
5351                            wih_off: (arena.offset(node.inputs[1]) / 4) as u32,
5352                            whh_off: (arena.offset(node.inputs[2]) / 4) as u32,
5353                            bih_off: (arena.offset(node.inputs[3]) / 4) as u32,
5354                            bhh_off: (arena.offset(node.inputs[4]) / 4) as u32,
5355                            out_off: (arena.offset(node.id) / 4) as u32,
5356                            seq_stride: seq,
5357                            _p1: 0,
5358                            _p2: 0,
5359                            _p3: 0,
5360                            _p4: 0,
5361                            _p5: 0,
5362                        };
5363                        schedule.push(Step::Gru { params: p });
5364                        let gk = gru_kernel(&dev.device);
5365                        let u = emit_uniform(std::mem::size_of::<GruParams>());
5366                        let bg = bind_op_output_window(&dev.device, gk, &arena, node.id, &u);
5367                        uniforms.push(u);
5368                        bind_groups.push(bg);
5369                    } else {
5370                        let h0 = if *carry {
5371                            arena.offset(node.inputs[5]) as u32
5372                        } else {
5373                            0
5374                        };
5375                        schedule.push(Step::GruHost {
5376                            x: arena.offset(x_id) as u32,
5377                            w_ih: arena.offset(node.inputs[1]) as u32,
5378                            w_hh: arena.offset(node.inputs[2]) as u32,
5379                            b_ih: arena.offset(node.inputs[3]) as u32,
5380                            b_hh: arena.offset(node.inputs[4]) as u32,
5381                            h0,
5382                            dst: arena.offset(node.id) as u32,
5383                            batch,
5384                            seq,
5385                            input_size,
5386                            hidden,
5387                            num_layers: *num_layers as u32,
5388                            bidirectional: *bidirectional,
5389                            carry: *carry,
5390                        });
5391                    }
5392                }
5393                Op::Rnn {
5394                    hidden_size,
5395                    num_layers,
5396                    bidirectional,
5397                    carry,
5398                    relu,
5399                } => {
5400                    let x_id = node.inputs[0];
5401                    let in_dims = graph.node(x_id).shape.dims();
5402                    let batch = in_dims[0].unwrap_static() as u32;
5403                    let seq = in_dims[1].unwrap_static() as u32;
5404                    let input_size = in_dims[2].unwrap_static() as u32;
5405                    let hidden = *hidden_size as u32;
5406                    let simple = *num_layers == 1 && !*bidirectional && !*carry;
5407                    if simple && hidden <= 256 {
5408                        let p = RnnParams {
5409                            batch,
5410                            seq,
5411                            input_size,
5412                            hidden,
5413                            x_off: (arena.offset(x_id) / 4) as u32,
5414                            wih_off: (arena.offset(node.inputs[1]) / 4) as u32,
5415                            whh_off: (arena.offset(node.inputs[2]) / 4) as u32,
5416                            bias_off: (arena.offset(node.inputs[3]) / 4) as u32,
5417                            out_off: (arena.offset(node.id) / 4) as u32,
5418                            seq_stride: seq,
5419                            relu: u32::from(*relu),
5420                            _p1: 0,
5421                            _p2: 0,
5422                            _p3: 0,
5423                            _p4: 0,
5424                            _p5: 0,
5425                        };
5426                        schedule.push(Step::Rnn { params: p });
5427                        let rk = rnn_kernel(&dev.device);
5428                        let u = emit_uniform(std::mem::size_of::<RnnParams>());
5429                        let bg = bind_op_output_window(&dev.device, rk, &arena, node.id, &u);
5430                        uniforms.push(u);
5431                        bind_groups.push(bg);
5432                    } else {
5433                        let h0 = if *carry {
5434                            arena.offset(node.inputs[4]) as u32
5435                        } else {
5436                            0
5437                        };
5438                        schedule.push(Step::RnnHost {
5439                            x: arena.offset(x_id) as u32,
5440                            w_ih: arena.offset(node.inputs[1]) as u32,
5441                            w_hh: arena.offset(node.inputs[2]) as u32,
5442                            bias: arena.offset(node.inputs[3]) as u32,
5443                            h0,
5444                            dst: arena.offset(node.id) as u32,
5445                            batch,
5446                            seq,
5447                            input_size,
5448                            hidden,
5449                            num_layers: *num_layers as u32,
5450                            bidirectional: *bidirectional,
5451                            carry: *carry,
5452                            relu: *relu,
5453                        });
5454                    }
5455                }
5456                Op::GatedDeltaNet {
5457                    state_size,
5458                    carry_state,
5459                } => {
5460                    if *state_size > rlx_cpu::gdn::GDN_MAX_STATE {
5461                        panic!(
5462                            "rlx-wgpu GatedDeltaNet: state_size {state_size} > {}",
5463                            rlx_cpu::gdn::GDN_MAX_STATE
5464                        );
5465                    }
5466                    let q_id = node.inputs[0];
5467                    let q_shape = &graph.node(q_id).shape;
5468                    let state_off = if *carry_state {
5469                        arena.offset(node.inputs[5])
5470                    } else {
5471                        0
5472                    };
5473                    schedule.push(Step::GatedDeltaNet {
5474                        q_byte_off: arena.offset(q_id) as u32,
5475                        k_byte_off: arena.offset(node.inputs[1]) as u32,
5476                        v_byte_off: arena.offset(node.inputs[2]) as u32,
5477                        g_byte_off: arena.offset(node.inputs[3]) as u32,
5478                        beta_byte_off: arena.offset(node.inputs[4]) as u32,
5479                        state_byte_off: state_off as u32,
5480                        dst_byte_off: arena.offset(node.id) as u32,
5481                        batch: q_shape.dim(0).unwrap_static() as u32,
5482                        seq: q_shape.dim(1).unwrap_static() as u32,
5483                        heads: q_shape.dim(2).unwrap_static() as u32,
5484                        state_size: *state_size as u32,
5485                        use_carry: *carry_state,
5486                    });
5487                    if gguf_host_pad.is_none() {
5488                        let bk = binary_kernel(&dev.device);
5489                        let u = emit_uniform(256);
5490                        gguf_host_pad = Some((
5491                            u.clone(),
5492                            bind_op_output_window(&dev.device, bk, &arena, node.id, &u),
5493                        ));
5494                    }
5495                    let (u, bg) = gguf_host_pad.as_ref().unwrap();
5496                    uniforms.push(u.clone());
5497                    bind_groups.push(bg.clone());
5498                }
5499                Op::Lstm {
5500                    hidden_size,
5501                    num_layers,
5502                    bidirectional,
5503                    carry,
5504                } => {
5505                    let x_shape = &graph.node(node.inputs[0]).shape;
5506                    let (h0, c0) = if *carry {
5507                        (
5508                            arena.offset(node.inputs[4]) as u32,
5509                            arena.offset(node.inputs[5]) as u32,
5510                        )
5511                    } else {
5512                        (0u32, 0u32)
5513                    };
5514                    schedule.push(Step::Lstm {
5515                        x_byte_off: arena.offset(node.inputs[0]) as u32,
5516                        w_ih_byte_off: arena.offset(node.inputs[1]) as u32,
5517                        w_hh_byte_off: arena.offset(node.inputs[2]) as u32,
5518                        bias_byte_off: arena.offset(node.inputs[3]) as u32,
5519                        h0_byte_off: h0,
5520                        c0_byte_off: c0,
5521                        dst_byte_off: arena.offset(node.id) as u32,
5522                        batch: x_shape.dim(0).unwrap_static() as u32,
5523                        seq: x_shape.dim(1).unwrap_static() as u32,
5524                        input_size: x_shape.dim(2).unwrap_static() as u32,
5525                        hidden: *hidden_size as u32,
5526                        num_layers: *num_layers as u32,
5527                        bidirectional: *bidirectional,
5528                        carry: *carry,
5529                    });
5530                    // Host step — keep schedule/uniform/bind_group lanes aligned.
5531                    if gguf_host_pad.is_none() {
5532                        let bk = binary_kernel(&dev.device);
5533                        let u = emit_uniform(256);
5534                        gguf_host_pad = Some((
5535                            u.clone(),
5536                            bind_op_output_window(&dev.device, bk, &arena, node.id, &u),
5537                        ));
5538                    }
5539                    let (u, bg) = gguf_host_pad.as_ref().unwrap();
5540                    uniforms.push(u.clone());
5541                    bind_groups.push(bg.clone());
5542                }
5543                Op::ConvTranspose2d {
5544                    kernel_size,
5545                    stride,
5546                    padding,
5547                    dilation,
5548                    output_padding: _,
5549                    groups,
5550                } => {
5551                    let in_shape = &graph.node(node.inputs[0]).shape;
5552                    let out_shape = &node.shape;
5553                    schedule.push(Step::ConvTranspose2d {
5554                        src_byte_off: arena.offset(node.inputs[0]) as u32,
5555                        weight_byte_off: arena.offset(node.inputs[1]) as u32,
5556                        dst_byte_off: arena.offset(node.id) as u32,
5557                        n: in_shape.dim(0).unwrap_static() as u32,
5558                        c_in: in_shape.dim(1).unwrap_static() as u32,
5559                        h: in_shape.dim(2).unwrap_static() as u32,
5560                        w_in: in_shape.dim(3).unwrap_static() as u32,
5561                        c_out: out_shape.dim(1).unwrap_static() as u32,
5562                        h_out: out_shape.dim(2).unwrap_static() as u32,
5563                        w_out: out_shape.dim(3).unwrap_static() as u32,
5564                        kh: kernel_size[0] as u32,
5565                        kw: kernel_size[1] as u32,
5566                        sh: stride[0] as u32,
5567                        sw: stride[1] as u32,
5568                        ph: padding[0] as u32,
5569                        pw: padding[1] as u32,
5570                        dh: dilation[0] as u32,
5571                        dw: dilation[1] as u32,
5572                        groups: *groups as u32,
5573                    });
5574                    // Host step: schedule-only, like `MsDeformAttnHost`/`UmapKnnHost`.
5575                    // The uniform/bind_group lanes are indexed by GPU-step count
5576                    // (`gpu_ui`), so host steps must NOT push to them.
5577                }
5578                Op::GroupNorm { num_groups, eps } => {
5579                    // NCHW: x [n,c,h,w], gamma/beta [c]. Host step (schedule-only).
5580                    let in_shape = &graph.node(node.inputs[0]).shape;
5581                    schedule.push(Step::GroupNormHost {
5582                        src_byte_off: arena.offset(node.inputs[0]) as u32,
5583                        gamma_byte_off: arena.offset(node.inputs[1]) as u32,
5584                        beta_byte_off: arena.offset(node.inputs[2]) as u32,
5585                        dst_byte_off: arena.offset(node.id) as u32,
5586                        n: in_shape.dim(0).unwrap_static() as u32,
5587                        c: in_shape.dim(1).unwrap_static() as u32,
5588                        h: in_shape.dim(2).unwrap_static() as u32,
5589                        w: in_shape.dim(3).unwrap_static() as u32,
5590                        num_groups: *num_groups as u32,
5591                        eps: *eps,
5592                    });
5593                }
5594                Op::LayerNorm2d { eps } => {
5595                    // NCHW: x [n,c,h,w], gamma/beta [c]. Host step (schedule-only).
5596                    let in_shape = &graph.node(node.inputs[0]).shape;
5597                    schedule.push(Step::LayerNorm2dHost {
5598                        src_byte_off: arena.offset(node.inputs[0]) as u32,
5599                        gamma_byte_off: arena.offset(node.inputs[1]) as u32,
5600                        beta_byte_off: arena.offset(node.inputs[2]) as u32,
5601                        dst_byte_off: arena.offset(node.id) as u32,
5602                        n: in_shape.dim(0).unwrap_static() as u32,
5603                        c: in_shape.dim(1).unwrap_static() as u32,
5604                        h: in_shape.dim(2).unwrap_static() as u32,
5605                        w: in_shape.dim(3).unwrap_static() as u32,
5606                        eps: *eps,
5607                    });
5608                }
5609                Op::ResizeNearest2x => {
5610                    // NCHW nearest 2× upsample. Host step (schedule-only).
5611                    let in_shape = &graph.node(node.inputs[0]).shape;
5612                    schedule.push(Step::ResizeNearest2xHost {
5613                        src_byte_off: arena.offset(node.inputs[0]) as u32,
5614                        dst_byte_off: arena.offset(node.id) as u32,
5615                        n: in_shape.dim(0).unwrap_static() as u32,
5616                        c: in_shape.dim(1).unwrap_static() as u32,
5617                        h: in_shape.dim(2).unwrap_static() as u32,
5618                        w: in_shape.dim(3).unwrap_static() as u32,
5619                    });
5620                }
5621                Op::Reverse { axes } => {
5622                    // Batch-general flip. Host step (schedule-only).
5623                    let in_shape = &graph.node(node.inputs[0]).shape;
5624                    let rank = in_shape.rank();
5625                    let dims: Vec<u32> = (0..rank)
5626                        .map(|i| in_shape.dim(i).unwrap_static() as u32)
5627                        .collect();
5628                    let mut rev_mask = vec![false; rank];
5629                    for &a in axes {
5630                        if a < rank {
5631                            rev_mask[a] = true;
5632                        }
5633                    }
5634                    schedule.push(Step::ReverseHost {
5635                        src_byte_off: arena.offset(node.inputs[0]) as u32,
5636                        dst_byte_off: arena.offset(node.id) as u32,
5637                        dims,
5638                        rev_mask,
5639                        elem_bytes: in_shape.dtype().size_bytes() as u32,
5640                    });
5641                }
5642                Op::ArgMax { axis, keep_dim: _ } | Op::ArgMin { axis, keep_dim: _ } => {
5643                    // ArgMax/ArgMin. Host step (schedule-only).
5644                    let in_shape = &graph.node(node.inputs[0]).shape;
5645                    let rank = in_shape.rank();
5646                    let outer: usize = (0..*axis)
5647                        .map(|i| in_shape.dim(i).unwrap_static())
5648                        .product::<usize>()
5649                        .max(1);
5650                    let reduced = in_shape.dim(*axis).unwrap_static();
5651                    let inner: usize = (*axis + 1..rank)
5652                        .map(|i| in_shape.dim(i).unwrap_static())
5653                        .product::<usize>()
5654                        .max(1);
5655                    schedule.push(Step::ArgReduceHost {
5656                        src_byte_off: arena.offset(node.inputs[0]) as u32,
5657                        dst_byte_off: arena.offset(node.id) as u32,
5658                        outer: outer as u32,
5659                        reduced: reduced as u32,
5660                        inner: inner as u32,
5661                        is_max: matches!(node.op, Op::ArgMax { .. }),
5662                    });
5663                }
5664                Op::Custom { name, attrs, .. } => match name.as_str() {
5665                    "llada2.group_limited_gate" => {
5666                        let sig_id = node.inputs[0];
5667                        let route_id = node.inputs[1];
5668                        let n_elems = graph.node(sig_id).shape.num_elements().unwrap() as u32;
5669                        let mut attr_buf = [0u8; 20];
5670                        let n = attrs.len().min(20);
5671                        attr_buf[..n].copy_from_slice(&attrs[..n]);
5672                        schedule.push(Step::Llada2GroupLimitedGate {
5673                            sig_byte_off: arena.offset(sig_id) as u32,
5674                            route_byte_off: arena.offset(route_id) as u32,
5675                            out_byte_off: arena.offset(node.id) as u32,
5676                            n_elems,
5677                            attrs: attr_buf,
5678                        });
5679                    }
5680                    "umap.knn" => {
5681                        let pw_id = node.inputs[0];
5682                        let pw_shape = graph.node(pw_id).shape.dims();
5683                        let n = pw_shape[0].unwrap_static() as u32;
5684                        let k = if attrs.len() >= 4 {
5685                            u32::from_le_bytes(attrs[..4].try_into().unwrap())
5686                        } else {
5687                            panic!("rlx-wgpu: umap.knn attrs missing k");
5688                        };
5689                        let pw_off = arena.offset(pw_id) as u32;
5690                        let out_off = arena.offset(node.id) as u32;
5691                        if n as usize >= crate::umap_knn_host::UMAP_KNN_GPU_MIN_N {
5692                            let p = UmapKnnParams {
5693                                n,
5694                                k,
5695                                pw_off: pw_off / 4,
5696                                out_off: out_off / 4,
5697                                _p0: 0,
5698                                _p1: 0,
5699                                _p2: 0,
5700                            };
5701                            schedule.push(Step::UmapKnn { params: p });
5702                            let uk = umap_knn_kernel(&dev.device);
5703                            let u = emit_uniform(std::mem::size_of::<UmapKnnParams>());
5704                            let bg = bind_op_output_window(&dev.device, uk, &arena, node.id, &u);
5705                            uniforms.push(u);
5706                            bind_groups.push(bg);
5707                        } else {
5708                            schedule.push(Step::UmapKnnHost {
5709                                pairwise_byte_off: pw_off,
5710                                out_byte_off: out_off,
5711                                n,
5712                                k,
5713                            });
5714                        }
5715                    }
5716                    "gdino.ms_deform_attn" => {
5717                        let in_offs: Vec<(u32, u32)> = node
5718                            .inputs
5719                            .iter()
5720                            .map(|&id| {
5721                                let bytes = graph.node(id).shape.num_elements().unwrap() * 4;
5722                                (arena.offset(id) as u32, bytes as u32)
5723                            })
5724                            .collect();
5725                        let out_bytes = (node.shape.num_elements().unwrap() * 4) as u32;
5726                        schedule.push(Step::MsDeformAttnHost {
5727                            in_offs,
5728                            out_byte_off: arena.offset(node.id) as u32,
5729                            out_bytes,
5730                            attrs: attrs.clone(),
5731                        });
5732                    }
5733                    other => panic!("rlx-wgpu: unsupported Op::Custom('{other}')"),
5734                },
5735                Op::GroupedMatMul => {
5736                    // Inputs: input [M, K], weight [E, K, N], expert_idx [M]
5737                    let in_id = node.inputs[0];
5738                    let w_id = node.inputs[1];
5739                    let idx_id = node.inputs[2];
5740                    let in_dims = graph.node(in_id).shape.dims();
5741                    let w_dims = graph.node(w_id).shape.dims();
5742                    let m = in_dims[0].unwrap_static() as u32;
5743                    let k = in_dims[1].unwrap_static() as u32;
5744                    let n = w_dims[2].unwrap_static() as u32;
5745                    let ne = w_dims[0].unwrap_static() as u32;
5746                    let p = GroupedMatmulParams {
5747                        m,
5748                        k,
5749                        n,
5750                        num_experts: ne,
5751                        in_off: (arena.offset(in_id) / 4) as u32,
5752                        w_off: (arena.offset(w_id) / 4) as u32,
5753                        idx_off: (arena.offset(idx_id) / 4) as u32,
5754                        out_off: (arena.offset(node.id) / 4) as u32,
5755                    };
5756                    schedule.push(Step::GroupedMatmul { params: p });
5757                    let gk = grouped_matmul_kernel(&dev.device);
5758                    let u = emit_uniform(std::mem::size_of::<GroupedMatmulParams>());
5759                    let bg = bind_op_output_window(&dev.device, gk, &arena, node.id, &u);
5760                    uniforms.push(u);
5761                    bind_groups.push(bg);
5762                }
5763                Op::DequantGroupedMatMul { scheme } => {
5764                    let in_id = node.inputs[0];
5765                    let w_id = node.inputs[1];
5766                    let idx_id = node.inputs[2];
5767                    let in_dims = graph.node(in_id).shape.dims();
5768                    let out_dims = node.shape.dims();
5769                    let m = in_dims[0].unwrap_static() as u32;
5770                    let k = in_dims[1].unwrap_static() as u32;
5771                    let n = out_dims[out_dims.len() - 1].unwrap_static() as u32;
5772                    let block_elems = scheme.gguf_block_size() as usize;
5773                    let block_bytes = scheme.gguf_block_bytes() as usize;
5774                    let slab_bytes = (k as usize * n as usize) / block_elems * block_bytes;
5775                    let total_bytes = graph.node(w_id).shape.num_elements().unwrap();
5776                    let ne = (total_bytes / slab_bytes.max(1)) as u32;
5777                    schedule.push(Step::DequantGroupedMatmulGguf {
5778                        m,
5779                        k,
5780                        n,
5781                        num_experts: ne,
5782                        scheme_id: crate::gguf_host::gguf_scheme_id(*scheme),
5783                        x_byte_off: arena.offset(in_id) as u64,
5784                        w_byte_off: arena.offset(w_id) as u64,
5785                        idx_byte_off: arena.offset(idx_id) as u64,
5786                        out_byte_off: arena.offset(node.id) as u64,
5787                    });
5788                    // Host step (builds its own bind groups): schedule-only, like
5789                    // `GroupNorm`. The uniform/bind_group lanes are indexed by
5790                    // GPU-step count (host steps skipped), so pushing here would
5791                    // shift every later GPU op's lane. Do NOT push.
5792                }
5793                Op::TopK { k } => {
5794                    let in_id = node.inputs[0];
5795                    let in_dims = graph.node(in_id).shape.dims();
5796                    let inner = in_dims.last().unwrap().unwrap_static() as u32;
5797                    let outer: u32 = in_dims[..in_dims.len() - 1]
5798                        .iter()
5799                        .map(|d| d.unwrap_static() as u32)
5800                        .product::<u32>()
5801                        .max(1);
5802                    let p = TopKParams {
5803                        outer,
5804                        inner,
5805                        k: *k as u32,
5806                        in_off: (arena.offset(in_id) / 4) as u32,
5807                        out_off: (arena.offset(node.id) / 4) as u32,
5808                        _p0: 0,
5809                        _p1: 0,
5810                        _p2: 0,
5811                    };
5812                    schedule.push(Step::TopK { params: p });
5813                    let tk = topk_kernel(&dev.device);
5814                    let u = emit_uniform(std::mem::size_of::<TopKParams>());
5815                    let bg = bind_op_output_window(&dev.device, tk, &arena, node.id, &u);
5816                    uniforms.push(u);
5817                    bind_groups.push(bg);
5818                }
5819                Op::ScatterAdd => {
5820                    // Inputs: updates [num_updates, trailing], indices [num_updates].
5821                    // Output: [out_dim, trailing]. Implemented as two phases:
5822                    //   1. Zero `out_dim * trailing` slots.
5823                    //   2. CAS-loop atomic-accumulate `num_updates * trailing` updates.
5824                    let upd_id = node.inputs[0];
5825                    let idx_id = node.inputs[1];
5826                    let upd_dims = graph.node(upd_id).shape.dims();
5827                    let out_dims = node.shape.dims();
5828                    let num_updates = upd_dims[0].unwrap_static() as u32;
5829                    let trailing: u32 = upd_dims
5830                        .iter()
5831                        .skip(1)
5832                        .map(|d| d.unwrap_static() as u32)
5833                        .product::<u32>()
5834                        .max(1);
5835                    let out_dim = out_dims[0].unwrap_static() as u32;
5836                    let out_total = out_dim * trailing;
5837
5838                    let common = ScatterAddParams {
5839                        op: 0,
5840                        out_off: (arena.offset(node.id) / 4) as u32,
5841                        upd_off: (arena.offset(upd_id) / 4) as u32,
5842                        idx_off: (arena.offset(idx_id) / 4) as u32,
5843                        out_total,
5844                        num_updates,
5845                        trailing,
5846                        out_dim,
5847                    };
5848                    let sk = scatter_add_kernel(&dev.device);
5849
5850                    // Phase 0: zero.
5851                    schedule.push(Step::ScatterAdd { params: common });
5852                    let u0 = emit_uniform(std::mem::size_of::<ScatterAddParams>());
5853                    let bg0 = bind_op_output_window(&dev.device, sk, &arena, node.id, &u0);
5854                    uniforms.push(u0);
5855                    bind_groups.push(bg0);
5856
5857                    // Phase 1: accumulate.
5858                    let mut acc = common;
5859                    acc.op = 1;
5860                    schedule.push(Step::ScatterAdd { params: acc });
5861                    let u1 = emit_uniform(std::mem::size_of::<ScatterAddParams>());
5862                    let bg1 = bind_op_output_window(&dev.device, sk, &arena, node.id, &u1);
5863                    uniforms.push(u1);
5864                    bind_groups.push(bg1);
5865                }
5866                Op::FusedResidualLN { has_bias, eps } => {
5867                    // Inputs: [x, residual, [bias], gamma, beta].
5868                    let x_id = node.inputs[0];
5869                    let r_id = node.inputs[1];
5870                    let (bias_id, g_id, b_id) = if *has_bias {
5871                        (node.inputs[2], node.inputs[3], node.inputs[4])
5872                    } else {
5873                        (x_id, node.inputs[2], node.inputs[3]) // bias unused
5874                    };
5875                    let in_dims = node.shape.dims();
5876                    let inner = in_dims[in_dims.len() - 1].unwrap_static() as u32;
5877                    let total: u32 = in_dims.iter().map(|d| d.unwrap_static() as u32).product();
5878                    let outer = total / inner.max(1);
5879                    let p = FusedResidualLnParams {
5880                        outer,
5881                        inner,
5882                        in_off: (arena.offset(x_id) / 4) as u32,
5883                        residual_off: (arena.offset(r_id) / 4) as u32,
5884                        bias_off: (arena.offset(bias_id) / 4) as u32,
5885                        gamma_off: (arena.offset(g_id) / 4) as u32,
5886                        beta_off: (arena.offset(b_id) / 4) as u32,
5887                        out_off: (arena.offset(node.id) / 4) as u32,
5888                        eps_bits: eps.to_bits(),
5889                        has_bias: if *has_bias { 1 } else { 0 },
5890                        _p0: 0,
5891                        _p1: 0,
5892                    };
5893                    schedule.push(Step::FusedResidualLn { params: p });
5894                    let frk = fused_residual_ln_kernel(&dev.device);
5895                    let u = emit_uniform(std::mem::size_of::<FusedResidualLnParams>());
5896                    let bg = bind_op_output_window(&dev.device, frk, &arena, node.id, &u);
5897                    uniforms.push(u);
5898                    bind_groups.push(bg);
5899                }
5900                Op::FusedResidualRmsNorm { has_bias, eps } => {
5901                    let x_id = node.inputs[0];
5902                    let r_id = node.inputs[1];
5903                    let (bias_id, g_id, b_id) = if *has_bias {
5904                        (node.inputs[2], node.inputs[3], node.inputs[4])
5905                    } else {
5906                        (x_id, node.inputs[2], node.inputs[3])
5907                    };
5908                    let in_dims = node.shape.dims();
5909                    let inner = in_dims[in_dims.len() - 1].unwrap_static() as u32;
5910                    let total: u32 = in_dims.iter().map(|d| d.unwrap_static() as u32).product();
5911                    let outer = total / inner.max(1);
5912                    let p = FusedResidualRmsNormParams {
5913                        outer,
5914                        inner,
5915                        in_off: (arena.offset(x_id) / 4) as u32,
5916                        residual_off: (arena.offset(r_id) / 4) as u32,
5917                        bias_off: (arena.offset(bias_id) / 4) as u32,
5918                        gamma_off: (arena.offset(g_id) / 4) as u32,
5919                        beta_off: (arena.offset(b_id) / 4) as u32,
5920                        out_off: (arena.offset(node.id) / 4) as u32,
5921                        eps_bits: eps.to_bits(),
5922                        has_bias: if *has_bias { 1 } else { 0 },
5923                        _p0: 0,
5924                        _p1: 0,
5925                    };
5926                    schedule.push(Step::FusedResidualRmsNorm { params: p });
5927                    let frk = fused_residual_rms_norm_kernel(&dev.device);
5928                    let u = emit_uniform(std::mem::size_of::<FusedResidualRmsNormParams>());
5929                    let bg = bind_op_output_window(&dev.device, frk, &arena, node.id, &u);
5930                    uniforms.push(u);
5931                    bind_groups.push(bg);
5932                }
5933                Op::DequantMatMul { scheme } => {
5934                    use rlx_ir::QuantScheme;
5935                    let x_id = node.inputs[0];
5936                    let w_id = node.inputs[1];
5937                    // Rank-agnostic GEMM dims: `n` = last output dim, `m` = the
5938                    // product of the leading output dims (e.g. batch·seq), `k` =
5939                    // last input dim. A 2D-only `out_dims[1]` read collapses a 3D
5940                    // decode output `[1, 1, hidden]` to `n = 1` (the seq axis),
5941                    // which then mis-sizes the weight window. Mirrors
5942                    // `gguf_gpu::dequant_gguf_scratch_bytes`.
5943                    let out_total = node.shape.num_elements().unwrap_or(0) as u32;
5944                    let n = node.shape.dim(node.shape.rank() - 1).unwrap_static() as u32;
5945                    let m = out_total / n.max(1);
5946                    let x_total = graph.node(x_id).shape.num_elements().unwrap_or(0) as u32;
5947                    let k = x_total / m.max(1);
5948                    if scheme.is_gguf() {
5949                        schedule.push(Step::DequantMatmulGguf {
5950                            m,
5951                            k,
5952                            n,
5953                            scheme_id: crate::gguf_host::gguf_scheme_id(*scheme),
5954                            x_byte_off: arena.offset(x_id) as u64,
5955                            w_byte_off: arena.offset(w_id) as u64,
5956                            out_byte_off: arena.offset(node.id) as u64,
5957                        });
5958                        // Host step (`run_dequant_matmul_gguf_gpu` builds its own
5959                        // bind groups): schedule-only, like `GroupNorm`. The
5960                        // uniform/bind_group lanes are indexed by GPU-step count
5961                        // (`gpu_ui`/`gpu_bi`, host steps skipped), so pushing here
5962                        // would shift every later GPU op's lane — fatal in the
5963                        // packed decode graph where GGUF ops interleave with
5964                        // RmsNorm/RoPE/Attention/SwiGLU. Do NOT push.
5965                    } else {
5966                        let (block_size, scheme_id) = match scheme {
5967                            QuantScheme::Int8Block { block_size } => (*block_size, 0u32),
5968                            QuantScheme::Int8BlockAsym { block_size } => (*block_size, 1u32),
5969                            QuantScheme::Int4Block { block_size } => (*block_size, 2u32),
5970                            QuantScheme::Fp8E4m3 => (1, 3u32),
5971                            QuantScheme::Fp8E5m2 => (1, 4u32),
5972                            QuantScheme::Nvfp4Block => (rlx_ir::NVFP4_GROUP_SIZE as u32, 5u32),
5973                            other => panic!("rlx-wgpu DequantMatMul: unsupported scheme {other:?}"),
5974                        };
5975                        let scale_id = node.inputs[2];
5976                        let zp_id = node.inputs[3];
5977                        let p = DequantMatmulParams {
5978                            m,
5979                            k,
5980                            n,
5981                            block_size,
5982                            scheme_id,
5983                            x_off: (arena.offset(x_id) / 4) as u32,
5984                            w_off: (arena.offset(w_id) / 4) as u32,
5985                            scale_off: (arena.offset(scale_id) / 4) as u32,
5986                            zp_off: (arena.offset(zp_id) / 4) as u32,
5987                            out_off: (arena.offset(node.id) / 4) as u32,
5988                            _p0: 0,
5989                            _p1: 0,
5990                        };
5991                        schedule.push(Step::DequantMatmul { params: p });
5992                        let dk = dequant_matmul_kernel(&dev.device);
5993                        let u = emit_uniform(std::mem::size_of::<DequantMatmulParams>());
5994                        let bg = bind_op_output_window(&dev.device, dk, &arena, node.id, &u);
5995                        uniforms.push(u);
5996                        bind_groups.push(bg);
5997                    }
5998                }
5999                Op::RmsNormBackwardInput { eps, .. }
6000                | Op::RmsNormBackwardGamma { eps, .. }
6001                | Op::RmsNormBackwardBeta { eps, .. } => {
6002                    let x_shape = &graph.node(node.inputs[0]).shape;
6003                    let h = x_shape.dim(x_shape.rank() - 1).unwrap_static() as u32;
6004                    let rows = (x_shape.num_elements().unwrap() / h.max(1) as usize) as u32;
6005                    let foff = |i: usize| (arena.offset(node.inputs[i]) / 4) as u32;
6006                    let wrt = match &node.op {
6007                        Op::RmsNormBackwardInput { .. } => 0u32,
6008                        Op::RmsNormBackwardGamma { .. } => 1u32,
6009                        Op::RmsNormBackwardBeta { .. } => 2u32,
6010                        _ => unreachable!(),
6011                    };
6012                    let p = RmsNormBwdParams {
6013                        outer: rows,
6014                        inner: h,
6015                        x_off: foff(0),
6016                        gamma_off: foff(1),
6017                        beta_off: foff(2),
6018                        dy_off: foff(3),
6019                        out_off: (arena.offset(node.id) / 4) as u32,
6020                        eps_bits: eps.to_bits(),
6021                        wrt,
6022                    };
6023                    let rk = if wrt == 0 {
6024                        rms_norm_backward_kernel(&dev.device)
6025                    } else {
6026                        rms_norm_backward_param_kernel(&dev.device)
6027                    };
6028                    let u = emit_uniform(std::mem::size_of::<RmsNormBwdParams>());
6029                    let bg = bind_op_output_window(&dev.device, rk, &arena, node.id, &u);
6030                    match &node.op {
6031                        Op::RmsNormBackwardInput { .. } => {
6032                            schedule.push(Step::RmsNormBackwardInput { params: p });
6033                        }
6034                        Op::RmsNormBackwardGamma { .. } => {
6035                            schedule.push(Step::RmsNormBackwardGamma { params: p });
6036                        }
6037                        Op::RmsNormBackwardBeta { .. } => {
6038                            schedule.push(Step::RmsNormBackwardBeta { params: p });
6039                        }
6040                        _ => unreachable!(),
6041                    }
6042                    uniforms.push(u);
6043                    bind_groups.push(bg);
6044                }
6045                Op::LayerNormBackwardInput { eps, .. } => {
6046                    let x_shape = &graph.node(node.inputs[0]).shape;
6047                    let h = x_shape.dim(x_shape.rank() - 1).unwrap_static() as u32;
6048                    let rows = (x_shape.num_elements().unwrap() / h.max(1) as usize) as u32;
6049                    let p = LayerNormBwdParams {
6050                        outer: rows,
6051                        inner: h,
6052                        x_off: (arena.offset(node.inputs[0]) / 4) as u32,
6053                        gamma_off: (arena.offset(node.inputs[1]) / 4) as u32,
6054                        dy_off: (arena.offset(node.inputs[2]) / 4) as u32,
6055                        out_off: (arena.offset(node.id) / 4) as u32,
6056                        eps_bits: eps.to_bits(),
6057                        scratch_off: 0,
6058                    };
6059                    let rk = layer_norm_backward_input_kernel(&dev.device);
6060                    let u = emit_uniform(std::mem::size_of::<LayerNormBwdParams>());
6061                    let bg = bind_op_output_window(&dev.device, rk, &arena, node.id, &u);
6062                    schedule.push(Step::LayerNormBackwardInput { params: p });
6063                    uniforms.push(u);
6064                    bind_groups.push(bg);
6065                }
6066                Op::LayerNormBackwardGamma { eps, .. } => {
6067                    // Inputs: [x, dy] — gamma_off is unused for this op.
6068                    // Emit two steps: a multi-workgroup partial that
6069                    // writes per-chunk dgamma to the tail scratch zone,
6070                    // and a single-workgroup reduce that sums chunks
6071                    // into the final dgamma slot.
6072                    let x_shape = &graph.node(node.inputs[0]).shape;
6073                    let h = x_shape.dim(x_shape.rank() - 1).unwrap_static() as u32;
6074                    let rows = (x_shape.num_elements().unwrap() / h.max(1) as usize) as u32;
6075                    const ROWS_PER_WG: u32 = 16;
6076                    let num_workgroups = rows.div_ceil(ROWS_PER_WG.max(1));
6077                    let scratch_off_words = (arena.scratch_off / 4) as u32;
6078                    let partial_params = LayerNormBwdParams {
6079                        outer: rows,
6080                        inner: h,
6081                        x_off: (arena.offset(node.inputs[0]) / 4) as u32,
6082                        gamma_off: 0,
6083                        dy_off: (arena.offset(node.inputs[1]) / 4) as u32,
6084                        out_off: 0, // unused by the partial kernel
6085                        eps_bits: eps.to_bits(),
6086                        scratch_off: scratch_off_words,
6087                    };
6088                    let reduce_params = LayerNormBwdParams {
6089                        // `outer` for the reduce kernel carries the
6090                        // number of partial chunks we just emitted.
6091                        outer: num_workgroups,
6092                        inner: h,
6093                        x_off: 0,
6094                        gamma_off: 0,
6095                        dy_off: 0,
6096                        out_off: (arena.offset(node.id) / 4) as u32,
6097                        eps_bits: eps.to_bits(),
6098                        scratch_off: scratch_off_words,
6099                    };
6100                    let p_k = layer_norm_backward_gamma_partial_kernel(&dev.device);
6101                    let r_k = layer_norm_backward_gamma_reduce_kernel(&dev.device);
6102                    let p_u = emit_uniform(std::mem::size_of::<LayerNormBwdParams>());
6103                    let r_u = emit_uniform(std::mem::size_of::<LayerNormBwdParams>());
6104                    let p_bg = bind_op_output_window(&dev.device, p_k, &arena, node.id, &p_u);
6105                    let r_bg = bind_op_output_window(&dev.device, r_k, &arena, node.id, &r_u);
6106                    schedule.push(Step::LayerNormBackwardGammaPartial {
6107                        params: partial_params,
6108                        num_workgroups,
6109                    });
6110                    schedule.push(Step::LayerNormBackwardGammaReduce {
6111                        params: reduce_params,
6112                    });
6113                    uniforms.push(p_u);
6114                    uniforms.push(r_u);
6115                    bind_groups.push(p_bg);
6116                    bind_groups.push(r_bg);
6117                }
6118                Op::RopeBackward { head_dim, n_rot } => {
6119                    let dy_shape = &graph.node(node.inputs[0]).shape;
6120                    let (batch, seq, hidden) = if dy_shape.rank() >= 3 {
6121                        (
6122                            dy_shape.dim(0).unwrap_static() as u32,
6123                            dy_shape.dim(1).unwrap_static() as u32,
6124                            dy_shape.dim(2).unwrap_static() as u32,
6125                        )
6126                    } else {
6127                        (
6128                            1,
6129                            dy_shape.dim(0).unwrap_static() as u32,
6130                            dy_shape.dim(1).unwrap_static() as u32,
6131                        )
6132                    };
6133                    let cos_len = graph.node(node.inputs[1]).shape.num_elements().unwrap() as u32;
6134                    let p = RopeBwdParams {
6135                        batch,
6136                        seq,
6137                        hidden,
6138                        head_dim: *head_dim as u32,
6139                        n_rot: *n_rot as u32,
6140                        dy_off: (arena.offset(node.inputs[0]) / 4) as u32,
6141                        cos_off: (arena.offset(node.inputs[1]) / 4) as u32,
6142                        sin_off: (arena.offset(node.inputs[2]) / 4) as u32,
6143                        dx_off: (arena.offset(node.id) / 4) as u32,
6144                        cos_len,
6145                    };
6146                    let rk = rope_backward_kernel(&dev.device);
6147                    let u = emit_uniform(std::mem::size_of::<RopeBwdParams>());
6148                    let bg = bind_op_output_window(&dev.device, rk, &arena, node.id, &u);
6149                    schedule.push(Step::RopeBackward { params: p });
6150                    uniforms.push(u);
6151                    bind_groups.push(bg);
6152                }
6153                Op::CumsumBackward { exclusive, .. } => {
6154                    let dy_shape = &graph.node(node.inputs[0]).shape;
6155                    let cols = dy_shape.dim(dy_shape.rank() - 1).unwrap_static() as u32;
6156                    let rows = (dy_shape.num_elements().unwrap() / cols.max(1) as usize) as u32;
6157                    let p = CumsumBwdParams {
6158                        outer: rows,
6159                        inner: cols,
6160                        dy_off: (arena.offset(node.inputs[0]) / 4) as u32,
6161                        dx_off: (arena.offset(node.id) / 4) as u32,
6162                        exclusive: if *exclusive { 1 } else { 0 },
6163                        _p0: 0,
6164                        _p1: 0,
6165                        _p2: 0,
6166                    };
6167                    let ck = cumsum_backward_kernel(&dev.device);
6168                    let u = emit_uniform(std::mem::size_of::<CumsumBwdParams>());
6169                    let bg = bind_op_output_window(&dev.device, ck, &arena, node.id, &u);
6170                    schedule.push(Step::CumsumBackward { params: p });
6171                    uniforms.push(u);
6172                    bind_groups.push(bg);
6173                }
6174                Op::GatherBackward { .. } => {
6175                    let dy_shape = &graph.node(node.inputs[0]).shape;
6176                    let idx_shape = &graph.node(node.inputs[1]).shape;
6177                    let out_shape = &node.shape;
6178                    let rank = out_shape.rank();
6179                    let axis = match &node.op {
6180                        Op::GatherBackward { axis } => *axis,
6181                        _ => 0,
6182                    };
6183                    let axis_u = if axis < 0 {
6184                        (rank as i32 + axis) as usize
6185                    } else {
6186                        axis as usize
6187                    };
6188                    let outer: usize = (0..axis_u)
6189                        .map(|i| dy_shape.dim(i).unwrap_static())
6190                        .product::<usize>()
6191                        .max(1);
6192                    let num_idx = idx_shape.dim(axis_u).unwrap_static();
6193                    let trailing: usize = (axis_u + 1..dy_shape.rank())
6194                        .map(|i| dy_shape.dim(i).unwrap_static())
6195                        .product::<usize>()
6196                        .max(1);
6197                    let axis_dim = out_shape.dim(axis_u).unwrap_static();
6198                    let p = GatherBwdParams {
6199                        outer: outer as u32,
6200                        axis_dim: axis_dim as u32,
6201                        num_idx: num_idx as u32,
6202                        trailing: trailing as u32,
6203                        dy_off: (arena.offset(node.inputs[0]) / 4) as u32,
6204                        idx_off: (arena.offset(node.inputs[1]) / 4) as u32,
6205                        dst_off: (arena.offset(node.id) / 4) as u32,
6206                        _p0: 0,
6207                    };
6208                    let zk = gather_backward_zero_kernel(&dev.device);
6209                    let u = emit_uniform(std::mem::size_of::<GatherBwdParams>());
6210                    let bg = bind_op_output_window(&dev.device, zk, &arena, node.id, &u);
6211                    schedule.push(Step::GatherBackward { params: p });
6212                    uniforms.push(u);
6213                    bind_groups.push(bg);
6214                }
6215                #[cfg(feature = "splat")]
6216                Op::GaussianSplatRender {
6217                    width,
6218                    height,
6219                    tile_size,
6220                    radius_scale,
6221                    alpha_cutoff,
6222                    max_splat_steps,
6223                    transmittance_threshold,
6224                    max_list_entries,
6225                } => {
6226                    let elem_len = |id: NodeId| -> u32 {
6227                        graph.node(id).shape.num_elements().unwrap_or(0) as u32
6228                    };
6229                    schedule.push(Step::GaussianSplatRender {
6230                        positions_byte_off: arena.offset(node.inputs[0]) as u32,
6231                        positions_len: elem_len(node.inputs[0]),
6232                        scales_byte_off: arena.offset(node.inputs[1]) as u32,
6233                        scales_len: elem_len(node.inputs[1]),
6234                        rotations_byte_off: arena.offset(node.inputs[2]) as u32,
6235                        rotations_len: elem_len(node.inputs[2]),
6236                        opacities_byte_off: arena.offset(node.inputs[3]) as u32,
6237                        opacities_len: elem_len(node.inputs[3]),
6238                        colors_byte_off: arena.offset(node.inputs[4]) as u32,
6239                        colors_len: elem_len(node.inputs[4]),
6240                        sh_coeffs_byte_off: arena.offset(node.inputs[5]) as u32,
6241                        sh_coeffs_len: elem_len(node.inputs[5]),
6242                        meta_byte_off: arena.offset(node.inputs[6]) as u32,
6243                        dst_byte_off: arena.offset(node.id) as u32,
6244                        dst_len: node.shape.num_elements().unwrap_or(0) as u32,
6245                        width: *width,
6246                        height: *height,
6247                        tile_size: *tile_size,
6248                        radius_scale: *radius_scale,
6249                        alpha_cutoff: *alpha_cutoff,
6250                        max_splat_steps: *max_splat_steps,
6251                        transmittance_threshold: *transmittance_threshold,
6252                        max_list_entries: *max_list_entries,
6253                    });
6254                }
6255
6256                #[cfg(feature = "splat")]
6257                Op::GaussianSplatRenderBackward {
6258                    width,
6259                    height,
6260                    tile_size,
6261                    radius_scale,
6262                    alpha_cutoff,
6263                    max_splat_steps,
6264                    transmittance_threshold,
6265                    max_list_entries,
6266                    loss_grad_clip,
6267                    sh_band,
6268                    max_anisotropy,
6269                } => {
6270                    let elem_len = |id: NodeId| -> u32 {
6271                        graph.node(id).shape.num_elements().unwrap_or(0) as u32
6272                    };
6273                    schedule.push(Step::GaussianSplatRenderBackward {
6274                        positions_byte_off: arena.offset(node.inputs[0]) as u32,
6275                        positions_len: elem_len(node.inputs[0]),
6276                        scales_byte_off: arena.offset(node.inputs[1]) as u32,
6277                        scales_len: elem_len(node.inputs[1]),
6278                        rotations_byte_off: arena.offset(node.inputs[2]) as u32,
6279                        rotations_len: elem_len(node.inputs[2]),
6280                        opacities_byte_off: arena.offset(node.inputs[3]) as u32,
6281                        opacities_len: elem_len(node.inputs[3]),
6282                        colors_byte_off: arena.offset(node.inputs[4]) as u32,
6283                        colors_len: elem_len(node.inputs[4]),
6284                        sh_coeffs_byte_off: arena.offset(node.inputs[5]) as u32,
6285                        sh_coeffs_len: elem_len(node.inputs[5]),
6286                        meta_byte_off: arena.offset(node.inputs[6]) as u32,
6287                        d_loss_byte_off: arena.offset(node.inputs[7]) as u32,
6288                        d_loss_len: elem_len(node.inputs[7]),
6289                        packed_byte_off: arena.offset(node.id) as u32,
6290                        packed_len: node.shape.num_elements().unwrap_or(0) as u32,
6291                        width: *width,
6292                        height: *height,
6293                        tile_size: *tile_size,
6294                        radius_scale: *radius_scale,
6295                        alpha_cutoff: *alpha_cutoff,
6296                        max_splat_steps: *max_splat_steps,
6297                        transmittance_threshold: *transmittance_threshold,
6298                        max_list_entries: *max_list_entries,
6299                        loss_grad_clip: *loss_grad_clip,
6300                        sh_band: *sh_band,
6301                        max_anisotropy: *max_anisotropy,
6302                    });
6303                }
6304
6305                #[cfg(feature = "splat")]
6306                Op::GaussianSplatPrepare {
6307                    width,
6308                    height,
6309                    tile_size,
6310                    radius_scale,
6311                    alpha_cutoff,
6312                    max_splat_steps,
6313                    transmittance_threshold,
6314                    max_list_entries,
6315                } => {
6316                    let elem_len = |id: NodeId| -> u32 {
6317                        graph.node(id).shape.num_elements().unwrap_or(0) as u32
6318                    };
6319                    schedule.push(Step::GaussianSplatPrepare {
6320                        positions_byte_off: arena.offset(node.inputs[0]) as u32,
6321                        positions_len: elem_len(node.inputs[0]),
6322                        scales_byte_off: arena.offset(node.inputs[1]) as u32,
6323                        scales_len: elem_len(node.inputs[1]),
6324                        rotations_byte_off: arena.offset(node.inputs[2]) as u32,
6325                        rotations_len: elem_len(node.inputs[2]),
6326                        opacities_byte_off: arena.offset(node.inputs[3]) as u32,
6327                        opacities_len: elem_len(node.inputs[3]),
6328                        colors_byte_off: arena.offset(node.inputs[4]) as u32,
6329                        colors_len: elem_len(node.inputs[4]),
6330                        sh_coeffs_byte_off: arena.offset(node.inputs[5]) as u32,
6331                        sh_coeffs_len: elem_len(node.inputs[5]),
6332                        meta_byte_off: arena.offset(node.inputs[6]) as u32,
6333                        meta_len: elem_len(node.inputs[6]),
6334                        prep_byte_off: arena.offset(node.id) as u32,
6335                        prep_len: node.shape.num_elements().unwrap_or(0) as u32,
6336                        width: *width,
6337                        height: *height,
6338                        tile_size: *tile_size,
6339                        radius_scale: *radius_scale,
6340                        alpha_cutoff: *alpha_cutoff,
6341                        max_splat_steps: *max_splat_steps,
6342                        transmittance_threshold: *transmittance_threshold,
6343                        max_list_entries: *max_list_entries,
6344                    });
6345                }
6346
6347                #[cfg(feature = "splat")]
6348                Op::GaussianSplatRasterize {
6349                    width,
6350                    height,
6351                    tile_size,
6352                    alpha_cutoff,
6353                    max_splat_steps,
6354                    transmittance_threshold,
6355                    max_list_entries,
6356                } => {
6357                    let elem_len = |id: NodeId| -> u32 {
6358                        graph.node(id).shape.num_elements().unwrap_or(0) as u32
6359                    };
6360                    let prep_id = node.inputs[0];
6361                    let count = match &graph.node(prep_id).op {
6362                        rlx_ir::Op::GaussianSplatPrepare { .. } => {
6363                            elem_len(graph.node(prep_id).inputs[0]) / 3
6364                        }
6365                        _ => 1,
6366                    };
6367                    schedule.push(Step::GaussianSplatRasterize {
6368                        prep_byte_off: arena.offset(prep_id) as u32,
6369                        prep_len: elem_len(prep_id),
6370                        meta_byte_off: arena.offset(node.inputs[1]) as u32,
6371                        meta_len: elem_len(node.inputs[1]),
6372                        dst_byte_off: arena.offset(node.id) as u32,
6373                        dst_len: node.shape.num_elements().unwrap_or(0) as u32,
6374                        count,
6375                        width: *width,
6376                        height: *height,
6377                        tile_size: *tile_size,
6378                        alpha_cutoff: *alpha_cutoff,
6379                        max_splat_steps: *max_splat_steps,
6380                        transmittance_threshold: *transmittance_threshold,
6381                        max_list_entries: *max_list_entries,
6382                    });
6383                }
6384
6385                Op::If { .. } | Op::While { .. } => {
6386                    // Should be unreachable: unfuse.rs inlines both branches
6387                    // (If) or unrolls max_iterations (While) into the parent
6388                    // graph using primitive ops + Where for the gating. If
6389                    // we hit this arm, the unfusion pass has a gap.
6390                    panic!(
6391                        "rlx-wgpu: Op::If/While leaked past unfusion pass — \
6392                            check unfuse.rs::expand_if / expand_while"
6393                    );
6394                }
6395                Op::RngNormal {
6396                    mean,
6397                    scale,
6398                    key,
6399                    op_seed,
6400                } => {
6401                    let len = node.shape.num_elements().unwrap_or(0) as u32;
6402                    schedule.push(Step::RngNormalHost {
6403                        dst_byte_off: arena.offset(node.id) as u32,
6404                        len,
6405                        mean: *mean,
6406                        scale: *scale,
6407                        key: *key,
6408                        op_seed: *op_seed,
6409                    });
6410                }
6411                Op::RngUniform {
6412                    low,
6413                    high,
6414                    key,
6415                    op_seed,
6416                } => {
6417                    let len = node.shape.num_elements().unwrap_or(0) as u32;
6418                    schedule.push(Step::RngUniformHost {
6419                        dst_byte_off: arena.offset(node.id) as u32,
6420                        len,
6421                        low: *low,
6422                        high: *high,
6423                        key: *key,
6424                        op_seed: *op_seed,
6425                    });
6426                }
6427                // Standalone nearest 2× upsample: the region-marking pass wraps
6428                // a bare `Op::ResizeNearest2x` into a single-step TransformRegion.
6429                // Route that to the host resize helper (mirrors the bare arm).
6430                Op::TransformRegion { steps, .. }
6431                    if steps.len() == 1
6432                        && matches!(
6433                            steps[0],
6434                            rlx_ir::op::TransformStep::ResizeNearest2x(
6435                                rlx_ir::op::ChainOperand::Input(0)
6436                            )
6437                        ) =>
6438                {
6439                    let in_shape = &graph.node(node.inputs[0]).shape;
6440                    schedule.push(Step::ResizeNearest2xHost {
6441                        src_byte_off: arena.offset(node.inputs[0]) as u32,
6442                        dst_byte_off: arena.offset(node.id) as u32,
6443                        n: in_shape.dim(0).unwrap_static() as u32,
6444                        c: in_shape.dim(1).unwrap_static() as u32,
6445                        h: in_shape.dim(2).unwrap_static() as u32,
6446                        w: in_shape.dim(3).unwrap_static() as u32,
6447                    });
6448                }
6449                other => panic!(
6450                    "rlx-wgpu: op {other:?} not yet lowered (v2 covers Matmul, \
6451                     Binary, Compare, Activation, Where — fall back to CPU/Metal/MLX)"
6452                ),
6453            }
6454        }
6455
6456        if rlx_ir::env::flag("RLX_WGPU_SCHEDULE") || rlx_ir::env::flag("RLX_DISPATCH_REPORT") {
6457            let mut counts: std::collections::BTreeMap<&'static str, usize> =
6458                std::collections::BTreeMap::new();
6459            let mut fft_gpu = 0usize;
6460            let mut fft_host = 0usize;
6461            for s in &schedule {
6462                *counts.entry(step_name(s)).or_insert(0) += 1;
6463                match s {
6464                    Step::FftGpu { .. } => fft_gpu += 1,
6465                    Step::FftHost { .. } => fft_host += 1,
6466                    _ => {}
6467                }
6468            }
6469            let arena_mb = arena.size as f64 / (1u64 << 20) as f64;
6470            eprintln!(
6471                "[rlx-wgpu] schedule: {} steps, arena={arena_mb:.1} MiB, fft_gpu={fft_gpu}, fft_host={fft_host}",
6472                schedule.len()
6473            );
6474            for (n, c) in &counts {
6475                eprintln!("    {c:>4} × {n}");
6476            }
6477        }
6478
6479        let coop_f16_vk = schedule_uses_coop_f16_vk(&schedule);
6480
6481        Self {
6482            graph,
6483            arena,
6484            dequant_scratch_off,
6485            schedule,
6486            input_offsets,
6487            param_offsets,
6488            uniforms,
6489            bind_groups,
6490            meta_buffers,
6491            unresolved: None,
6492            last_binding: None,
6493            pending_params: HashMap::new(),
6494            pending_param_bytes: HashMap::new(),
6495            active_extent: None,
6496            uniforms_active_extent: None,
6497            input_staging_hashes: HashMap::new(),
6498            coop_f16_vk,
6499            coop_f16_b_param,
6500            coop_f16_vk_wide_b: HashSet::new(),
6501            coop_f16_vk_wide_bind_groups,
6502            coop_f16_host_activations,
6503            stashed_params: HashMap::new(),
6504            readback_staging: None,
6505            tiny_readback: None,
6506            dispatch_only: false,
6507            fft_gpu_steps,
6508            gpu_handles: HashMap::new(),
6509            gpu_handle_feeds: HashMap::new(),
6510            gpu_handle_resident: HashSet::new(),
6511            pending_read_indices: None,
6512            rng,
6513        }
6514    }
6515
6516    pub fn set_param(&mut self, name: &str, data: &[f32]) {
6517        const STASH_MAX_BYTES: usize = 16 * 1024 * 1024;
6518        if data.len() * 4 <= STASH_MAX_BYTES {
6519            self.stashed_params.insert(name.to_string(), data.to_vec());
6520        }
6521        if self.coop_f16_vk {
6522            crate::coop_f16_vk::refresh_wide_b_flag(&mut self.coop_f16_vk_wide_b, name, data);
6523        }
6524        if self.unresolved.is_some() {
6525            self.pending_params.insert(name.to_string(), data.to_vec());
6526            return;
6527        }
6528        let dev = wgpu_device().expect("rlx-wgpu: device gone");
6529        if let Some(&id) = self.param_offsets.get(name)
6530            && self.arena.has(id)
6531        {
6532            self.arena.write_f32(&dev.queue, id, data);
6533        }
6534    }
6535
6536    /// Debug helper: run forward, then read every node slot back and
6537    /// report the first node whose output contains a NaN, plus a
6538    /// summary of the *previous* finite node's value range so the
6539    /// caller can see the input that broke. Slow — diagnosis only.
6540    pub fn debug_first_nan_node(
6541        &mut self,
6542        inputs: &[(&str, &[f32])],
6543    ) -> Option<(usize, String, String)> {
6544        let _ = self.run(inputs);
6545        let dev = wgpu_device().expect("rlx-wgpu: device gone");
6546        let mut prev_summary = String::from("(none)");
6547        for (i, node) in self.graph.nodes().iter().enumerate() {
6548            if !self.arena.has(node.id) {
6549                continue;
6550            }
6551            let elems = node.shape.num_elements().unwrap_or(0);
6552            if elems == 0 {
6553                continue;
6554            }
6555            let data = self.arena.read_f32(&dev.device, &dev.queue, node.id);
6556            let nan_count = data.iter().filter(|v| v.is_nan()).count();
6557            let inf_count = data.iter().filter(|v| v.is_infinite()).count();
6558            if nan_count > 0 || inf_count > 0 {
6559                return Some((i, format!("{:?}", node.op), prev_summary));
6560            }
6561            let max = data.iter().copied().fold(f32::NEG_INFINITY, f32::max);
6562            let min = data.iter().copied().fold(f32::INFINITY, f32::min);
6563            let abs_max = data.iter().map(|v| v.abs()).fold(0.0_f32, f32::max);
6564            prev_summary = format!(
6565                "node #{i} {:?} shape={:?}  min={min:.6e} max={max:.6e} |max|={abs_max:.6e}",
6566                node.op,
6567                node.shape
6568                    .dims()
6569                    .iter()
6570                    .map(|d| format!("{d:?}"))
6571                    .collect::<Vec<_>>()
6572            );
6573        }
6574        None
6575    }
6576
6577    /// Declared output dtypes (one per graph output). Used by the
6578    /// runtime wrapper's `run_typed` to narrow F32 results back to
6579    /// F16/BF16 etc. on the way out.
6580    pub fn output_dtypes(&self) -> Vec<rlx_ir::DType> {
6581        self.graph
6582            .outputs
6583            .iter()
6584            .map(|&id| self.graph.node(id).shape.dtype())
6585            .collect()
6586    }
6587
6588    /// Upload raw bytes for a Param. The bytes land tight-packed at
6589    /// the param's slot offset — no f32 round-trip. Used for quantized
6590    /// weights (int8 / int4) where the kernel reads the byte stream
6591    /// via `bitcast<u32>` from the f32-typed arena.
6592    pub fn set_param_bytes(&mut self, name: &str, data: &[u8]) {
6593        if self.unresolved.is_some() {
6594            self.pending_param_bytes
6595                .insert(name.to_string(), data.to_vec());
6596            return;
6597        }
6598        let dev = wgpu_device().expect("rlx-wgpu: device gone");
6599        if let Some(&id) = self.param_offsets.get(name)
6600            && self.arena.has(id)
6601        {
6602            dev.queue
6603                .write_buffer(&self.arena.buffer, self.arena.offset(id) as u64, data);
6604        }
6605    }
6606
6607    fn dump_node_stats_if_requested(&self, dev: &crate::device::WgpuDevice) {
6608        if !rlx_ir::env::flag("RLX_WGPU_DUMP_NODES") {
6609            return;
6610        }
6611        let flat_probe = rlx_ir::env::parse_or::<usize>("RLX_WGPU_DUMP_FLAT", usize::MAX);
6612        let limit = rlx_ir::env::parse_or("RLX_WGPU_DUMP_NODES_LIMIT", 40usize);
6613        eprintln!(
6614            "[rlx-wgpu-dump] per-node max |x| (topo order, limit={limit}{})",
6615            if flat_probe != usize::MAX {
6616                format!(", flat[{flat_probe}]")
6617            } else {
6618                String::new()
6619            }
6620        );
6621        let mut shown = 0usize;
6622        for (i, node) in self.graph.nodes().iter().enumerate() {
6623            if !self.arena.has(node.id) {
6624                continue;
6625            }
6626            if matches!(
6627                node.op,
6628                rlx_ir::Op::Input { .. }
6629                    | rlx_ir::Op::Param { .. }
6630                    | rlx_ir::Op::Constant { .. }
6631                    | rlx_ir::Op::Reshape { .. }
6632                    | rlx_ir::Op::Cast { .. }
6633            ) {
6634                continue;
6635            }
6636            let data = self.arena.read_f32(&dev.device, &dev.queue, node.id);
6637            let max = data.iter().fold(0.0f32, |m, &v| m.max(v.abs()));
6638            let nz = data.iter().filter(|&&v| v != 0.0).count();
6639            let flat_s = if flat_probe < data.len() {
6640                format!(" flat[{flat_probe}]={:.6}", data[flat_probe])
6641            } else {
6642                String::new()
6643            };
6644            eprintln!(
6645                "  [{i:>3}] {:?} max={max:.6} nonzero={}/{}{flat_s}",
6646                node.op,
6647                nz,
6648                data.len()
6649            );
6650            shown += 1;
6651            if shown >= limit {
6652                break;
6653            }
6654        }
6655    }
6656
6657    pub fn run(&mut self, inputs: &[(&str, &[f32])]) -> Vec<Vec<f32>> {
6658        self.run_read_outputs(inputs, None)
6659    }
6660
6661    pub fn run_read_outputs(
6662        &mut self,
6663        inputs: &[(&str, &[f32])],
6664        read_indices: Option<&[usize]>,
6665    ) -> Vec<Vec<f32>> {
6666        self.pending_read_indices = read_indices.map(|s| s.to_vec());
6667        let outs = self.run_inner(inputs);
6668        self.pending_read_indices = None;
6669        outs
6670    }
6671
6672    /// Async sibling of [`Self::run`] for the browser, where GPU→CPU readback
6673    /// cannot block the event loop. All compute is dispatched + submitted
6674    /// synchronously (via the normal `run_inner` in dispatch-only mode); the
6675    /// outputs are then read back from the arena asynchronously.
6676    ///
6677    /// Supports pure feed-forward graphs only — graphs with host-executed ops
6678    /// (GGUF dequant, LSTM/GRU, GatedDeltaNet, FFT-host, …) map intermediate
6679    /// results back mid-graph, which would block the browser. Such graphs
6680    /// panic with a clear message rather than hang.
6681    #[cfg(target_arch = "wasm32")]
6682    pub async fn run_async(&mut self, inputs: &[(&str, &[f32])]) -> Vec<Vec<f32>> {
6683        assert!(
6684            !self
6685                .schedule
6686                .iter()
6687                .any(|s| step_runs_on_host(s) || step_is_tail_host(s)),
6688            "rlx-wgpu run_async: graph contains host-executed ops unsupported on wasm"
6689        );
6690
6691        // 1. Dispatch + submit all compute, skipping the blocking readback.
6692        self.dispatch_only = true;
6693        let _ = self.run_inner(inputs);
6694        self.dispatch_only = false;
6695
6696        let dev =
6697            wgpu_device().expect("rlx-wgpu: device not initialized (call init_wgpu_device first)");
6698
6699        // 2. Read the selected outputs back from the arena asynchronously.
6700        let plan = self.readback_plan();
6701        let out_ids_all: Vec<_> = self.graph.outputs.clone();
6702        let out_ids: Vec<_> = plan.iter().map(|&i| out_ids_all[i]).collect();
6703        let layout = ReadbackLayout::for_nodes(&self.arena, &out_ids);
6704        if layout.regions.is_empty() {
6705            return Vec::new();
6706        }
6707        ReadbackStaging::prepare(&dev.device, &mut self.readback_staging, layout.total_bytes);
6708        let staging_buf = self
6709            .readback_staging
6710            .as_ref()
6711            .expect("readback staging")
6712            .buffer()
6713            .clone();
6714
6715        let mut enc = dev
6716            .device
6717            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
6718                label: Some("rlx-wgpu async readback"),
6719            });
6720        encode_readback_copies(&mut enc, &self.arena, &staging_buf, &out_ids, &layout);
6721        dev.queue.submit(std::iter::once(enc.finish()));
6722
6723        crate::buffer::wasm_async::map_read_async(&staging_buf, layout.total_bytes)
6724            .await
6725            .expect("rlx-wgpu async buffer map failed");
6726
6727        let partial = decode_mapped_readback_f32(&staging_buf, &layout);
6728        self.pack_readback_outputs(&plan, partial)
6729    }
6730
6731    pub fn bind_gpu_handle(&mut self, name: &str, data: &[f32]) -> bool {
6732        if !self.input_offsets.contains_key(name) {
6733            return false;
6734        }
6735        self.gpu_handle_resident.remove(name);
6736        self.gpu_handles.insert(name.to_string(), data.to_vec());
6737        true
6738    }
6739
6740    pub fn has_gpu_handle(&self, name: &str) -> bool {
6741        self.gpu_handles.contains_key(name)
6742    }
6743
6744    pub fn set_gpu_handle_feed(&mut self, handle_name: &str, output_index: usize) {
6745        self.gpu_handle_feeds
6746            .insert(handle_name.to_string(), output_index);
6747    }
6748
6749    pub fn read_gpu_handle(&self, name: &str) -> Option<Vec<f32>> {
6750        if let Some(&out_idx) = self.gpu_handle_feeds.get(name) {
6751            if out_idx < self.graph.outputs.len() {
6752                let id = self.graph.outputs[out_idx];
6753                if self.arena.has(id) {
6754                    let dev = wgpu_device().expect("rlx-wgpu: device gone");
6755                    return Some(self.arena.read_f32(&dev.device, &dev.queue, id));
6756                }
6757            }
6758        }
6759        if self.gpu_handle_resident.contains(name) {
6760            if let Some(&id) = self.input_offsets.get(name) {
6761                if self.arena.has(id) {
6762                    let dev = wgpu_device().expect("rlx-wgpu: device gone");
6763                    return Some(self.arena.read_f32(&dev.device, &dev.queue, id));
6764                }
6765            }
6766        }
6767        self.gpu_handles.get(name).cloned()
6768    }
6769
6770    /// Clone into an independent executable (recompiles from the stored graph).
6771    pub fn clone_for_cache(&self) -> Self {
6772        let graph = self
6773            .unresolved
6774            .clone()
6775            .unwrap_or_else(|| self.graph.clone());
6776        let mut exe = Self::compile_rng(graph, self.rng());
6777        for (k, v) in &self.stashed_params {
6778            exe.set_param(k, v);
6779        }
6780        for (k, v) in &self.pending_params {
6781            exe.set_param(k, v);
6782        }
6783        for (k, v) in &self.pending_param_bytes {
6784            exe.set_param_bytes(k, v);
6785        }
6786        for (k, v) in &self.gpu_handles {
6787            exe.bind_gpu_handle(k, v);
6788        }
6789        for (k, &idx) in &self.gpu_handle_feeds {
6790            exe.set_gpu_handle_feed(k, idx);
6791        }
6792        exe.set_active_extent(self.active_extent);
6793        exe.set_rng(self.rng());
6794        exe
6795    }
6796
6797    fn readback_plan(&self) -> Vec<usize> {
6798        let n = self.graph.outputs.len();
6799        if self.pending_read_indices.is_none() && self.gpu_handle_feeds.is_empty() {
6800            return (0..n).collect();
6801        }
6802        if let Some(ref want) = self.pending_read_indices {
6803            let mut v: Vec<_> = want.to_vec();
6804            v.sort_unstable();
6805            return v;
6806        }
6807        (0..n).collect()
6808    }
6809
6810    fn propagate_gpu_handle_feeds_on_gpu(
6811        &mut self,
6812        dev: &crate::device::WgpuDevice,
6813        enc: &mut wgpu::CommandEncoder,
6814    ) {
6815        let extent = self.active_extent;
6816        let feeds: Vec<(String, usize)> = self
6817            .gpu_handle_feeds
6818            .iter()
6819            .map(|(n, &i)| (n.clone(), i))
6820            .collect();
6821        for (name, out_idx) in feeds {
6822            if out_idx >= self.graph.outputs.len() {
6823                continue;
6824            }
6825            let out_id = self.graph.outputs[out_idx];
6826            let Some(&in_id) = self.input_offsets.get(name.as_str()) else {
6827                continue;
6828            };
6829            if in_id != out_id {
6830                let out_bytes = self.arena.len_of(out_id);
6831                let copy_bytes = match extent {
6832                    Some((actual, upper)) if upper > 0 => {
6833                        let stride = (out_bytes / (upper + 1)).max(4);
6834                        (actual * stride).min(out_bytes)
6835                    }
6836                    _ => out_bytes,
6837                };
6838                self.dispatch_arena_copy_bytes(dev, enc, out_id, in_id, copy_bytes);
6839            }
6840            self.gpu_handle_resident.insert(name.clone());
6841            self.gpu_handles.insert(name.clone(), Vec::new());
6842        }
6843    }
6844
6845    fn dispatch_arena_copy_bytes(
6846        &self,
6847        dev: &crate::device::WgpuDevice,
6848        enc: &mut wgpu::CommandEncoder,
6849        src_id: NodeId,
6850        dst_id: NodeId,
6851        nbytes: usize,
6852    ) {
6853        if nbytes == 0 {
6854            return;
6855        }
6856        let src = self.arena.offset(src_id) as u64;
6857        let dst = self.arena.offset(dst_id) as u64;
6858        let nbytes = nbytes
6859            .min(self.arena.len_of(src_id))
6860            .min(self.arena.len_of(dst_id)) as u64;
6861        let elems = (nbytes / 4).max(1) as u32;
6862        let lo = src.min(dst);
6863        let hi = src.saturating_add(nbytes).max(dst.saturating_add(nbytes));
6864        let max_binding = dev.device.limits().max_storage_buffer_binding_size;
6865        let mut size = hi.saturating_sub(lo).div_ceil(256) * 256;
6866        size = size.max(256).min(max_binding);
6867        let mut base = (lo / 256) * 256;
6868        if base.saturating_add(size) > self.arena.size as u64 {
6869            base = (self.arena.size as u64).saturating_sub(size);
6870            base = (base / 256) * 256;
6871        }
6872        let p = CopyParams {
6873            n: elems,
6874            in_off: (src.saturating_sub(base) / 4) as u32,
6875            out_off: (dst.saturating_sub(base) / 4) as u32,
6876            _p0: 0,
6877            _p1: 0,
6878            _p2: 0,
6879            _p3: 0,
6880            _p4: 0,
6881        };
6882        let ck = copy_kernel(&dev.device);
6883        let u = dev.device.create_buffer(&wgpu::BufferDescriptor {
6884            label: Some("rlx-wgpu kv_feed_copy uniform"),
6885            size: std::mem::size_of::<CopyParams>() as u64,
6886            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
6887            mapped_at_creation: false,
6888        });
6889        dev.queue.write_buffer(&u, 0, bytemuck::bytes_of(&p));
6890        let bg = bind_two_buf0_window(&dev.device, ck, &self.arena.buffer, base, size, &u);
6891        let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
6892            label: Some("rlx-wgpu kv_feed_copy pass"),
6893            ..Default::default()
6894        });
6895        pass.set_pipeline(&ck.pipeline);
6896        pass.set_bind_group(0, &bg, &[]);
6897        let (gx, gy, gz) = dispatch_dims(elems, 64);
6898        pass.dispatch_workgroups(gx, gy, gz);
6899    }
6900
6901    #[allow(dead_code)]
6902    fn dispatch_arena_copy_between_nodes(
6903        &self,
6904        dev: &crate::device::WgpuDevice,
6905        enc: &mut wgpu::CommandEncoder,
6906        src_id: NodeId,
6907        dst_id: NodeId,
6908    ) {
6909        let nbytes = self.arena.len_of(src_id).min(self.arena.len_of(dst_id));
6910        self.dispatch_arena_copy_bytes(dev, enc, src_id, dst_id, nbytes);
6911    }
6912
6913    fn stage_gpu_handle_inputs(
6914        &mut self,
6915        dev: &crate::device::WgpuDevice,
6916        inputs: &[(&str, &[f32])],
6917    ) {
6918        for (name, data) in &self.gpu_handles {
6919            if self.gpu_handle_resident.contains(name) || inputs.iter().any(|(n, _)| n == name) {
6920                continue;
6921            }
6922            if let Some(&id) = self.input_offsets.get(name.as_str())
6923                && self.arena.has(id)
6924            {
6925                self.arena.write_f32(&dev.queue, id, data);
6926                self.input_staging_hashes.remove(name);
6927            }
6928        }
6929    }
6930
6931    fn pack_readback_outputs(&mut self, plan: &[usize], partial: Vec<Vec<f32>>) -> Vec<Vec<f32>> {
6932        if self.pending_read_indices.is_none() {
6933            for (pos, &out_i) in plan.iter().enumerate() {
6934                if let Some(data) = partial.get(pos) {
6935                    for (name, &feed_i) in &self.gpu_handle_feeds {
6936                        if feed_i == out_i {
6937                            self.gpu_handles.insert(name.clone(), data.clone());
6938                        }
6939                    }
6940                }
6941            }
6942        }
6943        if self.pending_read_indices.is_none() && plan.len() == self.graph.outputs.len() {
6944            return partial;
6945        }
6946        let want = self.pending_read_indices.as_deref().unwrap_or(plan);
6947        let mut by_idx = std::collections::HashMap::new();
6948        for (pos, &i) in plan.iter().enumerate() {
6949            if let Some(d) = partial.get(pos) {
6950                by_idx.insert(i, d.clone());
6951            }
6952        }
6953        want.iter()
6954            .map(|&i| {
6955                by_idx
6956                    .get(&i)
6957                    .cloned()
6958                    .expect("readback plan missing output")
6959            })
6960            .collect()
6961    }
6962
6963    fn run_tail_host_audio_ops(&self, dev: &crate::device::WgpuDevice) {
6964        if !self.schedule.iter().any(step_is_tail_host) {
6965            return;
6966        }
6967        for step in &self.schedule {
6968            if !step_is_tail_host(step) {
6969                continue;
6970            }
6971            match step {
6972                Step::WelchPeaksHost {
6973                    spec_byte_off,
6974                    dst_byte_off,
6975                    welch_batch,
6976                    n_fft,
6977                    n_segments,
6978                    k,
6979                } => {
6980                    crate::welch_peaks_host::run_welch_peaks(
6981                        &self.arena,
6982                        &dev.device,
6983                        &dev.queue,
6984                        *spec_byte_off as usize,
6985                        *dst_byte_off as usize,
6986                        *welch_batch as usize,
6987                        *n_fft as usize,
6988                        *n_segments as usize,
6989                        *k as usize,
6990                    );
6991                }
6992                Step::LogMelHost {
6993                    spec_byte_off,
6994                    filt_byte_off,
6995                    dst_byte_off,
6996                    outer,
6997                    n_fft,
6998                    n_bins,
6999                    n_mels,
7000                } => {
7001                    crate::log_mel_host::run_log_mel(
7002                        &self.arena,
7003                        &dev.device,
7004                        &dev.queue,
7005                        *spec_byte_off as usize,
7006                        *filt_byte_off as usize,
7007                        *dst_byte_off as usize,
7008                        *outer as usize,
7009                        *n_fft as usize,
7010                        *n_bins as usize,
7011                        *n_mels as usize,
7012                    );
7013                }
7014                Step::LogMelBackwardHost {
7015                    spec_byte_off,
7016                    filt_byte_off,
7017                    dy_byte_off,
7018                    dst_byte_off,
7019                    outer,
7020                    n_fft,
7021                    n_bins,
7022                    n_mels,
7023                } => {
7024                    crate::log_mel_host::run_log_mel_backward(
7025                        &self.arena,
7026                        &dev.device,
7027                        &dev.queue,
7028                        *spec_byte_off as usize,
7029                        *filt_byte_off as usize,
7030                        *dy_byte_off as usize,
7031                        *dst_byte_off as usize,
7032                        *outer as usize,
7033                        *n_fft as usize,
7034                        *n_bins as usize,
7035                        *n_mels as usize,
7036                    );
7037                }
7038                _ => {}
7039            }
7040        }
7041    }
7042
7043    fn run_inner(&mut self, inputs: &[(&str, &[f32])]) -> Vec<Vec<f32>> {
7044        // Lazy compile path: if we deferred compile waiting for shapes,
7045        // infer the binding from input data lengths now and compile.
7046        if self.unresolved.is_some() {
7047            self.lazy_compile_for_inputs(inputs);
7048        }
7049        let dev = wgpu_device().expect("rlx-wgpu: device gone");
7050        self.stage_gpu_handle_inputs(dev, inputs);
7051        let skip_input_upload =
7052            !rlx_ir::env::flag("RLX_WGPU_FORCE_INPUT_UPLOAD") && !self.coop_f16_vk;
7053        for &(name, data) in inputs {
7054            if let Some(&id) = self.input_offsets.get(name)
7055                && self.arena.has(id)
7056            {
7057                if skip_input_upload {
7058                    let h = hash_f32_input(data);
7059                    if self.input_staging_hashes.get(name) == Some(&h) {
7060                        if self.arena.f16_buffer.is_some() {
7061                            self.arena.write_f16_shadow(&dev.queue, id, data);
7062                        }
7063                        continue;
7064                    }
7065                    self.arena.write_f32(&dev.queue, id, data);
7066                    self.input_staging_hashes.insert(name.to_string(), h);
7067                } else {
7068                    self.arena.write_f32(&dev.queue, id, data);
7069                }
7070            }
7071        }
7072        for &(act_id, act, ref src_name) in &self.coop_f16_host_activations {
7073            let src =
7074                host_tensor_f32(src_name, inputs, &self.stashed_params).unwrap_or_else(|| {
7075                    panic!("rlx-wgpu CoopF16Vk host activation: missing tensor {src_name:?}")
7076                });
7077            let mirrored = apply_activation_host(act, src);
7078            self.arena.write_f32(&dev.queue, act_id, &mirrored);
7079        }
7080        if !self.coop_f16_host_activations.is_empty() {
7081            // Ensure host staging writes are visible before CoopF16Vk reads f16.
7082            let flush = dev
7083                .device
7084                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
7085                    label: Some("rlx-wgpu host mirror flush"),
7086                });
7087            dev.queue.submit(std::iter::once(flush.finish()));
7088            let _ = dev.device.poll(wgpu::PollType::wait_indefinitely());
7089        }
7090
7091        // Active-extent (PLAN L1): scale safe Steps' primary dim by
7092        // actual/upper. Used in BOTH the uniform-write loop (so the
7093        // kernel sees the scaled count) AND the dispatch loop (so the
7094        // workgroup grid is shrunk).
7095        let active = self.active_extent.filter(|_| self.all_safe_for_active());
7096        let scale = |full: u32| -> u32 {
7097            match active {
7098                Some((a, u)) if u > 0 => {
7099                    let f = full as usize;
7100                    (f * a).div_ceil(u).min(f) as u32
7101                }
7102                _ => full,
7103            }
7104        };
7105
7106        // Stage uniform writes — but skip the loop entirely when the
7107        // bytes already in the uniforms match this run's active extent.
7108        // BERT inference at fixed batch hits this path: 100+ tiny
7109        // queue.write_buffer calls (one per Step) collapse to zero,
7110        // saving milliseconds of staging-copy overhead.
7111        let need_uniform_writes = self.uniforms_active_extent != Some(active);
7112        if need_uniform_writes {
7113            let mut gpu_ui = 0usize;
7114            for step in self.schedule.iter() {
7115                if step_runs_on_host(step) {
7116                    continue;
7117                }
7118                match step {
7119                    Step::CastF32ToF16 { .. } => {
7120                        // Params are static for this step (offset+len), so the
7121                        // pre-pass write at compile time is sufficient. No
7122                        // active-extent scaling — len is the full element count.
7123                    }
7124                    Step::Matmul {
7125                        m,
7126                        k,
7127                        n,
7128                        a_off_f32,
7129                        b_off_f32,
7130                        c_off_f32,
7131                        batch,
7132                        a_batch_stride,
7133                        b_batch_stride,
7134                        c_batch_stride,
7135                        has_bias,
7136                        bias_off_f32,
7137                        act_id,
7138                        b_is_param: _,
7139                        compute_precision: _,
7140                    } => {
7141                        // PLAN L1 (safe at any batch — c_batch_stride is
7142                        // pre-baked at compile time at FULL m, so scaling
7143                        // params.m only changes per-thread bound checks).
7144                        let m_scaled = scale(*m);
7145                        let p = MatmulParams {
7146                            m: m_scaled,
7147                            k: *k,
7148                            n: *n,
7149                            a_off: *a_off_f32,
7150                            b_off: *b_off_f32,
7151                            c_off: *c_off_f32,
7152                            batch: *batch,
7153                            a_batch_stride: *a_batch_stride,
7154                            b_batch_stride: *b_batch_stride,
7155                            c_batch_stride: *c_batch_stride,
7156                            has_bias: *has_bias,
7157                            bias_off: *bias_off_f32,
7158                            act_id: *act_id,
7159                            _pad0: 0,
7160                            _pad1: 0,
7161                            _pad2: 0,
7162                        };
7163                        dev.queue
7164                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7165                    }
7166                    Step::Binary { params } | Step::Compare { params } => {
7167                        let mut p = *params;
7168                        p.n = scale(p.n);
7169                        dev.queue
7170                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7171                    }
7172                    Step::Unary { params, .. } => {
7173                        let mut p = *params;
7174                        p.n = scale(p.n);
7175                        dev.queue
7176                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7177                    }
7178                    Step::Where { params } => {
7179                        let mut p = *params;
7180                        p.n = scale(p.n);
7181                        dev.queue
7182                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7183                    }
7184                    Step::Fma { params } => {
7185                        let mut p = *params;
7186                        p.n = scale(p.n);
7187                        dev.queue
7188                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7189                    }
7190                    Step::Reduce { params } => {
7191                        let mut p = *params;
7192                        p.outer = scale(p.outer);
7193                        dev.queue
7194                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7195                    }
7196                    Step::Softmax { params } => {
7197                        let mut p = *params;
7198                        p.outer = scale(p.outer);
7199                        dev.queue
7200                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7201                    }
7202                    Step::SoftmaxCrossEntropy { params } => {
7203                        let mut p = *params;
7204                        p.outer = scale(p.outer);
7205                        dev.queue
7206                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7207                    }
7208                    Step::LayerNorm { params } => {
7209                        let mut p = *params;
7210                        p.outer = scale(p.outer);
7211                        dev.queue
7212                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7213                    }
7214                    Step::RmsNormBackwardInput { params }
7215                    | Step::RmsNormBackwardGamma { params }
7216                    | Step::RmsNormBackwardBeta { params } => {
7217                        let mut p = *params;
7218                        p.outer = scale(p.outer);
7219                        dev.queue
7220                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7221                    }
7222                    Step::LayerNormBackwardInput { params } => {
7223                        let mut p = *params;
7224                        p.outer = scale(p.outer);
7225                        dev.queue
7226                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7227                    }
7228                    Step::LayerNormBackwardGammaPartial { params, .. } => {
7229                        let mut p = *params;
7230                        p.outer = scale(p.outer);
7231                        dev.queue
7232                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7233                    }
7234                    Step::LayerNormBackwardGammaReduce { params } => {
7235                        // `outer` here is the partial chunk count (not
7236                        // a batch dim) — do NOT apply active-extent
7237                        // scaling.
7238                        dev.queue.write_buffer(
7239                            &self.uniforms[gpu_ui],
7240                            0,
7241                            bytemuck::bytes_of(params),
7242                        );
7243                    }
7244                    Step::CumsumBackward { params } => {
7245                        let mut p = *params;
7246                        p.outer = scale(p.outer);
7247                        dev.queue
7248                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7249                    }
7250                    Step::RopeBackward { params } => {
7251                        let mut p = *params;
7252                        p.seq = scale(p.seq);
7253                        dev.queue
7254                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7255                    }
7256                    Step::GatherBackward { params } => {
7257                        let mut p = *params;
7258                        p.outer = scale(p.outer);
7259                        dev.queue
7260                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7261                    }
7262                    Step::Cumsum { params } => {
7263                        let mut p = *params;
7264                        p.outer = scale(p.outer);
7265                        dev.queue
7266                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7267                    }
7268                    Step::FftGpu { .. } => {}
7269                    Step::Copy { params } => {
7270                        let mut p = *params;
7271                        p.n = scale(p.n);
7272                        dev.queue
7273                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7274                    }
7275                    Step::BufferCopy { .. } => {}
7276                    Step::ElementwiseRegion { params } => {
7277                        // Active-extent: scale element count.
7278                        let mut p = *params;
7279                        p.len = scale(p.len);
7280                        dev.queue
7281                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7282                    }
7283                    Step::BatchElementwiseRegion { params } => {
7284                        let mut p = *params;
7285                        p.slice_len = scale(p.slice_len);
7286                        p.num_batch = scale(p.num_batch);
7287                        dev.queue
7288                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7289                    }
7290                    Step::Transpose { params, .. } => {
7291                        // PLAN L1: when bucket_outermost == 1, scale
7292                        // `out_total` proportional to scaling `out_dim_0`.
7293                        // Other transposes leave out_total at full extent
7294                        // (predicate prevents the active-extent path).
7295                        let mut p = *params;
7296                        if p.bucket_outermost == 1 && p.out_dim_0 > 0 {
7297                            let scaled_d0 = scale(p.out_dim_0);
7298                            let inner = p.out_total / p.out_dim_0;
7299                            p.out_total = scaled_d0 * inner;
7300                        }
7301                        dev.queue
7302                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7303                    }
7304                    Step::Narrow { params } => {
7305                        let mut p = *params;
7306                        p.total = scale(p.total);
7307                        dev.queue
7308                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7309                    }
7310                    Step::Concat { params } => {
7311                        let mut p = *params;
7312                        p.total = scale(p.total);
7313                        dev.queue
7314                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7315                    }
7316                    Step::Gather { params } => {
7317                        let mut p = *params;
7318                        p.n_out = scale(p.n_out);
7319                        dev.queue
7320                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7321                    }
7322                    Step::GatherAxis { params } => {
7323                        let mut p = *params;
7324                        p.total = scale(p.total);
7325                        dev.queue
7326                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7327                    }
7328                    Step::Attention { params, .. } => {
7329                        // PLAN L1: scale seq_q + seq_k. Stride fields
7330                        // (seq_q_stride / seq_k_stride) stay at the
7331                        // compile-time full extent, so per-(batch, head)
7332                        // offset math in the WGSL stays correct.
7333                        let mut p = *params;
7334                        p.seq_q = scale(p.seq_q);
7335                        p.seq_k = scale(p.seq_k);
7336                        dev.queue
7337                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7338                    }
7339                    Step::AttentionBackward { params, .. } => {
7340                        let mut p = *params;
7341                        if p.wrt == 0 {
7342                            p.seq_q = scale(p.seq_q);
7343                        } else {
7344                            p.seq_k = scale(p.seq_k);
7345                        }
7346                        dev.queue
7347                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7348                    }
7349                    Step::Rope { params } => {
7350                        // PLAN L1: scale `seq` and `n_total` proportionally.
7351                        // `seq_stride` and `batch` stay at compile-time
7352                        // values; the WGSL kernel uses them for buffer
7353                        // offsets while `seq` / `n_total` are loop bounds.
7354                        let mut p = *params;
7355                        let s_active = scale(p.seq);
7356                        p.seq = s_active;
7357                        p.n_total = p.batch * s_active * p.last_dim;
7358                        dev.queue
7359                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7360                    }
7361                    Step::Expand { params, .. } => {
7362                        // PLAN L1: same pattern as Transpose.
7363                        let mut p = *params;
7364                        if p.bucket_outermost == 1 && p.out_dim_0 > 0 {
7365                            let scaled_d0 = scale(p.out_dim_0);
7366                            let inner = p.out_total / p.out_dim_0;
7367                            p.out_total = scaled_d0 * inner;
7368                        }
7369                        dev.queue
7370                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7371                    }
7372                    Step::Argmax { params } => {
7373                        let mut p = *params;
7374                        p.outer = scale(p.outer);
7375                        dev.queue
7376                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7377                    }
7378                    Step::Pool2d { params } => {
7379                        let mut p = *params;
7380                        p.n = scale(p.n);
7381                        dev.queue
7382                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7383                    }
7384                    Step::Conv2d { params } => {
7385                        let mut p = *params;
7386                        p.n = scale(p.n);
7387                        dev.queue
7388                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7389                    }
7390                    Step::Pool1d { params } => {
7391                        let mut p = *params;
7392                        p.n = scale(p.n);
7393                        dev.queue
7394                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7395                    }
7396                    Step::Pool3d { params } => {
7397                        let mut p = *params;
7398                        p.n = scale(p.n);
7399                        dev.queue
7400                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7401                    }
7402                    Step::Conv1d { params } => {
7403                        let mut p = *params;
7404                        p.n = scale(p.n);
7405                        dev.queue
7406                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7407                    }
7408                    Step::Conv3d { params } => {
7409                        let mut p = *params;
7410                        p.n = scale(p.n);
7411                        dev.queue
7412                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7413                    }
7414                    Step::ScatterAdd { params } => {
7415                        // Two-phase: phase 0 zeros the FULL output (preserves
7416                        // accumulator semantics); phase 1 scatters first
7417                        // num_updates_active updates only.
7418                        let mut p = *params;
7419                        if p.op == 1 {
7420                            p.num_updates = scale(p.num_updates);
7421                        }
7422                        dev.queue
7423                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7424                    }
7425                    Step::TopK { params } => {
7426                        let mut p = *params;
7427                        p.outer = scale(p.outer);
7428                        dev.queue
7429                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7430                    }
7431                    Step::WelchPeaksGpu { params } => {
7432                        let mut p = *params;
7433                        p.welch_batch = scale(p.welch_batch);
7434                        dev.queue
7435                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7436                    }
7437                    Step::UmapKnn { params } => {
7438                        let mut p = *params;
7439                        p.n = scale(p.n);
7440                        dev.queue
7441                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7442                    }
7443                    Step::GroupedMatmul { params } => {
7444                        let mut p = *params;
7445                        p.m = scale(p.m);
7446                        dev.queue
7447                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7448                    }
7449                    Step::Sample { params } => {
7450                        let mut p = *params;
7451                        p.outer = scale(p.outer);
7452                        dev.queue
7453                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7454                    }
7455                    Step::SelectiveScan { params } => {
7456                        // Predicate-gated to batch=1: scale seq.
7457                        let mut p = *params;
7458                        p.seq = scale(p.seq);
7459                        dev.queue
7460                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7461                    }
7462                    Step::Mamba2 { params } => {
7463                        let mut p = *params;
7464                        p.seq = scale(p.seq);
7465                        dev.queue
7466                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7467                    }
7468                    Step::Gru { params } => {
7469                        let mut p = *params;
7470                        p.seq = scale(p.seq);
7471                        dev.queue
7472                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7473                    }
7474                    Step::Rnn { params } => {
7475                        let mut p = *params;
7476                        p.seq = scale(p.seq);
7477                        dev.queue
7478                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7479                    }
7480                    Step::DequantMatmul { params } => {
7481                        let mut p = *params;
7482                        p.m = scale(p.m);
7483                        dev.queue
7484                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7485                    }
7486                    Step::GatherSplit { .. }
7487                    | Step::DequantMatmulGguf { .. }
7488                    | Step::DequantGroupedMatmulGguf { .. }
7489                    | Step::GatedDeltaNet { .. }
7490                    | Step::Lstm { .. }
7491                    | Step::ConvTranspose2d { .. }
7492                    | Step::GroupNormHost { .. }
7493                    | Step::LayerNorm2dHost { .. }
7494                    | Step::ResizeNearest2xHost { .. }
7495                    | Step::ReverseHost { .. }
7496                    | Step::ArgReduceHost { .. }
7497                    | Step::GruHost { .. }
7498                    | Step::RnnHost { .. }
7499                    | Step::Llada2GroupLimitedGate { .. }
7500                    | Step::UmapKnnHost { .. }
7501                    | Step::MsDeformAttnHost { .. }
7502                    | Step::FftHost { .. }
7503                    | Step::Im2ColHost { .. }
7504                    | Step::RngNormalHost { .. }
7505                    | Step::RngUniformHost { .. }
7506                    | Step::WelchPeaksHost { .. }
7507                    | Step::LogMelHost { .. }
7508                    | Step::LogMelBackwardHost { .. } => {}
7509                    Step::FusedResidualLn { params } => {
7510                        let mut p = *params;
7511                        p.outer = scale(p.outer);
7512                        dev.queue
7513                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7514                    }
7515                    Step::FusedResidualLnTee { params } => {
7516                        let mut p = *params;
7517                        p.outer = scale(p.outer);
7518                        dev.queue
7519                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7520                    }
7521                    Step::FusedResidualRmsNorm { params } => {
7522                        let mut p = *params;
7523                        p.outer = scale(p.outer);
7524                        dev.queue
7525                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7526                    }
7527                    Step::MatmulQkv { params, kind: _ } => {
7528                        let mut p = *params;
7529                        p.m = scale(p.m);
7530                        dev.queue
7531                            .write_buffer(&self.uniforms[gpu_ui], 0, bytemuck::bytes_of(&p));
7532                    }
7533                    #[cfg(feature = "splat")]
7534                    Step::GaussianSplatRender { .. }
7535                    | Step::GaussianSplatRenderBackward { .. }
7536                    | Step::GaussianSplatPrepare { .. }
7537                    | Step::GaussianSplatRasterize { .. } => {}
7538                }
7539                if !matches!(step, Step::FftGpu { .. }) {
7540                    gpu_ui += 1;
7541                }
7542            }
7543            self.uniforms_active_extent = Some(active);
7544        }
7545
7546        // Encode + submit.
7547        let mm_k = matmul_kernel(&dev.device);
7548        let mm_w_active = matmul_wide_active_kernel(&dev.device);
7549        let mm_f16w = matmul_f16w_kernel(&dev.device);
7550        let mm_f16c = matmul_f16_compute_kernel(&dev.device);
7551        let mm_coop = matmul_coop16_kernel(&dev.device);
7552        let mm_coop_f16_vk = matmul_coop_f16_vulkan_kernel(&dev.device);
7553        let mm_coop_f32 = matmul_coop_f32_active_kernel(&dev.device);
7554        let mm_cast = cast_f32_to_f16_kernel(&dev.device);
7555        let bk = binary_kernel(&dev.device);
7556        let uk = unary_kernel(&dev.device);
7557        let ck = compare_kernel(&dev.device);
7558        let wk = where_kernel(&dev.device);
7559        let fk = fma_kernel(&dev.device);
7560        let mut step_i = 0;
7561        let mut gpu_bi = 0usize;
7562        let mut fft_i = 0usize;
7563        while step_i < self.schedule.len() {
7564            let mut enc = dev
7565                .device
7566                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
7567                    label: Some("rlx-wgpu run"),
7568                });
7569            {
7570                let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
7571                    label: Some("rlx-wgpu compute pass"),
7572                    timestamp_writes: None,
7573                });
7574                let mut pass_dispatched = false;
7575                while step_i < self.schedule.len() {
7576                    if step_is_tail_host(&self.schedule[step_i]) {
7577                        step_i += 1;
7578                        continue;
7579                    }
7580                    if step_runs_on_host(&self.schedule[step_i]) {
7581                        break;
7582                    }
7583                    // Vulkan/DX12: end the pass after unary/cast so f32→f16
7584                    // mirrors are visible to the next step. Only split once
7585                    // we've dispatched in *this* pass — otherwise the step that
7586                    // needs the flush would never run (infinite empty passes).
7587                    if pass_dispatched
7588                        && step_i > 0
7589                        && step_needs_pass_flush(&self.schedule[step_i], &self.schedule[step_i - 1])
7590                    {
7591                        break;
7592                    }
7593                    let step = &self.schedule[step_i];
7594                    // PLAN L3: per-step Perfetto trace span; no-op when
7595                    // env var RLX_TRACE_PERFETTO unset.
7596                    let _perf = rlx_ir::perfetto::TraceSpan::new(step_name(step), "wgpu");
7597                    match step {
7598                        Step::CastF32ToF16 { params } => {
7599                            // Pre-pass for matmul_coop16: mirror f32 arena
7600                            // region into f16 shadow buffer so the matmul
7601                            // kernel can read A as f16. One thread per
7602                            // element; 64-thread workgroups.
7603                            if let Some(cast_k) = mm_cast {
7604                                pass.set_pipeline(&cast_k.pipeline);
7605                                pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
7606                                let (gx, gy, gz) = dispatch_dims(params.len, 64);
7607                                pass.dispatch_workgroups(gx, gy, gz);
7608                            }
7609                        }
7610                        Step::Matmul {
7611                            m,
7612                            n,
7613                            batch,
7614                            b_off_f32,
7615                            b_is_param,
7616                            compute_precision,
7617                            ..
7618                        } =>
7619                        // The dispatch branches below use a chain of
7620                        // `is_some() && …unwrap()` to pick a pipeline
7621                        // because each variant cares about a different
7622                        // Option<Pipeline>. `if let Some(p) = …` chains
7623                        // would require nesting per variant; the flat
7624                        // form is the readable shape here.
7625                        {
7626                            #[allow(clippy::unnecessary_unwrap)]
7627                            // Safe at any batch (see safe_for_active_extent
7628                            // comment); scale m, output rows past m_s per
7629                            // batch retain prior values via c_batch_stride.
7630                            let m_s = scale(*m);
7631                            if m_s == 0 {
7632                                continue;
7633                            }
7634                            let coop_f16_wide = mm_coop_f16_vk.is_some()
7635                                && *compute_precision == MatmulCompute::CoopF16Vk
7636                                && crate::coop_f16_vk::use_wide_matmul(
7637                                    *b_off_f32,
7638                                    *n,
7639                                    &self.coop_f16_b_param,
7640                                    &self.coop_f16_vk_wide_b,
7641                                );
7642                            pass.set_bind_group(
7643                                0,
7644                                coop_f16_vk_bind_group(self, gpu_bi, coop_f16_wide),
7645                                &[],
7646                            );
7647                            // Kernel selection priority:
7648                            //   1. compute_precision == F16 + b_is_param +
7649                            //      SHADER_F16 → matmul_f16_compute
7650                            //      (f16 multiply, f32 acc — 2× ALU on Apple)
7651                            //   2. legacy RLX_WGPU_F16_WEIGHTS opt-in →
7652                            //      matmul_f16w (storage-only f16; experimental,
7653                            //      currently regresses on Apple)
7654                            //   3. wide-N (m≥32, n≥64)   → matmul_wide
7655                            //   4. otherwise            → matmul (small/skinny)
7656                            let f16w_opt_in = rlx_ir::env::flag("RLX_WGPU_F16_WEIGHTS");
7657                            if let Some(coop) = mm_coop.as_ref()
7658                                && *b_is_param
7659                                && *compute_precision == MatmulCompute::Coop16
7660                            {
7661                                // Hardware GEMM via simdgroup_matrix /
7662                                // KHR_cooperative_matrix. 32×32 output tile
7663                                // per workgroup (16 hardware-GEMM ops with
7664                                // shared A/B loads). Caller guaranteed m, n,
7665                                // k are multiples of 32/32/8.
7666                                pass.set_pipeline(&coop.pipeline);
7667                                pass.dispatch_workgroups(n.div_ceil(32), m_s.div_ceil(32), *batch);
7668                            } else if mm_coop_f16_vk.is_some()
7669                                && *compute_precision == MatmulCompute::CoopF16Vk
7670                            {
7671                                if coop_f16_wide {
7672                                    dispatch_wide_f32_matmul(
7673                                        &mut pass,
7674                                        mm_w_active,
7675                                        mm_k,
7676                                        m_s,
7677                                        *n,
7678                                        *batch,
7679                                    );
7680                                } else {
7681                                    let n_eff = scale(*n);
7682                                    let coop_vk =
7683                                        matmul_coop_f16_vulkan_active_kernel(&dev.device, n_eff)
7684                                            .expect("coop f16 vk kernel missing");
7685                                    pass.set_pipeline(&coop_vk.pipeline);
7686                                    pass.dispatch_workgroups(
7687                                        m_s.div_ceil(16),
7688                                        n.div_ceil(16),
7689                                        *batch,
7690                                    );
7691                                }
7692                            } else if let Some(coop_f32) = mm_coop_f32.as_ref()
7693                                && *b_is_param
7694                                && *compute_precision == MatmulCompute::CoopF32
7695                            {
7696                                // CoopF32: Metal uses 32×32 simdgroup tiles;
7697                                // Vulkan uses 8×8 coopLoadT portable kernel.
7698                                pass.set_pipeline(&coop_f32.pipeline);
7699                                let backend = wgpu_device()
7700                                    .map(|d| d.backend)
7701                                    .unwrap_or(wgpu::Backend::Noop);
7702                                let (gx, gy) = if backend == wgpu::Backend::Metal {
7703                                    (n.div_ceil(32), m_s.div_ceil(32))
7704                                } else {
7705                                    (m_s.div_ceil(8), n.div_ceil(8))
7706                                };
7707                                pass.dispatch_workgroups(gx, gy, *batch);
7708                            } else if let Some(f16c) = mm_f16c.as_ref()
7709                                && *b_is_param
7710                                && *compute_precision == MatmulCompute::F16
7711                            {
7712                                pass.set_pipeline(&f16c.pipeline);
7713                                pass.dispatch_workgroups(n.div_ceil(32), m_s.div_ceil(32), *batch);
7714                            } else if let Some(f16w) = mm_f16w.as_ref()
7715                                && *b_is_param
7716                                && f16w_opt_in
7717                            {
7718                                pass.set_pipeline(&f16w.pipeline);
7719                                pass.dispatch_workgroups(n.div_ceil(32), m_s.div_ceil(32), *batch);
7720                            } else if m_s >= 32 && *n >= 64 {
7721                                pass.set_pipeline(&mm_w_active.pipeline);
7722                                let backend = wgpu_device()
7723                                    .map(|d| d.backend)
7724                                    .unwrap_or(wgpu::Backend::Noop);
7725                                let (gx, gy) = if matches!(
7726                                    backend,
7727                                    wgpu::Backend::Vulkan | wgpu::Backend::Dx12
7728                                ) {
7729                                    (n.div_ceil(64), m_s.div_ceil(64))
7730                                } else {
7731                                    (n.div_ceil(64), m_s.div_ceil(32))
7732                                };
7733                                pass.dispatch_workgroups(gx, gy, *batch);
7734                            } else {
7735                                pass.set_pipeline(&mm_k.pipeline);
7736                                pass.dispatch_workgroups(n.div_ceil(32), m_s.div_ceil(32), *batch);
7737                            }
7738                        }
7739                        Step::Binary { params } => {
7740                            let n_s = scale(params.n);
7741                            if n_s == 0 {
7742                                continue;
7743                            }
7744                            pass.set_pipeline(&bk.pipeline);
7745                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
7746                            let (gx, gy, gz) = dispatch_dims(n_s, 64);
7747                            pass.dispatch_workgroups(gx, gy, gz);
7748                        }
7749                        Step::Compare { params } => {
7750                            let n_s = scale(params.n);
7751                            if n_s == 0 {
7752                                continue;
7753                            }
7754                            pass.set_pipeline(&ck.pipeline);
7755                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
7756                            let (gx, gy, gz) = dispatch_dims(n_s, 64);
7757                            pass.dispatch_workgroups(gx, gy, gz);
7758                        }
7759                        Step::Unary { params, f16_mirror } => {
7760                            let n_s = scale(params.n);
7761                            if n_s == 0 {
7762                                continue;
7763                            }
7764                            if *f16_mirror {
7765                                if let Some(uk_f16) = unary_f16_mirror_kernel(&dev.device) {
7766                                    pass.set_pipeline(&uk_f16.pipeline);
7767                                } else {
7768                                    pass.set_pipeline(&uk.pipeline);
7769                                }
7770                            } else {
7771                                pass.set_pipeline(&uk.pipeline);
7772                            }
7773                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
7774                            let (gx, gy, gz) = dispatch_dims(n_s, 64);
7775                            pass.dispatch_workgroups(gx, gy, gz);
7776                        }
7777                        Step::Where { params } => {
7778                            let n_s = scale(params.n);
7779                            if n_s == 0 {
7780                                continue;
7781                            }
7782                            pass.set_pipeline(&wk.pipeline);
7783                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
7784                            let (gx, gy, gz) = dispatch_dims(n_s, 64);
7785                            pass.dispatch_workgroups(gx, gy, gz);
7786                        }
7787                        Step::Fma { params } => {
7788                            let n_s = scale(params.n);
7789                            if n_s == 0 {
7790                                continue;
7791                            }
7792                            pass.set_pipeline(&fk.pipeline);
7793                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
7794                            let (gx, gy, gz) = dispatch_dims(n_s, 64);
7795                            pass.dispatch_workgroups(gx, gy, gz);
7796                        }
7797                        Step::Reduce { params } => {
7798                            let outer_s = scale(params.outer);
7799                            if outer_s == 0 {
7800                                continue;
7801                            }
7802                            let rk = reduce_kernel(&dev.device);
7803                            pass.set_pipeline(&rk.pipeline);
7804                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
7805                            let total_out = outer_s.saturating_mul(params.inner);
7806                            if params.reduce_dim <= 64 {
7807                                // Fast path: 1 thread per output cell.
7808                                let (gx, gy, gz) = dispatch_dims(total_out, 64);
7809                                pass.dispatch_workgroups(gx, gy, gz);
7810                            } else {
7811                                // Tree-reduce path: 1 workgroup (64
7812                                // threads) per output cell, parallel
7813                                // reduction with shared scratch.
7814                                let (gx, gy, gz) = dispatch_dims(total_out, 1);
7815                                pass.dispatch_workgroups(gx, gy, gz);
7816                            }
7817                        }
7818                        Step::Softmax { params } => {
7819                            let outer_s = scale(params.outer);
7820                            if outer_s == 0 {
7821                                continue;
7822                            }
7823                            let sk = softmax_kernel(&dev.device);
7824                            pass.set_pipeline(&sk.pipeline);
7825                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
7826                            let (gx, gy, gz) = dispatch_dims(outer_s, 64);
7827                            pass.dispatch_workgroups(gx, gy, gz);
7828                        }
7829                        Step::SoftmaxCrossEntropy { params } => {
7830                            let outer_s = scale(params.outer);
7831                            if outer_s == 0 {
7832                                continue;
7833                            }
7834                            let sk = softmax_cross_entropy_kernel(&dev.device);
7835                            pass.set_pipeline(&sk.pipeline);
7836                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
7837                            let (gx, gy, gz) = dispatch_dims(outer_s, 64);
7838                            pass.dispatch_workgroups(gx, gy, gz);
7839                        }
7840                        Step::LayerNorm { params } => {
7841                            let outer_s = scale(params.outer);
7842                            if outer_s == 0 {
7843                                continue;
7844                            }
7845                            let lk = layernorm_kernel(&dev.device);
7846                            pass.set_pipeline(&lk.pipeline);
7847                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
7848                            let (gx, gy, gz) = dispatch_dims(outer_s, 64);
7849                            pass.dispatch_workgroups(gx, gy, gz);
7850                        }
7851                        Step::RmsNormBackwardInput { params } => {
7852                            let outer_s = scale(params.outer);
7853                            if outer_s == 0 {
7854                                continue;
7855                            }
7856                            let rk = rms_norm_backward_kernel(&dev.device);
7857                            pass.set_pipeline(&rk.pipeline);
7858                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
7859                            pass.dispatch_workgroups(outer_s, 1, 1);
7860                        }
7861                        Step::RmsNormBackwardGamma { params }
7862                        | Step::RmsNormBackwardBeta { params } => {
7863                            if params.inner == 0 {
7864                                continue;
7865                            }
7866                            let rk = rms_norm_backward_param_kernel(&dev.device);
7867                            pass.set_pipeline(&rk.pipeline);
7868                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
7869                            pass.dispatch_workgroups(1, 1, 1);
7870                        }
7871                        Step::LayerNormBackwardInput { params } => {
7872                            let outer_s = scale(params.outer);
7873                            if outer_s == 0 {
7874                                continue;
7875                            }
7876                            let lk = layer_norm_backward_input_kernel(&dev.device);
7877                            pass.set_pipeline(&lk.pipeline);
7878                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
7879                            pass.dispatch_workgroups(outer_s, 1, 1);
7880                        }
7881                        Step::LayerNormBackwardGammaPartial {
7882                            params,
7883                            num_workgroups,
7884                        } => {
7885                            if params.inner == 0 || *num_workgroups == 0 {
7886                                continue;
7887                            }
7888                            let lk = layer_norm_backward_gamma_partial_kernel(&dev.device);
7889                            pass.set_pipeline(&lk.pipeline);
7890                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
7891                            pass.dispatch_workgroups(*num_workgroups, 1, 1);
7892                        }
7893                        Step::LayerNormBackwardGammaReduce { params } => {
7894                            if params.inner == 0 {
7895                                continue;
7896                            }
7897                            let lk = layer_norm_backward_gamma_reduce_kernel(&dev.device);
7898                            pass.set_pipeline(&lk.pipeline);
7899                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
7900                            pass.dispatch_workgroups(1, 1, 1);
7901                        }
7902                        Step::CumsumBackward { params } => {
7903                            let outer_s = scale(params.outer);
7904                            if outer_s == 0 {
7905                                continue;
7906                            }
7907                            let ck = cumsum_backward_kernel(&dev.device);
7908                            pass.set_pipeline(&ck.pipeline);
7909                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
7910                            let (gx, gy, gz) = dispatch_dims(outer_s, 64);
7911                            pass.dispatch_workgroups(gx, gy, gz);
7912                        }
7913                        Step::RopeBackward { params } => {
7914                            let seq_s = scale(params.seq);
7915                            if seq_s == 0 {
7916                                continue;
7917                            }
7918                            let rk = rope_backward_kernel(&dev.device);
7919                            pass.set_pipeline(&rk.pipeline);
7920                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
7921                            let total = params.batch * seq_s * params.hidden;
7922                            let (gx, gy, gz) = dispatch_dims(total, 64);
7923                            pass.dispatch_workgroups(gx, gy, gz);
7924                        }
7925                        Step::GatherBackward { params } => {
7926                            let outer_s = scale(params.outer);
7927                            if outer_s == 0 {
7928                                continue;
7929                            }
7930                            let total = outer_s * params.axis_dim * params.trailing;
7931                            if total > 0 {
7932                                let zk = gather_backward_zero_kernel(&dev.device);
7933                                pass.set_pipeline(&zk.pipeline);
7934                                pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
7935                                let (gx, _, _) = dispatch_dims(total, 256);
7936                                pass.dispatch_workgroups(gx, 1, 1);
7937                            }
7938                            let ak = gather_backward_acc_kernel(&dev.device);
7939                            pass.set_pipeline(&ak.pipeline);
7940                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
7941                            pass.dispatch_workgroups(outer_s, 1, 1);
7942                        }
7943                        Step::Cumsum { params } => {
7944                            let outer_s = scale(params.outer);
7945                            if outer_s == 0 {
7946                                continue;
7947                            }
7948                            let ck2 = cumsum_kernel(&dev.device);
7949                            pass.set_pipeline(&ck2.pipeline);
7950                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
7951                            let (gx, gy, gz) = dispatch_dims(outer_s, 64);
7952                            pass.dispatch_workgroups(gx, gy, gz);
7953                        }
7954                        Step::FftGpu {
7955                            src_off,
7956                            dst_off,
7957                            outer,
7958                            n,
7959                            inverse,
7960                            norm_scale,
7961                        } => {
7962                            let res = &self.fft_gpu_steps[fft_i];
7963                            fft_i += 1;
7964                            crate::fft_dispatch::dispatch_fft_gpu_in_pass(
7965                                &dev.device,
7966                                &dev.queue,
7967                                &mut pass,
7968                                res,
7969                                *src_off,
7970                                *dst_off,
7971                                *outer,
7972                                *n,
7973                                *inverse != 0,
7974                                *norm_scale,
7975                            );
7976                        }
7977                        Step::Copy { params } => {
7978                            let n_s = scale(params.n);
7979                            if n_s == 0 {
7980                                continue;
7981                            }
7982                            let ck2 = copy_kernel(&dev.device);
7983                            pass.set_pipeline(&ck2.pipeline);
7984                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
7985                            let (gx, gy, gz) = dispatch_dims(n_s, 64);
7986                            pass.dispatch_workgroups(gx, gy, gz);
7987                        }
7988                        Step::BufferCopy { .. } => {
7989                            // Host step: `copy_buffer_to_buffer` runs outside compute passes.
7990                        }
7991                        Step::ElementwiseRegion { params } => {
7992                            let len_s = scale(params.len);
7993                            if len_s == 0 {
7994                                continue;
7995                            }
7996                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
7997                            if params.prologue == rlx_ir::REGION_PROLOGUE_RESIZE_NEAREST_2X_NCHW {
7998                                let ek = elementwise_region_spatial_kernel(&dev.device);
7999                                pass.set_pipeline(&ek.pipeline);
8000                                let (gx, gy, gz) = dispatch_prologue_nchw(
8001                                    params.out_w,
8002                                    params.out_h,
8003                                    params.out_n * params.out_c,
8004                                );
8005                                pass.dispatch_workgroups(gx, gy, gz);
8006                            } else {
8007                                let ek = elementwise_region_kernel(&dev.device);
8008                                pass.set_pipeline(&ek.pipeline);
8009                                let (gx, gy, gz) = dispatch_dims(len_s, 64);
8010                                pass.dispatch_workgroups(gx, gy, gz);
8011                            }
8012                        }
8013                        Step::BatchElementwiseRegion { params } => {
8014                            let slice_len_s = scale(params.slice_len);
8015                            let num_batch_s = scale(params.num_batch);
8016                            if slice_len_s == 0 || num_batch_s == 0 {
8017                                continue;
8018                            }
8019                            let ek = batch_elementwise_region_kernel(&dev.device);
8020                            pass.set_pipeline(&ek.pipeline);
8021                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
8022                            let (gx, gy, _) = dispatch_dims(slice_len_s, 64);
8023                            pass.dispatch_workgroups(gx, gy, num_batch_s);
8024                        }
8025                        Step::Transpose { params, .. } => {
8026                            // Compute scaled grid count to match the
8027                            // uniform's scaled out_total when bucket axis
8028                            // is outermost.
8029                            let total_s = if params.bucket_outermost == 1 && params.out_dim_0 > 0 {
8030                                let scaled_d0 = scale(params.out_dim_0);
8031                                let inner = params.out_total / params.out_dim_0;
8032                                scaled_d0 * inner
8033                            } else {
8034                                params.out_total
8035                            };
8036                            if total_s == 0 {
8037                                continue;
8038                            }
8039                            let tk = transpose_kernel(&dev.device);
8040                            pass.set_pipeline(&tk.pipeline);
8041                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
8042                            let (gx, gy, gz) = dispatch_dims(total_s, 64);
8043                            pass.dispatch_workgroups(gx, gy, gz);
8044                        }
8045                        Step::Narrow { params } => {
8046                            let total_s = scale(params.total);
8047                            if total_s == 0 {
8048                                continue;
8049                            }
8050                            let nk = narrow_kernel(&dev.device);
8051                            pass.set_pipeline(&nk.pipeline);
8052                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
8053                            let (gx, gy, gz) = dispatch_dims(total_s, 64);
8054                            pass.dispatch_workgroups(gx, gy, gz);
8055                        }
8056                        Step::Concat { params } => {
8057                            let total_s = scale(params.total);
8058                            if total_s == 0 {
8059                                continue;
8060                            }
8061                            let cck = concat_kernel(&dev.device);
8062                            pass.set_pipeline(&cck.pipeline);
8063                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
8064                            let (gx, gy, gz) = dispatch_dims(total_s, 64);
8065                            pass.dispatch_workgroups(gx, gy, gz);
8066                        }
8067                        Step::Gather { params } => {
8068                            let n_out_s = scale(params.n_out);
8069                            if n_out_s == 0 {
8070                                continue;
8071                            }
8072                            let gk = gather_kernel(&dev.device);
8073                            pass.set_pipeline(&gk.pipeline);
8074                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
8075                            let (gx, gy, gz) = dispatch_dims(n_out_s, 64);
8076                            pass.dispatch_workgroups(gx, gy, gz);
8077                        }
8078                        Step::GatherAxis { params } => {
8079                            let total_s = scale(params.total);
8080                            if total_s == 0 {
8081                                continue;
8082                            }
8083                            let gk = gather_axis_kernel(&dev.device);
8084                            pass.set_pipeline(&gk.pipeline);
8085                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
8086                            let (gx, gy, gz) = dispatch_dims(total_s, 64);
8087                            pass.dispatch_workgroups(gx, gy, gz);
8088                        }
8089                        Step::Attention { params, .. } => {
8090                            // Scale seq_q for grid dim; per-head strides
8091                            // come from seq_q_stride / seq_k_stride (full
8092                            // extent) inside the WGSL.
8093                            let seq_q_s = scale(params.seq_q);
8094                            if seq_q_s == 0 {
8095                                continue;
8096                            }
8097                            let ak = attention_kernel(&dev.device);
8098                            pass.set_pipeline(&ak.pipeline);
8099                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
8100                            let total = params.batch * params.heads * seq_q_s;
8101                            let (gx, gy, gz) = dispatch_dims(total, 64);
8102                            pass.dispatch_workgroups(gx, gy, gz);
8103                        }
8104                        Step::AttentionBackward { params, .. } => {
8105                            let axis = if params.wrt == 0 {
8106                                params.seq_q
8107                            } else {
8108                                params.seq_k
8109                            };
8110                            let axis_s = scale(axis);
8111                            if axis_s == 0 {
8112                                continue;
8113                            }
8114                            let ak = attention_bwd_kernel(&dev.device);
8115                            pass.set_pipeline(&ak.pipeline);
8116                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
8117                            let total = params.batch * params.heads * axis_s;
8118                            let (gx, gy, gz) = dispatch_dims(total, 64);
8119                            pass.dispatch_workgroups(gx, gy, gz);
8120                        }
8121                        Step::Rope { params } => {
8122                            // Multi-batch via stride-field WGSL fix:
8123                            // iterate `batch * scaled_seq * last_dim` items.
8124                            let s_active = scale(params.seq);
8125                            let total_s = params.batch * s_active * params.last_dim;
8126                            if total_s == 0 {
8127                                continue;
8128                            }
8129                            let rk = rope_kernel(&dev.device);
8130                            pass.set_pipeline(&rk.pipeline);
8131                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
8132                            let (gx, gy, gz) = dispatch_dims(total_s, 64);
8133                            pass.dispatch_workgroups(gx, gy, gz);
8134                        }
8135                        Step::Expand { params, .. } => {
8136                            let total_s = if params.bucket_outermost == 1 && params.out_dim_0 > 0 {
8137                                let scaled_d0 = scale(params.out_dim_0);
8138                                let inner = params.out_total / params.out_dim_0;
8139                                scaled_d0 * inner
8140                            } else {
8141                                params.out_total
8142                            };
8143                            if total_s == 0 {
8144                                continue;
8145                            }
8146                            let ek = expand_kernel(&dev.device);
8147                            pass.set_pipeline(&ek.pipeline);
8148                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
8149                            let (gx, gy, gz) = dispatch_dims(total_s, 64);
8150                            pass.dispatch_workgroups(gx, gy, gz);
8151                        }
8152                        Step::Argmax { params } => {
8153                            let outer_s = scale(params.outer);
8154                            if outer_s == 0 {
8155                                continue;
8156                            }
8157                            let amk = argmax_kernel(&dev.device);
8158                            pass.set_pipeline(&amk.pipeline);
8159                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
8160                            let (gx, gy, gz) = dispatch_dims(outer_s, 64);
8161                            pass.dispatch_workgroups(gx, gy, gz);
8162                        }
8163                        Step::Pool2d { params } => {
8164                            let n_s = scale(params.n);
8165                            if n_s == 0 {
8166                                continue;
8167                            }
8168                            let pk = pool2d_kernel(&dev.device);
8169                            pass.set_pipeline(&pk.pipeline);
8170                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
8171                            let total = n_s * params.c * params.h_out * params.w_out;
8172                            let (gx, gy, gz) = dispatch_dims(total, 64);
8173                            pass.dispatch_workgroups(gx, gy, gz);
8174                        }
8175                        Step::Conv2d { params } => {
8176                            let n_s = scale(params.n);
8177                            if n_s == 0 {
8178                                continue;
8179                            }
8180                            let ck2 = conv2d_kernel(&dev.device);
8181                            pass.set_pipeline(&ck2.pipeline);
8182                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
8183                            // conv2d.wgsl tiles `CONV2D_TILE` output spatial
8184                            // positions per thread (const must match the kernel).
8185                            let spatial = params.h_out * params.w_out;
8186                            let sp_tiles = spatial.div_ceil(CONV2D_TILE);
8187                            let total = n_s * params.c_out * sp_tiles;
8188                            let (gx, gy, gz) = dispatch_dims(total, 64);
8189                            pass.dispatch_workgroups(gx, gy, gz);
8190                        }
8191                        Step::Pool1d { params } => {
8192                            let n_s = scale(params.n);
8193                            if n_s == 0 {
8194                                continue;
8195                            }
8196                            let pk = pool1d_kernel(&dev.device);
8197                            pass.set_pipeline(&pk.pipeline);
8198                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
8199                            let total = n_s * params.c * params.l_out;
8200                            let (gx, gy, gz) = dispatch_dims(total, 64);
8201                            pass.dispatch_workgroups(gx, gy, gz);
8202                        }
8203                        Step::Pool3d { params } => {
8204                            let n_s = scale(params.n);
8205                            if n_s == 0 {
8206                                continue;
8207                            }
8208                            let pk = pool3d_kernel(&dev.device);
8209                            pass.set_pipeline(&pk.pipeline);
8210                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
8211                            let total = n_s * params.c * params.d_out * params.h_out * params.w_out;
8212                            let (gx, gy, gz) = dispatch_dims(total, 64);
8213                            pass.dispatch_workgroups(gx, gy, gz);
8214                        }
8215                        Step::Conv1d { params } => {
8216                            let n_s = scale(params.n);
8217                            if n_s == 0 {
8218                                continue;
8219                            }
8220                            let ck = conv1d_kernel(&dev.device);
8221                            pass.set_pipeline(&ck.pipeline);
8222                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
8223                            let total = n_s * params.c_out * params.l_out;
8224                            let (gx, gy, gz) = dispatch_dims(total, 64);
8225                            pass.dispatch_workgroups(gx, gy, gz);
8226                        }
8227                        Step::Conv3d { params } => {
8228                            let n_s = scale(params.n);
8229                            if n_s == 0 {
8230                                continue;
8231                            }
8232                            let ck = conv3d_kernel(&dev.device);
8233                            pass.set_pipeline(&ck.pipeline);
8234                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
8235                            let total =
8236                                n_s * params.c_out * params.d_out * params.h_out * params.w_out;
8237                            let (gx, gy, gz) = dispatch_dims(total, 64);
8238                            pass.dispatch_workgroups(gx, gy, gz);
8239                        }
8240                        Step::ScatterAdd { params } => {
8241                            let sk = scatter_add_kernel(&dev.device);
8242                            pass.set_pipeline(&sk.pipeline);
8243                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
8244                            // Phase 0 zeros the FULL output (preserves
8245                            // accumulator semantics). Phase 1 scatters first
8246                            // num_updates_active updates only; serial single
8247                            // workgroup either way (atomic CAS unsupported in
8248                            // naga's MSL emitter — see scatter_add.wgsl).
8249                            if params.op == 0 {
8250                                let (gx, gy, gz) = dispatch_dims(params.out_total, 64);
8251                                pass.dispatch_workgroups(gx, gy, gz);
8252                            } else {
8253                                pass.dispatch_workgroups(1, 1, 1);
8254                            }
8255                        }
8256                        Step::TopK { params } => {
8257                            let outer_s = scale(params.outer);
8258                            if outer_s == 0 {
8259                                continue;
8260                            }
8261                            let tk = topk_kernel(&dev.device);
8262                            pass.set_pipeline(&tk.pipeline);
8263                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
8264                            let (gx, gy, gz) = dispatch_dims(outer_s, 64);
8265                            pass.dispatch_workgroups(gx, gy, gz);
8266                        }
8267                        Step::WelchPeaksGpu { params } => {
8268                            let batch_s = scale(params.welch_batch);
8269                            if batch_s == 0 {
8270                                continue;
8271                            }
8272                            let wk = welch_peaks_gpu_kernel(&dev.device);
8273                            pass.set_pipeline(&wk.pipeline);
8274                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
8275                            let (gx, gy, gz) = dispatch_dims(batch_s, 64);
8276                            pass.dispatch_workgroups(gx, gy, gz);
8277                        }
8278                        Step::UmapKnn { params } => {
8279                            let n_s = scale(params.n);
8280                            if n_s == 0 {
8281                                continue;
8282                            }
8283                            let uk = umap_knn_kernel(&dev.device);
8284                            pass.set_pipeline(&uk.pipeline);
8285                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
8286                            let (gx, gy, gz) = dispatch_dims(n_s, 64);
8287                            pass.dispatch_workgroups(gx, gy, gz);
8288                        }
8289                        Step::GroupedMatmul { params } => {
8290                            let m_s = scale(params.m);
8291                            if m_s == 0 {
8292                                continue;
8293                            }
8294                            let gk = grouped_matmul_kernel(&dev.device);
8295                            pass.set_pipeline(&gk.pipeline);
8296                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
8297                            pass.dispatch_workgroups(params.n.div_ceil(8), m_s.div_ceil(8), 1);
8298                        }
8299                        Step::Sample { params } => {
8300                            let outer_s = scale(params.outer);
8301                            if outer_s == 0 {
8302                                continue;
8303                            }
8304                            let sk = sample_kernel(&dev.device);
8305                            pass.set_pipeline(&sk.pipeline);
8306                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
8307                            let (gx, gy, gz) = dispatch_dims(outer_s, 64);
8308                            pass.dispatch_workgroups(gx, gy, gz);
8309                        }
8310                        Step::SelectiveScan { params } => {
8311                            // Predicate-gated to batch=1; the seq scaling
8312                            // happens inside the kernel (uniform sees scaled
8313                            // seq). Dispatch grid here is per-(batch, hidden);
8314                            // unaffected by seq scaling.
8315                            let ssk = selective_scan_kernel(&dev.device);
8316                            pass.set_pipeline(&ssk.pipeline);
8317                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
8318                            let total = params.batch * params.hidden;
8319                            let (gx, gy, gz) = dispatch_dims(total, 64);
8320                            pass.dispatch_workgroups(gx, gy, gz);
8321                        }
8322                        Step::Mamba2 { params } => {
8323                            let mk = mamba2_kernel(&dev.device);
8324                            pass.set_pipeline(&mk.pipeline);
8325                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
8326                            let total = params.batch * params.heads * params.head_dim;
8327                            let (gx, gy, gz) = dispatch_dims(total, 64);
8328                            pass.dispatch_workgroups(gx, gy, gz);
8329                        }
8330                        Step::Gru { params } => {
8331                            // One workgroup per batch item (workgroup_size=256).
8332                            let gk = gru_kernel(&dev.device);
8333                            pass.set_pipeline(&gk.pipeline);
8334                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
8335                            pass.dispatch_workgroups(params.batch, 1, 1);
8336                        }
8337                        Step::Rnn { params } => {
8338                            let rk = rnn_kernel(&dev.device);
8339                            pass.set_pipeline(&rk.pipeline);
8340                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
8341                            pass.dispatch_workgroups(params.batch, 1, 1);
8342                        }
8343                        Step::DequantMatmul { params } => {
8344                            let m_s = scale(params.m);
8345                            if m_s == 0 {
8346                                continue;
8347                            }
8348                            let dk = dequant_matmul_kernel(&dev.device);
8349                            pass.set_pipeline(&dk.pipeline);
8350                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
8351                            pass.dispatch_workgroups(params.n.div_ceil(8), m_s.div_ceil(8), 1);
8352                        }
8353                        Step::FusedResidualLn { params } => {
8354                            let outer_s = scale(params.outer);
8355                            if outer_s == 0 {
8356                                continue;
8357                            }
8358                            let frk = fused_residual_ln_kernel(&dev.device);
8359                            pass.set_pipeline(&frk.pipeline);
8360                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
8361                            let (gx, gy, gz) = dispatch_dims(outer_s, 64);
8362                            pass.dispatch_workgroups(gx, gy, gz);
8363                        }
8364                        Step::FusedResidualLnTee { params } => {
8365                            let outer_s = scale(params.outer);
8366                            if outer_s == 0 {
8367                                continue;
8368                            }
8369                            let frtk = fused_residual_ln_tee_kernel(&dev.device);
8370                            pass.set_pipeline(&frtk.pipeline);
8371                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
8372                            let (gx, gy, gz) = dispatch_dims(outer_s, 64);
8373                            pass.dispatch_workgroups(gx, gy, gz);
8374                        }
8375                        Step::FusedResidualRmsNorm { params } => {
8376                            let outer_s = scale(params.outer);
8377                            if outer_s == 0 {
8378                                continue;
8379                            }
8380                            let frk = fused_residual_rms_norm_kernel(&dev.device);
8381                            pass.set_pipeline(&frk.pipeline);
8382                            pass.set_bind_group(0, &self.bind_groups[gpu_bi], &[]);
8383                            let (gx, gy, gz) = dispatch_dims(outer_s, 64);
8384                            pass.dispatch_workgroups(gx, gy, gz);
8385                        }
8386                        Step::MatmulQkv { params, kind } => {
8387                            let m_s = scale(params.m);
8388                            if m_s == 0 {
8389                                continue;
8390                            }
8391                            let qkv_coop_wide = matches!(kind, MatmulQkvKind::CoopF16Vk)
8392                                && crate::coop_f16_vk::use_wide_matmul(
8393                                    params.b_off,
8394                                    params.n,
8395                                    &self.coop_f16_b_param,
8396                                    &self.coop_f16_vk_wide_b,
8397                                );
8398                            pass.set_bind_group(
8399                                0,
8400                                coop_f16_vk_bind_group(self, gpu_bi, qkv_coop_wide),
8401                                &[],
8402                            );
8403                            match kind {
8404                                MatmulQkvKind::CoopF16Vk => {
8405                                    if qkv_coop_wide {
8406                                        pass.set_pipeline(&matmul_qkv_kernel(&dev.device).pipeline);
8407                                        pass.dispatch_workgroups(
8408                                            params.n.div_ceil(32),
8409                                            m_s.div_ceil(32),
8410                                            1,
8411                                        );
8412                                    } else {
8413                                        let n_eff = scale(params.n);
8414                                        let mqk = matmul_qkv_coop_f16_vk_active_kernel(
8415                                            &dev.device,
8416                                            n_eff,
8417                                        )
8418                                        .expect("coop f16 matmul_qkv kernel missing");
8419                                        pass.set_pipeline(&mqk.pipeline);
8420                                        pass.dispatch_workgroups(
8421                                            m_s.div_ceil(16),
8422                                            params.n.div_ceil(16),
8423                                            1,
8424                                        );
8425                                    }
8426                                }
8427                                MatmulQkvKind::CoopF32 => {
8428                                    pass.set_pipeline(
8429                                        &matmul_qkv_coop_f32_kernel(&dev.device)
8430                                            .expect("coop matmul_qkv kernel missing")
8431                                            .pipeline,
8432                                    );
8433                                    pass.dispatch_workgroups(
8434                                        params.n.div_ceil(32),
8435                                        m_s.div_ceil(32),
8436                                        1,
8437                                    );
8438                                }
8439                                MatmulQkvKind::F32 => {
8440                                    pass.set_pipeline(&matmul_qkv_kernel(&dev.device).pipeline);
8441                                    pass.dispatch_workgroups(
8442                                        params.n.div_ceil(32),
8443                                        m_s.div_ceil(32),
8444                                        1,
8445                                    );
8446                                }
8447                            }
8448                        }
8449                        Step::GatherSplit { .. }
8450                        | Step::DequantMatmulGguf { .. }
8451                        | Step::DequantGroupedMatmulGguf { .. }
8452                        | Step::GatedDeltaNet { .. }
8453                        | Step::Lstm { .. }
8454                        | Step::ConvTranspose2d { .. }
8455                        | Step::GroupNormHost { .. }
8456                        | Step::LayerNorm2dHost { .. }
8457                        | Step::ResizeNearest2xHost { .. }
8458                        | Step::ReverseHost { .. }
8459                        | Step::ArgReduceHost { .. }
8460                        | Step::GruHost { .. }
8461                        | Step::RnnHost { .. }
8462                        | Step::Llada2GroupLimitedGate { .. }
8463                        | Step::UmapKnnHost { .. }
8464                        | Step::MsDeformAttnHost { .. }
8465                        | Step::FftHost { .. }
8466                        | Step::Im2ColHost { .. }
8467                        | Step::RngNormalHost { .. }
8468                        | Step::RngUniformHost { .. }
8469                        | Step::WelchPeaksHost { .. }
8470                        | Step::LogMelHost { .. }
8471                        | Step::LogMelBackwardHost { .. } => {}
8472                        #[cfg(feature = "splat")]
8473                        Step::GaussianSplatRender { .. }
8474                        | Step::GaussianSplatRenderBackward { .. }
8475                        | Step::GaussianSplatPrepare { .. }
8476                        | Step::GaussianSplatRasterize { .. } => {}
8477                    }
8478                    if !matches!(step, Step::FftGpu { .. }) {
8479                        gpu_bi += 1;
8480                    }
8481                    step_i += 1;
8482                    pass_dispatched = true;
8483                }
8484            }
8485            let needs_f16_drain = step_i < self.schedule.len()
8486                && !step_runs_on_host(&self.schedule[step_i])
8487                && step_i > 0
8488                && step_needs_pass_flush(&self.schedule[step_i], &self.schedule[step_i - 1]);
8489            let gpu_schedule_done = step_i >= self.schedule.len();
8490            let skip_readback = rlx_ir::env::flag("RLX_BENCH_DISPATCH_ONLY") || self.dispatch_only;
8491            let defer_tail = gpu_schedule_done && self.schedule.iter().any(step_is_tail_host);
8492            let mut fused_readback: Option<(
8493                ReadbackLayout,
8494                std::sync::mpsc::Receiver<Result<(), wgpu::BufferAsyncError>>,
8495                Vec<usize>,
8496            )> = None;
8497            if gpu_schedule_done && !skip_readback && !defer_tail {
8498                if !self.gpu_handle_feeds.is_empty() {
8499                    self.propagate_gpu_handle_feeds_on_gpu(dev, &mut enc);
8500                }
8501                let plan = self.readback_plan();
8502                let out_ids_all: Vec<_> = self.graph.outputs.clone();
8503                let out_ids: Vec<_> = plan.iter().map(|&i| out_ids_all[i]).collect();
8504                let layout = ReadbackLayout::for_nodes(&self.arena, &out_ids);
8505                if use_tiny_readback(&layout, out_ids.len()) && plan == vec![0] {
8506                    if self.tiny_readback.is_none() {
8507                        self.tiny_readback = Some(TinyReadbackStaging::new(&dev.device));
8508                    }
8509                    let tiny = self.tiny_readback.as_ref().expect("tiny readback");
8510                    encode_readback_copies(&mut enc, &self.arena, tiny.buffer(), &out_ids, &layout);
8511                    let map_rx = schedule_readback_map(&mut enc, tiny.buffer(), &layout);
8512                    let sub = dev.queue.submit(std::iter::once(enc.finish()));
8513                    wait_readback_map(&dev.device, sub, &map_rx, layout.total_bytes);
8514                    map_rx.recv().unwrap().unwrap();
8515                    return self.pack_readback_outputs(
8516                        &plan,
8517                        vec![decode_tiny_mapped_f32(tiny.buffer(), layout.total_bytes)],
8518                    );
8519                }
8520                ReadbackStaging::prepare(
8521                    &dev.device,
8522                    &mut self.readback_staging,
8523                    layout.total_bytes,
8524                );
8525                if let Some(staging) = self.readback_staging.as_ref() {
8526                    encode_readback_copies(
8527                        &mut enc,
8528                        &self.arena,
8529                        staging.buffer(),
8530                        &out_ids,
8531                        &layout,
8532                    );
8533                    let map_rx = schedule_readback_map(&mut enc, staging.buffer(), &layout);
8534                    fused_readback = Some((layout, map_rx, plan));
8535                }
8536            }
8537            let main_submission = dev.queue.submit(std::iter::once(enc.finish()));
8538            if defer_tail {
8539                let _ = dev.device.poll(wgpu::PollType::wait_indefinitely());
8540                self.run_tail_host_audio_ops(dev);
8541                if !skip_readback {
8542                    let mut rb_enc =
8543                        dev.device
8544                            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
8545                                label: Some("rlx-wgpu readback after tail-host"),
8546                            });
8547                    if !self.gpu_handle_feeds.is_empty() {
8548                        self.propagate_gpu_handle_feeds_on_gpu(dev, &mut rb_enc);
8549                    }
8550                    let plan = self.readback_plan();
8551                    let out_ids_all: Vec<_> = self.graph.outputs.clone();
8552                    let out_ids: Vec<_> = plan.iter().map(|&i| out_ids_all[i]).collect();
8553                    let layout = ReadbackLayout::for_nodes(&self.arena, &out_ids);
8554                    if use_tiny_readback(&layout, out_ids.len()) && plan == vec![0] {
8555                        if self.tiny_readback.is_none() {
8556                            self.tiny_readback = Some(TinyReadbackStaging::new(&dev.device));
8557                        }
8558                        let tiny = self.tiny_readback.as_ref().expect("tiny readback");
8559                        encode_readback_copies(
8560                            &mut rb_enc,
8561                            &self.arena,
8562                            tiny.buffer(),
8563                            &out_ids,
8564                            &layout,
8565                        );
8566                        let map_rx = schedule_readback_map(&mut rb_enc, tiny.buffer(), &layout);
8567                        let sub = dev.queue.submit(std::iter::once(rb_enc.finish()));
8568                        wait_readback_map(&dev.device, sub, &map_rx, layout.total_bytes);
8569                        map_rx.recv().unwrap().unwrap();
8570                        return self.pack_readback_outputs(
8571                            &plan,
8572                            vec![decode_tiny_mapped_f32(tiny.buffer(), layout.total_bytes)],
8573                        );
8574                    }
8575                    ReadbackStaging::prepare(
8576                        &dev.device,
8577                        &mut self.readback_staging,
8578                        layout.total_bytes,
8579                    );
8580                    if let Some(staging) = self.readback_staging.as_ref() {
8581                        encode_readback_copies(
8582                            &mut rb_enc,
8583                            &self.arena,
8584                            staging.buffer(),
8585                            &out_ids,
8586                            &layout,
8587                        );
8588                        let map_rx = schedule_readback_map(&mut rb_enc, staging.buffer(), &layout);
8589                        let sub = dev.queue.submit(std::iter::once(rb_enc.finish()));
8590                        wait_readback_map(&dev.device, sub, &map_rx, layout.total_bytes);
8591                        map_rx.recv().unwrap().unwrap();
8592                        self.dump_node_stats_if_requested(dev);
8593                        let partial = decode_mapped_readback_f32(staging.buffer(), &layout);
8594                        return self.pack_readback_outputs(&plan, partial);
8595                    }
8596                }
8597            }
8598            if needs_f16_drain {
8599                let _ = dev.device.poll(wgpu::PollType::wait_indefinitely());
8600            }
8601            let need_host_sync =
8602                step_i < self.schedule.len() && step_runs_on_host(&self.schedule[step_i]);
8603            if need_host_sync {
8604                let _ = dev.device.poll(wgpu::PollType::wait_indefinitely());
8605            }
8606            if gpu_schedule_done {
8607                if skip_readback || defer_tail {
8608                    return self
8609                        .graph
8610                        .outputs
8611                        .iter()
8612                        .map(|&id| {
8613                            let n = self.graph.node(id).shape.num_elements().unwrap_or(0);
8614                            vec![0.0; n]
8615                        })
8616                        .collect();
8617                }
8618                if let (Some((layout, map_rx, plan)), Some(staging)) =
8619                    (fused_readback, self.readback_staging.as_ref())
8620                {
8621                    wait_readback_map(&dev.device, main_submission, &map_rx, layout.total_bytes);
8622                    map_rx.recv().unwrap().unwrap();
8623                    self.dump_node_stats_if_requested(dev);
8624                    let partial = decode_mapped_readback_f32(staging.buffer(), &layout);
8625                    return self.pack_readback_outputs(&plan, partial);
8626                }
8627                break;
8628            }
8629            match &self.schedule[step_i] {
8630                Step::BufferCopy {
8631                    src_byte_off,
8632                    dst_byte_off,
8633                    bytes,
8634                } => {
8635                    // wgpu forbids `copy_buffer_to_buffer` on the same buffer;
8636                    // use the generic copy compute kernel instead.
8637                    let src = *src_byte_off;
8638                    let dst = *dst_byte_off;
8639                    let nbytes = *bytes as u64;
8640                    let elems = (nbytes / 4).max(1) as u32;
8641                    let lo = src.min(dst);
8642                    let hi = src.saturating_add(nbytes).max(dst.saturating_add(nbytes));
8643                    let max_binding = dev.device.limits().max_storage_buffer_binding_size;
8644                    // On >4 GiB arenas (GGUF decode) src and dst can be more than
8645                    // `max_binding` apart, so no single ≤4 GiB compute bind window
8646                    // covers both — the kernel copy would silently read/write out
8647                    // of its window. Fall back to a host round-trip (small staging
8648                    // copies only; capped by ARENA_STAGE_CAP).
8649                    if hi.saturating_sub(lo) > max_binding {
8650                        let bytes_host = self.arena.read_bytes_range(
8651                            &dev.device,
8652                            &dev.queue,
8653                            src as usize,
8654                            nbytes as usize,
8655                        );
8656                        self.arena
8657                            .write_bytes_range(&dev.queue, dst as usize, &bytes_host);
8658                        let _ = dev.device.poll(wgpu::PollType::wait_indefinitely());
8659                        step_i += 1;
8660                        continue;
8661                    }
8662                    let mut base = (lo / 256) * 256;
8663                    // The bind window must cover [base, hi]. `base` is floored to 256B
8664                    // alignment (so base ≤ lo); measuring the size from `lo` instead of
8665                    // `base` clips the window when the copy straddles a 256B boundary,
8666                    // silently dropping the tail (e.g. a 92B Bool→F32 mask copy whose
8667                    // dst sat just past the window → x_mask stayed 0).
8668                    let span = hi.saturating_sub(base).max(1);
8669                    let mut size = span.div_ceil(256) * 256;
8670                    size = size.max(256).min(max_binding);
8671                    if base.saturating_add(size) > self.arena.size as u64 {
8672                        base = (self.arena.size as u64).saturating_sub(size);
8673                        base = (base / 256) * 256;
8674                    }
8675                    let p = CopyParams {
8676                        n: elems,
8677                        in_off: (src.saturating_sub(base) / 4) as u32,
8678                        out_off: (dst.saturating_sub(base) / 4) as u32,
8679                        _p0: 0,
8680                        _p1: 0,
8681                        _p2: 0,
8682                        _p3: 0,
8683                        _p4: 0,
8684                    };
8685                    let ck = copy_kernel(&dev.device);
8686                    let u = dev.device.create_buffer(&wgpu::BufferDescriptor {
8687                        label: Some("rlx-wgpu arena_copy uniform"),
8688                        size: std::mem::size_of::<CopyParams>() as u64,
8689                        usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
8690                        mapped_at_creation: false,
8691                    });
8692                    dev.queue.write_buffer(&u, 0, bytemuck::bytes_of(&p));
8693                    let bg =
8694                        bind_two_buf0_window(&dev.device, ck, &self.arena.buffer, base, size, &u);
8695                    let mut enc =
8696                        dev.device
8697                            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
8698                                label: Some("rlx-wgpu arena_copy"),
8699                            });
8700                    {
8701                        let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
8702                            label: Some("rlx-wgpu arena_copy pass"),
8703                            ..Default::default()
8704                        });
8705                        pass.set_pipeline(&ck.pipeline);
8706                        pass.set_bind_group(0, &bg, &[]);
8707                        let (gx, gy, gz) = dispatch_dims(elems, 64);
8708                        pass.dispatch_workgroups(gx, gy, gz);
8709                    }
8710                    dev.queue.submit(std::iter::once(enc.finish()));
8711                    let _ = dev.device.poll(wgpu::PollType::wait_indefinitely());
8712                }
8713                Step::GatherSplit {
8714                    n_out,
8715                    n_idx,
8716                    dim,
8717                    vocab,
8718                    table_byte_off,
8719                    idx_byte_off,
8720                    out_byte_off,
8721                } => {
8722                    run_gather_split(
8723                        &self.arena,
8724                        &dev.device,
8725                        &dev.queue,
8726                        *n_out,
8727                        *n_idx,
8728                        *dim,
8729                        *vocab,
8730                        *table_byte_off as usize,
8731                        *idx_byte_off as usize,
8732                        *out_byte_off as usize,
8733                    );
8734                }
8735                Step::DequantMatmulGguf {
8736                    m,
8737                    k,
8738                    n,
8739                    scheme_id,
8740                    x_byte_off,
8741                    w_byte_off,
8742                    out_byte_off,
8743                } => {
8744                    if *m == 1 && crate::gguf_gpu::gemv_supports_scheme(*scheme_id) {
8745                        // Decode GEMV: fused dequant+matmul, windowed bindings,
8746                        // no f32 scratch (handles arenas larger than the 4 GiB
8747                        // single-binding limit).
8748                        crate::gguf_gpu::run_dequant_matmul_gguf_gemv(
8749                            &self.arena,
8750                            &dev.device,
8751                            &dev.queue,
8752                            *k as usize,
8753                            *n as usize,
8754                            *scheme_id,
8755                            *x_byte_off as usize,
8756                            *w_byte_off as usize,
8757                            *out_byte_off as usize,
8758                        );
8759                    } else if self.dequant_scratch_off > 0 {
8760                        crate::gguf_gpu::run_dequant_matmul_gguf_gpu(
8761                            &self.arena,
8762                            &dev.device,
8763                            &dev.queue,
8764                            *m as usize,
8765                            *k as usize,
8766                            *n as usize,
8767                            *scheme_id,
8768                            *x_byte_off as usize,
8769                            *w_byte_off as usize,
8770                            self.dequant_scratch_off,
8771                            *out_byte_off as usize,
8772                        );
8773                    } else {
8774                        crate::gguf_host::run_dequant_matmul_gguf(
8775                            &self.arena,
8776                            &dev.device,
8777                            &dev.queue,
8778                            *m as usize,
8779                            *k as usize,
8780                            *n as usize,
8781                            *scheme_id,
8782                            *x_byte_off as usize,
8783                            *w_byte_off as usize,
8784                            *out_byte_off as usize,
8785                        );
8786                    }
8787                }
8788                Step::DequantGroupedMatmulGguf {
8789                    m,
8790                    k,
8791                    n,
8792                    num_experts,
8793                    scheme_id,
8794                    x_byte_off,
8795                    w_byte_off,
8796                    idx_byte_off,
8797                    out_byte_off,
8798                } => {
8799                    if self.dequant_scratch_off > 0 {
8800                        crate::gguf_gpu::run_dequant_grouped_matmul_gguf_gpu(
8801                            &self.arena,
8802                            &dev.device,
8803                            &dev.queue,
8804                            *m as usize,
8805                            *k as usize,
8806                            *n as usize,
8807                            *num_experts as usize,
8808                            *scheme_id,
8809                            *x_byte_off as usize,
8810                            *w_byte_off as usize,
8811                            *idx_byte_off as usize,
8812                            self.dequant_scratch_off,
8813                            *out_byte_off as usize,
8814                        );
8815                    } else {
8816                        crate::gguf_host::run_dequant_grouped_matmul_gguf(
8817                            &self.arena,
8818                            &dev.device,
8819                            &dev.queue,
8820                            *m as usize,
8821                            *k as usize,
8822                            *n as usize,
8823                            *num_experts as usize,
8824                            *scheme_id,
8825                            *x_byte_off as usize,
8826                            *w_byte_off as usize,
8827                            *idx_byte_off as usize,
8828                            *out_byte_off as usize,
8829                        );
8830                    }
8831                }
8832                Step::GatedDeltaNet {
8833                    q_byte_off,
8834                    k_byte_off,
8835                    v_byte_off,
8836                    g_byte_off,
8837                    beta_byte_off,
8838                    state_byte_off,
8839                    dst_byte_off,
8840                    batch,
8841                    seq,
8842                    heads,
8843                    state_size,
8844                    use_carry,
8845                } => {
8846                    crate::gdn_host::run_gated_delta_net(
8847                        &self.arena,
8848                        &dev.device,
8849                        &dev.queue,
8850                        *q_byte_off as usize,
8851                        *k_byte_off as usize,
8852                        *v_byte_off as usize,
8853                        *g_byte_off as usize,
8854                        *beta_byte_off as usize,
8855                        *state_byte_off as usize,
8856                        *dst_byte_off as usize,
8857                        *batch as usize,
8858                        *seq as usize,
8859                        *heads as usize,
8860                        *state_size as usize,
8861                        *use_carry,
8862                    );
8863                }
8864                Step::Lstm {
8865                    x_byte_off,
8866                    w_ih_byte_off,
8867                    w_hh_byte_off,
8868                    bias_byte_off,
8869                    h0_byte_off,
8870                    c0_byte_off,
8871                    dst_byte_off,
8872                    batch,
8873                    seq,
8874                    input_size,
8875                    hidden,
8876                    num_layers,
8877                    bidirectional,
8878                    carry,
8879                } => {
8880                    crate::lstm_host::run_lstm(
8881                        &self.arena,
8882                        &dev.device,
8883                        &dev.queue,
8884                        *x_byte_off as usize,
8885                        *w_ih_byte_off as usize,
8886                        *w_hh_byte_off as usize,
8887                        *bias_byte_off as usize,
8888                        *h0_byte_off as usize,
8889                        *c0_byte_off as usize,
8890                        *dst_byte_off as usize,
8891                        *batch as usize,
8892                        *seq as usize,
8893                        *input_size as usize,
8894                        *hidden as usize,
8895                        *num_layers as usize,
8896                        *bidirectional,
8897                        *carry,
8898                    );
8899                }
8900                Step::ConvTranspose2d {
8901                    src_byte_off,
8902                    weight_byte_off,
8903                    dst_byte_off,
8904                    n,
8905                    c_in,
8906                    h,
8907                    w_in,
8908                    c_out,
8909                    h_out,
8910                    w_out,
8911                    kh,
8912                    kw,
8913                    sh,
8914                    sw,
8915                    ph,
8916                    pw,
8917                    dh,
8918                    dw,
8919                    groups,
8920                } => {
8921                    crate::conv_transpose2d_host::run_conv_transpose2d(
8922                        &self.arena,
8923                        &dev.device,
8924                        &dev.queue,
8925                        *src_byte_off as usize,
8926                        *weight_byte_off as usize,
8927                        *dst_byte_off as usize,
8928                        *n as usize,
8929                        *c_in as usize,
8930                        *h as usize,
8931                        *w_in as usize,
8932                        *c_out as usize,
8933                        *h_out as usize,
8934                        *w_out as usize,
8935                        *kh as usize,
8936                        *kw as usize,
8937                        *sh as usize,
8938                        *sw as usize,
8939                        *ph as usize,
8940                        *pw as usize,
8941                        *dh as usize,
8942                        *dw as usize,
8943                        *groups as usize,
8944                    );
8945                }
8946                Step::GroupNormHost {
8947                    src_byte_off,
8948                    gamma_byte_off,
8949                    beta_byte_off,
8950                    dst_byte_off,
8951                    n,
8952                    c,
8953                    h,
8954                    w,
8955                    num_groups,
8956                    eps,
8957                } => {
8958                    crate::vision_host::run_group_norm(
8959                        &self.arena,
8960                        &dev.device,
8961                        &dev.queue,
8962                        *src_byte_off as usize,
8963                        *gamma_byte_off as usize,
8964                        *beta_byte_off as usize,
8965                        *dst_byte_off as usize,
8966                        *n as usize,
8967                        *c as usize,
8968                        *h as usize,
8969                        *w as usize,
8970                        *num_groups as usize,
8971                        *eps,
8972                    );
8973                }
8974                Step::LayerNorm2dHost {
8975                    src_byte_off,
8976                    gamma_byte_off,
8977                    beta_byte_off,
8978                    dst_byte_off,
8979                    n,
8980                    c,
8981                    h,
8982                    w,
8983                    eps,
8984                } => {
8985                    crate::vision_host::run_layer_norm2d(
8986                        &self.arena,
8987                        &dev.device,
8988                        &dev.queue,
8989                        *src_byte_off as usize,
8990                        *gamma_byte_off as usize,
8991                        *beta_byte_off as usize,
8992                        *dst_byte_off as usize,
8993                        *n as usize,
8994                        *c as usize,
8995                        *h as usize,
8996                        *w as usize,
8997                        *eps,
8998                    );
8999                }
9000                Step::ResizeNearest2xHost {
9001                    src_byte_off,
9002                    dst_byte_off,
9003                    n,
9004                    c,
9005                    h,
9006                    w,
9007                } => {
9008                    crate::vision_host::run_resize_nearest_2x(
9009                        &self.arena,
9010                        &dev.device,
9011                        &dev.queue,
9012                        *src_byte_off as usize,
9013                        *dst_byte_off as usize,
9014                        *n as usize,
9015                        *c as usize,
9016                        *h as usize,
9017                        *w as usize,
9018                    );
9019                }
9020                Step::ReverseHost {
9021                    src_byte_off,
9022                    dst_byte_off,
9023                    dims,
9024                    rev_mask,
9025                    elem_bytes,
9026                } => {
9027                    crate::vision_host::run_reverse(
9028                        &self.arena,
9029                        &dev.device,
9030                        &dev.queue,
9031                        *src_byte_off as usize,
9032                        *dst_byte_off as usize,
9033                        dims,
9034                        rev_mask,
9035                        *elem_bytes as usize,
9036                    );
9037                }
9038                Step::ArgReduceHost {
9039                    src_byte_off,
9040                    dst_byte_off,
9041                    outer,
9042                    reduced,
9043                    inner,
9044                    is_max,
9045                } => {
9046                    crate::vision_host::run_argreduce(
9047                        &self.arena,
9048                        &dev.device,
9049                        &dev.queue,
9050                        *src_byte_off as usize,
9051                        *dst_byte_off as usize,
9052                        *outer as usize,
9053                        *reduced as usize,
9054                        *inner as usize,
9055                        *is_max,
9056                    );
9057                }
9058                Step::GruHost {
9059                    x,
9060                    w_ih,
9061                    w_hh,
9062                    b_ih,
9063                    b_hh,
9064                    h0,
9065                    dst,
9066                    batch,
9067                    seq,
9068                    input_size,
9069                    hidden,
9070                    num_layers,
9071                    bidirectional,
9072                    carry,
9073                } => {
9074                    crate::vision_host::run_gru(
9075                        &self.arena,
9076                        &dev.device,
9077                        &dev.queue,
9078                        *x as usize,
9079                        *w_ih as usize,
9080                        *w_hh as usize,
9081                        *b_ih as usize,
9082                        *b_hh as usize,
9083                        *h0 as usize,
9084                        *dst as usize,
9085                        *batch as usize,
9086                        *seq as usize,
9087                        *input_size as usize,
9088                        *hidden as usize,
9089                        *num_layers as usize,
9090                        *bidirectional,
9091                        *carry,
9092                    );
9093                }
9094                Step::RnnHost {
9095                    x,
9096                    w_ih,
9097                    w_hh,
9098                    bias,
9099                    h0,
9100                    dst,
9101                    batch,
9102                    seq,
9103                    input_size,
9104                    hidden,
9105                    num_layers,
9106                    bidirectional,
9107                    carry,
9108                    relu,
9109                } => {
9110                    crate::vision_host::run_rnn(
9111                        &self.arena,
9112                        &dev.device,
9113                        &dev.queue,
9114                        *x as usize,
9115                        *w_ih as usize,
9116                        *w_hh as usize,
9117                        *bias as usize,
9118                        *h0 as usize,
9119                        *dst as usize,
9120                        *batch as usize,
9121                        *seq as usize,
9122                        *input_size as usize,
9123                        *hidden as usize,
9124                        *num_layers as usize,
9125                        *bidirectional,
9126                        *carry,
9127                        *relu,
9128                    );
9129                }
9130                Step::Llada2GroupLimitedGate {
9131                    sig_byte_off,
9132                    route_byte_off,
9133                    out_byte_off,
9134                    n_elems,
9135                    attrs,
9136                } => {
9137                    crate::llada2_gate_host::run_llada2_group_limited_gate(
9138                        &self.arena,
9139                        &dev.device,
9140                        &dev.queue,
9141                        *sig_byte_off as usize,
9142                        *route_byte_off as usize,
9143                        *out_byte_off as usize,
9144                        *n_elems as usize,
9145                        attrs,
9146                    );
9147                }
9148                Step::UmapKnnHost {
9149                    pairwise_byte_off,
9150                    out_byte_off,
9151                    n,
9152                    k,
9153                } => {
9154                    crate::umap_knn_host::run_umap_knn(
9155                        &self.arena,
9156                        &dev.device,
9157                        &dev.queue,
9158                        *pairwise_byte_off as usize,
9159                        *out_byte_off as usize,
9160                        *n as usize,
9161                        *k as usize,
9162                    );
9163                }
9164                Step::MsDeformAttnHost {
9165                    in_offs,
9166                    out_byte_off,
9167                    out_bytes,
9168                    attrs,
9169                } => {
9170                    crate::ms_deform_attn::run_ms_deform_attn(
9171                        &self.arena,
9172                        &dev.device,
9173                        &dev.queue,
9174                        in_offs,
9175                        *out_byte_off as usize,
9176                        *out_bytes as usize,
9177                        attrs,
9178                    );
9179                }
9180                Step::FftHost {
9181                    src_byte_off,
9182                    dst_byte_off,
9183                    outer,
9184                    n_complex,
9185                    inverse,
9186                    norm_tag,
9187                    dtype_tag,
9188                } => {
9189                    crate::fft_host::run_fft1d(
9190                        &self.arena,
9191                        &dev.device,
9192                        &dev.queue,
9193                        *src_byte_off as usize,
9194                        *dst_byte_off as usize,
9195                        *outer as usize,
9196                        *n_complex as usize,
9197                        *inverse,
9198                        *norm_tag,
9199                        fft_dtype_from_tag(*dtype_tag),
9200                    );
9201                }
9202                Step::WelchPeaksHost { .. }
9203                | Step::LogMelHost { .. }
9204                | Step::LogMelBackwardHost { .. } => {
9205                    unreachable!("tail-host audio ops run after GPU wait")
9206                }
9207                Step::Im2ColHost {
9208                    x_byte_off,
9209                    col_byte_off,
9210                    n,
9211                    c_in,
9212                    h,
9213                    w,
9214                    h_out,
9215                    w_out,
9216                    kh,
9217                    kw,
9218                    sh,
9219                    sw,
9220                    ph,
9221                    pw,
9222                    dh,
9223                    dw_dil,
9224                } => {
9225                    crate::im2col_host::run_im2col(
9226                        &self.arena,
9227                        &dev.device,
9228                        &dev.queue,
9229                        *x_byte_off as usize,
9230                        *col_byte_off as usize,
9231                        *n,
9232                        *c_in,
9233                        *h,
9234                        *w,
9235                        *h_out,
9236                        *w_out,
9237                        *kh,
9238                        *kw,
9239                        *sh,
9240                        *sw,
9241                        *ph,
9242                        *pw,
9243                        *dh,
9244                        *dw_dil,
9245                    );
9246                }
9247                Step::RngNormalHost {
9248                    dst_byte_off,
9249                    len,
9250                    mean,
9251                    scale,
9252                    key,
9253                    op_seed,
9254                } => {
9255                    let opts = *self.rng.read().expect("rng lock");
9256                    crate::rng_host::run_rng_normal(
9257                        &self.arena,
9258                        &dev.queue,
9259                        *dst_byte_off as usize,
9260                        *len as usize,
9261                        *mean,
9262                        *scale,
9263                        *key,
9264                        *op_seed,
9265                        opts,
9266                    );
9267                }
9268                Step::RngUniformHost {
9269                    dst_byte_off,
9270                    len,
9271                    low,
9272                    high,
9273                    key,
9274                    op_seed,
9275                } => {
9276                    let opts = *self.rng.read().expect("rng lock");
9277                    crate::rng_host::run_rng_uniform(
9278                        &self.arena,
9279                        &dev.queue,
9280                        *dst_byte_off as usize,
9281                        *len as usize,
9282                        *low,
9283                        *high,
9284                        *key,
9285                        *op_seed,
9286                        opts,
9287                    );
9288                }
9289                #[cfg(feature = "splat")]
9290                Step::GaussianSplatRender {
9291                    positions_byte_off,
9292                    positions_len,
9293                    scales_byte_off,
9294                    scales_len,
9295                    rotations_byte_off,
9296                    rotations_len,
9297                    opacities_byte_off,
9298                    opacities_len,
9299                    colors_byte_off,
9300                    colors_len,
9301                    sh_coeffs_byte_off,
9302                    sh_coeffs_len,
9303                    meta_byte_off,
9304                    dst_byte_off,
9305                    dst_len,
9306                    width,
9307                    height,
9308                    tile_size,
9309                    radius_scale,
9310                    alpha_cutoff,
9311                    max_splat_steps,
9312                    transmittance_threshold,
9313                    max_list_entries,
9314                } => {
9315                    crate::splat::run_gaussian_splat_render(
9316                        &self.arena,
9317                        &dev.device,
9318                        &dev.queue,
9319                        *positions_byte_off as usize,
9320                        *positions_len as usize,
9321                        *scales_byte_off as usize,
9322                        *scales_len as usize,
9323                        *rotations_byte_off as usize,
9324                        *rotations_len as usize,
9325                        *opacities_byte_off as usize,
9326                        *opacities_len as usize,
9327                        *colors_byte_off as usize,
9328                        *colors_len as usize,
9329                        *sh_coeffs_byte_off as usize,
9330                        *sh_coeffs_len as usize,
9331                        *meta_byte_off as usize,
9332                        *dst_byte_off as usize,
9333                        *dst_len as usize,
9334                        *width,
9335                        *height,
9336                        *tile_size,
9337                        *radius_scale,
9338                        *alpha_cutoff,
9339                        *max_splat_steps,
9340                        *transmittance_threshold,
9341                        *max_list_entries,
9342                    );
9343                }
9344                #[cfg(feature = "splat")]
9345                Step::GaussianSplatPrepare {
9346                    positions_byte_off,
9347                    positions_len,
9348                    scales_byte_off,
9349                    scales_len,
9350                    rotations_byte_off,
9351                    rotations_len,
9352                    opacities_byte_off,
9353                    opacities_len,
9354                    colors_byte_off,
9355                    colors_len,
9356                    sh_coeffs_byte_off,
9357                    sh_coeffs_len,
9358                    meta_byte_off,
9359                    meta_len,
9360                    prep_byte_off,
9361                    prep_len,
9362                    width,
9363                    height,
9364                    tile_size,
9365                    radius_scale,
9366                    alpha_cutoff,
9367                    max_splat_steps,
9368                    transmittance_threshold,
9369                    max_list_entries,
9370                } => {
9371                    crate::splat::run_gaussian_splat_prepare(
9372                        &self.arena,
9373                        &dev.device,
9374                        &dev.queue,
9375                        *positions_byte_off as usize,
9376                        *positions_len as usize,
9377                        *scales_byte_off as usize,
9378                        *scales_len as usize,
9379                        *rotations_byte_off as usize,
9380                        *rotations_len as usize,
9381                        *opacities_byte_off as usize,
9382                        *opacities_len as usize,
9383                        *colors_byte_off as usize,
9384                        *colors_len as usize,
9385                        *sh_coeffs_byte_off as usize,
9386                        *sh_coeffs_len as usize,
9387                        *meta_byte_off as usize,
9388                        *meta_len as usize,
9389                        *prep_byte_off as usize,
9390                        *prep_len as usize,
9391                        *width,
9392                        *height,
9393                        *tile_size,
9394                        *radius_scale,
9395                        *alpha_cutoff,
9396                        *max_splat_steps,
9397                        *transmittance_threshold,
9398                        *max_list_entries,
9399                    );
9400                }
9401                #[cfg(feature = "splat")]
9402                Step::GaussianSplatRasterize {
9403                    prep_byte_off,
9404                    prep_len,
9405                    meta_byte_off,
9406                    meta_len,
9407                    dst_byte_off,
9408                    dst_len,
9409                    count,
9410                    width,
9411                    height,
9412                    tile_size,
9413                    alpha_cutoff,
9414                    max_splat_steps,
9415                    transmittance_threshold,
9416                    max_list_entries,
9417                } => {
9418                    crate::splat::run_gaussian_splat_rasterize(
9419                        &self.arena,
9420                        &dev.device,
9421                        &dev.queue,
9422                        *prep_byte_off as usize,
9423                        *prep_len as usize,
9424                        *meta_byte_off as usize,
9425                        *meta_len as usize,
9426                        *dst_byte_off as usize,
9427                        *dst_len as usize,
9428                        *count as usize,
9429                        *width,
9430                        *height,
9431                        *tile_size,
9432                        *alpha_cutoff,
9433                        *max_splat_steps,
9434                        *transmittance_threshold,
9435                        *max_list_entries,
9436                    );
9437                }
9438                #[cfg(feature = "splat")]
9439                Step::GaussianSplatRenderBackward {
9440                    positions_byte_off,
9441                    positions_len,
9442                    scales_byte_off,
9443                    scales_len,
9444                    rotations_byte_off,
9445                    rotations_len,
9446                    opacities_byte_off,
9447                    opacities_len,
9448                    colors_byte_off,
9449                    colors_len,
9450                    sh_coeffs_byte_off,
9451                    sh_coeffs_len,
9452                    meta_byte_off,
9453                    d_loss_byte_off,
9454                    d_loss_len,
9455                    packed_byte_off,
9456                    packed_len,
9457                    width,
9458                    height,
9459                    tile_size,
9460                    radius_scale,
9461                    alpha_cutoff,
9462                    max_splat_steps,
9463                    transmittance_threshold,
9464                    max_list_entries,
9465                    loss_grad_clip,
9466                    sh_band,
9467                    max_anisotropy,
9468                } => {
9469                    crate::splat::run_gaussian_splat_render_backward(
9470                        &self.arena,
9471                        &dev.device,
9472                        &dev.queue,
9473                        *positions_byte_off as usize,
9474                        *positions_len as usize,
9475                        *scales_byte_off as usize,
9476                        *scales_len as usize,
9477                        *rotations_byte_off as usize,
9478                        *rotations_len as usize,
9479                        *opacities_byte_off as usize,
9480                        *opacities_len as usize,
9481                        *colors_byte_off as usize,
9482                        *colors_len as usize,
9483                        *sh_coeffs_byte_off as usize,
9484                        *sh_coeffs_len as usize,
9485                        *meta_byte_off as usize,
9486                        *d_loss_byte_off as usize,
9487                        *d_loss_len as usize,
9488                        *packed_byte_off as usize,
9489                        *packed_len as usize,
9490                        *width,
9491                        *height,
9492                        *tile_size,
9493                        *radius_scale,
9494                        *alpha_cutoff,
9495                        *max_splat_steps,
9496                        *transmittance_threshold,
9497                        *max_list_entries,
9498                        *loss_grad_clip,
9499                        *sh_band,
9500                        *max_anisotropy,
9501                    );
9502                }
9503                _ => break,
9504            }
9505            step_i += 1;
9506        }
9507
9508        self.dump_node_stats_if_requested(dev);
9509
9510        if rlx_ir::env::flag("RLX_WGPU_NAN_TRACE") {
9511            let mut bad_nodes = Vec::new();
9512            for node in self.graph.nodes() {
9513                if !self.arena.has(node.id) {
9514                    continue;
9515                }
9516                // Skip leaves — populated by host writes, not kernels.
9517                if matches!(
9518                    node.op,
9519                    rlx_ir::Op::Input { .. }
9520                        | rlx_ir::Op::Param { .. }
9521                        | rlx_ir::Op::Constant { .. }
9522                ) {
9523                    continue;
9524                }
9525                let data = self.arena.read_f32(&dev.device, &dev.queue, node.id);
9526                let nan_count = data.iter().filter(|v| v.is_nan()).count();
9527                let inf_count = data.iter().filter(|v| v.is_infinite()).count();
9528                if nan_count > 0 || inf_count > 0 {
9529                    // Capture first NaN index + the values around it.
9530                    let first_nan = data.iter().position(|v| v.is_nan());
9531                    if let Some(idx) = first_nan {
9532                        let lo = idx.saturating_sub(2);
9533                        let hi = (idx + 3).min(data.len());
9534                        eprintln!(
9535                            "  node {:?} op={:?} len={} nan={} inf={} \
9536                                   first_nan_idx={} ctx={:?}",
9537                            node.id,
9538                            node.op,
9539                            data.len(),
9540                            nan_count,
9541                            inf_count,
9542                            idx,
9543                            &data[lo..hi]
9544                        );
9545                    }
9546                    bad_nodes.push((node.id, data.len(), nan_count, inf_count));
9547                    if bad_nodes.len() >= 3 {
9548                        break;
9549                    }
9550                }
9551            }
9552            if bad_nodes.is_empty() {
9553                eprintln!("[wgpu-nan-trace] no NaN/Inf in any node — clean run");
9554            } else {
9555                eprintln!(
9556                    "[wgpu-nan-trace] first {} bad nodes (above)",
9557                    bad_nodes.len()
9558                );
9559            }
9560        }
9561
9562        if rlx_ir::env::flag("RLX_BENCH_DISPATCH_ONLY") {
9563            return self
9564                .graph
9565                .outputs
9566                .iter()
9567                .map(|&id| {
9568                    let n = self.graph.node(id).shape.num_elements().unwrap_or(0);
9569                    vec![0.0; n]
9570                })
9571                .collect();
9572        }
9573        let out_ids: Vec<_> = self.graph.outputs.clone();
9574        read_f32_many_pooled(
9575            &self.arena,
9576            &dev.device,
9577            &dev.queue,
9578            &out_ids,
9579            &mut self.readback_staging,
9580        )
9581    }
9582}
9583
9584/// Compute a (X, Y, 1) workgroup grid for a 1-D workload.
9585///
9586/// WebGPU caps `dispatch_workgroups` per-dimension at 65535. For
9587/// workloads beyond `65535 × workgroup_size_x` threads we split into
9588/// a 2-D grid; kernels recover the linear thread index via
9589/// `gid.x + gid.y * num_workgroups.x * 64u`.
9590fn dispatch_prologue_nchw(w: u32, h: u32, nc: u32) -> (u32, u32, u32) {
9591    (w.div_ceil(8).max(1), h.div_ceil(8).max(1), nc.max(1))
9592}
9593
9594fn dispatch_dims(threads_total: u32, workgroup_size: u32) -> (u32, u32, u32) {
9595    let groups = threads_total.div_ceil(workgroup_size);
9596    if groups <= 65535 {
9597        (groups, 1, 1)
9598    } else {
9599        let gx = 65535u32;
9600        let gy = groups.div_ceil(gx);
9601        (gx, gy, 1)
9602    }
9603}
9604
9605/// Shape/feature gate for CoopF16Vk (no operand tracing — avoids circular
9606/// dependency with compile-time f16 mirror planning).
9607///
9608/// **Default OFF.** The Vulkan/DX12 cooperative-matrix matmul path
9609/// silently produces wrong output on BERT-family attention chains on at
9610/// least RTX 4090 (verified empirically against Bio_ClinicalBERT:
9611/// encoder cosine collapses from ≈1.0 on the wide-F32 fallback to ≈0.09
9612/// when the coop path runs, regardless of whether the kernel uses
9613/// F16-acc or F32-acc accumulators). The root cause is upstream — likely
9614/// in how wgpu's `coopLoadT` / `coopMultiplyAdd` interact with strided
9615/// arena buffers on non-Apple drivers — and needs a focused
9616/// reproducer before it can be fixed in `rlx-wgpu`. Until then the
9617/// correctness-first default is to route Vulkan/DX12 matmuls through the
9618/// wide-F32 path, even though it's substantially slower (~80× on this
9619/// shape).
9620///
9621/// Opt back in (at the user's risk) with `RLX_WGPU_COOP_F16_VK_ENABLE=1`
9622/// — useful for measuring the perf headroom or for non-BERT models
9623/// where the precision loss may be acceptable. Legacy
9624/// `RLX_WGPU_NO_COOP_F16_VK=1` and explicit
9625/// `RLX_WGPU_COOP_F16_VK_DISABLE=1` are honored for completeness.
9626fn coop_f16_vk_eligible(dev: &wgpu::Device, m: u32, k: u32, n: u32) -> bool {
9627    if rlx_ir::env::flag("RLX_WGPU_NO_COOP_F16_VK")
9628        || rlx_ir::env::flag("RLX_WGPU_COOP_F16_VK_DISABLE")
9629    {
9630        return false;
9631    }
9632    if !rlx_ir::env::flag("RLX_WGPU_COOP_F16_VK_ENABLE") {
9633        return false;
9634    }
9635    m.is_multiple_of(16)
9636        && k.is_multiple_of(16)
9637        && n.is_multiple_of(16)
9638        && dev
9639            .features()
9640            .contains(wgpu::Features::EXPERIMENTAL_COOPERATIVE_MATRIX)
9641        && dev.features().contains(wgpu::Features::SHADER_F16)
9642        && crate::device::coop_discrete_backend()
9643        && crate::device::coop_f16_16x16_supported()
9644}
9645
9646fn step_needs_pass_flush(step: &Step, prev: &Step) -> bool {
9647    match step {
9648        Step::CastF32ToF16 { .. } => matches!(
9649            prev,
9650            Step::Unary {
9651                f16_mirror: false,
9652                ..
9653            }
9654        ),
9655        Step::Matmul {
9656            compute_precision: MatmulCompute::CoopF16Vk,
9657            ..
9658        }
9659        | Step::MatmulQkv {
9660            kind: MatmulQkvKind::CoopF16Vk,
9661            ..
9662        } => matches!(prev, Step::Unary { .. } | Step::CastF32ToF16 { .. }),
9663        _ => false,
9664    }
9665}
9666
9667fn dispatch_wide_f32_matmul(
9668    pass: &mut wgpu::ComputePass<'_>,
9669    mm_w_active: &Kernel,
9670    mm_k: &Kernel,
9671    m_s: u32,
9672    n: u32,
9673    batch: u32,
9674) {
9675    // Tile-size selection differs by GPU backend.
9676    //
9677    // **Vulkan / DX12** (`matmul_wide_nv`, 64×64 tile): when `m_s < 64`
9678    // the bottom rows of every workgroup's M-axis tile contain padded
9679    // zeros that the kernel still computes and writes back — pure
9680    // wasted work on small-M shapes like BERT-base prefill (m=32). The
9681    // regular 32×32-tile kernel sidesteps the M-axis padding and is
9682    // ~8% faster end-to-end on RTX 4090 (verified on Bio_ClinicalBERT:
9683    // encoder forward 58.9 ms → 54.1 ms at cosine 0.9999995 vs HF).
9684    //
9685    // **Metal / other** (`matmul_wide`, 64×64 tile): the wider tile
9686    // wins even on small M — Apple GPUs prefer the larger workgroup
9687    // and amortize the M-padding well. Forcing the 32×32 kernel here
9688    // regresses Mac WGPU encoder time (26.6 → 29.1 ms verified).
9689    let backend = wgpu_device()
9690        .map(|d| d.backend)
9691        .unwrap_or(wgpu::Backend::Noop);
9692    let is_vulkan_dx12 = matches!(backend, wgpu::Backend::Vulkan | wgpu::Backend::Dx12);
9693    let prefer_small_for_m = is_vulkan_dx12 && m_s < 64;
9694    let use_wide = !prefer_small_for_m && m_s >= 32 && n >= 64;
9695    if use_wide {
9696        pass.set_pipeline(&mm_w_active.pipeline);
9697        let (gx, gy) = if is_vulkan_dx12 {
9698            (n.div_ceil(64), m_s.div_ceil(64))
9699        } else {
9700            (n.div_ceil(64), m_s.div_ceil(32))
9701        };
9702        pass.dispatch_workgroups(gx, gy, batch);
9703    } else {
9704        pass.set_pipeline(&mm_k.pipeline);
9705        pass.dispatch_workgroups(n.div_ceil(32), m_s.div_ceil(32), batch);
9706    }
9707}
9708
9709fn coop_f16_vk_bind_group(exe: &WgpuExecutable, gpu_bi: usize, use_wide: bool) -> &wgpu::BindGroup {
9710    if use_wide {
9711        exe.coop_f16_vk_wide_bind_groups
9712            .get(&gpu_bi)
9713            .unwrap_or(&exe.bind_groups[gpu_bi])
9714    } else {
9715        &exe.bind_groups[gpu_bi]
9716    }
9717}
9718
9719fn require_equal_shapes(graph: &Graph, ids: &[NodeId], op_name: &str) {
9720    let s0 = graph.node(ids[0]).shape.num_elements().unwrap_or(0);
9721    for &id in &ids[1..] {
9722        let si = graph.node(id).shape.num_elements().unwrap_or(0);
9723        if si != s0 {
9724            panic!(
9725                "rlx-wgpu {op_name}: broadcasting not yet implemented; \
9726                    inputs must have the same element count (got {s0} vs {si})"
9727            );
9728        }
9729    }
9730}
9731
9732/// Bind the entire arena in one storage buffer range when it fits the device limit.
9733fn arena_whole_arena_bind(arena: &Arena, max_binding: u64) -> Option<(u64, u64)> {
9734    let need = arena.size as u64;
9735    if need > max_binding {
9736        return None;
9737    }
9738    // Bind size must not exceed the allocated buffer (planner may leave a small tail gap).
9739    let buf_bytes = arena.buffer.size();
9740    let size = need.min(buf_bytes).max(256);
9741    Some((0, size))
9742}
9743
9744fn arena_window_for_nodes(dev: &wgpu::Device, arena: &Arena, ids: &[NodeId]) -> (u64, u64) {
9745    // wgpu requires storage buffer binding offsets aligned to 256 bytes.
9746    const ALIGN: u64 = 256;
9747    let max_binding = dev.limits().max_storage_buffer_binding_size;
9748    if let Some(w) = arena_whole_arena_bind(arena, max_binding) {
9749        return w;
9750    }
9751    let mut lo: u64 = u64::MAX;
9752    let mut hi: u64 = 0;
9753    for &id in ids {
9754        let off = arena.offset(id) as u64;
9755        let len = arena.len_of(id) as u64;
9756        lo = lo.min(off);
9757        hi = hi.max(off.saturating_add(len));
9758    }
9759    if lo == u64::MAX {
9760        return (0, max_binding.max(256));
9761    }
9762    let span = hi.saturating_sub(lo).max(1);
9763    if span > max_binding {
9764        let mut details = String::new();
9765        for &id in ids.iter().take(6) {
9766            let off = arena.offset(id);
9767            let len = arena.len_of(id);
9768            details.push_str(&format!(" id={id:?}@{off}+{len};"));
9769        }
9770        panic!(
9771            "rlx-wgpu: op needs {} bytes of arena span (>{});{}",
9772            span, max_binding, details
9773        );
9774    }
9775    let mut base = (lo / ALIGN) * ALIGN;
9776    // Bind only the byte span the op needs (not the full 4 GiB cap) so we
9777    // don't slide the window to the arena tail and drop low-offset tensors.
9778    let mut size = span.div_ceil(ALIGN) * ALIGN;
9779    size = size.max(256).min(max_binding);
9780    if base.saturating_add(size) > arena.size as u64 {
9781        base = (arena.size as u64).saturating_sub(size);
9782        base = (base / ALIGN) * ALIGN;
9783    }
9784    if base > lo || base.saturating_add(size) < hi {
9785        base = (lo / ALIGN) * ALIGN;
9786        size = hi.saturating_sub(base).div_ceil(ALIGN) * ALIGN;
9787        size = size.max(256).min(max_binding);
9788        if base.saturating_add(size) > arena.size as u64 {
9789            base = hi.saturating_sub(size);
9790            base = (base / ALIGN) * ALIGN;
9791        }
9792    }
9793    (base, size)
9794}
9795
9796fn arena_local_off_f32(arena: &Arena, id: NodeId, base: u64) -> u32 {
9797    (((arena.offset(id) as u64).saturating_sub(base)) / 4) as u32
9798}
9799
9800/// Split-binding embedding gather for >4 GiB arenas (see `Step::GatherSplit`).
9801///
9802/// The table and the idx/output slots can be more than one ≤4 GiB binding
9803/// window apart, so they're bound as separate read-only windows of the arena;
9804/// the output goes to a dedicated read-write buffer that is copied back into
9805/// the arena afterwards (the arena cannot also be bound read-write in the same
9806/// dispatch — wgpu treats STORAGE_READ_WRITE as exclusive per buffer). Mirrors
9807/// [`crate::gguf_gpu::run_dequant_matmul_gguf_gemv`].
9808#[allow(clippy::too_many_arguments)]
9809fn run_gather_split(
9810    arena: &Arena,
9811    device: &wgpu::Device,
9812    queue: &wgpu::Queue,
9813    n_out: u32,
9814    n_idx: u32,
9815    dim: u32,
9816    vocab: u32,
9817    table_byte_off: usize,
9818    idx_byte_off: usize,
9819    out_byte_off: usize,
9820) {
9821    const ALIGN: u64 = 256;
9822    let arena_size = arena.size as u64;
9823    let max_bind = device.limits().max_storage_buffer_binding_size;
9824
9825    // Table window: cover [table_byte_off, + vocab*dim*4).
9826    let t0 = table_byte_off as u64;
9827    let t_bytes = (vocab as u64) * (dim as u64) * 4;
9828    let t_base = (t0 / ALIGN) * ALIGN;
9829    let t_size = ((t0 + t_bytes - t_base).div_ceil(16) * 16).min(arena_size - t_base);
9830
9831    // Index window: cover [idx_byte_off, + n_idx*4).
9832    let i0 = idx_byte_off as u64;
9833    let i_bytes = ((n_idx as u64) * 4).max(4);
9834    let i_base = (i0 / ALIGN) * ALIGN;
9835    let i_size = ((i0 + i_bytes - i_base).div_ceil(16) * 16).min(arena_size - i_base);
9836
9837    assert!(
9838        t_size <= max_bind && i_size <= max_bind,
9839        "rlx-wgpu gather_split: window too large (table={t_size}, idx={i_size}, max={max_bind})"
9840    );
9841
9842    // Separate output buffer (rw) — copied into the arena after the dispatch.
9843    let out_bytes = ((n_out as u64) * 4).max(4);
9844    let out_buf = device.create_buffer(&wgpu::BufferDescriptor {
9845        label: Some("rlx-wgpu gather_split out"),
9846        size: out_bytes.div_ceil(16) * 16,
9847        usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
9848        mapped_at_creation: false,
9849    });
9850
9851    let p = GatherParams {
9852        n_out,
9853        n_idx,
9854        dim,
9855        vocab,
9856        in_off: ((t0 - t_base) / 4) as u32,
9857        idx_off: ((i0 - i_base) / 4) as u32,
9858        out_off: 0,
9859        _p0: 0,
9860    };
9861    let u = device.create_buffer(&wgpu::BufferDescriptor {
9862        label: Some("rlx-wgpu gather_split uniform"),
9863        size: std::mem::size_of::<GatherParams>() as u64,
9864        usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
9865        mapped_at_creation: false,
9866    });
9867    queue.write_buffer(&u, 0, bytemuck::bytes_of(&p));
9868
9869    let gk = gather_split_kernel(device);
9870    let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
9871        label: Some("rlx-wgpu gather_split bg"),
9872        layout: &gk.bgl,
9873        entries: &[
9874            wgpu::BindGroupEntry {
9875                binding: 0,
9876                resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
9877                    buffer: &arena.buffer,
9878                    offset: t_base,
9879                    size: wgpu::BufferSize::new(t_size),
9880                }),
9881            },
9882            wgpu::BindGroupEntry {
9883                binding: 1,
9884                resource: u.as_entire_binding(),
9885            },
9886            wgpu::BindGroupEntry {
9887                binding: 2,
9888                resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
9889                    buffer: &arena.buffer,
9890                    offset: i_base,
9891                    size: wgpu::BufferSize::new(i_size),
9892                }),
9893            },
9894            wgpu::BindGroupEntry {
9895                binding: 3,
9896                resource: out_buf.as_entire_binding(),
9897            },
9898        ],
9899    });
9900
9901    let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
9902        label: Some("rlx-wgpu gather_split"),
9903    });
9904    {
9905        let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
9906            label: Some("rlx-wgpu gather_split pass"),
9907            ..Default::default()
9908        });
9909        pass.set_pipeline(&gk.pipeline);
9910        pass.set_bind_group(0, &bg, &[]);
9911        let (gx, gy, gz) = dispatch_dims(n_out, 64);
9912        pass.dispatch_workgroups(gx, gy, gz);
9913    }
9914    // Copy the embedding back into the arena (distinct buffers → legal).
9915    enc.copy_buffer_to_buffer(&out_buf, 0, &arena.buffer, out_byte_off as u64, out_bytes);
9916    queue.submit(std::iter::once(enc.finish()));
9917}
9918
9919fn arena_tensor_in_window(arena: &Arena, id: NodeId, base: u64, size: u64) -> bool {
9920    let src = arena.offset(id) as u64;
9921    let len = arena.len_of(id) as u64;
9922    src >= base && src.saturating_add(len) <= base.saturating_add(size)
9923}
9924
9925/// True when two planned arena slots share any byte (memory planner reuse).
9926fn arena_tensors_overlap(arena: &Arena, a: NodeId, b: NodeId) -> bool {
9927    if a == b {
9928        return true;
9929    }
9930    let (a0, al) = (arena.offset(a) as u64, arena.len_of(a) as u64);
9931    let (b0, bl) = (arena.offset(b) as u64, arena.len_of(b) as u64);
9932    if al == 0 || bl == 0 {
9933        return false;
9934    }
9935    let a1 = a0.saturating_add(al);
9936    let b1 = b0.saturating_add(bl);
9937    a0 < b1 && b0 < a1
9938}
9939
9940/// True when a matmul reads its weight `B` from the separate f16 shadow buffer
9941/// (so `B` is NOT bound through the arena binding). For these precisions the
9942/// arena window must cover only the activation + output, never the weight.
9943fn matmul_b_from_f16(precision: MatmulCompute, b_is_param: bool) -> bool {
9944    b_is_param
9945        && matches!(
9946            precision,
9947            MatmulCompute::F16 | MatmulCompute::Coop16 | MatmulCompute::CoopF16Vk
9948        )
9949}
9950
9951/// Arena bind window for matmul: when the weight alone fits the bind limit but
9952/// activations + weight do not, anchor on the param tensor (e.g. tied `LmHead`).
9953fn arena_matmul_bind_window(
9954    device: &wgpu::Device,
9955    arena: &Arena,
9956    graph: &Graph,
9957    param_offsets: &HashMap<String, NodeId>,
9958    out_id: NodeId,
9959    a_id: NodeId,
9960    b_id: NodeId,
9961    b_in_arena: bool,
9962) -> (u64, u64, bool) {
9963    let max_binding = device.limits().max_storage_buffer_binding_size;
9964    if let Some((base, size)) = arena_whole_arena_bind(arena, max_binding) {
9965        return (base, size, false);
9966    }
9967    if !b_in_arena {
9968        // B is read from the separate f16 shadow buffer (F16 / Coop16 /
9969        // CoopF16Vk), so the arena binding only carries the activation A and
9970        // the output C. Anchor on [out, a] — NOT on B. Anchoring on a >4 GiB
9971        // param B (e.g. the tied F32 lm_head/embedding) would push the window
9972        // onto the weight and leave C outside it, dropping the output write
9973        // (logits stayed mostly zero → argmax = STOP).
9974        let (base, size) = arena_window_for_nodes(device, arena, &[out_id, a_id]);
9975        return (base, size, false);
9976    }
9977    let ids = [out_id, a_id, b_id];
9978    let all_fits = arena_span_bytes(arena, &ids) <= max_binding;
9979    let b_bytes = arena.len_of(b_id) as u64;
9980    let b_is_param = tensor_is_graph_param(graph, param_offsets, b_id);
9981    let param_anchor =
9982        b_is_param && b_bytes <= max_binding && (!all_fits || b_bytes > ARENA_STAGE_CAP);
9983    let (mut base, mut size) = if param_anchor {
9984        arena_window_for_nodes(device, arena, &[b_id])
9985    } else if all_fits {
9986        arena_window_for_nodes(device, arena, &ids)
9987    } else {
9988        arena_window_for_nodes(device, arena, &[out_id])
9989    };
9990    let param_anchor = param_anchor
9991        || (b_is_param
9992            && b_bytes <= max_binding
9993            && !arena_tensor_in_window(arena, b_id, base, size));
9994    if param_anchor && !arena_tensor_in_window(arena, b_id, base, size) {
9995        (base, size) = arena_window_for_nodes(device, arena, &[b_id]);
9996    }
9997    (base, size, param_anchor)
9998}
9999
10000/// Grow `[base, base+size)` to cover all listed tensors when the span still
10001/// fits `max_storage_buffer_binding_size` (avoids spurious staging copies).
10002fn arena_expand_bind_window(
10003    arena: &Arena,
10004    ids: &[NodeId],
10005    base: &mut u64,
10006    size: &mut u64,
10007    max_binding: u64,
10008) {
10009    const ALIGN: u64 = 256;
10010    let mut lo = *base;
10011    let mut hi = base.saturating_add(*size);
10012    for &id in ids {
10013        let off = arena.offset(id) as u64;
10014        let len = arena.len_of(id) as u64;
10015        lo = lo.min(off);
10016        hi = hi.max(off.saturating_add(len));
10017    }
10018    let span = hi.saturating_sub(lo).max(1);
10019    if span > max_binding {
10020        return;
10021    }
10022    *base = (lo / ALIGN) * ALIGN;
10023    *size = span.div_ceil(ALIGN) * ALIGN;
10024    *size = (*size).max(256).min(max_binding);
10025    if (*base).saturating_add(*size) > arena.size as u64 {
10026        *base = (arena.size as u64).saturating_sub(*size);
10027        *base = (*base / ALIGN) * ALIGN;
10028    }
10029}
10030
10031fn arena_off_in_bind_window(
10032    graph: &Graph,
10033    param_offsets: &HashMap<String, NodeId>,
10034    device: &wgpu::Device,
10035    arena: &Arena,
10036    schedule: &mut Vec<Step>,
10037    scratch: &mut u64,
10038    id: NodeId,
10039    base: &mut u64,
10040    size: &mut u64,
10041) -> u32 {
10042    let max_binding = device.limits().max_storage_buffer_binding_size;
10043    if let Some((b, s)) = arena_whole_arena_bind(arena, max_binding) {
10044        *base = b;
10045        *size = s;
10046        return arena_local_off_f32(arena, id, b);
10047    }
10048    if arena_tensor_in_window(arena, id, *base, *size) {
10049        arena_local_off_f32(arena, id, *base)
10050    } else {
10051        let len = arena.len_of(id) as u64;
10052        if tensor_is_graph_param(graph, param_offsets, id) && len > max_binding {
10053            panic!(
10054                "rlx-wgpu: param node {:?} ({} bytes) exceeds max_storage_buffer_binding_size \
10055                 ({max_binding}); split weights or use f16 shadow binds",
10056                id, len
10057            );
10058        }
10059        if len > ARENA_STAGE_CAP {
10060            let op = &graph.node(id).op;
10061            panic!(
10062                "rlx-wgpu: bind_window would stage {} bytes for {:?} op={op:?} \
10063                 (off={}, base={}, bind_size={})",
10064                len,
10065                id,
10066                arena.offset(id),
10067                *base,
10068                *size,
10069            );
10070        }
10071        arena_off_in_window_or_stage(arena, schedule, scratch, base, size, max_binding, id)
10072    }
10073}
10074
10075/// Bind window for ops that read/write multiple arena tensors (conv, concat, …).
10076/// Returns `(base, size)` and rebased f32 offsets; stages operands that fall outside
10077/// the window when the full span exceeds `max_storage_buffer_binding_size`.
10078fn arena_multi_op_window(
10079    dev: &wgpu::Device,
10080    arena: &Arena,
10081    graph: &Graph,
10082    param_offsets: &HashMap<String, NodeId>,
10083    _schedule: &mut Vec<Step>,
10084    scratch: &mut u64,
10085    ids: &[NodeId],
10086) -> (u64, u64, bool) {
10087    let max_binding = dev.limits().max_storage_buffer_binding_size;
10088    if let Some((base, size)) = arena_whole_arena_bind(arena, max_binding) {
10089        *scratch = arena.scratch_off as u64;
10090        return (base, size, false);
10091    }
10092    let param_anchor = if arena_span_bytes(arena, ids) > max_binding {
10093        ids.iter()
10094            .find(|&&id| {
10095                let nbytes = arena.len_of(id) as u64;
10096                tensor_is_graph_param(graph, param_offsets, id) && nbytes <= max_binding
10097            })
10098            .copied()
10099    } else {
10100        None
10101    };
10102    let mut param_anchored = param_anchor.is_some();
10103    let (mut base, mut size) = if arena_span_bytes(arena, ids) <= max_binding {
10104        arena_window_for_nodes(dev, arena, ids)
10105    } else if let Some(id) = param_anchor {
10106        arena_window_for_nodes(dev, arena, &[id])
10107    } else {
10108        arena_window_for_nodes(dev, arena, &[ids[0]])
10109    };
10110    if let Some(id) = param_anchor {
10111        if !arena_tensor_in_window(arena, id, base, size) {
10112            (base, size) = arena_window_for_nodes(dev, arena, &[id]);
10113        }
10114        param_anchored = true;
10115    } else {
10116        for &id in ids {
10117            let nbytes = arena.len_of(id) as u64;
10118            if tensor_is_graph_param(graph, param_offsets, id)
10119                && nbytes <= max_binding
10120                && !arena_tensor_in_window(arena, id, base, size)
10121            {
10122                (base, size) = arena_window_for_nodes(dev, arena, &[id]);
10123                param_anchored = true;
10124                break;
10125            }
10126        }
10127    }
10128    *scratch = arena.scratch_off as u64;
10129    if param_anchored {
10130        arena_ensure_scratch_in_window(scratch, base, size);
10131    }
10132    (base, size, param_anchored)
10133}
10134
10135fn arena_bind_window_covering_scratch_if_needed(
10136    arena: &Arena,
10137    base: u64,
10138    size: u64,
10139    scratch: u64,
10140) -> u64 {
10141    // Planner places scratch at the arena tail; do not relocate the bind
10142    // window until this op has actually started staging into scratch.
10143    if scratch <= arena.scratch_off as u64 {
10144        return base;
10145    }
10146    if scratch >= base && scratch.saturating_add(ARENA_STAGE_CAP) <= base.saturating_add(size) {
10147        return base;
10148    }
10149    arena_window_covering_scratch(arena, base, size)
10150}
10151
10152/// Keep staging writes inside `[base, base+size)` when the bind window is anchored on a
10153/// param far from the arena tail scratch zone.
10154fn arena_ensure_scratch_in_window(scratch: &mut u64, base: u64, size: u64) {
10155    let cap = ARENA_STAGE_CAP.min(size);
10156    let end = base.saturating_add(size);
10157    if *scratch < base || scratch.saturating_add(cap) > end {
10158        *scratch = end.saturating_sub(cap);
10159        *scratch = (*scratch / 256) * 256;
10160    }
10161}
10162
10163#[allow(dead_code)]
10164fn arena_off_for_window(
10165    arena: &Arena,
10166    schedule: &mut Vec<Step>,
10167    scratch: &mut u64,
10168    id: NodeId,
10169    _window_ids: &[NodeId],
10170    mut base: u64,
10171    mut size: u64,
10172    max_binding: u64,
10173    _fits_in_one_binding: bool,
10174) -> u32 {
10175    let src = arena.offset(id) as u64;
10176    let len = arena.len_of(id) as u64;
10177    if src >= base && src.saturating_add(len) <= base.saturating_add(size) {
10178        arena_local_off_f32(arena, id, base)
10179    } else {
10180        arena_off_in_window_or_stage(
10181            arena,
10182            schedule,
10183            scratch,
10184            &mut base,
10185            &mut size,
10186            max_binding,
10187            id,
10188        )
10189    }
10190}
10191
10192/// f16 shadow buffer window matching an f32 arena bind `[arena_base, arena_base+arena_size)`.
10193fn f16_shadow_bind_range(arena_base: u64, arena_size: u64, f16_buf_bytes: u64) -> (u64, u64) {
10194    const ALIGN: u64 = 256;
10195    let mut base = (arena_base / 2 / ALIGN) * ALIGN;
10196    let mut size = (arena_size / 2).div_ceil(ALIGN) * ALIGN;
10197    size = size.max(256).min(f16_buf_bytes);
10198    if base.saturating_add(size) > f16_buf_bytes {
10199        base = f16_buf_bytes.saturating_sub(size);
10200        base = (base / ALIGN) * ALIGN;
10201    }
10202    (base, size)
10203}
10204
10205/// Window into `f16_buffer` for matmul weight reads (`params.b_off` is in
10206/// f16-element indices, matching the f32 arena word index).
10207fn f16_weight_bind_range(
10208    dev: &wgpu::Device,
10209    f16_buf_bytes: u64,
10210    b_off: u32,
10211    k: u32,
10212    n: u32,
10213    batch: u32,
10214    b_batch_stride: u32,
10215) -> (u64, u64, u32) {
10216    const ALIGN: u64 = 256;
10217    let max_binding = dev.limits().max_storage_buffer_binding_size;
10218    let b0 = b_off as u64;
10219    let span = (k as u64).saturating_mul(n as u64);
10220    let batch_n = batch.max(1) as u64;
10221    let stride = if batch_n > 1 {
10222        b_batch_stride as u64
10223    } else {
10224        span
10225    };
10226    let hi_elems = b0
10227        .saturating_add((batch_n - 1).saturating_mul(stride))
10228        .saturating_add(span);
10229    let lo_byte = b0.saturating_mul(2);
10230    let hi_byte = hi_elems.saturating_mul(2).saturating_add(8);
10231    let need = hi_byte.saturating_sub(lo_byte).max(1);
10232    if need > max_binding {
10233        panic!(
10234            "rlx-wgpu: f16 weight region needs {need} bytes (> {max_binding}); \
10235             matmul k={k} n={n} batch={batch}"
10236        );
10237    }
10238    let mut base = (lo_byte / ALIGN) * ALIGN;
10239    let mut size = need.div_ceil(ALIGN) * ALIGN;
10240    size = size.max(256).min(max_binding).min(f16_buf_bytes);
10241    if base.saturating_add(size) < hi_byte {
10242        base = hi_byte.saturating_sub(size);
10243        base = (base / ALIGN) * ALIGN;
10244    }
10245    if base.saturating_add(size) > f16_buf_bytes {
10246        base = f16_buf_bytes.saturating_sub(size);
10247        base = (base / ALIGN) * ALIGN;
10248    }
10249    let rebased = b_off.saturating_sub((base / 2) as u32);
10250    (base, size, rebased)
10251}
10252
10253const ARENA_STAGE_CAP: u64 = 256 * 1024 * 1024;
10254
10255/// Output spatial positions computed per thread by `conv2d.wgsl` (register
10256/// tiling for weight reuse). MUST equal `TILE` in that kernel.
10257const CONV2D_TILE: u32 = 4;
10258
10259/// Return a window-local f32 offset, staging into scratch when the tensor lies
10260/// outside the bind window (via `copy_buffer_to_buffer`).
10261fn arena_off_in_window_or_stage(
10262    arena: &Arena,
10263    schedule: &mut Vec<Step>,
10264    scratch: &mut u64,
10265    base: &mut u64,
10266    size: &mut u64,
10267    max_binding: u64,
10268    id: NodeId,
10269) -> u32 {
10270    let src = arena.offset(id) as u64;
10271    let len = arena.len_of(id) as u64;
10272    if src >= *base && src.saturating_add(len) <= (*base).saturating_add(*size) {
10273        return arena_local_off_f32(arena, id, *base);
10274    }
10275    if len > ARENA_STAGE_CAP {
10276        panic!(
10277            "rlx-wgpu: cannot stage {} bytes for node {:?} (cap {ARENA_STAGE_CAP})",
10278            len, id
10279        );
10280    }
10281    let aligned = len.div_ceil(256) * 256;
10282    let dst = *scratch;
10283    *scratch = scratch.saturating_add(aligned);
10284    schedule.push(Step::BufferCopy {
10285        src_byte_off: src,
10286        dst_byte_off: dst,
10287        bytes: len as u32,
10288    });
10289    let lo = (*base).min(dst);
10290    let hi = (*base)
10291        .saturating_add(*size)
10292        .max(dst.saturating_add(aligned));
10293    let span = hi.saturating_sub(lo).max(1);
10294    if span <= max_binding {
10295        const ALIGN: u64 = 256;
10296        *base = (lo / ALIGN) * ALIGN;
10297        *size = span.div_ceil(ALIGN) * ALIGN;
10298        *size = (*size).max(256).min(max_binding);
10299        if (*base).saturating_add(*size) > arena.size as u64 {
10300            *base = (arena.size as u64).saturating_sub(*size);
10301            *base = (*base / ALIGN) * ALIGN;
10302        }
10303    }
10304    if arena_tensor_in_window(arena, id, *base, *size) {
10305        arena_local_off_f32(arena, id, *base)
10306    } else {
10307        ((dst.saturating_sub(*base)) / 4) as u32
10308    }
10309}
10310
10311/// If scratch does not fall inside `[base, base+size)`, slide the window to the tail.
10312fn arena_window_covering_scratch(arena: &Arena, base: u64, size: u64) -> u64 {
10313    let scratch = arena.scratch_off as u64;
10314    if scratch >= base && scratch.saturating_add(ARENA_STAGE_CAP) <= base.saturating_add(size) {
10315        return base;
10316    }
10317    let new_base = (arena.size as u64).saturating_sub(size);
10318    (new_base / 256) * 256
10319}
10320
10321fn arena_span_bytes(arena: &Arena, ids: &[NodeId]) -> u64 {
10322    let mut lo: u64 = u64::MAX;
10323    let mut hi: u64 = 0;
10324    for &id in ids {
10325        let off = arena.offset(id) as u64;
10326        let len = arena.len_of(id) as u64;
10327        lo = lo.min(off);
10328        hi = hi.max(off.saturating_add(len));
10329    }
10330    if lo == u64::MAX {
10331        0
10332    } else {
10333        hi.saturating_sub(lo)
10334    }
10335}
10336
10337#[allow(dead_code)]
10338fn bind_two(
10339    device: &wgpu::Device,
10340    kernel: &Kernel,
10341    buf0: &wgpu::Buffer,
10342    buf1: &wgpu::Buffer,
10343) -> wgpu::BindGroup {
10344    let max_binding = device.limits().max_storage_buffer_binding_size;
10345    if buf0.size() > max_binding {
10346        panic!(
10347            "rlx-wgpu: bind_two buffer {} bytes exceeds max_storage_buffer_binding_size {}; \
10348             use bind_two_buf0_window or bind_op_output_window",
10349            buf0.size(),
10350            max_binding
10351        );
10352    }
10353    device.create_bind_group(&wgpu::BindGroupDescriptor {
10354        label: Some("rlx-wgpu bg"),
10355        layout: &kernel.bgl,
10356        entries: &[
10357            wgpu::BindGroupEntry {
10358                binding: 0,
10359                resource: buf0.as_entire_binding(),
10360            },
10361            wgpu::BindGroupEntry {
10362                binding: 1,
10363                resource: buf1.as_entire_binding(),
10364            },
10365        ],
10366    })
10367}
10368
10369/// Windowed arena bind. When `operand_ids` is non-empty and their span with
10370/// `out_id` exceeds the binding limit, falls back to output-only window
10371/// (callers should stage operands and rebase offsets).
10372fn bind_op_output_window(
10373    device: &wgpu::Device,
10374    kernel: &Kernel,
10375    arena: &Arena,
10376    out_id: NodeId,
10377    params: &wgpu::Buffer,
10378) -> wgpu::BindGroup {
10379    bind_op_window(device, kernel, arena, &[out_id], params)
10380}
10381
10382fn bind_op_window(
10383    device: &wgpu::Device,
10384    kernel: &Kernel,
10385    arena: &Arena,
10386    ids: &[NodeId],
10387    params: &wgpu::Buffer,
10388) -> wgpu::BindGroup {
10389    let max_binding = device.limits().max_storage_buffer_binding_size;
10390    let (base, size) = if arena_span_bytes(arena, ids) <= max_binding {
10391        arena_window_for_nodes(device, arena, ids)
10392    } else {
10393        arena_window_for_nodes(device, arena, &[ids[0]])
10394    };
10395    bind_two_buf0_window(device, kernel, &arena.buffer, base, size, params)
10396}
10397
10398fn bind_two_buf0_window(
10399    device: &wgpu::Device,
10400    kernel: &Kernel,
10401    buf0: &wgpu::Buffer,
10402    buf0_base: u64,
10403    buf0_size: u64,
10404    buf1: &wgpu::Buffer,
10405) -> wgpu::BindGroup {
10406    device.create_bind_group(&wgpu::BindGroupDescriptor {
10407        label: Some("rlx-wgpu bg window"),
10408        layout: &kernel.bgl,
10409        entries: &[
10410            wgpu::BindGroupEntry {
10411                binding: 0,
10412                resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
10413                    buffer: buf0,
10414                    offset: buf0_base,
10415                    size: NonZeroU64::new(buf0_size),
10416                }),
10417            },
10418            wgpu::BindGroupEntry {
10419                binding: 1,
10420                resource: buf1.as_entire_binding(),
10421            },
10422        ],
10423    })
10424}
10425
10426/// Compute precision selector: derive from IR dtypes of A and B and
10427/// the device features.
10428///
10429/// Priority:
10430///   1. Coop16 — if EXPERIMENTAL_COOPERATIVE_MATRIX + SHADER_F16 +
10431///      F16 IR tag + b traces to a Param + M/K/N are 32/8/32 aligned.
10432///      Unlocks Apple's `simdgroup_matrix` / Vulkan's KHR_cooperative
10433///      hardware GEMM units (~18× faster than f32 ALU on Apple M-series).
10434///   2. F32 — every other case, *including* when AutoMixedPrecision
10435///      tagged the matmul as F16 but it failed Coop16's alignment
10436///      check. The non-coop F16 path (`matmul_f16_compute.wgsl`) was
10437///      empirically measured 4-5× SLOWER than the f32 baseline on
10438///      Apple via wgpu/naga 29 — the WGSL→MSL emit doesn't unlock
10439///      Apple's f16 ALU through portable WGSL ALU. So at small /
10440///      unaligned shapes we lose nothing by ignoring the IR's f16
10441///      tag and using f32 — precision improves AND speed wins.
10442///
10443/// (The F16 variant of `MatmulCompute` and `matmul_f16_compute.wgsl`
10444/// remain for future use — e.g. when naga gains a portable subgroup-
10445/// matrix surface that lowers efficiently without needing the full
10446/// coop-matrix dance, or when bf16 hardware lands. Today no path
10447/// dispatches them.)
10448fn derive_matmul_compute(
10449    dev: &wgpu::Device,
10450    graph: &Graph,
10451    mirror_acts: &HashSet<NodeId>,
10452    a_id: NodeId,
10453    b_id: NodeId,
10454    m: u32,
10455    k: u32,
10456    n: u32,
10457) -> MatmulCompute {
10458    if rlx_ir::env::flag("RLX_WGPU_MATMUL_F32_ONLY") {
10459        return MatmulCompute::F32;
10460    }
10461    use rlx_ir::DType;
10462    let a_dt = graph.node(a_id).shape.dtype();
10463    let b_dt = graph.node(b_id).shape.dtype();
10464    let any_low =
10465        matches!(a_dt, DType::F16 | DType::BF16) || matches!(b_dt, DType::F16 | DType::BF16);
10466    // CoopF32 (`simdgroup_float8x8`) needs K and N aligned to 8 and 32
10467    // (one micro-tile per K-iter, one 32-col workgroup per N-tile).
10468    // M can be arbitrary — the kernel pads to the next multiple of 32
10469    // and bounds-checks the output writes so out-of-range rows stay
10470    // untouched. (The Coop16 / matmul_qkv paths still require m%32==0;
10471    // their kernels don't have the same bounds check.)
10472    //
10473    // Vulkan uses `matmul_coop_f32_portable` (8×8 tiles, coopLoadT) which
10474    // only requires k%8 and n%8.
10475    let coop16_aligned = m.is_multiple_of(32) && k.is_multiple_of(8) && n.is_multiple_of(32);
10476    let coop_f32_metal_aligned = k.is_multiple_of(8) && n.is_multiple_of(32);
10477    let coop_f32_portable_aligned = k.is_multiple_of(8) && n.is_multiple_of(8);
10478    let has_coop = dev
10479        .features()
10480        .contains(wgpu::Features::EXPERIMENTAL_COOPERATIVE_MATRIX);
10481    let backend = crate::device::wgpu_device().map(|d| d.backend);
10482    // Coop16 has an f16 accumulator (Naga 29 can't compile the mixed
10483    // f32-acc / f16-operand form). Sums of 3072 BERT-FFN activations
10484    // overflow f16, so we only enter on F16/BF16 IR tags — AutoMixed
10485    // users have already opted into the precision tradeoff.
10486    if any_low
10487        && has_coop
10488        && dev.features().contains(wgpu::Features::SHADER_F16)
10489        && traces_to_param(graph, b_id)
10490        && coop16_aligned
10491    {
10492        return MatmulCompute::Coop16;
10493    }
10494    if !any_low && coop_f16_vk_eligible(dev, m, k, n) {
10495        if traces_to_param(graph, b_id)
10496            && !mirror_acts.contains(&a_id)
10497            && !mirror_acts.contains(&b_id)
10498        {
10499            return MatmulCompute::CoopF16Vk;
10500        }
10501    }
10502    // CoopF32 (`simdgroup_float8x8` on Apple): the f32 hardware-GEMM
10503    // path. Used whenever cooperative-matrix is available, B is a
10504    // Param, and shapes align — gives ~5-10× speedup over the
10505    // tiled `matmul_wide` path with no precision loss vs the f32
10506    // baseline (BERT max|Δ| stays at 2.3e-3 vs CPU on Apple).
10507    //
10508    // CoopF32: Metal-only by default. Vulkan portable 8×8 is opt-in via
10509    // RLX_WGPU_FORCE_COOP_F32 (RTX lacks 8×8 f32 coop; output is unreliable).
10510    let disabled = rlx_ir::env::flag("RLX_WGPU_NO_COOP_F32");
10511    let forced = rlx_ir::env::flag("RLX_WGPU_FORCE_COOP_F32");
10512    let metal_coop = !disabled
10513        && has_coop
10514        && coop_f32_metal_aligned
10515        && traces_to_param(graph, b_id)
10516        && (forced || matches!(backend, Some(wgpu::Backend::Metal)));
10517    let vulkan_coop = !disabled
10518        && has_coop
10519        && coop_f32_portable_aligned
10520        && traces_to_param(graph, b_id)
10521        && crate::device::coop_discrete_backend()
10522        && crate::device::coop_f32_8x8_supported();
10523    if metal_coop
10524        || vulkan_coop
10525        || (forced
10526            && has_coop
10527            && traces_to_param(graph, b_id)
10528            && (coop_f32_metal_aligned || coop_f32_portable_aligned))
10529    {
10530        return MatmulCompute::CoopF32;
10531    }
10532    MatmulCompute::F32
10533}
10534
10535/// Detects the BERT-style fused-QKV-then-narrow-then-attention
10536/// pattern. When all three of an attention's Q/K/V inputs are
10537/// `Op::Narrow` of a single source tensor on the last axis with
10538/// sequential offsets `(0, H·D, 2·H·D)` and equal lengths `H·D`,
10539/// returns `Some((qkv_source_node, h_d))` — naming the source
10540/// tensor and per-slice width.
10541///
10542/// EMPIRICAL FINDING: the obvious "skip the narrow + read attention
10543/// directly from QKV with stride 3·H·D" optimization REGRESSED end-
10544/// to-end perf 7-15× on Apple M4 Pro. The narrow's apparent overhead
10545/// (~3 dispatches per attention block, ~150µs at small batch) is
10546/// dwarfed by the cost of strided attention reads — stepping by
10547/// 3·H·D = 4.6 KB between sequence positions defeats the hardware
10548/// prefetcher (prefetch distance maxes around 1-2 KB on M-series).
10549/// Cosine stayed 0.9999+ (output is correct, just slow).
10550///
10551/// Kept as a helper for future smarter fusions — e.g. a coop kernel
10552/// that reads Q/K/V cooperatively from QKV in a single pass over
10553/// the sequence dim, avoiding the random-access stride pattern.
10554#[allow(dead_code)]
10555fn detect_qkv_narrow_pattern(
10556    graph: &Graph,
10557    q_id: NodeId,
10558    k_id: NodeId,
10559    v_id: NodeId,
10560) -> Option<(NodeId, u32)> {
10561    let unwrap_narrow = |id: NodeId| -> Option<(NodeId, usize, usize, usize)> {
10562        let node = graph.node(id);
10563        match &node.op {
10564            Op::Narrow { axis, start, len } => Some((node.inputs[0], *axis, *start, *len)),
10565            _ => None,
10566        }
10567    };
10568    let (q_src, q_axis, q_start, q_len) = unwrap_narrow(q_id)?;
10569    let (k_src, k_axis, k_start, k_len) = unwrap_narrow(k_id)?;
10570    let (v_src, v_axis, v_start, v_len) = unwrap_narrow(v_id)?;
10571    // Same source tensor.
10572    if q_src != k_src || k_src != v_src {
10573        return None;
10574    }
10575    // Equal slice widths (= H · D).
10576    if q_len != k_len || k_len != v_len {
10577        return None;
10578    }
10579    // Sequential offsets 0, H·D, 2·H·D.
10580    if q_start != 0 || k_start != q_len || v_start != q_len * 2 {
10581        return None;
10582    }
10583    // All on the LAST axis of the source.
10584    let src_rank = graph.node(q_src).shape.dims().len();
10585    if q_axis + 1 != src_rank || k_axis + 1 != src_rank || v_axis + 1 != src_rank {
10586        return None;
10587    }
10588    Some((q_src, q_len as u32))
10589}
10590
10591/// Detects the (FusedMatMulBiasAct → Narrow×3) split-QKV pattern that
10592/// shows up at the start of every BERT-style attention block. Returns
10593/// a map `parent_fmb_id → (q_narrow_id, k_narrow_id, v_narrow_id)`
10594/// for every site where the pattern can be replaced by one
10595/// `Step::MatmulQkv` dispatch.
10596///
10597/// Pattern requirements:
10598///   - Parent is `Op::FusedMatMulBiasAct { activation: None }` with
10599///     output shape `[..., 3·head_width]`.
10600///   - The parent's *only* consumers are exactly 3 `Op::Narrow` nodes,
10601///     all on the last axis, with offsets `(0, head_width, 2·head_width)`
10602///     and equal `len = head_width`.
10603///
10604/// The win is purely structural: same FMA work, but the 3 narrow
10605/// dispatches (and their full-tensor read+write of the QKV intermediate)
10606/// disappear. Different from the reverted "skip narrow + read attention
10607/// strided" approach because reads from each Q/K/V buffer remain
10608/// sequential — the prefetcher stays happy.
10609/// Detects (`Op::Binary(Add) → Op::LayerNorm`) where the Add has more
10610/// than one consumer in the graph — the case `FuseResidualLN` declines
10611/// because its single-consumer guard would force materializing the sum.
10612///
10613/// Returns:
10614///   - `ln_to_tee`: `ln_id → (h, delta, gamma, beta, sum_id)` so the
10615///     wgpu LayerNorm lowering can emit `Step::FusedResidualLnTee`
10616///     using the existing arena slot for the sum (= the Add's slot).
10617///   - `skip_adds`: the set of Add `NodeId`s whose normal Step emission
10618///     should be suppressed; their output value is written by the tee
10619///     step instead.
10620fn detect_residual_ln_tee_pattern(
10621    graph: &Graph,
10622) -> (
10623    HashMap<NodeId, (NodeId, NodeId, NodeId, NodeId, NodeId)>,
10624    HashSet<NodeId>,
10625) {
10626    use rlx_ir::op::BinaryOp;
10627    // Consumer counts (output references count once each).
10628    let mut consumers: HashMap<NodeId, usize> = HashMap::new();
10629    for node in graph.nodes() {
10630        for &input in &node.inputs {
10631            *consumers.entry(input).or_insert(0) += 1;
10632        }
10633    }
10634    for &out in &graph.outputs {
10635        *consumers.entry(out).or_insert(0) += 1;
10636    }
10637
10638    let mut ln_to_tee = HashMap::new();
10639    let mut skip_adds = HashSet::new();
10640    for node in graph.nodes() {
10641        let Op::LayerNorm { axis: _, eps: _ } = &node.op else {
10642            continue;
10643        };
10644        if node.inputs.len() < 3 {
10645            continue;
10646        } // need [in, gamma, beta]
10647        let in_id = node.inputs[0];
10648        let in_node = graph.node(in_id);
10649        if !matches!(in_node.op, Op::Binary(BinaryOp::Add)) {
10650            continue;
10651        }
10652        // Only fire when Add has >= 2 consumers (otherwise `FuseResidualLN`
10653        // already collapses it into Op::FusedResidualLN upstream).
10654        if consumers.get(&in_id).copied().unwrap_or(0) < 2 {
10655            continue;
10656        }
10657        // Add must be plain — both operands shape-equal to LN's input
10658        // and to each other.
10659        if in_node.inputs.len() != 2 {
10660            continue;
10661        }
10662        let h_id = in_node.inputs[0];
10663        let delta_id = in_node.inputs[1];
10664        if graph.node(h_id).shape.dims() != node.shape.dims() {
10665            continue;
10666        }
10667        if graph.node(delta_id).shape.dims() != node.shape.dims() {
10668            continue;
10669        }
10670        let gamma_id = node.inputs[1];
10671        let beta_id = node.inputs[2];
10672        ln_to_tee.insert(node.id, (h_id, delta_id, gamma_id, beta_id, in_id));
10673        skip_adds.insert(in_id);
10674    }
10675    (ln_to_tee, skip_adds)
10676}
10677
10678fn detect_split_qkv_pattern(graph: &Graph) -> HashMap<NodeId, (NodeId, NodeId, NodeId)> {
10679    // consumers[parent] = list of node ids that read parent
10680    let mut consumers: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
10681    for node in graph.nodes() {
10682        for &input in &node.inputs {
10683            consumers.entry(input).or_default().push(node.id);
10684        }
10685    }
10686    // Output nodes also count as consumers — would prevent QKV elision
10687    // if the matmul output is ever read externally.
10688    for &out_id in &graph.outputs {
10689        consumers.entry(out_id).or_default().push(NodeId(u32::MAX));
10690    }
10691
10692    let mut result = HashMap::new();
10693    for node in graph.nodes() {
10694        if !matches!(node.op, Op::FusedMatMulBiasAct { activation: None }) {
10695            continue;
10696        }
10697        let cs = match consumers.get(&node.id) {
10698            Some(c) if c.len() == 3 => c,
10699            _ => continue,
10700        };
10701        let dims = node.shape.dims();
10702        if dims.is_empty() {
10703            continue;
10704        }
10705        let last_axis = dims.len() - 1;
10706        let n = dims[last_axis].unwrap_static();
10707        if n % 3 != 0 {
10708            continue;
10709        }
10710        let head_width = n / 3;
10711
10712        // Each consumer must be a Narrow on the last axis, len = head_width.
10713        let mut narrows: Vec<(usize, NodeId)> = Vec::with_capacity(3);
10714        let mut all_match = true;
10715        for &c in cs {
10716            let cn = graph.node(c);
10717            match cn.op {
10718                Op::Narrow { axis, start, len }
10719                    if axis == last_axis && len == head_width && cn.inputs[0] == node.id =>
10720                {
10721                    narrows.push((start, c));
10722                }
10723                _ => {
10724                    all_match = false;
10725                    break;
10726                }
10727            }
10728        }
10729        if !all_match {
10730            continue;
10731        }
10732        narrows.sort_by_key(|&(start, _)| start);
10733        if narrows[0].0 != 0 || narrows[1].0 != head_width || narrows[2].0 != 2 * head_width {
10734            continue;
10735        }
10736        result.insert(node.id, (narrows[0].1, narrows[1].1, narrows[2].1));
10737    }
10738    result
10739}
10740
10741/// Walk through Cast/Reshape nodes (which alias the underlying arena
10742/// slot, per `plan_f32_uniform`) to find whether `id` ultimately
10743/// refers to an `Op::Param`. AutoMixedPrecision wraps params in
10744/// Cast(F32→F16) nodes, so a literal `matches!(node.op, Op::Param)`
10745/// check on the matmul's `b_id` would miss the Cast(Param) case.
10746fn node_is_arena_param(param_offsets: &HashMap<String, NodeId>, id: NodeId) -> bool {
10747    param_offsets.values().any(|&nid| nid == id)
10748}
10749
10750fn traces_to_param(graph: &Graph, mut id: NodeId) -> bool {
10751    loop {
10752        let node = graph.node(id);
10753        match &node.op {
10754            Op::Param { .. } => return true,
10755            Op::Cast { .. } | Op::Reshape { .. } | Op::Transpose { .. } => {
10756                if node.inputs.is_empty() {
10757                    return false;
10758                }
10759                id = node.inputs[0];
10760            }
10761            _ => return false,
10762        }
10763    }
10764}
10765
10766fn tensor_is_graph_param(
10767    graph: &Graph,
10768    param_offsets: &HashMap<String, NodeId>,
10769    id: NodeId,
10770) -> bool {
10771    node_is_arena_param(param_offsets, id) || traces_to_param(graph, id)
10772}
10773
10774fn traces_to_input(graph: &Graph, mut id: NodeId) -> bool {
10775    loop {
10776        let node = graph.node(id);
10777        match &node.op {
10778            Op::Input { .. } => return true,
10779            Op::Cast { .. } | Op::Reshape { .. } => {
10780                if node.inputs.is_empty() {
10781                    return false;
10782                }
10783                id = node.inputs[0];
10784            }
10785            _ => return false,
10786        }
10787    }
10788}
10789
10790/// Mirror A/B into the f16 shadow buffer before CoopF16Vk when the operand
10791/// is not already mirrored (Inputs/Params are written via `write_f32`).
10792fn schedule_uses_coop_f16_vk(schedule: &[Step]) -> bool {
10793    schedule.iter().any(|s| {
10794        matches!(
10795            s,
10796            Step::Matmul {
10797                compute_precision: MatmulCompute::CoopF16Vk,
10798                ..
10799            } | Step::MatmulQkv {
10800                kind: MatmulQkvKind::CoopF16Vk,
10801                ..
10802            }
10803        )
10804    })
10805}
10806
10807fn register_coop_f16_vk_b_param(
10808    map: &mut HashMap<u32, String>,
10809    param_offsets: &HashMap<String, NodeId>,
10810    b_id: NodeId,
10811    b_off_f32: u32,
10812    compute: MatmulCompute,
10813) {
10814    if compute != MatmulCompute::CoopF16Vk {
10815        return;
10816    }
10817    for (name, &id) in param_offsets {
10818        if id == b_id {
10819            map.insert(b_off_f32, name.clone());
10820            return;
10821        }
10822    }
10823}
10824
10825fn tensor_host_name(
10826    input_offsets: &HashMap<String, NodeId>,
10827    param_offsets: &HashMap<String, NodeId>,
10828    id: NodeId,
10829) -> String {
10830    for (name, &nid) in input_offsets {
10831        if nid == id {
10832            return name.clone();
10833        }
10834    }
10835    for (name, &nid) in param_offsets {
10836        if nid == id {
10837            return name.clone();
10838        }
10839    }
10840    panic!("rlx-wgpu: CoopF16Vk host activation source {id} is not an input or param");
10841}
10842
10843fn host_tensor_f32<'a>(
10844    name: &str,
10845    inputs: &'a [(&str, &[f32])],
10846    stashed_params: &'a HashMap<String, Vec<f32>>,
10847) -> Option<&'a [f32]> {
10848    inputs
10849        .iter()
10850        .find(|(n, _)| *n == name)
10851        .map(|(_, d)| *d)
10852        .or_else(|| stashed_params.get(name).map(|v| v.as_slice()))
10853}
10854
10855fn apply_activation_host(act: Activation, data: &[f32]) -> Vec<f32> {
10856    data.iter()
10857        .map(|&x| match act {
10858            Activation::Relu => x.max(0.0),
10859            Activation::Sigmoid => 1.0 / (1.0 + (-x).exp()),
10860            Activation::Tanh => x.tanh(),
10861            Activation::Exp => x.exp(),
10862            Activation::Log => x.ln(),
10863            Activation::Sqrt => x.sqrt(),
10864            Activation::Rsqrt => 1.0 / x.sqrt(),
10865            Activation::Neg => -x,
10866            Activation::Abs => x.abs(),
10867            Activation::Gelu | Activation::GeluApprox => {
10868                let c = 0.797_884_6_f32;
10869                let x3 = x * x * x;
10870                let inner = (c * (x + 0.044_715 * x3)).clamp(-15.0, 15.0);
10871                0.5 * x * (1.0 + inner.tanh())
10872            }
10873            Activation::Silu => {
10874                let nx = (-x).clamp(-88.0, 88.0);
10875                x / (1.0 + nx.exp())
10876            }
10877            Activation::Round => x.round(),
10878            Activation::Sin => x.sin(),
10879            Activation::Cos => x.cos(),
10880            Activation::Tan => x.tan(),
10881            Activation::Atan => x.atan(),
10882        })
10883        .collect()
10884}
10885
10886/// Activation node ids consumed as CoopF16Vk matmul A/B operands.
10887fn collect_coop_f16_vk_mirror_activations(graph: &Graph, dev: &wgpu::Device) -> HashSet<NodeId> {
10888    let mut acts = HashSet::new();
10889    for node in graph.nodes() {
10890        if !matches!(node.op, Op::MatMul) {
10891            continue;
10892        }
10893        let a_id = node.inputs[0];
10894        let b_id = node.inputs[1];
10895        let a_shape = graph.node(a_id).shape.dims();
10896        let b_shape = graph.node(b_id).shape.dims();
10897        if a_shape.len() != 2 || b_shape.len() != 2 {
10898            continue;
10899        }
10900        let m = a_shape[0].unwrap_static() as u32;
10901        let k = a_shape[1].unwrap_static() as u32;
10902        let n = b_shape[1].unwrap_static() as u32;
10903        if !coop_f16_vk_eligible(dev, m, k, n) || !traces_to_param(graph, b_id) {
10904            continue;
10905        }
10906        if matches!(graph.node(a_id).op, Op::Activation(_)) {
10907            acts.insert(a_id);
10908        }
10909        if matches!(graph.node(b_id).op, Op::Activation(_)) {
10910            acts.insert(b_id);
10911        }
10912    }
10913    acts
10914}
10915
10916/// When A/B are computed (not Input/Param), mirror f32 arena into f16 shadow
10917/// via `cast_f32_to_f16` before CoopF16Vk matmul (non-activation intermediates).
10918fn maybe_push_coop_f16_vk_casts(
10919    graph: &Graph,
10920    a_id: NodeId,
10921    b_id: NodeId,
10922    mirror_acts: &HashSet<NodeId>,
10923    device: &wgpu::Device,
10924    arena: &Arena,
10925    schedule: &mut Vec<Step>,
10926    uniforms: &mut Vec<wgpu::Buffer>,
10927    bind_groups: &mut Vec<wgpu::BindGroup>,
10928    mm_cast: &Option<&'static Kernel>,
10929    compute_precision: MatmulCompute,
10930    a_off_f32: u32,
10931    m: u32,
10932    k: u32,
10933    batch: u32,
10934    b_off_f32: u32,
10935    n: u32,
10936) {
10937    if compute_precision != MatmulCompute::CoopF16Vk {
10938        return;
10939    }
10940    let batch_n = batch.max(1);
10941    if !traces_to_input(graph, a_id)
10942        && !traces_to_param(graph, a_id)
10943        && !mirror_acts.contains(&a_id)
10944    {
10945        let a_elems = m.saturating_mul(k).saturating_mul(batch_n);
10946        let (base, size) = arena_window_for_nodes(device, arena, &[a_id]);
10947        push_cast_f32_to_f16_step(
10948            device,
10949            arena,
10950            base,
10951            size,
10952            schedule,
10953            uniforms,
10954            bind_groups,
10955            mm_cast,
10956            a_off_f32,
10957            a_elems,
10958        );
10959    }
10960    if !traces_to_input(graph, b_id)
10961        && !traces_to_param(graph, b_id)
10962        && !mirror_acts.contains(&b_id)
10963    {
10964        let b_elems = k.saturating_mul(n).saturating_mul(batch_n);
10965        let (base, size) = arena_window_for_nodes(device, arena, &[b_id]);
10966        push_cast_f32_to_f16_step(
10967            device,
10968            arena,
10969            base,
10970            size,
10971            schedule,
10972            uniforms,
10973            bind_groups,
10974            mm_cast,
10975            b_off_f32,
10976            b_elems,
10977        );
10978    }
10979}
10980
10981fn build_matmul_qkv_coop_f16_vk_bind_group(
10982    device: &wgpu::Device,
10983    mqk: &Kernel,
10984    arena: &Arena,
10985    arena_base: u64,
10986    arena_size: u64,
10987    params: &wgpu::Buffer,
10988    k: u32,
10989    n: u32,
10990    b_off: u32,
10991) -> (wgpu::BindGroup, u32) {
10992    let f16_buf = arena
10993        .f16_buffer
10994        .as_ref()
10995        .expect("CoopF16Vk QKV requires SHADER_F16 f16 shadow arena");
10996    let (f16_res, rebased_b) = {
10997        let (base, size, rebased) =
10998            f16_weight_bind_range(device, f16_buf.size(), b_off, k, n, 1, 0);
10999        (
11000            wgpu::BindingResource::Buffer(wgpu::BufferBinding {
11001                buffer: f16_buf,
11002                offset: base,
11003                size: NonZeroU64::new(size),
11004            }),
11005            rebased,
11006        )
11007    };
11008    (
11009        device.create_bind_group(&wgpu::BindGroupDescriptor {
11010            label: Some("rlx-wgpu matmul_qkv_coop_f16_vk bg"),
11011            layout: &mqk.bgl,
11012            entries: &[
11013                wgpu::BindGroupEntry {
11014                    binding: 0,
11015                    resource: f16_res,
11016                },
11017                wgpu::BindGroupEntry {
11018                    binding: 1,
11019                    resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
11020                        buffer: &arena.buffer,
11021                        offset: arena_base,
11022                        size: NonZeroU64::new(arena_size),
11023                    }),
11024                },
11025                wgpu::BindGroupEntry {
11026                    binding: 2,
11027                    resource: params.as_entire_binding(),
11028                },
11029            ],
11030        }),
11031        rebased_b,
11032    )
11033}
11034/// Append a CastF32ToF16 pre-pass: mirrors `arena[off..off+len]` (f32) into
11035/// `arena_f16[off..off+len]` (f16) so coop matmul kernels can read operands
11036/// as f16. Used before CoopF16Vk when A/B are computed activations.
11037fn push_cast_f32_to_f16_step(
11038    device: &wgpu::Device,
11039    arena: &Arena,
11040    arena_base: u64,
11041    arena_size: u64,
11042    schedule: &mut Vec<Step>,
11043    uniforms: &mut Vec<wgpu::Buffer>,
11044    bind_groups: &mut Vec<wgpu::BindGroup>,
11045    mm_cast: &Option<&'static Kernel>,
11046    src_off: u32,
11047    len: u32,
11048) {
11049    let kernel = match mm_cast {
11050        Some(k) => *k,
11051        None => return, // device lacks SHADER_F16; fall through, dispatch will skip
11052    };
11053    let f16_buf = match &arena.f16_buffer {
11054        Some(b) => b,
11055        None => return,
11056    };
11057    let p = CastF32ToF16Params {
11058        src_off: src_off.saturating_sub((arena_base / 4) as u32),
11059        len,
11060        _p0: 0,
11061        _p1: 0,
11062    };
11063    let u = device.create_buffer(&wgpu::BufferDescriptor {
11064        label: Some("rlx-wgpu cast_f32_to_f16 uniform"),
11065        size: std::mem::size_of::<CastF32ToF16Params>() as u64,
11066        usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
11067        mapped_at_creation: false,
11068    });
11069    // Write params at compile (kernel doesn't depend on active extent).
11070    let dev = wgpu_device().expect("rlx-wgpu: device gone");
11071    dev.queue.write_buffer(&u, 0, bytemuck::bytes_of(&p));
11072    let (f16_base, f16_size) = f16_shadow_bind_range(arena_base, arena_size, f16_buf.size());
11073    let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
11074        label: Some("rlx-wgpu cast_f32_to_f16 bg"),
11075        layout: &kernel.bgl,
11076        entries: &[
11077            wgpu::BindGroupEntry {
11078                binding: 0,
11079                resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
11080                    buffer: f16_buf,
11081                    offset: f16_base,
11082                    size: NonZeroU64::new(f16_size),
11083                }),
11084            },
11085            wgpu::BindGroupEntry {
11086                binding: 1,
11087                resource: u.as_entire_binding(),
11088            },
11089            wgpu::BindGroupEntry {
11090                binding: 2,
11091                resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
11092                    buffer: &arena.buffer,
11093                    offset: arena_base,
11094                    size: NonZeroU64::new(arena_size),
11095                }),
11096            },
11097        ],
11098    });
11099    schedule.push(Step::CastF32ToF16 { params: p });
11100    uniforms.push(u);
11101    bind_groups.push(bg);
11102}
11103
11104/// Per-Matmul-step bind group builder. Returns `(bind_group, rebased_b_off)`;
11105/// `rebased_b_off` adjusts `MatmulParams.b_off` when the f16 weight buffer is
11106/// window-bound.
11107fn build_matmul_bind_group(
11108    device: &wgpu::Device,
11109    mm_k: &Kernel,
11110    _mm_w: &Kernel,
11111    mm_f16w: &Option<&'static Kernel>,
11112    mm_f16c: &Option<&'static Kernel>,
11113    mm_coop: &Option<&'static Kernel>,
11114    mm_coop_f32: &Option<&'static Kernel>,
11115    arena: &Arena,
11116    arena_base: u64,
11117    arena_size: u64,
11118    params: &wgpu::Buffer,
11119    b_is_param: bool,
11120    compute_precision: MatmulCompute,
11121    k: u32,
11122    n: u32,
11123    batch: u32,
11124    b_off: u32,
11125    b_batch_stride: u32,
11126) -> (wgpu::BindGroup, u32) {
11127    let f16_bind = |b_off: u32| -> (wgpu::BindingResource<'_>, u32) {
11128        let f16_buf = arena
11129            .f16_buffer
11130            .as_ref()
11131            .expect("f16 weight bind without f16_buffer");
11132        let (base, size, rebased) =
11133            f16_weight_bind_range(device, f16_buf.size(), b_off, k, n, batch, b_batch_stride);
11134        (
11135            wgpu::BindingResource::Buffer(wgpu::BufferBinding {
11136                buffer: f16_buf,
11137                offset: base,
11138                size: NonZeroU64::new(size),
11139            }),
11140            rebased,
11141        )
11142    };
11143    if compute_precision == MatmulCompute::CoopF16Vk
11144        && let (Some(coop_vk), Some(_f16_buf)) =
11145            (matmul_coop_f16_vulkan_kernel(device), &arena.f16_buffer)
11146    {
11147        let (f16_res, rebased_b) = f16_bind(b_off);
11148        return (
11149            device.create_bind_group(&wgpu::BindGroupDescriptor {
11150                label: Some("rlx-wgpu matmul_coop_f16_vulkan bg"),
11151                layout: &coop_vk.bgl,
11152                entries: &[
11153                    wgpu::BindGroupEntry {
11154                        binding: 0,
11155                        resource: f16_res,
11156                    },
11157                    wgpu::BindGroupEntry {
11158                        binding: 1,
11159                        resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
11160                            buffer: &arena.buffer,
11161                            offset: arena_base,
11162                            size: NonZeroU64::new(arena_size),
11163                        }),
11164                    },
11165                    wgpu::BindGroupEntry {
11166                        binding: 2,
11167                        resource: params.as_entire_binding(),
11168                    },
11169                ],
11170            }),
11171            rebased_b,
11172        );
11173    }
11174    if b_is_param
11175        && compute_precision == MatmulCompute::CoopF32
11176        && let Some(coop_f32) = mm_coop_f32
11177    {
11178        // 2-binding layout — both A and B come from the f32 arena
11179        // (no f16 shadow buffer needed for the pure-f32 path).
11180        return (
11181            device.create_bind_group(&wgpu::BindGroupDescriptor {
11182                label: Some("rlx-wgpu matmul_coop_f32 bg"),
11183                layout: &coop_f32.bgl,
11184                entries: &[
11185                    wgpu::BindGroupEntry {
11186                        binding: 0,
11187                        resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
11188                            buffer: &arena.buffer,
11189                            offset: arena_base,
11190                            size: NonZeroU64::new(arena_size),
11191                        }),
11192                    },
11193                    wgpu::BindGroupEntry {
11194                        binding: 1,
11195                        resource: params.as_entire_binding(),
11196                    },
11197                ],
11198            }),
11199            b_off,
11200        );
11201    }
11202    if b_is_param
11203        && compute_precision == MatmulCompute::Coop16
11204        && let (Some(_f16_buf), Some(coop)) = (&arena.f16_buffer, mm_coop)
11205    {
11206        let (f16_res, rebased_b) = f16_bind(b_off);
11207        // 3-binding layout — A is staged from arena (f32) through
11208        // workgroup-shared memory inside the kernel, no separate
11209        // f16 binding for A.
11210        return (
11211            device.create_bind_group(&wgpu::BindGroupDescriptor {
11212                label: Some("rlx-wgpu matmul_coop16 bg"),
11213                layout: &coop.bgl,
11214                entries: &[
11215                    wgpu::BindGroupEntry {
11216                        binding: 0,
11217                        resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
11218                            buffer: &arena.buffer,
11219                            offset: arena_base,
11220                            size: NonZeroU64::new(arena_size),
11221                        }),
11222                    },
11223                    wgpu::BindGroupEntry {
11224                        binding: 1,
11225                        resource: params.as_entire_binding(),
11226                    },
11227                    wgpu::BindGroupEntry {
11228                        binding: 2,
11229                        resource: f16_res,
11230                    }, // weights
11231                ],
11232            }),
11233            rebased_b,
11234        );
11235    }
11236    if b_is_param
11237        && compute_precision == MatmulCompute::F16
11238        && let (Some(_f16_buf), Some(f16c)) = (&arena.f16_buffer, mm_f16c)
11239    {
11240        let (f16_res, rebased_b) = f16_bind(b_off);
11241        return (
11242            device.create_bind_group(&wgpu::BindGroupDescriptor {
11243                label: Some("rlx-wgpu matmul_f16_compute bg"),
11244                layout: &f16c.bgl,
11245                entries: &[
11246                    wgpu::BindGroupEntry {
11247                        binding: 0,
11248                        resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
11249                            buffer: &arena.buffer,
11250                            offset: arena_base,
11251                            size: NonZeroU64::new(arena_size),
11252                        }),
11253                    },
11254                    wgpu::BindGroupEntry {
11255                        binding: 1,
11256                        resource: params.as_entire_binding(),
11257                    },
11258                    wgpu::BindGroupEntry {
11259                        binding: 2,
11260                        resource: f16_res,
11261                    },
11262                ],
11263            }),
11264            rebased_b,
11265        );
11266    }
11267    let f16w_opt_in = rlx_ir::env::flag("RLX_WGPU_F16_WEIGHTS");
11268    if b_is_param
11269        && f16w_opt_in
11270        && let (Some(_f16_buf), Some(f16w)) = (&arena.f16_buffer, mm_f16w)
11271    {
11272        let (f16_res, rebased_b) = f16_bind(b_off);
11273        return (
11274            device.create_bind_group(&wgpu::BindGroupDescriptor {
11275                label: Some("rlx-wgpu matmul_f16w bg"),
11276                layout: &f16w.bgl,
11277                entries: &[
11278                    wgpu::BindGroupEntry {
11279                        binding: 0,
11280                        resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
11281                            buffer: &arena.buffer,
11282                            offset: arena_base,
11283                            size: NonZeroU64::new(arena_size),
11284                        }),
11285                    },
11286                    wgpu::BindGroupEntry {
11287                        binding: 1,
11288                        resource: params.as_entire_binding(),
11289                    },
11290                    wgpu::BindGroupEntry {
11291                        binding: 2,
11292                        resource: f16_res,
11293                    },
11294                ],
11295            }),
11296            rebased_b,
11297        );
11298    }
11299    (
11300        bind_two_buf0_window(device, mm_k, &arena.buffer, arena_base, arena_size, params),
11301        b_off,
11302    )
11303}