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