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