Skip to main content

rlx_wgpu/backend/
mod.rs

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