Skip to main content

rlx_wgpu/backend/
compile.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//! `compile` — extracted from the `backend` module for navigability (see `mod.rs`).
17
18#![allow(unused_imports)]
19
20use crate::buffer::{
21    Arena, ReadbackLayout, ReadbackStaging, TinyReadbackStaging, decode_mapped_readback_f32,
22    decode_tiny_mapped_f32, encode_readback_copies, plan_f32_uniform, read_f32_many_pooled,
23    schedule_readback_map, use_tiny_readback, wait_readback_map,
24};
25use crate::device::wgpu_device;
26use crate::kernels::{
27    ArgmaxParams, AttentionBwdParams, AttentionParams, BatchElementwiseRegionParams, BinaryParams,
28    Conv1dParams, Conv2dParams, Conv3dParams, CopyParams, CumsumBwdParams, CumsumParams,
29    DequantMatmulParams, ElementwiseRegionParams, ExpandParams, FmaParams, FusedResidualLnParams,
30    FusedResidualLnTeeParams, FusedResidualRmsNormParams, GatherAxisParams, GatherBwdParams,
31    GatherParams, GroupedMatmulParams, GruParams, Im2Col2dParams, Kernel, LayerNormBwdParams,
32    LayerNormParams, Mamba2Params, MatmulParams, MatmulQkvParams, NarrowConcatParams, Pool1dParams,
33    Pool2dParams, Pool3dParams, ReduceParams, RmsNormBwdParams, RnnParams, RopeBwdParams,
34    RopeParams, SampleParams, ScatterAddParams, SceParams, SelectiveScanParams, SoftmaxParams,
35    TopKParams, TransposeParams, UmapKnnParams, UnaryParams, WelchPeaksGpuParams, WhereParams,
36    argmax_kernel, attention_bwd_kernel, attention_kernel, batch_elementwise_region_kernel,
37    binary_kernel, cast_f32_to_f16_kernel, compare_kernel, concat_kernel, conv1d_kernel,
38    conv1d_tiled_kernel, conv2d_kernel, conv3d_kernel, copy_kernel, cumsum_backward_kernel,
39    cumsum_kernel, dequant_matmul_kernel, elementwise_region_kernel,
40    elementwise_region_spatial_kernel, expand_kernel, fma_kernel, fused_residual_ln_kernel,
41    fused_residual_ln_tee_kernel, fused_residual_rms_norm_kernel, gather_axis_kernel,
42    gather_backward_acc_kernel, gather_backward_zero_kernel, gather_kernel, gather_split_kernel,
43    grouped_matmul_kernel, gru_kernel, im2col2d_kernel, layer_norm_backward_gamma_partial_kernel,
44    layer_norm_backward_gamma_reduce_kernel, layer_norm_backward_input_kernel, layernorm_kernel,
45    mamba2_kernel, matmul_coop_f16_vulkan_active_kernel, matmul_coop_f16_vulkan_kernel,
46    matmul_coop_f32_active_kernel, matmul_coop16_kernel, matmul_f16_compute_kernel,
47    matmul_f16w_kernel, matmul_kernel, matmul_qkv_coop_f16_vk_active_kernel,
48    matmul_qkv_coop_f16_vk_kernel, matmul_qkv_coop_f32_kernel, matmul_qkv_kernel,
49    matmul_wide_active_kernel, matmul_wide_kernel, narrow_kernel, pool1d_kernel, pool2d_kernel,
50    pool3d_kernel, reduce_kernel, rms_norm_backward_kernel, rms_norm_backward_param_kernel,
51    rnn_kernel, rope_backward_kernel, rope_kernel, sample_kernel, scatter_add_kernel,
52    selective_scan_kernel, softmax_cross_entropy_kernel, softmax_kernel, topk_kernel,
53    transpose_kernel, umap_knn_kernel, unary_f16_mirror_kernel, unary_kernel,
54    welch_peaks_gpu_kernel, where_kernel,
55};
56use rlx_ir::dynamic::{bind_graph, has_dynamic_dims, infer_bindings_from_f32_inputs, same_binding};
57use rlx_ir::op::{Activation, BinaryOp, CmpOp, MaskKind, ReduceOp};
58use rlx_ir::shape::DimBinding;
59use rlx_ir::{Graph, NodeId, Op};
60use std::collections::{HashMap, HashSet};
61use std::num::NonZeroU64;
62
63use super::*;
64
65impl WgpuExecutable {
66    /// Compile against an explicit `DimBinding`. Each `Dim::Dynamic`
67    /// in the graph that maps to a symbol in `bindings` is replaced
68    /// with `Dim::Static(size)` before the standard compile runs.
69    /// Symbols not in the binding stay dynamic — and then `compile`
70    /// will panic with the usual diagnostic.
71    pub fn compile_with_bindings(graph: Graph, bindings: &DimBinding) -> Self {
72        if bindings.is_empty() {
73            return Self::compile(graph);
74        }
75        // Walk the graph and bind every node's shape.
76        let mut fresh = Graph::new(&graph.name);
77        for node in graph.nodes() {
78            let bound = node.shape.bind(bindings);
79            fresh.add_node(node.op.clone(), node.inputs.clone(), bound);
80        }
81        fresh.set_outputs(graph.outputs.clone());
82        Self::compile(fresh)
83    }
84
85    pub fn compile(graph: Graph) -> Self {
86        Self::compile_rng(graph, rlx_ir::RngOptions::default())
87    }
88
89    pub fn compile_rng(graph: Graph, rng: rlx_ir::RngOptions) -> Self {
90        let rng = std::sync::Arc::new(std::sync::RwLock::new(rng));
91        if has_dynamic_dims(&graph) {
92            return Self::deferred(graph, rng);
93        }
94        Self::compile_static_inner(graph, rng)
95    }
96
97    pub(crate) fn compile_static_inner(
98        graph: Graph,
99        rng: std::sync::Arc<std::sync::RwLock<rlx_ir::RngOptions>>,
100    ) -> Self {
101        let dev = wgpu_device().expect("rlx-wgpu: no compatible adapter found");
102
103        // Decompose composed/fused ops (FusedMatMulBiasAct, LoraMatMul,
104        // FusedAttentionBlock, FusedTransformerLayer, ...) into primitive
105        // sequences before memory planning so every intermediate gets a
106        // regular arena slot. CPU/Metal/MLX lower the fused variants
107        // directly with bespoke kernels; we choose simplicity over peak
108        // throughput here.
109        let graph = crate::unfuse::unfuse(graph);
110
111        // f32-uniform slots + liveness reuse (pairwise `[n,n]` graphs).
112        let mut plan = plan_f32_uniform(&graph, 16);
113        let dequant_scratch = crate::gguf_gpu::dequant_gguf_scratch_bytes(&graph);
114        let dequant_scratch_off = if dequant_scratch > 0 {
115            let aligned = plan.arena_size.div_ceil(16) * 16;
116            let new_size = aligned + dequant_scratch.div_ceil(16) * 16;
117            if (new_size as u64) <= dev.device.limits().max_buffer_size {
118                plan.arena_size = new_size;
119                aligned
120            } else {
121                0
122            }
123        } else {
124            0
125        };
126        // Pre-walk to compute the max scratch any single op needs.
127        // Currently only `Op::LayerNormBackwardGamma` uses scratch
128        // (`num_workgroups * H * 4` bytes for the partial-sums buffer).
129        let base_scratch_bytes = compute_scratch_bytes(&graph);
130        // Reserve tail scratch for the im2col `col` matrix only when the opt-in
131        // im2col+GEMM conv path is enabled (the default tiled/direct convs need
132        // no extra scratch, so the arena stays lean).
133        let conv_col_scratch = if rlx_ir::env::flag("RLX_WGPU_CONV_IM2COL") {
134            conv_im2col_scratch_bytes(
135                &graph,
136                plan.arena_size,
137                dev.device.limits().max_storage_buffer_binding_size,
138            )
139        } else {
140            0
141        };
142        let scratch_bytes = base_scratch_bytes.max(conv_col_scratch);
143        let mut arena = Arena::from_plan_with_scratch(&dev.device, &plan, scratch_bytes);
144        // Override slot lengths with the actual elem*4 byte counts so
145        // readback returns the right element count (slots may be
146        // padded for alignment).
147        for node in graph.nodes() {
148            let elems = node.shape.num_elements().unwrap_or(0);
149            arena.set_actual_len(node.id, elems * 4);
150        }
151
152        // Initialize Constants directly into the arena. The wgpu arena is f32-
153        // uniform (4 bytes/elem; integer inputs are widened on upload), so integer
154        // and bool constants must be converted to f32 here too — writing their raw
155        // bytes would corrupt them (e.g. the VITS sequence-mask `arange` i64 const,
156        // 8 bytes/elem, read back as f32 garbage → all-zero mask → dead encoder).
157        for node in graph.nodes() {
158            if let Op::Constant { data } = &node.op
159                && arena.has(node.id)
160                && !data.is_empty()
161            {
162                let widened: Option<Vec<u8>> = match node.shape.dtype() {
163                    rlx_ir::DType::I64 => Some(
164                        data.chunks_exact(8)
165                            .flat_map(|c| {
166                                (i64::from_le_bytes(c.try_into().unwrap()) as f32).to_le_bytes()
167                            })
168                            .collect(),
169                    ),
170                    rlx_ir::DType::I32 => Some(
171                        data.chunks_exact(4)
172                            .flat_map(|c| {
173                                (i32::from_le_bytes(c.try_into().unwrap()) as f32).to_le_bytes()
174                            })
175                            .collect(),
176                    ),
177                    rlx_ir::DType::U32 => Some(
178                        data.chunks_exact(4)
179                            .flat_map(|c| {
180                                (u32::from_le_bytes(c.try_into().unwrap()) as f32).to_le_bytes()
181                            })
182                            .collect(),
183                    ),
184                    rlx_ir::DType::Bool | rlx_ir::DType::U8 => Some(
185                        data.iter()
186                            .flat_map(|&b| (b as f32).to_le_bytes())
187                            .collect(),
188                    ),
189                    rlx_ir::DType::I8 => Some(
190                        data.iter()
191                            .flat_map(|&b| ((b as i8) as f32).to_le_bytes())
192                            .collect(),
193                    ),
194                    _ => None,
195                };
196                let bytes: &[u8] = widened.as_deref().unwrap_or(data);
197                let bytes_to_write = bytes.len().min(arena.len_of(node.id));
198                dev.queue.write_buffer(
199                    &arena.buffer,
200                    arena.offset(node.id) as u64,
201                    &bytes[..bytes_to_write],
202                );
203            }
204        }
205
206        let mut input_offsets = HashMap::new();
207        let mut param_offsets = HashMap::new();
208        for node in graph.nodes() {
209            match &node.op {
210                Op::Input { name } => {
211                    input_offsets.insert(name.clone(), node.id);
212                }
213                Op::Param { name } => {
214                    param_offsets.insert(name.clone(), node.id);
215                }
216                _ => {}
217            }
218        }
219
220        let mm_k = matmul_kernel(&dev.device);
221        let mm_w = matmul_wide_kernel(&dev.device);
222        let _mm_w_active = matmul_wide_active_kernel(&dev.device);
223        let mm_f16w = matmul_f16w_kernel(&dev.device);
224        let mm_f16c = matmul_f16_compute_kernel(&dev.device);
225        let mm_coop = matmul_coop16_kernel(&dev.device);
226        let mm_coop_f32 = matmul_coop_f32_active_kernel(&dev.device);
227        let mm_cast = cast_f32_to_f16_kernel(&dev.device);
228        let bk = binary_kernel(&dev.device);
229        let uk = unary_kernel(&dev.device);
230        let ck = compare_kernel(&dev.device);
231        let wk = where_kernel(&dev.device);
232        let fk = fma_kernel(&dev.device);
233
234        let mut schedule = Vec::new();
235        let mut uniforms = Vec::new();
236        let mut bind_groups = Vec::new();
237        let mut fft_gpu_steps: Vec<crate::fft_dispatch::FftGpuResources> = Vec::new();
238        let mut gguf_host_pad: Option<(wgpu::Buffer, wgpu::BindGroup)> = None;
239        let mut meta_buffers: Vec<wgpu::Buffer> = Vec::new();
240        let mut coop_f16_b_param: HashMap<u32, String> = HashMap::new();
241        let mut coop_f16_vk_wide_bind_groups: HashMap<usize, wgpu::BindGroup> = HashMap::new();
242        let mm_w_active_compile = matmul_wide_active_kernel(&dev.device);
243
244        let coop_f16_vk_mirror_acts = collect_coop_f16_vk_mirror_activations(&graph, &dev.device);
245
246        // Detect (FusedMatMulBiasAct → Narrow×3) split-QKV pattern. Returns
247        // a map parent_node_id → (q_narrow_id, k_narrow_id, v_narrow_id).
248        // The matmul_qkv kernel collapses the matmul + 3 narrows into one
249        // dispatch by routing each output column to the right Q/K/V sink.
250        //
251        // CRITICAL: only mark a pattern site for elision when the parent
252        // FMB will actually take the MatmulQkv path (which only fires
253        // for F32 compute precision). For Coop16/CoopF32-eligible FMBs,
254        // those kernels write to the FMB's *own* output slot, NOT the
255        // 3 narrow slots — skipping the narrows would leave Q/K/V
256        // uninitialized and attention would read garbage. Predict the
257        // compute precision the FMB will receive; only skip when F32.
258        let mut qkv_split: HashMap<NodeId, (NodeId, NodeId, NodeId)> = HashMap::new();
259        for (parent_id, qkv) in detect_split_qkv_pattern(&graph) {
260            let parent = graph.node(parent_id);
261            // Mirror the lowering's precision derivation. FMB inputs:
262            // [a, w, bias]; we need (m, k, n) to query.
263            let a_id = parent.inputs[0];
264            let b_id = parent.inputs[1];
265            let a_dims = graph.node(a_id).shape.dims();
266            let b_dims = graph.node(b_id).shape.dims();
267            let out_dims = parent.shape.dims();
268            let (m, k, n) =
269                if a_dims.len() >= 2 && b_dims.len() == 2 && out_dims.len() == a_dims.len() {
270                    let leading: usize = a_dims[..a_dims.len() - 2]
271                        .iter()
272                        .map(|d| d.unwrap_static())
273                        .product();
274                    let m_inner = a_dims[a_dims.len() - 2].unwrap_static();
275                    let k_inner = a_dims[a_dims.len() - 1].unwrap_static();
276                    let n_inner = b_dims[1].unwrap_static();
277                    ((leading * m_inner) as u32, k_inner as u32, n_inner as u32)
278                } else if a_dims.len() == 2 && b_dims.len() == 2 {
279                    (
280                        a_dims[0].unwrap_static() as u32,
281                        a_dims[1].unwrap_static() as u32,
282                        b_dims[1].unwrap_static() as u32,
283                    )
284                } else {
285                    continue; // unusual shape — let the regular FMB path handle
286                };
287            let cp = derive_matmul_compute(
288                &dev.device,
289                &graph,
290                &coop_f16_vk_mirror_acts,
291                a_id,
292                b_id,
293                m,
294                k,
295                n,
296            );
297            // F32 → matmul_qkv. CoopF32 → matmul_qkv_coop_f32. Both write
298            // Q/K/V into the narrow output slots, so the narrows can be
299            // elided. Coop16 still falls back to FMB+narrows (kernel
300            // would need an f16-acc variant; deferred).
301            if cp == MatmulCompute::F32 || cp == MatmulCompute::CoopF32 {
302                qkv_split.insert(parent_id, qkv);
303            }
304        }
305        let qkv_skip_narrows: HashSet<NodeId> = qkv_split
306            .values()
307            .flat_map(|&(q, k, v)| [q, k, v])
308            .collect();
309
310        // EEG-DINO / packed QKV: FMB → [B,S,3,H,D] → Narrow×3 (axis 2) → Attention.
311        // Match CPU `compile_thunks` fused_strided_attn: read Q/K/V from the
312        // packed parent with seq stride 3·H·D instead of materializing narrows.
313        let mut packed_bshd_attn: HashMap<NodeId, (NodeId, u32)> = HashMap::new();
314        let mut packed_bshd_skip_narrows: HashSet<NodeId> = HashSet::new();
315        if !rlx_ir::env::flag("RLX_WGPU_NO_PACKED_BSHD_ATTN") {
316            for node in graph.nodes() {
317                let Op::Attention { .. } = &node.op else {
318                    continue;
319                };
320                if node.inputs.len() < 3 {
321                    continue;
322                }
323                if let Some((parent, head_width, narrows)) =
324                    rlx_ir::detect_packed_bshd_qkv_attention(
325                        &graph,
326                        node.inputs[0],
327                        node.inputs[1],
328                        node.inputs[2],
329                    )
330                {
331                    packed_bshd_attn.insert(node.id, (parent, head_width as u32));
332                    for narrow in narrows {
333                        if rlx_ir::packed_bshd_narrow_elidable(&graph, narrow, node.id) {
334                            packed_bshd_skip_narrows.insert(narrow);
335                        }
336                    }
337                }
338            }
339        }
340
341        // Detect (Add → LayerNorm) where Add has multi-consumer downstream.
342        // The standard `FuseResidualLN` pass declines to fuse these (its
343        // single-consumer guard forces materializing the sum); we collapse
344        // them here at the wgpu lowering level via `Step::FusedResidualLnTee`.
345        // Returns:
346        //   ln_to_tee: ln_id  → (h, delta, gamma, beta, sum_arena_id)
347        //   skip_adds: { add_id }  — these Add nodes are computed by the
348        //                            tee step; their normal Step emission
349        //                            is suppressed.
350        let (ln_to_tee, skip_adds) = detect_residual_ln_tee_pattern(&graph);
351
352        let mut coop_f16_host_activations: Vec<(NodeId, Activation, String)> = Vec::new();
353
354        let emit_uniform = |size: usize| -> wgpu::Buffer {
355            dev.device.create_buffer(&wgpu::BufferDescriptor {
356                label: Some("rlx-wgpu uniform"),
357                size: size as u64,
358                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
359                mapped_at_creation: false,
360            })
361        };
362
363        for node in graph.nodes() {
364            // Helpers — capture device + arena into closures isn't
365            // ergonomic in the loop, so inline the bind-group build
366            // when each step is emitted below.
367            let elems = node.shape.num_elements().unwrap_or(0) as u32;
368            match &node.op {
369                Op::Input { .. } | Op::Param { .. } | Op::Constant { .. } => continue,
370                Op::MatMul => {
371                    let a_id = node.inputs[0];
372                    let b_id = node.inputs[1];
373                    let a_shape = graph.node(a_id).shape.dims();
374                    let b_shape = graph.node(b_id).shape.dims();
375                    let out_shape = node.shape.dims();
376                    // Three patterns:
377                    //   • 2D×2D                              → batch=1
378                    //   • [..,M,K] × [K,N]  (broadcast rhs)  → batch=1, flatten leading into M
379                    //   • [..,M,K] × [..,K,N] (matched batch)→ batch=prod(leading), per-batch strides
380                    let (m, k, n, batch, a_bs, b_bs, c_bs) = if a_shape.len() == 2
381                        && b_shape.len() == 2
382                        && out_shape.len() == 2
383                    {
384                        (
385                            a_shape[0].unwrap_static() as u32,
386                            a_shape[1].unwrap_static() as u32,
387                            b_shape[1].unwrap_static() as u32,
388                            1u32,
389                            0u32,
390                            0u32,
391                            0u32,
392                        )
393                    } else if a_shape.len() >= 2
394                        && b_shape.len() == 2
395                        && out_shape.len() == a_shape.len()
396                    {
397                        let leading: usize = a_shape[..a_shape.len() - 2]
398                            .iter()
399                            .map(|d| d.unwrap_static())
400                            .product();
401                        let m_inner = a_shape[a_shape.len() - 2].unwrap_static();
402                        let k_inner = a_shape[a_shape.len() - 1].unwrap_static();
403                        let n_inner = b_shape[1].unwrap_static();
404                        (
405                            (leading * m_inner) as u32,
406                            k_inner as u32,
407                            n_inner as u32,
408                            1u32,
409                            0u32,
410                            0u32,
411                            0u32,
412                        )
413                    } else if (a_shape.len() >= 3 || b_shape.len() >= 3)
414                        && a_shape.len() >= 2
415                        && b_shape.len() >= 2
416                        && out_shape.len() == a_shape.len().max(b_shape.len())
417                    {
418                        // Batched: leading PRODUCTS must match. Allows broadcasting a
419                        // leading-1 across a rank mismatch, e.g. the audio rel-pos attn
420                        // a=[1,H,M,K] (lead 1·H) × b=[H,K,N] (lead H) → b_count=H.
421                        let lead_a: usize = a_shape[..a_shape.len() - 2]
422                            .iter()
423                            .map(|d| d.unwrap_static())
424                            .product();
425                        let lead_b: usize = b_shape[..b_shape.len() - 2]
426                            .iter()
427                            .map(|d| d.unwrap_static())
428                            .product();
429                        if lead_a != lead_b {
430                            panic!(
431                                "rlx-wgpu MatMul: batched leading-product mismatch \
432                                    a={a_shape:?} b={b_shape:?} out={out_shape:?}"
433                            );
434                        }
435                        let b_count: usize = lead_a;
436                        let m_inner = a_shape[a_shape.len() - 2].unwrap_static();
437                        let k_inner = a_shape[a_shape.len() - 1].unwrap_static();
438                        let n_inner = b_shape[b_shape.len() - 1].unwrap_static();
439                        (
440                            m_inner as u32,
441                            k_inner as u32,
442                            n_inner as u32,
443                            b_count as u32,
444                            (m_inner * k_inner) as u32,
445                            (k_inner * n_inner) as u32,
446                            (m_inner * n_inner) as u32,
447                        )
448                    } else {
449                        panic!(
450                            "rlx-wgpu MatMul: unsupported shapes a={a_shape:?} b={b_shape:?} \
451                                out={out_shape:?} (supported: 2D×2D, [..,M,K]×[K,N], [..,M,K]×[..,K,N])"
452                        );
453                    };
454                    let b_is_param = tensor_is_graph_param(&graph, &param_offsets, b_id);
455                    let b_bytes = arena.len_of(b_id) as u64;
456                    let mut compute_precision = derive_matmul_compute(
457                        &dev.device,
458                        &graph,
459                        &coop_f16_vk_mirror_acts,
460                        a_id,
461                        b_id,
462                        m,
463                        k,
464                        n,
465                    );
466                    if b_is_param
467                        && b_bytes > ARENA_STAGE_CAP
468                        && arena.param_fits_f16_mirror(b_id)
469                        && !rlx_ir::env::flag("RLX_WGPU_NO_F16_MIRROR")
470                    {
471                        compute_precision = MatmulCompute::F16;
472                    }
473                    let b_in_arena = !matmul_b_from_f16(compute_precision, b_is_param);
474                    let (mut base, mut size, param_anchor) = arena_matmul_bind_window(
475                        &dev.device,
476                        &arena,
477                        &graph,
478                        &param_offsets,
479                        node.id,
480                        a_id,
481                        b_id,
482                        b_in_arena,
483                    );
484                    let max_binding = dev.device.limits().max_storage_buffer_binding_size;
485                    // Only grow to cover B when B is actually bound through the arena.
486                    let expand_ids: &[NodeId] = if b_in_arena {
487                        &[node.id, a_id, b_id]
488                    } else {
489                        &[node.id, a_id]
490                    };
491                    arena_expand_bind_window(&arena, expand_ids, &mut base, &mut size, max_binding);
492                    let mut scratch = arena.scratch_off as u64;
493                    if param_anchor {
494                        arena_ensure_scratch_in_window(&mut scratch, base, size);
495                    }
496                    if b_is_param && b_bytes > ARENA_STAGE_CAP && b_in_arena {
497                        // The invariant we actually need is that the large param
498                        // B is addressable in the bound window. That holds either
499                        // via an explicit param anchor OR when the whole arena is
500                        // bound (`arena_whole_arena_bind`), in which case
501                        // `arena_matmul_bind_window` returns `param_anchor=false`
502                        // but B is trivially in `[0, arena.size)`. Keying the
503                        // assert on `param_anchor` alone spuriously panicked for
504                        // models whose entire arena fits `max_binding`.
505                        assert!(
506                            arena_tensor_in_window(&arena, b_id, base, size),
507                            "rlx-wgpu matmul: large param B {:?} off={} not in window base={base} size={size}",
508                            b_id,
509                            arena.offset(b_id),
510                        );
511                    }
512                    let a_off_f32 = arena_off_in_bind_window(
513                        &graph,
514                        &param_offsets,
515                        &dev.device,
516                        &arena,
517                        &mut schedule,
518                        &mut scratch,
519                        a_id,
520                        &mut base,
521                        &mut size,
522                    );
523                    let b_off_f32 = if !b_in_arena {
524                        // B is read from the f16 shadow buffer (separate binding);
525                        // never bind/stage it through the arena window. Use the
526                        // global arena word index — build_matmul_bind_group rebases
527                        // it into the f16 weight window.
528                        (arena.offset(b_id) / 4) as u32
529                    } else if b_is_param
530                        && b_bytes > ARENA_STAGE_CAP
531                        && arena_tensor_in_window(&arena, b_id, base, size)
532                    {
533                        arena_local_off_f32(&arena, b_id, base)
534                    } else {
535                        arena_off_in_bind_window(
536                            &graph,
537                            &param_offsets,
538                            &dev.device,
539                            &arena,
540                            &mut schedule,
541                            &mut scratch,
542                            b_id,
543                            &mut base,
544                            &mut size,
545                        )
546                    };
547                    maybe_push_coop_f16_vk_casts(
548                        &graph,
549                        a_id,
550                        b_id,
551                        &coop_f16_vk_mirror_acts,
552                        &dev.device,
553                        &arena,
554                        &mut schedule,
555                        &mut uniforms,
556                        &mut bind_groups,
557                        &mm_cast,
558                        compute_precision,
559                        a_off_f32,
560                        m,
561                        k,
562                        batch,
563                        b_off_f32,
564                        n,
565                    );
566                    schedule.push(Step::Matmul {
567                        m,
568                        k,
569                        n,
570                        batch,
571                        a_batch_stride: a_bs,
572                        b_batch_stride: b_bs,
573                        c_batch_stride: c_bs,
574                        a_off_f32,
575                        b_off_f32,
576                        c_off_f32: arena_local_off_f32(&arena, node.id, base),
577                        has_bias: 0,
578                        bias_off_f32: 0,
579                        act_id: 0xFFFF,
580                        b_is_param,
581                        compute_precision,
582                    });
583                    let b_off_global = (arena.offset(b_id) / 4) as u32;
584                    let b_off_bind = if b_is_param
585                        && matches!(
586                            compute_precision,
587                            MatmulCompute::Coop16 | MatmulCompute::CoopF16Vk | MatmulCompute::F16
588                        ) {
589                        b_off_global
590                    } else {
591                        b_off_f32
592                    };
593                    register_coop_f16_vk_b_param(
594                        &mut coop_f16_b_param,
595                        &param_offsets,
596                        b_id,
597                        b_off_bind,
598                        compute_precision,
599                    );
600                    let u = emit_uniform(std::mem::size_of::<MatmulParams>());
601                    let (bg, b_off_adj) = build_matmul_bind_group(
602                        &dev.device,
603                        mm_k,
604                        mm_w,
605                        &mm_f16w,
606                        &mm_f16c,
607                        &mm_coop,
608                        &mm_coop_f32,
609                        &arena,
610                        base,
611                        size,
612                        &u,
613                        b_is_param,
614                        compute_precision,
615                        k,
616                        n,
617                        batch,
618                        b_off_bind,
619                        b_bs,
620                    );
621                    if let Some(Step::Matmul { b_off_f32, .. }) = schedule.last_mut() {
622                        *b_off_f32 = b_off_adj;
623                    }
624                    uniforms.push(u);
625                    bind_groups.push(bg);
626                    if compute_precision == MatmulCompute::CoopF16Vk {
627                        coop_f16_vk_wide_bind_groups.insert(
628                            schedule.len() - 1,
629                            bind_two_buf0_window(
630                                &dev.device,
631                                mm_w_active_compile,
632                                &arena.buffer,
633                                base,
634                                size,
635                                &uniforms[uniforms.len() - 1],
636                            ),
637                        );
638                    }
639                }
640                Op::Binary(bop) => {
641                    // Skip emit when this Add is consumed by a downstream
642                    // FRLTee — the tee step writes the sum to this node's
643                    // arena slot directly. Subsequent consumers read the
644                    // same slot and find correct data.
645                    if skip_adds.contains(&node.id) {
646                        continue;
647                    }
648                    require_equal_shapes(&graph, &node.inputs, "Binary");
649                    let a_id = node.inputs[0];
650                    let b_id = node.inputs[1];
651                    let win_ids = [node.id, a_id, b_id];
652                    let max_binding = dev.device.limits().max_storage_buffer_binding_size;
653                    let fits = arena_span_bytes(&arena, &win_ids) <= max_binding;
654                    let mut scratch = arena.scratch_off as u64;
655                    let (mut base, mut size, param_anchor) = arena_multi_op_window(
656                        &dev.device,
657                        &arena,
658                        &graph,
659                        &param_offsets,
660                        &mut schedule,
661                        &mut scratch,
662                        &win_ids,
663                    );
664                    if !fits && !param_anchor {
665                        base = arena_bind_window_covering_scratch_if_needed(
666                            &arena, base, size, scratch,
667                        );
668                    }
669                    let a_off = arena_off_in_bind_window(
670                        &graph,
671                        &param_offsets,
672                        &dev.device,
673                        &arena,
674                        &mut schedule,
675                        &mut scratch,
676                        a_id,
677                        &mut base,
678                        &mut size,
679                    );
680                    let b_off = arena_off_in_bind_window(
681                        &graph,
682                        &param_offsets,
683                        &dev.device,
684                        &arena,
685                        &mut schedule,
686                        &mut scratch,
687                        b_id,
688                        &mut base,
689                        &mut size,
690                    );
691                    let p = BinaryParams {
692                        n: elems,
693                        a_off,
694                        b_off,
695                        c_off: arena_local_off_f32(&arena, node.id, base),
696                        op: binary_op_id(*bop),
697                        _p0: 0,
698                        _p1: 0,
699                        _p2: 0,
700                    };
701                    schedule.push(Step::Binary { params: p });
702                    let u = emit_uniform(std::mem::size_of::<BinaryParams>());
703                    let bg = bind_two_buf0_window(&dev.device, bk, &arena.buffer, base, size, &u);
704                    uniforms.push(u);
705                    bind_groups.push(bg);
706                }
707                Op::Compare(cop) => {
708                    require_equal_shapes(&graph, &node.inputs, "Compare");
709                    let (mut base, size) = arena_window_for_nodes(&dev.device, &arena, &[node.id]);
710                    let a_id = node.inputs[0];
711                    let b_id = node.inputs[1];
712                    let a_src = arena.offset(a_id) as u64;
713                    let b_src = arena.offset(b_id) as u64;
714                    let a_len = arena.len_of(a_id) as u64;
715                    let b_len = arena.len_of(b_id) as u64;
716                    let a_in = a_src >= base && a_src + a_len <= base + size;
717                    let b_in = b_src >= base && b_src + b_len <= base + size;
718                    let a_dst = arena.scratch_off as u64;
719                    let a_aligned = a_len.div_ceil(256) * 256;
720                    let b_dst = a_dst + a_aligned;
721                    if a_dst < base || b_dst + b_len > base + size {
722                        base = (arena.size as u64).saturating_sub(size);
723                        base = (base / 256) * 256;
724                    }
725                    let a_off = if a_in {
726                        arena_local_off_f32(&arena, a_id, base)
727                    } else {
728                        if a_len > 64 * 1024 * 1024 {
729                            panic!("rlx-wgpu: Compare staging operand A too large ({a_len} bytes)");
730                        }
731                        schedule.push(Step::BufferCopy {
732                            src_byte_off: a_src,
733                            dst_byte_off: a_dst,
734                            bytes: a_len as u32,
735                        });
736                        ((a_dst.saturating_sub(base)) / 4) as u32
737                    };
738                    let b_off = if b_in {
739                        arena_local_off_f32(&arena, b_id, base)
740                    } else {
741                        if b_len > 64 * 1024 * 1024 {
742                            panic!("rlx-wgpu: Compare staging operand B too large ({b_len} bytes)");
743                        }
744                        schedule.push(Step::BufferCopy {
745                            src_byte_off: b_src,
746                            dst_byte_off: b_dst,
747                            bytes: b_len as u32,
748                        });
749                        ((b_dst.saturating_sub(base)) / 4) as u32
750                    };
751                    let p = BinaryParams {
752                        n: elems,
753                        a_off,
754                        b_off,
755                        c_off: arena_local_off_f32(&arena, node.id, base),
756                        op: compare_op_id(*cop),
757                        _p0: 0,
758                        _p1: 0,
759                        _p2: 0,
760                    };
761                    schedule.push(Step::Compare { params: p });
762                    let u = emit_uniform(std::mem::size_of::<BinaryParams>());
763                    let bg = bind_two_buf0_window(&dev.device, ck, &arena.buffer, base, size, &u);
764                    uniforms.push(u);
765                    bind_groups.push(bg);
766                }
767                Op::Activation(act) => {
768                    if coop_f16_vk_mirror_acts.contains(&node.id) {
769                        let src_name =
770                            tensor_host_name(&input_offsets, &param_offsets, node.inputs[0]);
771                        coop_f16_host_activations.push((node.id, *act, src_name));
772                        continue;
773                    }
774                    let in_id = node.inputs[0];
775                    let win_ids = [node.id, in_id];
776                    let max_binding = dev.device.limits().max_storage_buffer_binding_size;
777                    let fits = arena_span_bytes(&arena, &win_ids) <= max_binding;
778                    let mut scratch = arena.scratch_off as u64;
779                    let (mut base, mut size, param_anchor) = arena_multi_op_window(
780                        &dev.device,
781                        &arena,
782                        &graph,
783                        &param_offsets,
784                        &mut schedule,
785                        &mut scratch,
786                        &win_ids,
787                    );
788                    if !fits && !param_anchor {
789                        base = arena_bind_window_covering_scratch_if_needed(
790                            &arena, base, size, scratch,
791                        );
792                    }
793                    let in_off = arena_off_in_bind_window(
794                        &graph,
795                        &param_offsets,
796                        &dev.device,
797                        &arena,
798                        &mut schedule,
799                        &mut scratch,
800                        in_id,
801                        &mut base,
802                        &mut size,
803                    );
804                    let p = UnaryParams {
805                        n: elems,
806                        in_off,
807                        out_off: arena_local_off_f32(&arena, node.id, base),
808                        op: activation_op_id(*act),
809                        _p0: 0,
810                        _p1: 0,
811                        _p2: 0,
812                        _p3: 0,
813                    };
814                    schedule.push(Step::Unary {
815                        params: p,
816                        f16_mirror: false,
817                    });
818                    let u = emit_uniform(std::mem::size_of::<UnaryParams>());
819                    let bg = bind_two_buf0_window(&dev.device, uk, &arena.buffer, base, size, &u);
820                    uniforms.push(u);
821                    bind_groups.push(bg);
822                }
823                Op::Where => {
824                    let (mut base, size) = arena_window_for_nodes(&dev.device, &arena, &[node.id]);
825                    let cond_id = node.inputs[0];
826                    let x_id = node.inputs[1];
827                    let y_id = node.inputs[2];
828                    let cond_src = arena.offset(cond_id) as u64;
829                    let x_src = arena.offset(x_id) as u64;
830                    let y_src = arena.offset(y_id) as u64;
831                    let cond_len = arena.len_of(cond_id) as u64;
832                    let x_len = arena.len_of(x_id) as u64;
833                    let y_len = arena.len_of(y_id) as u64;
834                    let cond_in = cond_src >= base && cond_src + cond_len <= base + size;
835                    let x_in = x_src >= base && x_src + x_len <= base + size;
836                    let y_in = y_src >= base && y_src + y_len <= base + size;
837                    let cond_dst = arena.scratch_off as u64;
838                    let cond_aligned = cond_len.div_ceil(256) * 256;
839                    let x_dst = cond_dst + cond_aligned;
840                    let x_aligned = x_len.div_ceil(256) * 256;
841                    let y_dst = x_dst + x_aligned;
842                    if cond_dst < base || y_dst + y_len > base + size {
843                        base = (arena.size as u64).saturating_sub(size);
844                        base = (base / 256) * 256;
845                    }
846                    let cond_off = if cond_in {
847                        arena_local_off_f32(&arena, cond_id, base)
848                    } else {
849                        if cond_len > 64 * 1024 * 1024 {
850                            panic!("rlx-wgpu: Where staging cond too large ({cond_len} bytes)");
851                        }
852                        schedule.push(Step::BufferCopy {
853                            src_byte_off: cond_src,
854                            dst_byte_off: cond_dst,
855                            bytes: cond_len as u32,
856                        });
857                        ((cond_dst.saturating_sub(base)) / 4) as u32
858                    };
859                    let x_off = if x_in {
860                        arena_local_off_f32(&arena, x_id, base)
861                    } else {
862                        if x_len > 64 * 1024 * 1024 {
863                            panic!("rlx-wgpu: Where staging x too large ({x_len} bytes)");
864                        }
865                        schedule.push(Step::BufferCopy {
866                            src_byte_off: x_src,
867                            dst_byte_off: x_dst,
868                            bytes: x_len as u32,
869                        });
870                        ((x_dst.saturating_sub(base)) / 4) as u32
871                    };
872                    let y_off = if y_in {
873                        arena_local_off_f32(&arena, y_id, base)
874                    } else {
875                        if y_len > 64 * 1024 * 1024 {
876                            panic!("rlx-wgpu: Where staging y too large ({y_len} bytes)");
877                        }
878                        schedule.push(Step::BufferCopy {
879                            src_byte_off: y_src,
880                            dst_byte_off: y_dst,
881                            bytes: y_len as u32,
882                        });
883                        ((y_dst.saturating_sub(base)) / 4) as u32
884                    };
885                    let p = WhereParams {
886                        n: elems,
887                        cond_off,
888                        x_off,
889                        y_off,
890                        out_off: arena_local_off_f32(&arena, node.id, base),
891                        _p0: 0,
892                        _p1: 0,
893                        _p2: 0,
894                    };
895                    schedule.push(Step::Where { params: p });
896                    let u = emit_uniform(std::mem::size_of::<WhereParams>());
897                    let bg = bind_two_buf0_window(&dev.device, wk, &arena.buffer, base, size, &u);
898                    uniforms.push(u);
899                    bind_groups.push(bg);
900                }
901
902                Op::Fma => {
903                    let (mut base, size) = arena_window_for_nodes(&dev.device, &arena, &[node.id]);
904                    let a_id = node.inputs[0];
905                    let b_id = node.inputs[1];
906                    let c_id = node.inputs[2];
907                    let a_src = arena.offset(a_id) as u64;
908                    let b_src = arena.offset(b_id) as u64;
909                    let c_src = arena.offset(c_id) as u64;
910                    let a_len = arena.len_of(a_id) as u64;
911                    let b_len = arena.len_of(b_id) as u64;
912                    let c_len = arena.len_of(c_id) as u64;
913                    let a_in = a_src >= base && a_src + a_len <= base + size;
914                    let b_in = b_src >= base && b_src + b_len <= base + size;
915                    let c_in = c_src >= base && c_src + c_len <= base + size;
916                    let a_dst = arena.scratch_off as u64;
917                    let a_aligned = a_len.div_ceil(256) * 256;
918                    let b_dst = a_dst + a_aligned;
919                    let b_aligned = b_len.div_ceil(256) * 256;
920                    let c_dst = b_dst + b_aligned;
921                    if a_dst < base || c_dst + c_len > base + size {
922                        base = (arena.size as u64).saturating_sub(size);
923                        base = (base / 256) * 256;
924                    }
925                    let a_off = if a_in {
926                        arena_local_off_f32(&arena, a_id, base)
927                    } else {
928                        if a_len > 64 * 1024 * 1024 {
929                            panic!("rlx-wgpu: Fma staging a too large ({a_len} bytes)");
930                        }
931                        schedule.push(Step::BufferCopy {
932                            src_byte_off: a_src,
933                            dst_byte_off: a_dst,
934                            bytes: a_len as u32,
935                        });
936                        ((a_dst.saturating_sub(base)) / 4) as u32
937                    };
938                    let b_off = if b_in {
939                        arena_local_off_f32(&arena, b_id, base)
940                    } else {
941                        if b_len > 64 * 1024 * 1024 {
942                            panic!("rlx-wgpu: Fma staging b too large ({b_len} bytes)");
943                        }
944                        schedule.push(Step::BufferCopy {
945                            src_byte_off: b_src,
946                            dst_byte_off: b_dst,
947                            bytes: b_len as u32,
948                        });
949                        ((b_dst.saturating_sub(base)) / 4) as u32
950                    };
951                    let c_off = if c_in {
952                        arena_local_off_f32(&arena, c_id, base)
953                    } else {
954                        if c_len > 64 * 1024 * 1024 {
955                            panic!("rlx-wgpu: Fma staging c too large ({c_len} bytes)");
956                        }
957                        schedule.push(Step::BufferCopy {
958                            src_byte_off: c_src,
959                            dst_byte_off: c_dst,
960                            bytes: c_len as u32,
961                        });
962                        ((c_dst.saturating_sub(base)) / 4) as u32
963                    };
964                    let p = FmaParams {
965                        n: elems,
966                        a_off,
967                        b_off,
968                        c_off,
969                        out_off: arena_local_off_f32(&arena, node.id, base),
970                        _p0: 0,
971                        _p1: 0,
972                        _p2: 0,
973                    };
974                    schedule.push(Step::Fma { params: p });
975                    let u = emit_uniform(std::mem::size_of::<FmaParams>());
976                    let bg = bind_two_buf0_window(&dev.device, fk, &arena.buffer, base, size, &u);
977                    uniforms.push(u);
978                    bind_groups.push(bg);
979                }
980
981                Op::BatchElementwiseRegion {
982                    chain,
983                    num_batch_inputs,
984                    scalar_input_mask,
985                    input_modulus,
986                    prologue,
987                    prologue_input,
988                } => {
989                    let n = *num_batch_inputs as usize;
990                    if n == 0 || chain.len() > 32 {
991                        panic!(
992                            "rlx-wgpu BatchElementwiseRegion: num_batch_inputs={n} steps={}",
993                            chain.len()
994                        );
995                    }
996                    let slice_shape = rlx_ir::batch_region_slice_shape(&node.shape);
997                    let slice_elems = rlx_ir::batch_region_slice_elems(&node.shape, n)
998                        .expect("batch region static shape");
999                    let mut win_ids: Vec<NodeId> = vec![node.id];
1000                    win_ids.extend(node.inputs.iter().copied());
1001                    let max_binding = dev.device.limits().max_storage_buffer_binding_size;
1002                    let fits = arena_span_bytes(&arena, &win_ids) <= max_binding;
1003                    let mut scratch = arena.scratch_off as u64;
1004                    let (mut base, mut size, param_anchor) = arena_multi_op_window(
1005                        &dev.device,
1006                        &arena,
1007                        &graph,
1008                        &param_offsets,
1009                        &mut schedule,
1010                        &mut scratch,
1011                        &win_ids,
1012                    );
1013                    if !fits && !param_anchor {
1014                        base = arena_bind_window_covering_scratch_if_needed(
1015                            &arena, base, size, scratch,
1016                        );
1017                    }
1018                    let chain_enc = rlx_ir::encode_chain_steps(chain);
1019                    let tail =
1020                        rlx_ir::encode_prologue_tail(*prologue, &slice_shape, *prologue_input);
1021                    let base_dst = arena_local_off_f32(&arena, node.id, base);
1022                    let use_single = rlx_ir::fk_batch_use_single_launch(n, *prologue);
1023                    if use_single {
1024                        let mut batch_input_offs = [0u32; 64];
1025                        for i in 0..n {
1026                            batch_input_offs[i] = arena_off_in_bind_window(
1027                                &graph,
1028                                &param_offsets,
1029                                &dev.device,
1030                                &arena,
1031                                &mut schedule,
1032                                &mut scratch,
1033                                node.inputs[i],
1034                                &mut base,
1035                                &mut size,
1036                            );
1037                        }
1038                        let p = BatchElementwiseRegionParams {
1039                            slice_len: slice_elems,
1040                            num_batch: n as u32,
1041                            num_steps: chain.len() as u32,
1042                            base_dst_off: base_dst,
1043                            slice_elems,
1044                            batch_input_offs,
1045                            chain: chain_enc,
1046                            scalar_input_mask: *scalar_input_mask,
1047                            input_modulus: *input_modulus,
1048                        };
1049                        schedule.push(Step::BatchElementwiseRegion { params: p });
1050                        let ek = batch_elementwise_region_kernel(&dev.device);
1051                        let u = dev.device.create_buffer(&wgpu::BufferDescriptor {
1052                            label: Some("rlx-wgpu batch region params"),
1053                            size: std::mem::size_of::<BatchElementwiseRegionParams>() as u64,
1054                            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
1055                            mapped_at_creation: false,
1056                        });
1057                        let bg =
1058                            bind_two_buf0_window(&dev.device, ek, &arena.buffer, base, size, &u);
1059                        uniforms.push(u);
1060                        bind_groups.push(bg);
1061                    } else {
1062                        let spatial = tail[0] == rlx_ir::REGION_PROLOGUE_RESIZE_NEAREST_2X_NCHW;
1063                        let ek = if spatial {
1064                            elementwise_region_spatial_kernel(&dev.device)
1065                        } else {
1066                            elementwise_region_kernel(&dev.device)
1067                        };
1068                        for i in 0..n {
1069                            let mut input_offs = [0u32; 16];
1070                            input_offs[0] = arena_off_in_bind_window(
1071                                &graph,
1072                                &param_offsets,
1073                                &dev.device,
1074                                &arena,
1075                                &mut schedule,
1076                                &mut scratch,
1077                                node.inputs[i],
1078                                &mut base,
1079                                &mut size,
1080                            );
1081                            let p = ElementwiseRegionParams {
1082                                len: slice_elems,
1083                                num_inputs: 1,
1084                                num_steps: chain.len() as u32,
1085                                dst_off: rlx_ir::batch_region_slice_dst_off_f32(
1086                                    base_dst,
1087                                    slice_elems,
1088                                    i,
1089                                ),
1090                                input_offs,
1091                                chain: chain_enc,
1092                                scalar_input_mask: *scalar_input_mask,
1093                                prologue: tail[0],
1094                                out_n: tail[1],
1095                                out_c: tail[2],
1096                                out_h: tail[3],
1097                                out_w: tail[4],
1098                                prologue_input: tail[5],
1099                                input_modulus: *input_modulus,
1100                            };
1101                            schedule.push(Step::ElementwiseRegion { params: p });
1102                            let u = dev.device.create_buffer(&wgpu::BufferDescriptor {
1103                                label: Some("rlx-wgpu batch region params"),
1104                                size: std::mem::size_of::<ElementwiseRegionParams>() as u64,
1105                                usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
1106                                mapped_at_creation: false,
1107                            });
1108                            let bg = bind_two_buf0_window(
1109                                &dev.device,
1110                                ek,
1111                                &arena.buffer,
1112                                base,
1113                                size,
1114                                &u,
1115                            );
1116                            uniforms.push(u);
1117                            bind_groups.push(bg);
1118                        }
1119                    }
1120                }
1121                Op::ElementwiseRegion {
1122                    chain,
1123                    num_inputs,
1124                    scalar_input_mask,
1125                    input_modulus,
1126                    prologue,
1127                    prologue_input,
1128                } => {
1129                    // PLAN L2 native lowering. Encode the chain into a
1130                    // fixed-size u32 buffer; one uniform per region.
1131                    let n = *num_inputs as usize;
1132                    if n > 16 || chain.len() > 32 {
1133                        panic!(
1134                            "rlx-wgpu ElementwiseRegion: chain too large \
1135                                (inputs={n}, steps={}). Caps: 16 / 32. \
1136                                Use UnfuseElementwiseRegions to fall back.",
1137                            chain.len()
1138                        );
1139                    }
1140                    let mut win_ids: Vec<NodeId> = vec![node.id];
1141                    win_ids.extend(node.inputs.iter().copied());
1142                    let max_binding = dev.device.limits().max_storage_buffer_binding_size;
1143                    let fits = arena_span_bytes(&arena, &win_ids) <= max_binding;
1144                    let mut scratch = arena.scratch_off as u64;
1145                    let (mut base, mut size, param_anchor) = arena_multi_op_window(
1146                        &dev.device,
1147                        &arena,
1148                        &graph,
1149                        &param_offsets,
1150                        &mut schedule,
1151                        &mut scratch,
1152                        &win_ids,
1153                    );
1154                    if !fits && !param_anchor {
1155                        base = arena_bind_window_covering_scratch_if_needed(
1156                            &arena, base, size, scratch,
1157                        );
1158                    }
1159                    let mut input_offs = [0u32; 16];
1160                    for (i, &id) in node.inputs.iter().enumerate() {
1161                        input_offs[i] = arena_off_in_bind_window(
1162                            &graph,
1163                            &param_offsets,
1164                            &dev.device,
1165                            &arena,
1166                            &mut schedule,
1167                            &mut scratch,
1168                            id,
1169                            &mut base,
1170                            &mut size,
1171                        );
1172                    }
1173                    let chain_enc = rlx_ir::encode_chain_steps(chain);
1174                    let tail =
1175                        rlx_ir::encode_prologue_tail(*prologue, &node.shape, *prologue_input);
1176                    let p = ElementwiseRegionParams {
1177                        len: elems,
1178                        num_inputs: *num_inputs,
1179                        num_steps: chain.len() as u32,
1180                        dst_off: arena_local_off_f32(&arena, node.id, base),
1181                        input_offs,
1182                        chain: chain_enc,
1183                        scalar_input_mask: *scalar_input_mask,
1184                        prologue: tail[0],
1185                        out_n: tail[1],
1186                        out_c: tail[2],
1187                        out_h: tail[3],
1188                        out_w: tail[4],
1189                        prologue_input: tail[5],
1190                        input_modulus: *input_modulus,
1191                    };
1192                    schedule.push(Step::ElementwiseRegion { params: p });
1193                    let ek = if p.prologue == rlx_ir::REGION_PROLOGUE_RESIZE_NEAREST_2X_NCHW {
1194                        elementwise_region_spatial_kernel(&dev.device)
1195                    } else {
1196                        elementwise_region_kernel(&dev.device)
1197                    };
1198                    // STORAGE (not UNIFORM) — the WGSL params struct
1199                    // contains `array<u32, N>` arrays whose 4-byte
1200                    // stride violates uniform's 16-byte stride rule.
1201                    let u = dev.device.create_buffer(&wgpu::BufferDescriptor {
1202                        label: Some("rlx-wgpu region params"),
1203                        size: std::mem::size_of::<ElementwiseRegionParams>() as u64,
1204                        usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
1205                        mapped_at_creation: false,
1206                    });
1207                    let bg = bind_two_buf0_window(&dev.device, ek, &arena.buffer, base, size, &u);
1208                    uniforms.push(u);
1209                    bind_groups.push(bg);
1210                }
1211
1212                Op::Reduce {
1213                    op: rop,
1214                    axes,
1215                    keep_dim: _,
1216                } => {
1217                    // Single-axis reduce OR contiguous multi-axis reduce.
1218                    // The kernel walks the input as `[outer, reduce_dim,
1219                    // inner]` — for contiguous axes [k..k+m], we set
1220                    // `reduce_dim = product(dims[k..k+m])`.
1221                    // Non-contiguous reductions are not yet wired (no
1222                    // model has hit them); transposing into contiguous
1223                    // form first is the future fix.
1224                    let in_id = node.inputs[0];
1225                    let in_shape = graph.node(in_id).shape.dims();
1226                    let mut sorted = axes.clone();
1227                    sorted.sort_unstable();
1228                    let contiguous = sorted.windows(2).all(|w| w[1] == w[0] + 1);
1229                    if !contiguous {
1230                        panic!(
1231                            "rlx-wgpu Reduce: non-contiguous axes not yet wired \
1232                             (got axes={axes:?}, rank={})",
1233                            in_shape.len()
1234                        );
1235                    }
1236                    let ax_first = sorted[0];
1237                    let ax_last = *sorted.last().unwrap();
1238                    let dims_u32: Vec<u32> =
1239                        in_shape.iter().map(|d| d.unwrap_static() as u32).collect();
1240                    let outer: u32 = dims_u32[..ax_first].iter().product();
1241                    let reduce_dim: u32 = dims_u32[ax_first..=ax_last].iter().product();
1242                    let inner: u32 = dims_u32[ax_last + 1..].iter().product();
1243                    let red_ids = [node.id, in_id];
1244                    let max_binding = dev.device.limits().max_storage_buffer_binding_size;
1245                    let red_fits = arena_span_bytes(&arena, &red_ids) <= max_binding;
1246                    let mut scratch = arena.scratch_off as u64;
1247                    let (mut base, mut size, param_anchor) = arena_multi_op_window(
1248                        &dev.device,
1249                        &arena,
1250                        &graph,
1251                        &param_offsets,
1252                        &mut schedule,
1253                        &mut scratch,
1254                        &red_ids,
1255                    );
1256                    if !red_fits && !param_anchor {
1257                        base = arena_bind_window_covering_scratch_if_needed(
1258                            &arena, base, size, scratch,
1259                        );
1260                    }
1261                    let in_off = arena_off_in_bind_window(
1262                        &graph,
1263                        &param_offsets,
1264                        &dev.device,
1265                        &arena,
1266                        &mut schedule,
1267                        &mut scratch,
1268                        in_id,
1269                        &mut base,
1270                        &mut size,
1271                    );
1272                    let p = ReduceParams {
1273                        outer,
1274                        reduce_dim,
1275                        inner,
1276                        in_off,
1277                        out_off: arena_local_off_f32(&arena, node.id, base),
1278                        op: reduce_op_id(*rop),
1279                        _p0: 0,
1280                        _p1: 0,
1281                    };
1282                    schedule.push(Step::Reduce { params: p });
1283                    let rk = reduce_kernel(&dev.device);
1284                    let u = emit_uniform(std::mem::size_of::<ReduceParams>());
1285                    let bg = bind_two_buf0_window(&dev.device, rk, &arena.buffer, base, size, &u);
1286                    uniforms.push(u);
1287                    bind_groups.push(bg);
1288                }
1289
1290                Op::Softmax { axis } => {
1291                    let in_id = node.inputs[0];
1292                    let in_shape = graph.node(in_id).shape.dims();
1293                    let last = (in_shape.len() - 1) as i32;
1294                    if *axis != -1 && *axis != last {
1295                        panic!("rlx-wgpu Softmax: only last-axis wired (got axis={axis})");
1296                    }
1297                    let inner = in_shape[in_shape.len() - 1].unwrap_static() as u32;
1298                    let total: u32 = in_shape.iter().map(|d| d.unwrap_static() as u32).product();
1299                    let outer = total / inner.max(1);
1300                    let sm_ids = [node.id, in_id];
1301                    let max_binding = dev.device.limits().max_storage_buffer_binding_size;
1302                    let sm_fits = arena_span_bytes(&arena, &sm_ids) <= max_binding;
1303                    let mut scratch = arena.scratch_off as u64;
1304                    let (mut base, mut size, param_anchor) = arena_multi_op_window(
1305                        &dev.device,
1306                        &arena,
1307                        &graph,
1308                        &param_offsets,
1309                        &mut schedule,
1310                        &mut scratch,
1311                        &sm_ids,
1312                    );
1313                    if !sm_fits && !param_anchor {
1314                        base = arena_bind_window_covering_scratch_if_needed(
1315                            &arena, base, size, scratch,
1316                        );
1317                    }
1318                    let in_off = arena_off_in_bind_window(
1319                        &graph,
1320                        &param_offsets,
1321                        &dev.device,
1322                        &arena,
1323                        &mut schedule,
1324                        &mut scratch,
1325                        in_id,
1326                        &mut base,
1327                        &mut size,
1328                    );
1329                    let p = SoftmaxParams {
1330                        outer,
1331                        inner,
1332                        in_off,
1333                        out_off: arena_local_off_f32(&arena, node.id, base),
1334                        _p0: 0,
1335                        _p1: 0,
1336                        _p2: 0,
1337                        _p3: 0,
1338                    };
1339                    schedule.push(Step::Softmax { params: p });
1340                    let sk = softmax_kernel(&dev.device);
1341                    let u = emit_uniform(std::mem::size_of::<SoftmaxParams>());
1342                    let bg = bind_two_buf0_window(&dev.device, sk, &arena.buffer, base, size, &u);
1343                    uniforms.push(u);
1344                    bind_groups.push(bg);
1345                }
1346
1347                Op::SoftmaxCrossEntropy => {
1348                    // Dense / soft-label cross-entropy: logits [N,C], targets
1349                    // [N,C] → loss [N]. One thread per row (outer=N, inner=C).
1350                    // Window must cover both inputs + the [N] output slot;
1351                    // mirrors the LayerNorm multi-input arena-window dance.
1352                    let logits_id = node.inputs[0];
1353                    let targets_id = node.inputs[1];
1354                    let logits_shape = graph.node(logits_id).shape.dims();
1355                    let inner = logits_shape[logits_shape.len() - 1].unwrap_static() as u32;
1356                    let total: u32 = logits_shape
1357                        .iter()
1358                        .map(|d| d.unwrap_static() as u32)
1359                        .product();
1360                    let outer = total / inner.max(1);
1361
1362                    let sce_win = vec![node.id, logits_id, targets_id];
1363                    let max_binding = dev.device.limits().max_storage_buffer_binding_size;
1364                    let sce_fits = arena_span_bytes(&arena, &sce_win) <= max_binding;
1365                    let mut scratch = arena.scratch_off as u64;
1366                    let (mut base, mut size, param_anchor) = arena_multi_op_window(
1367                        &dev.device,
1368                        &arena,
1369                        &graph,
1370                        &param_offsets,
1371                        &mut schedule,
1372                        &mut scratch,
1373                        &sce_win,
1374                    );
1375                    if !sce_fits && !param_anchor {
1376                        base = arena_bind_window_covering_scratch_if_needed(
1377                            &arena, base, size, scratch,
1378                        );
1379                    }
1380                    let logits_off = arena_off_in_bind_window(
1381                        &graph,
1382                        &param_offsets,
1383                        &dev.device,
1384                        &arena,
1385                        &mut schedule,
1386                        &mut scratch,
1387                        logits_id,
1388                        &mut base,
1389                        &mut size,
1390                    );
1391                    let targets_off = arena_off_in_bind_window(
1392                        &graph,
1393                        &param_offsets,
1394                        &dev.device,
1395                        &arena,
1396                        &mut schedule,
1397                        &mut scratch,
1398                        targets_id,
1399                        &mut base,
1400                        &mut size,
1401                    );
1402                    let p = SceParams {
1403                        outer,
1404                        inner,
1405                        logits_off,
1406                        targets_off,
1407                        out_off: arena_local_off_f32(&arena, node.id, base),
1408                        _p0: 0,
1409                        _p1: 0,
1410                        _p2: 0,
1411                    };
1412                    schedule.push(Step::SoftmaxCrossEntropy { params: p });
1413                    let sk = softmax_cross_entropy_kernel(&dev.device);
1414                    let u = emit_uniform(std::mem::size_of::<SceParams>());
1415                    let bg = bind_two_buf0_window(&dev.device, sk, &arena.buffer, base, size, &u);
1416                    uniforms.push(u);
1417                    bind_groups.push(bg);
1418                }
1419
1420                Op::LayerNorm { axis: _, eps } | Op::RmsNorm { axis: _, eps } => {
1421                    let in_id = node.inputs[0];
1422                    let in_shape = graph.node(in_id).shape.dims();
1423                    let inner = in_shape[in_shape.len() - 1].unwrap_static() as u32;
1424                    let total: u32 = in_shape.iter().map(|d| d.unwrap_static() as u32).product();
1425                    let outer = total / inner.max(1);
1426                    let is_layer_norm = matches!(&node.op, Op::LayerNorm { .. });
1427
1428                    // FRLTee fast path: if this LN is the head of a
1429                    // (multi-consumer Add → LN) pattern, emit one
1430                    // `Step::FusedResidualLnTee` that writes the sum to
1431                    // the eliminated Add's arena slot AND the LN result
1432                    // to this LN's slot. The Add itself is skipped
1433                    // upstream (`skip_adds`).
1434                    if is_layer_norm
1435                        && let Some(&(h_id, delta_id, gamma_id, beta_id, sum_id)) =
1436                            ln_to_tee.get(&node.id)
1437                    {
1438                        let gamma_is_param =
1439                            tensor_is_graph_param(&graph, &param_offsets, gamma_id);
1440                        let gamma_bytes = arena.len_of(gamma_id) as u64;
1441                        let frlt_win: Vec<NodeId> =
1442                            if gamma_is_param && gamma_bytes > ARENA_STAGE_CAP {
1443                                vec![gamma_id, node.id, h_id, delta_id, beta_id, sum_id]
1444                            } else {
1445                                vec![node.id, h_id, delta_id, gamma_id, beta_id, sum_id]
1446                            };
1447                        let mut scratch = arena.scratch_off as u64;
1448                        let (mut base, mut size, param_anchor) = arena_multi_op_window(
1449                            &dev.device,
1450                            &arena,
1451                            &graph,
1452                            &param_offsets,
1453                            &mut schedule,
1454                            &mut scratch,
1455                            &frlt_win,
1456                        );
1457                        if !param_anchor {
1458                            base = arena_bind_window_covering_scratch_if_needed(
1459                                &arena, base, size, scratch,
1460                            );
1461                        }
1462                        let in_off = arena_off_in_bind_window(
1463                            &graph,
1464                            &param_offsets,
1465                            &dev.device,
1466                            &arena,
1467                            &mut schedule,
1468                            &mut scratch,
1469                            h_id,
1470                            &mut base,
1471                            &mut size,
1472                        );
1473                        let residual_off = arena_off_in_bind_window(
1474                            &graph,
1475                            &param_offsets,
1476                            &dev.device,
1477                            &arena,
1478                            &mut schedule,
1479                            &mut scratch,
1480                            delta_id,
1481                            &mut base,
1482                            &mut size,
1483                        );
1484                        let sum_off = arena_off_in_bind_window(
1485                            &graph,
1486                            &param_offsets,
1487                            &dev.device,
1488                            &arena,
1489                            &mut schedule,
1490                            &mut scratch,
1491                            sum_id,
1492                            &mut base,
1493                            &mut size,
1494                        );
1495                        let gamma_off = arena_off_in_bind_window(
1496                            &graph,
1497                            &param_offsets,
1498                            &dev.device,
1499                            &arena,
1500                            &mut schedule,
1501                            &mut scratch,
1502                            gamma_id,
1503                            &mut base,
1504                            &mut size,
1505                        );
1506                        let beta_off = arena_off_in_bind_window(
1507                            &graph,
1508                            &param_offsets,
1509                            &dev.device,
1510                            &arena,
1511                            &mut schedule,
1512                            &mut scratch,
1513                            beta_id,
1514                            &mut base,
1515                            &mut size,
1516                        );
1517                        let p = FusedResidualLnTeeParams {
1518                            outer,
1519                            inner,
1520                            in_off,
1521                            residual_off,
1522                            bias_off: 0, // FRLTee currently no-bias only
1523                            gamma_off,
1524                            beta_off,
1525                            sum_off,
1526                            ln_out_off: arena_local_off_f32(&arena, node.id, base),
1527                            eps_bits: eps.to_bits(),
1528                            has_bias: 0,
1529                            _p0: 0,
1530                        };
1531                        schedule.push(Step::FusedResidualLnTee { params: p });
1532                        let frtk = fused_residual_ln_tee_kernel(&dev.device);
1533                        let u = emit_uniform(std::mem::size_of::<FusedResidualLnTeeParams>());
1534                        let bg =
1535                            bind_two_buf0_window(&dev.device, frtk, &arena.buffer, base, size, &u);
1536                        uniforms.push(u);
1537                        bind_groups.push(bg);
1538                        continue;
1539                    }
1540
1541                    let gamma_id = node.inputs[1];
1542                    // beta is the third input for LayerNorm; RmsNorm
1543                    // ignores it (kernel branch on `op` skips the read).
1544                    let beta_id = if is_layer_norm && node.inputs.len() >= 3 {
1545                        node.inputs[2]
1546                    } else {
1547                        // Use gamma's offset as a benign placeholder;
1548                        // the RmsNorm kernel branch never reads it.
1549                        gamma_id
1550                    };
1551                    let gamma_is_param = tensor_is_graph_param(&graph, &param_offsets, gamma_id);
1552                    let gamma_bytes = arena.len_of(gamma_id) as u64;
1553                    let ln_win: Vec<NodeId> = if gamma_is_param && gamma_bytes > ARENA_STAGE_CAP {
1554                        vec![gamma_id, node.id, in_id]
1555                    } else {
1556                        let mut v = vec![node.id, in_id];
1557                        if gamma_is_param {
1558                            v.push(gamma_id);
1559                        }
1560                        if is_layer_norm {
1561                            v.push(beta_id);
1562                        }
1563                        v
1564                    };
1565                    let max_binding = dev.device.limits().max_storage_buffer_binding_size;
1566                    let ln_fits = arena_span_bytes(&arena, &ln_win) <= max_binding;
1567                    let mut scratch = arena.scratch_off as u64;
1568                    let (mut base, mut size, param_anchor) = arena_multi_op_window(
1569                        &dev.device,
1570                        &arena,
1571                        &graph,
1572                        &param_offsets,
1573                        &mut schedule,
1574                        &mut scratch,
1575                        &ln_win,
1576                    );
1577                    if !ln_fits && !param_anchor {
1578                        base = arena_bind_window_covering_scratch_if_needed(
1579                            &arena, base, size, scratch,
1580                        );
1581                    }
1582                    let in_off = arena_off_in_bind_window(
1583                        &graph,
1584                        &param_offsets,
1585                        &dev.device,
1586                        &arena,
1587                        &mut schedule,
1588                        &mut scratch,
1589                        in_id,
1590                        &mut base,
1591                        &mut size,
1592                    );
1593                    let gamma_off = arena_off_in_bind_window(
1594                        &graph,
1595                        &param_offsets,
1596                        &dev.device,
1597                        &arena,
1598                        &mut schedule,
1599                        &mut scratch,
1600                        gamma_id,
1601                        &mut base,
1602                        &mut size,
1603                    );
1604                    let beta_off = arena_off_in_bind_window(
1605                        &graph,
1606                        &param_offsets,
1607                        &dev.device,
1608                        &arena,
1609                        &mut schedule,
1610                        &mut scratch,
1611                        beta_id,
1612                        &mut base,
1613                        &mut size,
1614                    );
1615                    let p = LayerNormParams {
1616                        outer,
1617                        inner,
1618                        in_off,
1619                        out_off: arena_local_off_f32(&arena, node.id, base),
1620                        gamma_off,
1621                        beta_off,
1622                        eps_bits: eps.to_bits(),
1623                        op: if is_layer_norm { 0 } else { 1 },
1624                    };
1625                    schedule.push(Step::LayerNorm { params: p });
1626                    let lk = layernorm_kernel(&dev.device);
1627                    let u = emit_uniform(std::mem::size_of::<LayerNormParams>());
1628                    let bg = bind_two_buf0_window(&dev.device, lk, &arena.buffer, base, size, &u);
1629                    uniforms.push(u);
1630                    bind_groups.push(bg);
1631                }
1632
1633                Op::Reshape { .. } => {
1634                    // No-op: memory planner view-aliased this slot.
1635                }
1636
1637                Op::Cast { .. } => {
1638                    // A same-dtype Cast is view-aliased by the planner (src==dst) →
1639                    // no-op. A dtype-changing Cast (e.g. Bool→F32 for VITS sequence
1640                    // masks) gets its own slot; on the f32-uniform arena every value
1641                    // is already f32-encoded, so the cast is a value-preserving copy.
1642                    // Without this the output slot keeps its (zero) init — a Bool→F32
1643                    // mask Cast silently produced all-zeros, killing the encoder.
1644                    let in_id = node.inputs[0];
1645                    let src = arena.offset(in_id);
1646                    let dst = arena.offset(node.id);
1647                    if src != dst {
1648                        let bytes = arena.len_of(in_id).min(arena.len_of(node.id));
1649                        schedule.push(Step::BufferCopy {
1650                            src_byte_off: src as u64,
1651                            dst_byte_off: dst as u64,
1652                            bytes: bytes as u32,
1653                        });
1654                    }
1655                }
1656
1657                Op::Transpose { perm } => {
1658                    let in_id = node.inputs[0];
1659                    let in_shape = graph.node(in_id).shape.dims();
1660                    let out_shape = node.shape.dims();
1661                    let rank = perm.len();
1662                    if rank != in_shape.len() || rank != out_shape.len() {
1663                        panic!("rlx-wgpu Transpose: rank mismatch");
1664                    }
1665                    let in_dims: Vec<u32> =
1666                        in_shape.iter().map(|d| d.unwrap_static() as u32).collect();
1667                    let out_dims: Vec<u32> =
1668                        out_shape.iter().map(|d| d.unwrap_static() as u32).collect();
1669                    // Input cumulative strides (row-major).
1670                    let mut in_strides = vec![1u32; rank];
1671                    for i in (0..rank.saturating_sub(1)).rev() {
1672                        in_strides[i] = in_strides[i + 1] * in_dims[i + 1];
1673                    }
1674                    // For each *output* axis i, the corresponding input
1675                    // axis is perm[i] — its stride is in_strides[perm[i]].
1676                    let strides_for_out: Vec<u32> =
1677                        (0..rank).map(|i| in_strides[perm[i]]).collect();
1678
1679                    // Build meta buffer: dims (rank u32s) + strides (rank u32s).
1680                    let mut meta_data: Vec<u32> = Vec::with_capacity(rank * 2);
1681                    meta_data.extend_from_slice(&out_dims);
1682                    meta_data.extend_from_slice(&strides_for_out);
1683                    let meta_buf = dev.device.create_buffer(&wgpu::BufferDescriptor {
1684                        label: Some("rlx-wgpu transpose meta"),
1685                        size: (meta_data.len() * 4).max(4) as u64,
1686                        usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
1687                        mapped_at_creation: false,
1688                    });
1689                    dev.queue
1690                        .write_buffer(&meta_buf, 0, bytemuck::cast_slice(&meta_data));
1691                    let meta_idx = meta_buffers.len();
1692                    meta_buffers.push(meta_buf);
1693
1694                    // PLAN L1: precompute "bucket axis stays at out
1695                    // axis 0" flag from perm. When `perm[0] == 0`,
1696                    // active-extent scaling of `out_total` is safe.
1697                    let bucket_outermost = if perm[0] == 0 { 1u32 } else { 0u32 };
1698                    let tr_ids = [node.id, in_id];
1699                    let max_binding = dev.device.limits().max_storage_buffer_binding_size;
1700                    let in_is_param = tensor_is_graph_param(&graph, &param_offsets, in_id);
1701                    let in_bytes = arena.len_of(in_id) as u64;
1702                    let (mut base, mut size) = if in_is_param && in_bytes <= max_binding {
1703                        arena_window_for_nodes(&dev.device, &arena, &[in_id])
1704                    } else if arena_span_bytes(&arena, &tr_ids) <= max_binding {
1705                        arena_window_for_nodes(&dev.device, &arena, &tr_ids)
1706                    } else {
1707                        arena_window_for_nodes(&dev.device, &arena, &[node.id])
1708                    };
1709                    let mut scratch = arena.scratch_off as u64;
1710                    let in_off = arena_off_in_bind_window(
1711                        &graph,
1712                        &param_offsets,
1713                        &dev.device,
1714                        &arena,
1715                        &mut schedule,
1716                        &mut scratch,
1717                        in_id,
1718                        &mut base,
1719                        &mut size,
1720                    );
1721                    let out_off = arena_off_in_bind_window(
1722                        &graph,
1723                        &param_offsets,
1724                        &dev.device,
1725                        &arena,
1726                        &mut schedule,
1727                        &mut scratch,
1728                        node.id,
1729                        &mut base,
1730                        &mut size,
1731                    );
1732                    let p = TransposeParams {
1733                        rank: rank as u32,
1734                        out_total: elems,
1735                        in_off,
1736                        out_off,
1737                        bucket_outermost,
1738                        out_dim_0: out_dims[0],
1739                        _p2: 0,
1740                        _p3: 0,
1741                    };
1742                    schedule.push(Step::Transpose {
1743                        params: p,
1744                        meta_idx,
1745                    });
1746                    let tk = transpose_kernel(&dev.device);
1747                    let u = emit_uniform(std::mem::size_of::<TransposeParams>());
1748                    let bg = dev.device.create_bind_group(&wgpu::BindGroupDescriptor {
1749                        label: Some("rlx-wgpu transpose bg"),
1750                        layout: &tk.bgl,
1751                        entries: &[
1752                            wgpu::BindGroupEntry {
1753                                binding: 0,
1754                                resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
1755                                    buffer: &arena.buffer,
1756                                    offset: base,
1757                                    size: aligned_bind_size(size, base, arena.buffer.size()),
1758                                }),
1759                            },
1760                            wgpu::BindGroupEntry {
1761                                binding: 1,
1762                                resource: u.as_entire_binding(),
1763                            },
1764                            wgpu::BindGroupEntry {
1765                                binding: 2,
1766                                resource: meta_buffers[meta_idx].as_entire_binding(),
1767                            },
1768                        ],
1769                    });
1770                    uniforms.push(u);
1771                    bind_groups.push(bg);
1772                }
1773
1774                Op::Narrow { axis, start, len } => {
1775                    // Part of a split-QKV pattern: the parent FMB has been
1776                    // (or will be) replaced by Step::MatmulQkv that writes
1777                    // directly into this narrow's arena slot. Skip the
1778                    // narrow's own dispatch.
1779                    if qkv_skip_narrows.contains(&node.id)
1780                        || packed_bshd_skip_narrows.contains(&node.id)
1781                    {
1782                        continue;
1783                    }
1784                    let in_id = node.inputs[0];
1785                    let in_shape = graph.node(in_id).shape.dims();
1786                    let outer: u32 = in_shape[..*axis]
1787                        .iter()
1788                        .map(|d| d.unwrap_static() as u32)
1789                        .product::<u32>()
1790                        .max(1);
1791                    let inner: u32 = in_shape[*axis + 1..]
1792                        .iter()
1793                        .map(|d| d.unwrap_static() as u32)
1794                        .product::<u32>()
1795                        .max(1);
1796                    let axis_in = in_shape[*axis].unwrap_static() as u32;
1797                    let win_ids = [node.id, in_id];
1798                    let max_binding = dev.device.limits().max_storage_buffer_binding_size;
1799                    let fits = arena_span_bytes(&arena, &win_ids) <= max_binding;
1800                    let mut scratch = arena.scratch_off as u64;
1801                    let (mut base, mut size, param_anchor) = arena_multi_op_window(
1802                        &dev.device,
1803                        &arena,
1804                        &graph,
1805                        &param_offsets,
1806                        &mut schedule,
1807                        &mut scratch,
1808                        &win_ids,
1809                    );
1810                    if !fits && !param_anchor {
1811                        base = arena_bind_window_covering_scratch_if_needed(
1812                            &arena, base, size, scratch,
1813                        );
1814                    }
1815                    let in_off = arena_off_in_bind_window(
1816                        &graph,
1817                        &param_offsets,
1818                        &dev.device,
1819                        &arena,
1820                        &mut schedule,
1821                        &mut scratch,
1822                        in_id,
1823                        &mut base,
1824                        &mut size,
1825                    );
1826                    let out_off = arena_off_in_bind_window(
1827                        &graph,
1828                        &param_offsets,
1829                        &dev.device,
1830                        &arena,
1831                        &mut schedule,
1832                        &mut scratch,
1833                        node.id,
1834                        &mut base,
1835                        &mut size,
1836                    );
1837                    let p = NarrowConcatParams {
1838                        total: elems,
1839                        outer,
1840                        inner,
1841                        axis_in_size: axis_in,
1842                        axis_out_size: *len as u32,
1843                        start: *start as u32,
1844                        in_off,
1845                        out_off,
1846                    };
1847                    schedule.push(Step::Narrow { params: p });
1848                    let nk = narrow_kernel(&dev.device);
1849                    let u = emit_uniform(std::mem::size_of::<NarrowConcatParams>());
1850                    let bg = bind_two_buf0_window(&dev.device, nk, &arena.buffer, base, size, &u);
1851                    uniforms.push(u);
1852                    bind_groups.push(bg);
1853                }
1854
1855                Op::Concat { axis } => {
1856                    let out_shape = node.shape.dims();
1857                    let outer: u32 = out_shape[..*axis]
1858                        .iter()
1859                        .map(|d| d.unwrap_static() as u32)
1860                        .product::<u32>()
1861                        .max(1);
1862                    let inner: u32 = out_shape[*axis + 1..]
1863                        .iter()
1864                        .map(|d| d.unwrap_static() as u32)
1865                        .product::<u32>()
1866                        .max(1);
1867                    let axis_out = out_shape[*axis].unwrap_static() as u32;
1868
1869                    let all_ids: Vec<NodeId> = std::iter::once(node.id)
1870                        .chain(node.inputs.iter().copied())
1871                        .collect();
1872                    let max_binding = dev.device.limits().max_storage_buffer_binding_size;
1873                    let fits_all = arena_span_bytes(&arena, &all_ids) <= max_binding;
1874                    let mut scratch = arena.scratch_off as u64;
1875                    let (mut base, mut size, param_anchor) = arena_multi_op_window(
1876                        &dev.device,
1877                        &arena,
1878                        &graph,
1879                        &param_offsets,
1880                        &mut schedule,
1881                        &mut scratch,
1882                        &all_ids,
1883                    );
1884                    arena_expand_bind_window(&arena, &all_ids, &mut base, &mut size, max_binding);
1885                    if !fits_all && !param_anchor {
1886                        base = arena_bind_window_covering_scratch_if_needed(
1887                            &arena, base, size, scratch,
1888                        );
1889                    }
1890                    let mut start_pos: u32 = 0;
1891                    for &in_id in &node.inputs {
1892                        let in_shape = graph.node(in_id).shape.dims();
1893                        let axis_in = in_shape[*axis].unwrap_static() as u32;
1894                        let in_total: u32 =
1895                            in_shape.iter().map(|d| d.unwrap_static() as u32).product();
1896                        let _win_ids = [node.id, in_id];
1897                        let in_off = arena_off_in_bind_window(
1898                            &graph,
1899                            &param_offsets,
1900                            &dev.device,
1901                            &arena,
1902                            &mut schedule,
1903                            &mut scratch,
1904                            in_id,
1905                            &mut base,
1906                            &mut size,
1907                        );
1908                        // Recompute the output offset against the *current* base:
1909                        // `arena_off_in_bind_window` above may have shifted `base`
1910                        // to cover this input (large decoder arenas), which would
1911                        // otherwise leave a pre-loop `out_off` stale → writes land
1912                        // outside the bound window and the output stays zero.
1913                        let out_off = arena_local_off_f32(&arena, node.id, base);
1914                        let p = NarrowConcatParams {
1915                            total: in_total,
1916                            outer,
1917                            inner,
1918                            axis_in_size: axis_in,
1919                            axis_out_size: axis_out,
1920                            start: start_pos,
1921                            in_off,
1922                            out_off,
1923                        };
1924                        schedule.push(Step::Concat { params: p });
1925                        let cck = concat_kernel(&dev.device);
1926                        let u = emit_uniform(std::mem::size_of::<NarrowConcatParams>());
1927                        let bg =
1928                            bind_two_buf0_window(&dev.device, cck, &arena.buffer, base, size, &u);
1929                        uniforms.push(u);
1930                        bind_groups.push(bg);
1931                        start_pos += axis_in;
1932                    }
1933                }
1934
1935                Op::Attention {
1936                    num_heads,
1937                    head_dim,
1938                    mask_kind,
1939                    score_scale,
1940                    attn_logit_softcap: _,
1941                } => {
1942                    // v5: rank-4 [B, H, S, D] inputs only. SlidingWindow
1943                    // synthesizes a Custom mask host-side.
1944                    let q_id = node.inputs[0];
1945                    let k_id = node.inputs[1];
1946                    let v_id = node.inputs[2];
1947                    let q_shape = graph.node(q_id).shape.dims();
1948                    let k_shape = graph.node(k_id).shape.dims();
1949                    // Accept either rank-4 [B, H, S, D] or rank-3 [B*H, S, D]
1950                    // (the latter is what BERT-flavored builders emit). For
1951                    // rank-3 we treat the leading dim as `batch * heads`,
1952                    // setting heads = num_heads from the Op so the kernel's
1953                    // (b, h) indexing folds back to the right offset.
1954                    let h = *num_heads as u32;
1955                    let hd = *head_dim as u32;
1956                    let q_ir = graph.node(q_id).shape.clone();
1957                    let k_ir = graph.node(k_id).shape.clone();
1958                    let geom = rlx_ir::attention_geom(&q_ir, &k_ir, *num_heads, *head_dim);
1959                    let bhsd = geom.bhsd;
1960                    let (batch, heads, seq_q, seq_k) = match q_shape.len() {
1961                        4 => (
1962                            geom.batch as u32,
1963                            geom.heads as u32,
1964                            geom.seq_q as u32,
1965                            geom.seq_k as u32,
1966                        ),
1967                        3 => {
1968                            // Two rank-3 layouts coexist:
1969                            //   [B, S, H·D] — transpose-elided layout
1970                            //   [B·H, S, D] — canonical compacted layout
1971                            // Distinguish by last-dim: if it equals H·D
1972                            // (the per-token feature width) it's [B, S, H·D];
1973                            // otherwise it's [B·H, S, D].
1974                            let last = q_shape[2].unwrap_static() as u32;
1975                            if last == h * hd {
1976                                // [B, S, H·D]: leading = B, seq = S
1977                                (
1978                                    q_shape[0].unwrap_static() as u32,
1979                                    h,
1980                                    q_shape[1].unwrap_static() as u32,
1981                                    k_shape[1].unwrap_static() as u32,
1982                                )
1983                            } else {
1984                                // [B·H, S, D]: leading must be divisible by H
1985                                let leading = q_shape[0].unwrap_static() as u32;
1986                                if !leading.is_multiple_of(h) {
1987                                    panic!(
1988                                        "rlx-wgpu Attention: rank-3 leading dim {leading} \
1989                                            not divisible by num_heads {h} (and last dim \
1990                                            {last} ≠ H·D = {})",
1991                                        h * hd
1992                                    );
1993                                }
1994                                (
1995                                    leading / h,
1996                                    h,
1997                                    q_shape[1].unwrap_static() as u32,
1998                                    k_shape[1].unwrap_static() as u32,
1999                                )
2000                            }
2001                        }
2002                        other => panic!(
2003                            "rlx-wgpu Attention: only rank-3 / rank-4 Q,K,V \
2004                                         inputs supported (got rank {other})"
2005                        ),
2006                    };
2007                    let scale = score_scale.unwrap_or(1.0_f32 / (hd as f32).sqrt());
2008
2009                    let (mask_kind_id, mask_buf, window) = match mask_kind {
2010                        MaskKind::None => (0u32, None, 0u32),
2011                        MaskKind::Causal => (1u32, None, 0u32),
2012                        // 2 = binary key-padding mask (Custom: <0.5 → -inf);
2013                        // 4 = additive bias mask (Bias: score += mask). These
2014                        // are NOT interchangeable — the encoder's block-diagonal
2015                        // winmask is additive, so folding it into the binary
2016                        // path silently corrupts attention.
2017                        MaskKind::Custom => (2u32, None, 0u32),
2018                        MaskKind::Bias => (4u32, None, 0u32),
2019                        MaskKind::SlidingWindow(w) => (3u32, None, *w as u32),
2020                    };
2021
2022                    // Mask address strides. For Custom masks, derive from
2023                    // the mask's IR shape so the kernel can broadcast a
2024                    // [B, S] padding mask without materializing the full
2025                    // [B, H, S_q, S_k] expansion. Other mask kinds use
2026                    // canonical [B, H, S_q, S_k] strides (the kernel's
2027                    // mask_partial computation is harmless when not read).
2028                    struct MStrides {
2029                        b: u32,
2030                        h: u32,
2031                        q: u32,
2032                        k: u32,
2033                    }
2034                    let mask_strides = if mask_kind_id == 2u32 || mask_kind_id == 4u32 {
2035                        let m_dims = graph.node(node.inputs[3]).shape.dims();
2036                        let dim = |i: usize| m_dims[i].unwrap_static() as u32;
2037                        match m_dims.len() {
2038                            2 => MStrides {
2039                                b: dim(1),
2040                                h: 0,
2041                                q: 0,
2042                                k: 1,
2043                            },
2044                            3 => MStrides {
2045                                b: dim(1) * dim(2),
2046                                h: 0,
2047                                q: dim(2),
2048                                k: 1,
2049                            },
2050                            4 => MStrides {
2051                                b: dim(1) * dim(2) * dim(3),
2052                                h: dim(2) * dim(3),
2053                                q: dim(3),
2054                                k: 1,
2055                            },
2056                            _ => MStrides {
2057                                b: heads * seq_q * seq_k,
2058                                h: seq_q * seq_k,
2059                                q: seq_k,
2060                                k: 1,
2061                            },
2062                        }
2063                    } else {
2064                        MStrides {
2065                            b: heads * seq_q * seq_k,
2066                            h: seq_q * seq_k,
2067                            q: seq_k,
2068                            k: 1,
2069                        }
2070                    };
2071
2072                    let stride = |shape: &[rlx_ir::shape::Dim], seq_extent: u32| {
2073                        rlx_ir::strides_for_shape(shape, heads, hd, seq_extent, bhsd)
2074                    };
2075                    let packed_parent = packed_bshd_attn.get(&node.id).copied();
2076                    // GQA/MQA: the KV-head count is layout-independent — K's
2077                    // element count over B·S_k·D. Packed QKV shares Q's head
2078                    // count (uniform strides), so treat it as MHA.
2079                    let nkv: u32 = if packed_parent.is_some() {
2080                        heads
2081                    } else {
2082                        let k_numel: u32 =
2083                            k_shape.iter().map(|d| d.unwrap_static() as u32).product();
2084                        (k_numel / (batch.max(1) * seq_k.max(1) * hd.max(1))).max(1)
2085                    };
2086                    let (q_b, q_h, q_s, k_b, k_h, k_s, v_b, v_h, v_s) =
2087                        if let Some((_parent, head_width)) = packed_parent {
2088                            let (batch_stride, head_stride, pack_seq) =
2089                                rlx_ir::packed_bshd_qkv_strides(head_width as usize, hd, seq_q);
2090                            (
2091                                batch_stride,
2092                                head_stride,
2093                                pack_seq,
2094                                batch_stride,
2095                                head_stride,
2096                                pack_seq,
2097                                batch_stride,
2098                                head_stride,
2099                                pack_seq,
2100                            )
2101                        } else {
2102                            let (qb, qh, qs) = stride(q_shape, seq_q);
2103                            // K/V carry nkv heads (GQA); strides_for_shape
2104                            // detects their BSHD/BHSD layout from nkv·D.
2105                            let (kb, kh, ks) =
2106                                rlx_ir::strides_for_shape(k_shape, nkv, hd, seq_k, bhsd);
2107                            let v_shape = graph.node(v_id).shape.dims();
2108                            let (vb, vh, vs) =
2109                                rlx_ir::strides_for_shape(v_shape, nkv, hd, seq_k, bhsd);
2110                            (qb, qh, qs, kb, kh, ks, vb, vh, vs)
2111                        };
2112                    let out_shape = node.shape.dims();
2113                    let (o_b, o_h, o_s) = stride(out_shape, seq_q);
2114                    let mut attn_ids = if let Some((parent, _)) = packed_parent {
2115                        vec![node.id, parent]
2116                    } else {
2117                        vec![node.id, q_id, k_id, v_id]
2118                    };
2119                    if mask_kind_id == 2 {
2120                        attn_ids.push(node.inputs[3]);
2121                    }
2122                    let mut scratch = arena.scratch_off as u64;
2123                    let (mut base, mut size, param_anchor) = arena_multi_op_window(
2124                        &dev.device,
2125                        &arena,
2126                        &graph,
2127                        &param_offsets,
2128                        &mut schedule,
2129                        &mut scratch,
2130                        &attn_ids,
2131                    );
2132                    if !param_anchor {
2133                        base = arena_bind_window_covering_scratch_if_needed(
2134                            &arena, base, size, scratch,
2135                        );
2136                    }
2137                    let (q_off, k_off, v_off) = if let Some((parent, head_width)) = packed_parent {
2138                        let parent_off = arena_off_in_bind_window(
2139                            &graph,
2140                            &param_offsets,
2141                            &dev.device,
2142                            &arena,
2143                            &mut schedule,
2144                            &mut scratch,
2145                            parent,
2146                            &mut base,
2147                            &mut size,
2148                        );
2149                        (
2150                            parent_off,
2151                            parent_off.saturating_add(head_width),
2152                            parent_off.saturating_add(head_width * 2),
2153                        )
2154                    } else {
2155                        let q_off = arena_off_in_bind_window(
2156                            &graph,
2157                            &param_offsets,
2158                            &dev.device,
2159                            &arena,
2160                            &mut schedule,
2161                            &mut scratch,
2162                            q_id,
2163                            &mut base,
2164                            &mut size,
2165                        );
2166                        let k_off = arena_off_in_bind_window(
2167                            &graph,
2168                            &param_offsets,
2169                            &dev.device,
2170                            &arena,
2171                            &mut schedule,
2172                            &mut scratch,
2173                            k_id,
2174                            &mut base,
2175                            &mut size,
2176                        );
2177                        let v_off = arena_off_in_bind_window(
2178                            &graph,
2179                            &param_offsets,
2180                            &dev.device,
2181                            &arena,
2182                            &mut schedule,
2183                            &mut scratch,
2184                            v_id,
2185                            &mut base,
2186                            &mut size,
2187                        );
2188                        (q_off, k_off, v_off)
2189                    };
2190                    let out_byte = arena.offset(node.id) as u64;
2191                    let out_len = arena.len_of(node.id) as u64;
2192                    let out_aliases_qkv = arena_tensors_overlap(&arena, node.id, q_id)
2193                        || arena_tensors_overlap(&arena, node.id, k_id)
2194                        || arena_tensors_overlap(&arena, node.id, v_id)
2195                        || packed_parent.is_some_and(|(parent, _)| {
2196                            arena_tensors_overlap(&arena, node.id, parent)
2197                        });
2198                    let mut kernel_out_off = arena_off_in_bind_window(
2199                        &graph,
2200                        &param_offsets,
2201                        &dev.device,
2202                        &arena,
2203                        &mut schedule,
2204                        &mut scratch,
2205                        node.id,
2206                        &mut base,
2207                        &mut size,
2208                    );
2209                    let mut attn_scratch_copy: Option<(u64, u32)> = None;
2210                    if out_aliases_qkv && rlx_ir::env::flag("RLX_WGPU_DEBUG_ATTN_ALIAS") {
2211                        eprintln!(
2212                            "rlx-wgpu Attention alias: out={:?}@{}+{} q={:?}@{} k={:?}@{} v={:?}@{}",
2213                            node.id,
2214                            out_byte,
2215                            out_len,
2216                            q_id,
2217                            arena.offset(q_id),
2218                            k_id,
2219                            arena.offset(k_id),
2220                            v_id,
2221                            arena.offset(v_id),
2222                        );
2223                    }
2224                    if out_aliases_qkv {
2225                        let tmp_byte = scratch;
2226                        let tmp_aligned = out_len.div_ceil(256) * 256;
2227                        scratch = scratch.saturating_add(tmp_aligned);
2228                        if param_anchor {
2229                            arena_ensure_scratch_in_window(&mut scratch, base, size);
2230                        } else {
2231                            base = arena_bind_window_covering_scratch_if_needed(
2232                                &arena, base, size, scratch,
2233                            );
2234                        }
2235                        kernel_out_off = ((tmp_byte.saturating_sub(base)) / 4) as u32;
2236                        attn_scratch_copy = Some((tmp_byte, out_len as u32));
2237                    }
2238                    let mask_off = if mask_kind_id == 2 {
2239                        arena_off_in_bind_window(
2240                            &graph,
2241                            &param_offsets,
2242                            &dev.device,
2243                            &arena,
2244                            &mut schedule,
2245                            &mut scratch,
2246                            node.inputs[3],
2247                            &mut base,
2248                            &mut size,
2249                        )
2250                    } else {
2251                        0
2252                    };
2253                    let p = AttentionParams {
2254                        batch,
2255                        heads,
2256                        seq_q,
2257                        seq_k,
2258                        head_dim: hd,
2259                        q_off,
2260                        k_off,
2261                        v_off,
2262                        out_off: kernel_out_off,
2263                        mask_off,
2264                        mask_kind: mask_kind_id,
2265                        scale_bits: scale.to_bits(),
2266                        window,
2267                        // Mask strides — derive from the mask's IR shape:
2268                        //   [B, S]:           (mb=S,        mh=0,    mq=0,   mk=1)
2269                        //   [B, S_q, S_k]:    (mb=S_q·S_k,  mh=0,    mq=S_k, mk=1)
2270                        //   [B, H, S_q, S_k]: (mb=H·S_q·S_k mh=S_q·S_k mq=S_k mk=1)
2271                        // Stride 0 means the kernel broadcasts across that
2272                        // axis (reads the same element for every value of
2273                        // the index). Lets us skip the Expand pre-pass that
2274                        // unfuse used to emit per attention block.
2275                        seq_q_stride: mask_strides.q,
2276                        seq_k_stride: mask_strides.k,
2277                        mask_batch_stride: mask_strides.b,
2278                        mask_head_stride: mask_strides.h,
2279                        kv_heads: nkv,
2280                        _pad_mask_1: 0,
2281                        _pad_mask_2: 0,
2282                        q_batch_stride: q_b,
2283                        q_head_stride: q_h,
2284                        q_seq_stride: q_s,
2285                        _pad_q: 0,
2286                        k_batch_stride: k_b,
2287                        k_head_stride: k_h,
2288                        k_seq_stride: k_s,
2289                        _pad_k: 0,
2290                        v_batch_stride: v_b,
2291                        v_head_stride: v_h,
2292                        v_seq_stride: v_s,
2293                        _pad_v: 0,
2294                        o_batch_stride: o_b,
2295                        o_head_stride: o_h,
2296                        o_seq_stride: o_s,
2297                        _pad_o: 0,
2298                    };
2299                    let _ = num_heads;
2300                    schedule.push(Step::Attention {
2301                        params: p,
2302                        mask_buf,
2303                    });
2304                    if let Some((tmp_byte, bytes)) = attn_scratch_copy {
2305                        schedule.push(Step::BufferCopy {
2306                            src_byte_off: tmp_byte,
2307                            dst_byte_off: out_byte,
2308                            bytes,
2309                        });
2310                    }
2311                    let ak = attention_kernel(&dev.device);
2312                    let u = emit_uniform(std::mem::size_of::<AttentionParams>());
2313                    let bg = bind_two_buf0_window(&dev.device, ak, &arena.buffer, base, size, &u);
2314                    uniforms.push(u);
2315                    bind_groups.push(bg);
2316                }
2317
2318                Op::AttentionBackward {
2319                    num_heads,
2320                    head_dim,
2321                    mask_kind,
2322                    wrt,
2323                } => {
2324                    use rlx_ir::op::AttentionBwdWrt;
2325                    let q_id = node.inputs[0];
2326                    let k_id = node.inputs[1];
2327                    let v_id = node.inputs[2];
2328                    let dy_id = node.inputs[3];
2329                    let q_shape = graph.node(q_id).shape.dims();
2330                    let k_shape = graph.node(k_id).shape.dims();
2331                    let hd = *head_dim as u32;
2332                    let q_ir = graph.node(q_id).shape.clone();
2333                    let k_ir = graph.node(k_id).shape.clone();
2334                    let geom = rlx_ir::attention_geom(&q_ir, &k_ir, *num_heads, *head_dim);
2335                    let bhsd = geom.bhsd;
2336                    let (batch, heads, seq_q, seq_k) = match q_shape.len() {
2337                        4 => (
2338                            geom.batch as u32,
2339                            geom.heads as u32,
2340                            geom.seq_q as u32,
2341                            geom.seq_k as u32,
2342                        ),
2343                        3 => {
2344                            let h = q_shape[2].unwrap_static() as u32 / hd;
2345                            (
2346                                q_shape[0].unwrap_static() as u32 / h,
2347                                h,
2348                                q_shape[1].unwrap_static() as u32,
2349                                k_shape[1].unwrap_static() as u32,
2350                            )
2351                        }
2352                        other => panic!(
2353                            "rlx-wgpu AttentionBackward: only rank-3/4 Q,K,V (got rank {other})"
2354                        ),
2355                    };
2356                    let scale = 1.0_f32 / (hd as f32).sqrt();
2357                    let (mask_kind_id, mask_off, mask_buf, window) = match mask_kind {
2358                        MaskKind::None => (0u32, 0u32, None, 0u32),
2359                        MaskKind::Causal => (1u32, 0u32, None, 0u32),
2360                        MaskKind::Custom => {
2361                            (2u32, (arena.offset(node.inputs[4]) / 4) as u32, None, 0u32)
2362                        }
2363                        MaskKind::Bias => {
2364                            (4u32, (arena.offset(node.inputs[4]) / 4) as u32, None, 0u32)
2365                        }
2366                        MaskKind::SlidingWindow(w) => (3u32, 0u32, None, *w as u32),
2367                    };
2368                    struct MStrides {
2369                        b: u32,
2370                        h: u32,
2371                        q: u32,
2372                        k: u32,
2373                    }
2374                    let mask_strides = if mask_kind_id == 2 || mask_kind_id == 4 {
2375                        let m_dims = graph.node(node.inputs[4]).shape.dims();
2376                        let dim = |i: usize| m_dims[i].unwrap_static() as u32;
2377                        match m_dims.len() {
2378                            2 => MStrides {
2379                                b: dim(1),
2380                                h: 0,
2381                                q: 0,
2382                                k: 1,
2383                            },
2384                            3 => MStrides {
2385                                b: dim(1) * dim(2),
2386                                h: 0,
2387                                q: dim(2),
2388                                k: 1,
2389                            },
2390                            4 => MStrides {
2391                                b: dim(1) * dim(2) * dim(3),
2392                                h: dim(2) * dim(3),
2393                                q: dim(3),
2394                                k: 1,
2395                            },
2396                            _ => MStrides {
2397                                b: heads * seq_q * seq_k,
2398                                h: seq_q * seq_k,
2399                                q: seq_k,
2400                                k: 1,
2401                            },
2402                        }
2403                    } else {
2404                        MStrides {
2405                            b: heads * seq_q * seq_k,
2406                            h: seq_q * seq_k,
2407                            q: seq_k,
2408                            k: 1,
2409                        }
2410                    };
2411                    let stride = |shape: &[rlx_ir::shape::Dim], seq_extent: u32| {
2412                        rlx_ir::strides_for_shape(shape, heads, hd, seq_extent, bhsd)
2413                    };
2414                    let (q_b, q_h, q_s) = stride(q_shape, seq_q);
2415                    let (k_b, k_h, k_s) = stride(k_shape, seq_k);
2416                    let v_shape = graph.node(v_id).shape.dims();
2417                    let (v_b, v_h, v_s) = stride(v_shape, seq_k);
2418                    let out_shape = node.shape.dims();
2419                    let out_seq = match wrt {
2420                        AttentionBwdWrt::Query => seq_q,
2421                        AttentionBwdWrt::Key | AttentionBwdWrt::Value => seq_k,
2422                    };
2423                    let (o_b, o_h, o_s) = stride(out_shape, out_seq);
2424                    let wrt_id = match wrt {
2425                        AttentionBwdWrt::Query => 0u32,
2426                        AttentionBwdWrt::Key => 1u32,
2427                        AttentionBwdWrt::Value => 2u32,
2428                    };
2429                    let p = AttentionBwdParams {
2430                        batch,
2431                        heads,
2432                        seq_q,
2433                        seq_k,
2434                        head_dim: hd,
2435                        q_off: (arena.offset(q_id) / 4) as u32,
2436                        k_off: (arena.offset(k_id) / 4) as u32,
2437                        v_off: (arena.offset(v_id) / 4) as u32,
2438                        dy_off: (arena.offset(dy_id) / 4) as u32,
2439                        out_off: (arena.offset(node.id) / 4) as u32,
2440                        mask_off,
2441                        mask_kind: mask_kind_id,
2442                        scale_bits: scale.to_bits(),
2443                        window,
2444                        wrt: wrt_id,
2445                        seq_q_stride: mask_strides.q,
2446                        seq_k_stride: mask_strides.k,
2447                        mask_batch_stride: mask_strides.b,
2448                        mask_head_stride: mask_strides.h,
2449                        _pad_mask_0: 0,
2450                        _pad_mask_1: 0,
2451                        _pad_mask_2: 0,
2452                        q_batch_stride: q_b,
2453                        q_head_stride: q_h,
2454                        q_seq_stride: q_s,
2455                        _pad_q: 0,
2456                        k_batch_stride: k_b,
2457                        k_head_stride: k_h,
2458                        k_seq_stride: k_s,
2459                        _pad_k: 0,
2460                        v_batch_stride: v_b,
2461                        v_head_stride: v_h,
2462                        v_seq_stride: v_s,
2463                        _pad_v: 0,
2464                        o_batch_stride: o_b,
2465                        o_head_stride: o_h,
2466                        o_seq_stride: o_s,
2467                        _pad_o: 0,
2468                    };
2469                    schedule.push(Step::AttentionBackward {
2470                        params: p,
2471                        mask_buf,
2472                    });
2473                    let ak = attention_bwd_kernel(&dev.device);
2474                    let u = emit_uniform(std::mem::size_of::<AttentionBwdParams>());
2475                    let bg = bind_op_output_window(&dev.device, ak, &arena, node.id, &u);
2476                    uniforms.push(u);
2477                    bind_groups.push(bg);
2478                }
2479
2480                Op::Rope {
2481                    head_dim,
2482                    n_rot,
2483                    style,
2484                } => {
2485                    let x_id = node.inputs[0];
2486                    let cos_id = node.inputs[1];
2487                    let sin_id = node.inputs[2];
2488                    let x_shape = graph.node(x_id).shape.dims();
2489                    let last = x_shape.last().map(|d| d.unwrap_static()).unwrap_or(0);
2490                    if !last.is_multiple_of(*head_dim) {
2491                        panic!(
2492                            "rlx-wgpu Rope: last_dim ({last}) must be a multiple \
2493                                of head_dim ({head_dim})"
2494                        );
2495                    }
2496                    if head_dim % 2 != 0 {
2497                        panic!("rlx-wgpu Rope: head_dim must be even");
2498                    }
2499                    let total: u32 = x_shape.iter().map(|d| d.unwrap_static() as u32).product();
2500                    let seq = x_shape[x_shape.len() - 2].unwrap_static() as u32;
2501                    // PLAN L1: derive batch from total / seq / last_dim
2502                    // (= product of leading dims). `seq_stride` stays at
2503                    // full seq for buffer offset math; `seq` becomes the
2504                    // runtime-scaled loop bound.
2505                    let batch = total / (seq * last as u32).max(1);
2506                    let cos_is_param = tensor_is_graph_param(&graph, &param_offsets, cos_id);
2507                    let cos_bytes = arena.len_of(cos_id) as u64;
2508                    let rope_win: Vec<NodeId> = if cos_is_param && cos_bytes > ARENA_STAGE_CAP {
2509                        vec![cos_id, sin_id, node.id, x_id]
2510                    } else {
2511                        vec![node.id, x_id, cos_id, sin_id]
2512                    };
2513                    let mut scratch = arena.scratch_off as u64;
2514                    let (mut base, mut size, param_anchor) = arena_multi_op_window(
2515                        &dev.device,
2516                        &arena,
2517                        &graph,
2518                        &param_offsets,
2519                        &mut schedule,
2520                        &mut scratch,
2521                        &rope_win,
2522                    );
2523                    if !param_anchor {
2524                        base = arena_bind_window_covering_scratch_if_needed(
2525                            &arena, base, size, scratch,
2526                        );
2527                    }
2528                    let in_off = arena_off_in_bind_window(
2529                        &graph,
2530                        &param_offsets,
2531                        &dev.device,
2532                        &arena,
2533                        &mut schedule,
2534                        &mut scratch,
2535                        x_id,
2536                        &mut base,
2537                        &mut size,
2538                    );
2539                    let cos_off = arena_off_in_bind_window(
2540                        &graph,
2541                        &param_offsets,
2542                        &dev.device,
2543                        &arena,
2544                        &mut schedule,
2545                        &mut scratch,
2546                        cos_id,
2547                        &mut base,
2548                        &mut size,
2549                    );
2550                    let sin_off = arena_off_in_bind_window(
2551                        &graph,
2552                        &param_offsets,
2553                        &dev.device,
2554                        &arena,
2555                        &mut schedule,
2556                        &mut scratch,
2557                        sin_id,
2558                        &mut base,
2559                        &mut size,
2560                    );
2561                    let p = RopeParams {
2562                        n_total: total,
2563                        seq,
2564                        head_dim: *head_dim as u32,
2565                        half: (*head_dim / 2) as u32,
2566                        in_off,
2567                        cos_off,
2568                        sin_off,
2569                        out_off: arena_local_off_f32(&arena, node.id, base),
2570                        last_dim: last as u32,
2571                        batch,
2572                        seq_stride: seq,
2573                        style: match style {
2574                            rlx_ir::op::RopeStyle::NeoX => 0,
2575                            rlx_ir::op::RopeStyle::GptJ => 1,
2576                        },
2577                        // Partial rotary: rotate only n_rot dims (Gemma 4 global
2578                        // layers use n_rot < head_dim). Equals half for full rope.
2579                        rot_half: (*n_rot / 2) as u32,
2580                    };
2581                    schedule.push(Step::Rope { params: p });
2582                    let rk = rope_kernel(&dev.device);
2583                    let u = emit_uniform(std::mem::size_of::<RopeParams>());
2584                    let bg = bind_two_buf0_window(&dev.device, rk, &arena.buffer, base, size, &u);
2585                    uniforms.push(u);
2586                    bind_groups.push(bg);
2587                }
2588
2589                Op::Expand { target_shape } => {
2590                    let in_id = node.inputs[0];
2591                    let in_shape = graph.node(in_id).shape.dims();
2592                    let in_rank = in_shape.len();
2593                    let rank = target_shape.len();
2594                    if in_rank > rank {
2595                        panic!(
2596                            "rlx-wgpu Expand: rank mismatch \
2597                                (in_rank={in_rank}, target_rank={rank})"
2598                        );
2599                    }
2600                    // Implicit leading 1s when input rank < target rank (e.g.
2601                    // scalar → vector from `LegalizeBroadcast`).
2602                    let pad = rank.saturating_sub(in_rank);
2603                    let out_dims: Vec<u32> = target_shape.iter().map(|&d| d as u32).collect();
2604                    let in_dims: Vec<u32> = (0..rank)
2605                        .map(|i| {
2606                            if i < pad {
2607                                1
2608                            } else {
2609                                in_shape[i - pad].unwrap_static() as u32
2610                            }
2611                        })
2612                        .collect();
2613                    // Cumulative input strides (row-major). When the
2614                    // input dim is 1 but target dim > 1, that axis
2615                    // broadcasts → stride = 0.
2616                    let mut in_strides_row = vec![1u32; rank];
2617                    for i in (0..rank.saturating_sub(1)).rev() {
2618                        in_strides_row[i] = in_strides_row[i + 1] * in_dims[i + 1];
2619                    }
2620                    let strides_for_out: Vec<u32> = (0..rank)
2621                        .map(|i| {
2622                            if in_dims[i] == 1 && out_dims[i] != 1 {
2623                                0
2624                            } else {
2625                                in_strides_row[i]
2626                            }
2627                        })
2628                        .collect();
2629
2630                    let mut meta_data: Vec<u32> = Vec::with_capacity(rank * 2);
2631                    meta_data.extend_from_slice(&out_dims);
2632                    meta_data.extend_from_slice(&strides_for_out);
2633                    let meta_buf = dev.device.create_buffer(&wgpu::BufferDescriptor {
2634                        label: Some("rlx-wgpu expand meta"),
2635                        size: (meta_data.len() * 4).max(4) as u64,
2636                        usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
2637                        mapped_at_creation: false,
2638                    });
2639                    dev.queue
2640                        .write_buffer(&meta_buf, 0, bytemuck::cast_slice(&meta_data));
2641                    let meta_idx = meta_buffers.len();
2642                    meta_buffers.push(meta_buf);
2643
2644                    // PLAN L1: bucket axis stays at out axis 0 iff the
2645                    // expand at axis 0 isn't a broadcast (in_dims[0]
2646                    // matches out_dims[0]). When broadcast at axis 0
2647                    // (in_dims[0]==1, out_dims[0]>1), the bucket-axis
2648                    // contract doesn't apply — fall back to full extent.
2649                    let bucket_outermost = if in_dims[0] == out_dims[0] {
2650                        1u32
2651                    } else {
2652                        0u32
2653                    };
2654                    let exp_ids = [node.id, in_id];
2655                    let max_binding = dev.device.limits().max_storage_buffer_binding_size;
2656                    let exp_fits = arena_span_bytes(&arena, &exp_ids) <= max_binding;
2657                    let mut scratch = arena.scratch_off as u64;
2658                    let (mut base, mut size, param_anchor) = arena_multi_op_window(
2659                        &dev.device,
2660                        &arena,
2661                        &graph,
2662                        &param_offsets,
2663                        &mut schedule,
2664                        &mut scratch,
2665                        &exp_ids,
2666                    );
2667                    if !exp_fits && !param_anchor {
2668                        base = arena_bind_window_covering_scratch_if_needed(
2669                            &arena, base, size, scratch,
2670                        );
2671                    }
2672                    let in_off = arena_off_in_bind_window(
2673                        &graph,
2674                        &param_offsets,
2675                        &dev.device,
2676                        &arena,
2677                        &mut schedule,
2678                        &mut scratch,
2679                        in_id,
2680                        &mut base,
2681                        &mut size,
2682                    );
2683                    let out_off = arena_off_in_bind_window(
2684                        &graph,
2685                        &param_offsets,
2686                        &dev.device,
2687                        &arena,
2688                        &mut schedule,
2689                        &mut scratch,
2690                        node.id,
2691                        &mut base,
2692                        &mut size,
2693                    );
2694                    let p = ExpandParams {
2695                        rank: rank as u32,
2696                        out_total: elems,
2697                        in_off,
2698                        out_off,
2699                        bucket_outermost,
2700                        out_dim_0: out_dims[0],
2701                        _p2: 0,
2702                        _p3: 0,
2703                    };
2704                    schedule.push(Step::Expand {
2705                        params: p,
2706                        meta_idx,
2707                    });
2708                    let ek = expand_kernel(&dev.device);
2709                    let u = emit_uniform(std::mem::size_of::<ExpandParams>());
2710                    let bg = dev.device.create_bind_group(&wgpu::BindGroupDescriptor {
2711                        label: Some("rlx-wgpu expand bg"),
2712                        layout: &ek.bgl,
2713                        entries: &[
2714                            wgpu::BindGroupEntry {
2715                                binding: 0,
2716                                resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
2717                                    buffer: &arena.buffer,
2718                                    offset: base,
2719                                    size: aligned_bind_size(size, base, arena.buffer.size()),
2720                                }),
2721                            },
2722                            wgpu::BindGroupEntry {
2723                                binding: 1,
2724                                resource: u.as_entire_binding(),
2725                            },
2726                            wgpu::BindGroupEntry {
2727                                binding: 2,
2728                                resource: meta_buffers[meta_idx].as_entire_binding(),
2729                            },
2730                        ],
2731                    });
2732                    uniforms.push(u);
2733                    bind_groups.push(bg);
2734                }
2735
2736                Op::Gather { axis } => {
2737                    let table_id = node.inputs[0];
2738                    let idx_id = node.inputs[1];
2739                    let table_is_param = tensor_is_graph_param(&graph, &param_offsets, table_id);
2740                    let table_bytes = arena.len_of(table_id) as u64;
2741                    // Split-binding path: on >4 GiB arenas the embedding output and
2742                    // its index can lie more than one ≤4 GiB binding window away
2743                    // from the (multi-GiB) table. A single arena binding then can't
2744                    // cover both — the kernel writes the output OUTSIDE the bound
2745                    // window (the write is dropped/clamped) and idx-staging would
2746                    // even overwrite part of the table. Route axis-0 embedding
2747                    // gathers through a host segment with separate table / idx
2748                    // windows and a dedicated output buffer (copied back).
2749                    let max_binding = dev.device.limits().max_storage_buffer_binding_size;
2750                    let gather_needs_split = *axis == 0
2751                        && arena_whole_arena_bind(&arena, max_binding).is_none()
2752                        && arena_span_bytes(&arena, &[table_id, idx_id, node.id]) > max_binding;
2753                    if gather_needs_split {
2754                        let table_shape = graph.node(table_id).shape.dims();
2755                        let idx_shape = graph.node(idx_id).shape.dims();
2756                        let vocab = table_shape[0].unwrap_static() as u32;
2757                        let dim: u32 = table_shape[1..]
2758                            .iter()
2759                            .map(|d| d.unwrap_static() as u32)
2760                            .product::<u32>()
2761                            .max(1);
2762                        let n_idx: u32 =
2763                            idx_shape.iter().map(|d| d.unwrap_static() as u32).product();
2764                        let table_w = arena.len_of(table_id) as u64;
2765                        assert!(
2766                            table_w <= max_binding,
2767                            "rlx-wgpu gather_split: embedding table {table_w} bytes exceeds \
2768                             max_storage_buffer_binding_size {max_binding}"
2769                        );
2770                        schedule.push(Step::GatherSplit {
2771                            n_out: elems,
2772                            n_idx,
2773                            dim,
2774                            vocab,
2775                            table_byte_off: arena.offset(table_id) as u64,
2776                            idx_byte_off: arena.offset(idx_id) as u64,
2777                            out_byte_off: arena.offset(node.id) as u64,
2778                        });
2779                        // Host segment: like DequantMatmulGguf, it builds its own
2780                        // bind group at exec time, so do NOT push a uniform/bind
2781                        // group lane (those are indexed by GPU-step count).
2782                        continue;
2783                    }
2784                    let gather_win: Vec<NodeId> = if table_is_param && table_bytes > ARENA_STAGE_CAP
2785                    {
2786                        vec![table_id, node.id, idx_id]
2787                    } else {
2788                        vec![node.id, idx_id, table_id]
2789                    };
2790                    let mut scratch = arena.scratch_off as u64;
2791                    let (mut base, mut size, table_anchor) = arena_multi_op_window(
2792                        &dev.device,
2793                        &arena,
2794                        &graph,
2795                        &param_offsets,
2796                        &mut schedule,
2797                        &mut scratch,
2798                        &gather_win,
2799                    );
2800                    if !table_anchor {
2801                        base = arena_bind_window_covering_scratch_if_needed(
2802                            &arena, base, size, scratch,
2803                        );
2804                    }
2805                    let in_off =
2806                        if table_anchor && arena_tensor_in_window(&arena, table_id, base, size) {
2807                            arena_local_off_f32(&arena, table_id, base)
2808                        } else {
2809                            arena_off_in_bind_window(
2810                                &graph,
2811                                &param_offsets,
2812                                &dev.device,
2813                                &arena,
2814                                &mut schedule,
2815                                &mut scratch,
2816                                table_id,
2817                                &mut base,
2818                                &mut size,
2819                            )
2820                        };
2821                    let idx_off = arena_off_in_bind_window(
2822                        &graph,
2823                        &param_offsets,
2824                        &dev.device,
2825                        &arena,
2826                        &mut schedule,
2827                        &mut scratch,
2828                        idx_id,
2829                        &mut base,
2830                        &mut size,
2831                    );
2832                    let out_off = arena_local_off_f32(&arena, node.id, base);
2833                    if *axis == 0 {
2834                        let table_shape = graph.node(table_id).shape.dims();
2835                        let idx_shape = graph.node(idx_id).shape.dims();
2836                        let vocab = table_shape[0].unwrap_static() as u32;
2837                        let dim: u32 = table_shape[1..]
2838                            .iter()
2839                            .map(|d| d.unwrap_static() as u32)
2840                            .product::<u32>()
2841                            .max(1);
2842                        let n_idx: u32 =
2843                            idx_shape.iter().map(|d| d.unwrap_static() as u32).product();
2844                        let p = GatherParams {
2845                            n_out: elems,
2846                            n_idx,
2847                            dim,
2848                            vocab,
2849                            in_off,
2850                            idx_off,
2851                            out_off,
2852                            _p0: 0,
2853                        };
2854                        schedule.push(Step::Gather { params: p });
2855                        let gk = gather_kernel(&dev.device);
2856                        let u = emit_uniform(std::mem::size_of::<GatherParams>());
2857                        let bg =
2858                            bind_two_buf0_window(&dev.device, gk, &arena.buffer, base, size, &u);
2859                        uniforms.push(u);
2860                        bind_groups.push(bg);
2861                    } else {
2862                        let table_shape = graph.node(table_id).shape.dims();
2863                        let idx_shape = graph.node(idx_id).shape.dims();
2864                        let outer: u32 = table_shape[..*axis]
2865                            .iter()
2866                            .map(|d| d.unwrap_static() as u32)
2867                            .product::<u32>()
2868                            .max(1);
2869                        let trailing: u32 = table_shape[*axis + 1..]
2870                            .iter()
2871                            .map(|d| d.unwrap_static() as u32)
2872                            .product::<u32>()
2873                            .max(1);
2874                        let axis_dim = table_shape[*axis].unwrap_static() as u32;
2875                        let num_idx: u32 =
2876                            idx_shape.iter().map(|d| d.unwrap_static() as u32).product();
2877                        let total = outer * num_idx * trailing;
2878                        let p = GatherAxisParams {
2879                            total,
2880                            outer,
2881                            axis_dim,
2882                            num_idx,
2883                            trailing,
2884                            table_off: in_off,
2885                            idx_off,
2886                            out_off,
2887                        };
2888                        schedule.push(Step::GatherAxis { params: p });
2889                        let gk = gather_axis_kernel(&dev.device);
2890                        let u = emit_uniform(std::mem::size_of::<GatherAxisParams>());
2891                        let bg =
2892                            bind_two_buf0_window(&dev.device, gk, &arena.buffer, base, size, &u);
2893                        uniforms.push(u);
2894                        bind_groups.push(bg);
2895                    }
2896                }
2897
2898                Op::FusedMatMulBiasAct { activation } => {
2899                    // Inputs: [x, w, bias]. We require 2D × 2D or
2900                    // [..,M,K] × [K,N] (broadcast bias). Bias is shape [N].
2901                    let a_id = node.inputs[0];
2902                    let b_id = node.inputs[1];
2903                    let bias_id = node.inputs[2];
2904                    let a_shape = graph.node(a_id).shape.dims();
2905                    let b_shape = graph.node(b_id).shape.dims();
2906                    let out_shape = node.shape.dims();
2907                    let (m, k, n) =
2908                        if a_shape.len() == 2 && b_shape.len() == 2 && out_shape.len() == 2 {
2909                            (
2910                                a_shape[0].unwrap_static() as u32,
2911                                a_shape[1].unwrap_static() as u32,
2912                                b_shape[1].unwrap_static() as u32,
2913                            )
2914                        } else if a_shape.len() >= 2
2915                            && b_shape.len() == 2
2916                            && out_shape.len() == a_shape.len()
2917                        {
2918                            let leading: usize = a_shape[..a_shape.len() - 2]
2919                                .iter()
2920                                .map(|d| d.unwrap_static())
2921                                .product();
2922                            let m_inner = a_shape[a_shape.len() - 2].unwrap_static();
2923                            let k_inner = a_shape[a_shape.len() - 1].unwrap_static();
2924                            let n_inner = b_shape[1].unwrap_static();
2925                            ((leading * m_inner) as u32, k_inner as u32, n_inner as u32)
2926                        } else {
2927                            panic!(
2928                                "rlx-wgpu FusedMatMulBiasAct: unsupported shapes \
2929                                a={a_shape:?} b={b_shape:?}"
2930                            );
2931                        };
2932                    let act_id = match activation {
2933                        None => 0xFFFFu32,
2934                        Some(a) => activation_op_id(*a),
2935                    };
2936                    let b_is_param = tensor_is_graph_param(&graph, &param_offsets, b_id);
2937                    let b_bytes = arena.len_of(b_id) as u64;
2938                    let mut compute_precision = derive_matmul_compute(
2939                        &dev.device,
2940                        &graph,
2941                        &coop_f16_vk_mirror_acts,
2942                        a_id,
2943                        b_id,
2944                        m,
2945                        k,
2946                        n,
2947                    );
2948                    if b_is_param
2949                        && b_bytes > ARENA_STAGE_CAP
2950                        && arena.param_fits_f16_mirror(b_id)
2951                        && !rlx_ir::env::flag("RLX_WGPU_NO_F16_MIRROR")
2952                    {
2953                        compute_precision = MatmulCompute::F16;
2954                    }
2955
2956                    // Split-QKV pattern: matmul writes Q/K/V directly into
2957                    // 3 separate output buffers, eliminating the 3 Narrow
2958                    // dispatches that would otherwise follow.
2959                    let mqk_eligible = act_id == 0xFFFFu32
2960                        && matches!(
2961                            compute_precision,
2962                            MatmulCompute::F32 | MatmulCompute::CoopF32 | MatmulCompute::CoopF16Vk
2963                        );
2964                    if mqk_eligible && let Some(&(q_id, k_id_n, v_id)) = qkv_split.get(&node.id) {
2965                        let head_width = n / 3;
2966                        let qkv_kind = match compute_precision {
2967                            MatmulCompute::CoopF16Vk => MatmulQkvKind::CoopF16Vk,
2968                            MatmulCompute::CoopF32 => MatmulQkvKind::CoopF32,
2969                            _ => MatmulQkvKind::F32,
2970                        };
2971                        let b_in_arena = !matmul_b_from_f16(compute_precision, b_is_param);
2972                        let (mut base, mut size, param_anchor) = arena_matmul_bind_window(
2973                            &dev.device,
2974                            &arena,
2975                            &graph,
2976                            &param_offsets,
2977                            q_id,
2978                            a_id,
2979                            b_id,
2980                            b_in_arena,
2981                        );
2982                        let mut scratch = arena.scratch_off as u64;
2983                        if param_anchor {
2984                            arena_ensure_scratch_in_window(&mut scratch, base, size);
2985                        }
2986                        if b_is_param && b_bytes > ARENA_STAGE_CAP && b_in_arena {
2987                            assert!(
2988                                param_anchor && arena_tensor_in_window(&arena, b_id, base, size),
2989                                "rlx-wgpu FusedMatMul QKV: large param B {:?} not in bind window",
2990                                b_id,
2991                            );
2992                        }
2993                        let a_off = arena_off_in_bind_window(
2994                            &graph,
2995                            &param_offsets,
2996                            &dev.device,
2997                            &arena,
2998                            &mut schedule,
2999                            &mut scratch,
3000                            a_id,
3001                            &mut base,
3002                            &mut size,
3003                        );
3004                        let q_off = arena_off_in_bind_window(
3005                            &graph,
3006                            &param_offsets,
3007                            &dev.device,
3008                            &arena,
3009                            &mut schedule,
3010                            &mut scratch,
3011                            q_id,
3012                            &mut base,
3013                            &mut size,
3014                        );
3015                        let k_off = arena_off_in_bind_window(
3016                            &graph,
3017                            &param_offsets,
3018                            &dev.device,
3019                            &arena,
3020                            &mut schedule,
3021                            &mut scratch,
3022                            k_id_n,
3023                            &mut base,
3024                            &mut size,
3025                        );
3026                        let v_off = arena_off_in_bind_window(
3027                            &graph,
3028                            &param_offsets,
3029                            &dev.device,
3030                            &arena,
3031                            &mut schedule,
3032                            &mut scratch,
3033                            v_id,
3034                            &mut base,
3035                            &mut size,
3036                        );
3037                        let bias_off = arena_off_in_bind_window(
3038                            &graph,
3039                            &param_offsets,
3040                            &dev.device,
3041                            &arena,
3042                            &mut schedule,
3043                            &mut scratch,
3044                            bias_id,
3045                            &mut base,
3046                            &mut size,
3047                        );
3048                        let b_off_f32 = if !b_in_arena {
3049                            (arena.offset(b_id) / 4) as u32
3050                        } else if b_is_param
3051                            && b_bytes > ARENA_STAGE_CAP
3052                            && arena_tensor_in_window(&arena, b_id, base, size)
3053                        {
3054                            arena_local_off_f32(&arena, b_id, base)
3055                        } else {
3056                            arena_off_in_bind_window(
3057                                &graph,
3058                                &param_offsets,
3059                                &dev.device,
3060                                &arena,
3061                                &mut schedule,
3062                                &mut scratch,
3063                                b_id,
3064                                &mut base,
3065                                &mut size,
3066                            )
3067                        };
3068                        let b_off_global = (arena.offset(b_id) / 4) as u32;
3069                        maybe_push_coop_f16_vk_casts(
3070                            &graph,
3071                            a_id,
3072                            b_id,
3073                            &coop_f16_vk_mirror_acts,
3074                            &dev.device,
3075                            &arena,
3076                            &mut schedule,
3077                            &mut uniforms,
3078                            &mut bind_groups,
3079                            &mm_cast,
3080                            compute_precision,
3081                            a_off,
3082                            m,
3083                            k,
3084                            1,
3085                            if qkv_kind == MatmulQkvKind::CoopF16Vk {
3086                                b_off_global
3087                            } else {
3088                                b_off_f32
3089                            },
3090                            n,
3091                        );
3092                        let p = MatmulQkvParams {
3093                            m,
3094                            k,
3095                            n,
3096                            a_off,
3097                            b_off: if qkv_kind == MatmulQkvKind::CoopF16Vk {
3098                                b_off_global
3099                            } else {
3100                                b_off_f32
3101                            },
3102                            q_off,
3103                            k_off,
3104                            v_off,
3105                            head_width,
3106                            has_bias: 1,
3107                            bias_off,
3108                            _p0: 0,
3109                            _p1: 0,
3110                            _p2: 0,
3111                            _p3: 0,
3112                            _p4: 0,
3113                        };
3114                        schedule.push(Step::MatmulQkv {
3115                            params: p,
3116                            kind: qkv_kind,
3117                        });
3118                        register_coop_f16_vk_b_param(
3119                            &mut coop_f16_b_param,
3120                            &param_offsets,
3121                            b_id,
3122                            p.b_off,
3123                            match qkv_kind {
3124                                MatmulQkvKind::CoopF16Vk => MatmulCompute::CoopF16Vk,
3125                                MatmulQkvKind::CoopF32 => MatmulCompute::CoopF32,
3126                                MatmulQkvKind::F32 => MatmulCompute::F32,
3127                            },
3128                        );
3129                        let u = emit_uniform(std::mem::size_of::<MatmulQkvParams>());
3130                        let bg = match qkv_kind {
3131                            MatmulQkvKind::CoopF16Vk => {
3132                                let mqk = matmul_qkv_coop_f16_vk_kernel(&dev.device).expect(
3133                                    "coop f16 matmul_qkv kernel: feature was checked but missing",
3134                                );
3135                                let (bg, b_off_adj) = build_matmul_qkv_coop_f16_vk_bind_group(
3136                                    &dev.device,
3137                                    mqk,
3138                                    &arena,
3139                                    base,
3140                                    size,
3141                                    &u,
3142                                    k,
3143                                    n,
3144                                    p.b_off,
3145                                );
3146                                if let Some(Step::MatmulQkv { params, .. }) = schedule.last_mut() {
3147                                    params.b_off = b_off_adj;
3148                                }
3149                                bg
3150                            }
3151                            MatmulQkvKind::CoopF32 => bind_two_buf0_window(
3152                                &dev.device,
3153                                matmul_qkv_coop_f32_kernel(&dev.device).expect(
3154                                    "coop matmul_qkv kernel: hardware feature was checked but kernel missing",
3155                                ),
3156                                &arena.buffer,
3157                                base,
3158                                size,
3159                                &u,
3160                            ),
3161                            MatmulQkvKind::F32 => bind_two_buf0_window(
3162                                &dev.device,
3163                                matmul_qkv_kernel(&dev.device),
3164                                &arena.buffer,
3165                                base,
3166                                size,
3167                                &u,
3168                            ),
3169                        };
3170                        uniforms.push(u);
3171                        bind_groups.push(bg);
3172                        if qkv_kind == MatmulQkvKind::CoopF16Vk {
3173                            coop_f16_vk_wide_bind_groups.insert(
3174                                schedule.len() - 1,
3175                                bind_two_buf0_window(
3176                                    &dev.device,
3177                                    matmul_qkv_kernel(&dev.device),
3178                                    &arena.buffer,
3179                                    base,
3180                                    size,
3181                                    &uniforms[uniforms.len() - 1],
3182                                ),
3183                            );
3184                        }
3185                    } else {
3186                        let b_in_arena = !matmul_b_from_f16(compute_precision, b_is_param);
3187                        let (mut base, mut size, param_anchor) = arena_matmul_bind_window(
3188                            &dev.device,
3189                            &arena,
3190                            &graph,
3191                            &param_offsets,
3192                            node.id,
3193                            a_id,
3194                            b_id,
3195                            b_in_arena,
3196                        );
3197                        let mut scratch = arena.scratch_off as u64;
3198                        if param_anchor {
3199                            arena_ensure_scratch_in_window(&mut scratch, base, size);
3200                        }
3201                        if b_is_param && b_bytes > ARENA_STAGE_CAP && b_in_arena {
3202                            assert!(
3203                                param_anchor && arena_tensor_in_window(&arena, b_id, base, size),
3204                                "rlx-wgpu FusedMatMul: large param B {:?} not in bind window",
3205                                b_id,
3206                            );
3207                        }
3208                        let a_off_f32 = arena_off_in_bind_window(
3209                            &graph,
3210                            &param_offsets,
3211                            &dev.device,
3212                            &arena,
3213                            &mut schedule,
3214                            &mut scratch,
3215                            a_id,
3216                            &mut base,
3217                            &mut size,
3218                        );
3219                        let b_off_f32 = if !b_in_arena {
3220                            (arena.offset(b_id) / 4) as u32
3221                        } else if b_is_param
3222                            && b_bytes > ARENA_STAGE_CAP
3223                            && arena_tensor_in_window(&arena, b_id, base, size)
3224                        {
3225                            arena_local_off_f32(&arena, b_id, base)
3226                        } else {
3227                            arena_off_in_bind_window(
3228                                &graph,
3229                                &param_offsets,
3230                                &dev.device,
3231                                &arena,
3232                                &mut schedule,
3233                                &mut scratch,
3234                                b_id,
3235                                &mut base,
3236                                &mut size,
3237                            )
3238                        };
3239                        let bias_off_f32 = arena_off_in_bind_window(
3240                            &graph,
3241                            &param_offsets,
3242                            &dev.device,
3243                            &arena,
3244                            &mut schedule,
3245                            &mut scratch,
3246                            bias_id,
3247                            &mut base,
3248                            &mut size,
3249                        );
3250                        let b_off_global = (arena.offset(b_id) / 4) as u32;
3251                        let b_off_bind = if b_is_param
3252                            && matches!(
3253                                compute_precision,
3254                                MatmulCompute::Coop16
3255                                    | MatmulCompute::CoopF16Vk
3256                                    | MatmulCompute::F16
3257                            ) {
3258                            b_off_global
3259                        } else {
3260                            b_off_f32
3261                        };
3262                        maybe_push_coop_f16_vk_casts(
3263                            &graph,
3264                            a_id,
3265                            b_id,
3266                            &coop_f16_vk_mirror_acts,
3267                            &dev.device,
3268                            &arena,
3269                            &mut schedule,
3270                            &mut uniforms,
3271                            &mut bind_groups,
3272                            &mm_cast,
3273                            compute_precision,
3274                            a_off_f32,
3275                            m,
3276                            k,
3277                            1,
3278                            b_off_bind,
3279                            n,
3280                        );
3281                        schedule.push(Step::Matmul {
3282                            m,
3283                            k,
3284                            n,
3285                            batch: 1,
3286                            a_batch_stride: 0,
3287                            b_batch_stride: 0,
3288                            c_batch_stride: 0,
3289                            a_off_f32,
3290                            b_off_f32,
3291                            c_off_f32: arena_local_off_f32(&arena, node.id, base),
3292                            has_bias: 1,
3293                            bias_off_f32,
3294                            act_id,
3295                            b_is_param,
3296                            compute_precision,
3297                        });
3298                        register_coop_f16_vk_b_param(
3299                            &mut coop_f16_b_param,
3300                            &param_offsets,
3301                            b_id,
3302                            b_off_bind,
3303                            compute_precision,
3304                        );
3305                        let u = emit_uniform(std::mem::size_of::<MatmulParams>());
3306                        let (bg, b_off_adj) = build_matmul_bind_group(
3307                            &dev.device,
3308                            mm_k,
3309                            mm_w,
3310                            &mm_f16w,
3311                            &mm_f16c,
3312                            &mm_coop,
3313                            &mm_coop_f32,
3314                            &arena,
3315                            base,
3316                            size,
3317                            &u,
3318                            b_is_param,
3319                            compute_precision,
3320                            k,
3321                            n,
3322                            1,
3323                            b_off_bind,
3324                            0,
3325                        );
3326                        if let Some(Step::Matmul { b_off_f32, .. }) = schedule.last_mut() {
3327                            *b_off_f32 = b_off_adj;
3328                        }
3329                        uniforms.push(u);
3330                        bind_groups.push(bg);
3331                        if compute_precision == MatmulCompute::CoopF16Vk {
3332                            coop_f16_vk_wide_bind_groups.insert(
3333                                schedule.len() - 1,
3334                                bind_two_buf0_window(
3335                                    &dev.device,
3336                                    mm_w_active_compile,
3337                                    &arena.buffer,
3338                                    base,
3339                                    size,
3340                                    &uniforms[uniforms.len() - 1],
3341                                ),
3342                            );
3343                        }
3344                    }
3345                }
3346
3347                Op::DotGeneral { .. } => {
3348                    // Should be unreachable: DotGeneral is decomposed into
3349                    // MatMul + Transpose + Reshape by the unfusion pass
3350                    // before memory planning. If we hit this arm, the
3351                    // unfusion pass has a gap.
3352                    panic!(
3353                        "rlx-wgpu DotGeneral: leaked past unfusion pass — \
3354                            check unfuse.rs::expand_dot_general for missing patterns"
3355                    );
3356                }
3357
3358                Op::Sample {
3359                    top_k,
3360                    top_p,
3361                    temperature,
3362                    seed,
3363                } => {
3364                    let in_id = node.inputs[0];
3365                    let in_shape = graph.node(in_id).shape.dims();
3366                    let inner = in_shape[in_shape.len() - 1].unwrap_static() as u32;
3367                    let total: u32 = in_shape.iter().map(|d| d.unwrap_static() as u32).product();
3368                    let outer = total / inner.max(1);
3369                    // Greedy fast-path: temperature == 1.0 with no top_k/top_p
3370                    // is an argmax — same numeric result, much cheaper kernel.
3371                    let is_greedy = *top_k == 0
3372                        && (*top_p - 1.0).abs() < 1e-6
3373                        && (*temperature - 1.0).abs() < 1e-6;
3374                    if is_greedy {
3375                        let p = ArgmaxParams {
3376                            outer,
3377                            inner,
3378                            in_off: (arena.offset(in_id) / 4) as u32,
3379                            out_off: (arena.offset(node.id) / 4) as u32,
3380                            _p0: 0,
3381                            _p1: 0,
3382                            _p2: 0,
3383                            _p3: 0,
3384                        };
3385                        schedule.push(Step::Argmax { params: p });
3386                        let amk = argmax_kernel(&dev.device);
3387                        let u = emit_uniform(std::mem::size_of::<ArgmaxParams>());
3388                        let bg = bind_op_output_window(&dev.device, amk, &arena, node.id, &u);
3389                        uniforms.push(u);
3390                        bind_groups.push(bg);
3391                    } else {
3392                        let p = SampleParams {
3393                            outer,
3394                            inner,
3395                            in_off: (arena.offset(in_id) / 4) as u32,
3396                            out_off: (arena.offset(node.id) / 4) as u32,
3397                            top_k: *top_k as u32,
3398                            top_p_bits: top_p.to_bits(),
3399                            temp_bits: temperature.to_bits(),
3400                            seed_lo: *seed as u32,
3401                            seed_hi: (*seed >> 32) as u32,
3402                            _p0: 0,
3403                            _p1: 0,
3404                            _p2: 0,
3405                        };
3406                        schedule.push(Step::Sample { params: p });
3407                        let sk = sample_kernel(&dev.device);
3408                        let u = emit_uniform(std::mem::size_of::<SampleParams>());
3409                        let bg = bind_op_output_window(&dev.device, sk, &arena, node.id, &u);
3410                        uniforms.push(u);
3411                        bind_groups.push(bg);
3412                    }
3413                }
3414
3415                Op::Pool {
3416                    kind,
3417                    kernel_size,
3418                    stride,
3419                    padding,
3420                } => {
3421                    let in_shape = graph.node(node.inputs[0]).shape.dims();
3422                    let out_shape = node.shape.dims();
3423                    let op_id: u32 = match kind {
3424                        ReduceOp::Sum => 0,
3425                        ReduceOp::Mean => 1,
3426                        ReduceOp::Max => 2,
3427                        ReduceOp::Min => 3,
3428                        ReduceOp::Prod => 4,
3429                    };
3430                    match (kernel_size.len(), in_shape.len(), out_shape.len()) {
3431                        (1, 3, 3) => {
3432                            let p = Pool1dParams {
3433                                n: in_shape[0].unwrap_static() as u32,
3434                                c: in_shape[1].unwrap_static() as u32,
3435                                l: in_shape[2].unwrap_static() as u32,
3436                                l_out: out_shape[2].unwrap_static() as u32,
3437                                kl: kernel_size[0] as u32,
3438                                sl: stride.first().copied().unwrap_or(1) as u32,
3439                                pl: padding.first().copied().unwrap_or(0) as u32,
3440                                op: op_id,
3441                                in_off: (arena.offset(node.inputs[0]) / 4) as u32,
3442                                out_off: (arena.offset(node.id) / 4) as u32,
3443                                _p0: 0,
3444                                _p1: 0,
3445                                _p2: 0,
3446                                _p3: 0,
3447                                _p4: 0,
3448                                _p5: 0,
3449                            };
3450                            schedule.push(Step::Pool1d { params: p });
3451                            let pk = pool1d_kernel(&dev.device);
3452                            let u = emit_uniform(std::mem::size_of::<Pool1dParams>());
3453                            let bg = bind_op_output_window(&dev.device, pk, &arena, node.id, &u);
3454                            uniforms.push(u);
3455                            bind_groups.push(bg);
3456                        }
3457                        (2, 4, 4) => {
3458                            let p = Pool2dParams {
3459                                n: in_shape[0].unwrap_static() as u32,
3460                                c: in_shape[1].unwrap_static() as u32,
3461                                h: in_shape[2].unwrap_static() as u32,
3462                                w: in_shape[3].unwrap_static() as u32,
3463                                h_out: out_shape[2].unwrap_static() as u32,
3464                                w_out: out_shape[3].unwrap_static() as u32,
3465                                kh: kernel_size[0] as u32,
3466                                kw: kernel_size[1] as u32,
3467                                sh: stride.first().copied().unwrap_or(1) as u32,
3468                                sw: stride.get(1).copied().unwrap_or(1) as u32,
3469                                ph: padding.first().copied().unwrap_or(0) as u32,
3470                                pw: padding.get(1).copied().unwrap_or(0) as u32,
3471                                op: op_id,
3472                                in_off: (arena.offset(node.inputs[0]) / 4) as u32,
3473                                out_off: (arena.offset(node.id) / 4) as u32,
3474                                _p0: 0,
3475                                _p1: 0,
3476                                _p2: 0,
3477                            };
3478                            schedule.push(Step::Pool2d { params: p });
3479                            let pk = pool2d_kernel(&dev.device);
3480                            let u = emit_uniform(std::mem::size_of::<Pool2dParams>());
3481                            let bg = bind_op_output_window(&dev.device, pk, &arena, node.id, &u);
3482                            uniforms.push(u);
3483                            bind_groups.push(bg);
3484                        }
3485                        (3, 5, 5) => {
3486                            let p = Pool3dParams {
3487                                n: in_shape[0].unwrap_static() as u32,
3488                                c: in_shape[1].unwrap_static() as u32,
3489                                d: in_shape[2].unwrap_static() as u32,
3490                                h: in_shape[3].unwrap_static() as u32,
3491                                w: in_shape[4].unwrap_static() as u32,
3492                                d_out: out_shape[2].unwrap_static() as u32,
3493                                h_out: out_shape[3].unwrap_static() as u32,
3494                                w_out: out_shape[4].unwrap_static() as u32,
3495                                kd: kernel_size[0] as u32,
3496                                kh: kernel_size[1] as u32,
3497                                kw: kernel_size[2] as u32,
3498                                sd: stride.first().copied().unwrap_or(1) as u32,
3499                                sh: stride.get(1).copied().unwrap_or(1) as u32,
3500                                sw: stride.get(2).copied().unwrap_or(1) as u32,
3501                                pd: padding.first().copied().unwrap_or(0) as u32,
3502                                ph: padding.get(1).copied().unwrap_or(0) as u32,
3503                                pw: padding.get(2).copied().unwrap_or(0) as u32,
3504                                op: op_id,
3505                                in_off: (arena.offset(node.inputs[0]) / 4) as u32,
3506                                out_off: (arena.offset(node.id) / 4) as u32,
3507                                _p0: 0,
3508                                _p1: 0,
3509                            };
3510                            schedule.push(Step::Pool3d { params: p });
3511                            let pk = pool3d_kernel(&dev.device);
3512                            let u = emit_uniform(std::mem::size_of::<Pool3dParams>());
3513                            let bg = bind_op_output_window(&dev.device, pk, &arena, node.id, &u);
3514                            uniforms.push(u);
3515                            bind_groups.push(bg);
3516                        }
3517                        (k, n, m) => panic!(
3518                            "rlx-wgpu Pool: kernel-rank {k} with input rank {n} / \
3519                             output rank {m} not supported (use 1D/2D/3D NCHW)"
3520                        ),
3521                    }
3522                }
3523
3524                Op::Conv {
3525                    kernel_size,
3526                    stride,
3527                    padding,
3528                    dilation,
3529                    groups,
3530                } => {
3531                    let in_id = node.inputs[0];
3532                    let w_id = node.inputs[1];
3533                    let win_ids = [node.id, in_id, w_id];
3534                    let max_binding = dev.device.limits().max_storage_buffer_binding_size;
3535                    let fits = arena_span_bytes(&arena, &win_ids) <= max_binding;
3536                    let mut scratch = arena.scratch_off as u64;
3537                    let (mut base, mut size, param_anchor) = arena_multi_op_window(
3538                        &dev.device,
3539                        &arena,
3540                        &graph,
3541                        &param_offsets,
3542                        &mut schedule,
3543                        &mut scratch,
3544                        &win_ids,
3545                    );
3546                    arena_expand_bind_window(&arena, &win_ids, &mut base, &mut size, max_binding);
3547                    if !fits && !param_anchor {
3548                        base = arena_bind_window_covering_scratch_if_needed(
3549                            &arena, base, size, scratch,
3550                        );
3551                    }
3552                    let in_off = arena_off_in_bind_window(
3553                        &graph,
3554                        &param_offsets,
3555                        &dev.device,
3556                        &arena,
3557                        &mut schedule,
3558                        &mut scratch,
3559                        in_id,
3560                        &mut base,
3561                        &mut size,
3562                    );
3563                    let w_off = arena_off_in_bind_window(
3564                        &graph,
3565                        &param_offsets,
3566                        &dev.device,
3567                        &arena,
3568                        &mut schedule,
3569                        &mut scratch,
3570                        w_id,
3571                        &mut base,
3572                        &mut size,
3573                    );
3574                    let out_off = arena_off_in_bind_window(
3575                        &graph,
3576                        &param_offsets,
3577                        &dev.device,
3578                        &arena,
3579                        &mut schedule,
3580                        &mut scratch,
3581                        node.id,
3582                        &mut base,
3583                        &mut size,
3584                    );
3585                    let in_shape = graph.node(in_id).shape.dims();
3586                    let w_shape = graph.node(w_id).shape.dims();
3587                    let out_shape = node.shape.dims();
3588                    let s = |i: usize| stride.get(i).copied().unwrap_or(1) as u32;
3589                    let p = |i: usize| padding.get(i).copied().unwrap_or(0) as u32;
3590                    let d = |i: usize| dilation.get(i).copied().unwrap_or(1) as u32;
3591                    match (
3592                        kernel_size.len(),
3593                        in_shape.len(),
3594                        w_shape.len(),
3595                        out_shape.len(),
3596                    ) {
3597                        (1, 3, 3, 3) => {
3598                            let p1 = Conv1dParams {
3599                                n: in_shape[0].unwrap_static() as u32,
3600                                c_in: in_shape[1].unwrap_static() as u32,
3601                                c_out: out_shape[1].unwrap_static() as u32,
3602                                l: in_shape[2].unwrap_static() as u32,
3603                                l_out: out_shape[2].unwrap_static() as u32,
3604                                kl: kernel_size[0] as u32,
3605                                sl: s(0),
3606                                pl: p(0),
3607                                dl: d(0),
3608                                groups: *groups as u32,
3609                                in_off,
3610                                w_off,
3611                                out_off,
3612                                _p0: 0,
3613                                _p1: 0,
3614                                _p2: 0,
3615                            };
3616                            schedule.push(Step::Conv1d { params: p1 });
3617                            let ck = conv1d_kernel(&dev.device);
3618                            let u = emit_uniform(std::mem::size_of::<Conv1dParams>());
3619                            let bg = bind_two_buf0_window(
3620                                &dev.device,
3621                                ck,
3622                                &arena.buffer,
3623                                base,
3624                                size,
3625                                &u,
3626                            );
3627                            uniforms.push(u);
3628                            bind_groups.push(bg);
3629                        }
3630                        (2, 4, 4, 4) => {
3631                            let h_in = in_shape[2].unwrap_static() as u32;
3632                            let w_in = in_shape[3].unwrap_static() as u32;
3633                            // rlx lowers ONNX 1D convs as 2D NCHW with a unit H axis
3634                            // and the length in W (`[N,C,1,L]`, kernel `[k,1]`). The 2D
3635                            // conv kernel would run the k-tap kernel over the singleton
3636                            // H axis. `[N,C,1,L]` and `[N,C,L,1]` share row-major layout,
3637                            // so relabel the length onto H (no data copy) — matching the
3638                            // CPU/MLX 1D paths and onnxruntime.
3639                            let one_d = h_in == 1
3640                                && w_in > 1
3641                                && kernel_size[0] > 1
3642                                && kernel_size.get(1).copied().unwrap_or(1) == 1;
3643                            let (h, w, h_out, w_out, kh, kw, sh, sw, ph, pw, dh, dw) = if one_d {
3644                                (
3645                                    w_in,
3646                                    1,
3647                                    out_shape[3].unwrap_static() as u32,
3648                                    1,
3649                                    kernel_size[0] as u32,
3650                                    1,
3651                                    s(0),
3652                                    1,
3653                                    p(0),
3654                                    0,
3655                                    d(0),
3656                                    1,
3657                                )
3658                            } else {
3659                                (
3660                                    h_in,
3661                                    w_in,
3662                                    out_shape[2].unwrap_static() as u32,
3663                                    out_shape[3].unwrap_static() as u32,
3664                                    kernel_size[0] as u32,
3665                                    kernel_size[1] as u32,
3666                                    s(0),
3667                                    s(1),
3668                                    p(0),
3669                                    p(1),
3670                                    d(0),
3671                                    d(1),
3672                                )
3673                            };
3674                            let p2 = Conv2dParams {
3675                                n: in_shape[0].unwrap_static() as u32,
3676                                c_in: in_shape[1].unwrap_static() as u32,
3677                                c_out: out_shape[1].unwrap_static() as u32,
3678                                h,
3679                                w,
3680                                h_out,
3681                                w_out,
3682                                kh,
3683                                kw,
3684                                sh,
3685                                sw,
3686                                ph,
3687                                pw,
3688                                dh,
3689                                dw,
3690                                groups: *groups as u32,
3691                                in_off,
3692                                w_off,
3693                                out_off,
3694                            };
3695                            // Two accelerated conv paths for the `one_d`
3696                            // (kw==1) vocoder convs:
3697                            //   • conv1d_tiled (default): 2D register-blocked
3698                            //     direct conv — reuses input across the output-
3699                            //     channel tile. Drop-in for the direct conv
3700                            //     (same params + bind window); no scratch.
3701                            //   • im2col + GEMM (opt-in, RLX_WGPU_CONV_IM2COL):
3702                            //     materialize `col` in scratch, run the tiled
3703                            //     f32 GEMM. Wins only for GEMM-friendly shapes;
3704                            //     needs whole-arena bind + reserved scratch.
3705                            // Everything else falls back to the direct conv.
3706                            let spatial = (p2.h_out as u64) * (p2.w_out as u64);
3707                            let k_total = (p2.c_in as u64) * (p2.kh as u64) * (p2.kw as u64);
3708                            let col_bytes = k_total.saturating_mul(spatial).saturating_mul(4);
3709                            let max_binding = dev.device.limits().max_storage_buffer_binding_size;
3710                            let whole = arena_whole_arena_bind(&arena, max_binding);
3711                            let im2col_opt_in = rlx_ir::env::flag("RLX_WGPU_CONV_IM2COL");
3712                            let use_im2col = im2col_opt_in
3713                                && p2.groups == 1
3714                                && p2.n == 1
3715                                && (p2.kh as u64) * (p2.kw as u64) >= 2
3716                                && spatial >= im2col_min_spatial()
3717                                && k_total >= im2col_min_k()
3718                                && (p2.c_out as u64) >= im2col_min_cout()
3719                                && col_bytes <= CONV_IM2COL_MAX_COL_BYTES
3720                                && col_bytes <= arena.scratch_bytes as u64
3721                                && whole.is_some();
3722                            let use_tiled = !use_im2col
3723                                && one_d
3724                                && p2.kw == 1
3725                                && p2.w == 1
3726                                && p2.w_out == 1
3727                                && p2.groups == 1
3728                                && p2.n == 1
3729                                && spatial >= conv_tiled_min_spatial()
3730                                && !rlx_ir::env::flag("RLX_WGPU_NO_TILED_CONV");
3731                            if use_im2col {
3732                                let (base_w, size_w) = whole.expect("whole-arena bind");
3733                                // col lives at the reserved scratch tail (free of
3734                                // live data); consumed immediately by the GEMM.
3735                                let col_word_off = (arena.scratch_off / 4) as u32;
3736                                let im_params = Im2Col2dParams {
3737                                    c_in: p2.c_in,
3738                                    h: p2.h,
3739                                    w: p2.w,
3740                                    h_out: p2.h_out,
3741                                    w_out: p2.w_out,
3742                                    kh: p2.kh,
3743                                    kw: p2.kw,
3744                                    sh: p2.sh,
3745                                    sw: p2.sw,
3746                                    ph: p2.ph,
3747                                    pw: p2.pw,
3748                                    dh: p2.dh,
3749                                    dw: p2.dw,
3750                                    in_off: (arena.offset(in_id) / 4) as u32,
3751                                    col_off: col_word_off,
3752                                    k_total: k_total as u32,
3753                                    spatial: spatial as u32,
3754                                    _p0: 0,
3755                                    _p1: 0,
3756                                    _p2: 0,
3757                                };
3758                                schedule.push(Step::Im2ColGpu { params: im_params });
3759                                let imk = im2col2d_kernel(&dev.device);
3760                                let u_im = emit_uniform(std::mem::size_of::<Im2Col2dParams>());
3761                                // Static params — write once at compile (the
3762                                // active-extent rewrite pass is a no-op here).
3763                                dev.queue
3764                                    .write_buffer(&u_im, 0, bytemuck::bytes_of(&im_params));
3765                                let bg_im = bind_two_buf0_window(
3766                                    &dev.device,
3767                                    imk,
3768                                    &arena.buffer,
3769                                    base_w,
3770                                    size_w,
3771                                    &u_im,
3772                                );
3773                                uniforms.push(u_im);
3774                                bind_groups.push(bg_im);
3775                                // GEMM: weight[c_out, K] @ col[K, spatial]
3776                                //   → out[c_out, spatial]  (NCHW, N==1).
3777                                let m = p2.c_out;
3778                                let kk = k_total as u32;
3779                                let nn2 = spatial as u32;
3780                                schedule.push(Step::Matmul {
3781                                    m,
3782                                    k: kk,
3783                                    n: nn2,
3784                                    batch: 1,
3785                                    a_batch_stride: m.saturating_mul(kk),
3786                                    b_batch_stride: kk.saturating_mul(nn2),
3787                                    c_batch_stride: m.saturating_mul(nn2),
3788                                    a_off_f32: (arena.offset(w_id) / 4) as u32,
3789                                    b_off_f32: col_word_off,
3790                                    c_off_f32: (arena.offset(node.id) / 4) as u32,
3791                                    has_bias: 0,
3792                                    bias_off_f32: 0,
3793                                    act_id: 0xFFFF,
3794                                    b_is_param: false,
3795                                    compute_precision: MatmulCompute::F32,
3796                                });
3797                                let u_mm = emit_uniform(std::mem::size_of::<MatmulParams>());
3798                                let bg_mm = bind_two_buf0_window(
3799                                    &dev.device,
3800                                    mm_k,
3801                                    &arena.buffer,
3802                                    base_w,
3803                                    size_w,
3804                                    &u_mm,
3805                                );
3806                                uniforms.push(u_mm);
3807                                bind_groups.push(bg_mm);
3808                            } else {
3809                                schedule.push(if use_tiled {
3810                                    Step::Conv2dTiled { params: p2 }
3811                                } else {
3812                                    Step::Conv2d { params: p2 }
3813                                });
3814                                let ck = if use_tiled {
3815                                    conv1d_tiled_kernel(&dev.device)
3816                                } else {
3817                                    conv2d_kernel(&dev.device)
3818                                };
3819                                let u = emit_uniform(std::mem::size_of::<Conv2dParams>());
3820                                let bg = bind_two_buf0_window(
3821                                    &dev.device,
3822                                    ck,
3823                                    &arena.buffer,
3824                                    base,
3825                                    size,
3826                                    &u,
3827                                );
3828                                uniforms.push(u);
3829                                bind_groups.push(bg);
3830                            }
3831                        }
3832                        (3, 5, 5, 5) => {
3833                            let p3 = Conv3dParams {
3834                                n: in_shape[0].unwrap_static() as u32,
3835                                c_in: in_shape[1].unwrap_static() as u32,
3836                                c_out: out_shape[1].unwrap_static() as u32,
3837                                d: in_shape[2].unwrap_static() as u32,
3838                                h: in_shape[3].unwrap_static() as u32,
3839                                w: in_shape[4].unwrap_static() as u32,
3840                                d_out: out_shape[2].unwrap_static() as u32,
3841                                h_out: out_shape[3].unwrap_static() as u32,
3842                                w_out: out_shape[4].unwrap_static() as u32,
3843                                kd: kernel_size[0] as u32,
3844                                kh: kernel_size[1] as u32,
3845                                kw: kernel_size[2] as u32,
3846                                sd: s(0),
3847                                sh: s(1),
3848                                sw: s(2),
3849                                pd: p(0),
3850                                ph: p(1),
3851                                pw: p(2),
3852                                dd: d(0),
3853                                dh: d(1),
3854                                dw: d(2),
3855                                groups: *groups as u32,
3856                                in_off,
3857                                w_off,
3858                                out_off,
3859                                _p0: 0,
3860                            };
3861                            schedule.push(Step::Conv3d { params: p3 });
3862                            let ck = conv3d_kernel(&dev.device);
3863                            let u = emit_uniform(std::mem::size_of::<Conv3dParams>());
3864                            let bg = bind_two_buf0_window(
3865                                &dev.device,
3866                                ck,
3867                                &arena.buffer,
3868                                base,
3869                                size,
3870                                &u,
3871                            );
3872                            uniforms.push(u);
3873                            bind_groups.push(bg);
3874                        }
3875                        (k, ni, wi, mi) => panic!(
3876                            "rlx-wgpu Conv: rank kernel={k} in={ni} weight={wi} out={mi} \
3877                             not supported (use 1D/2D/3D NCHW)"
3878                        ),
3879                    }
3880                }
3881
3882                Op::Im2Col {
3883                    kernel_size,
3884                    stride,
3885                    padding,
3886                    dilation,
3887                } => {
3888                    let x_shape = &graph.node(node.inputs[0]).shape;
3889                    if kernel_size.len() != 2 || x_shape.rank() != 4 {
3890                        panic!("rlx-wgpu Im2Col: 2D NCHW only");
3891                    }
3892                    let n = match x_shape.dim(0) {
3893                        rlx_ir::shape::Dim::Static(v) => v as u32,
3894                        _ => 0,
3895                    };
3896                    let c_in = x_shape.dim(1).unwrap_static() as u32;
3897                    let h = x_shape.dim(2).unwrap_static() as u32;
3898                    let w = x_shape.dim(3).unwrap_static() as u32;
3899                    let kh = kernel_size[0] as u32;
3900                    let kw = kernel_size[1] as u32;
3901                    let sh = stride.first().copied().unwrap_or(1) as u32;
3902                    let sw = stride.get(1).copied().unwrap_or(1) as u32;
3903                    let ph = padding.first().copied().unwrap_or(0) as u32;
3904                    let pw = padding.get(1).copied().unwrap_or(0) as u32;
3905                    let dh = dilation.first().copied().unwrap_or(1) as u32;
3906                    let dw_dil = dilation.get(1).copied().unwrap_or(1) as u32;
3907                    let h_out = rlx_ir::shape::conv2d_spatial_output(
3908                        h as usize,
3909                        kh as usize,
3910                        sh as usize,
3911                        ph as usize,
3912                        dh as usize,
3913                    ) as u32;
3914                    let w_out = rlx_ir::shape::conv2d_spatial_output(
3915                        w as usize,
3916                        kw as usize,
3917                        sw as usize,
3918                        pw as usize,
3919                        dw_dil as usize,
3920                    ) as u32;
3921                    schedule.push(Step::Im2ColHost {
3922                        x_byte_off: arena.offset(node.inputs[0]) as u32,
3923                        col_byte_off: arena.offset(node.id) as u32,
3924                        n,
3925                        c_in,
3926                        h,
3927                        w,
3928                        h_out,
3929                        w_out,
3930                        kh,
3931                        kw,
3932                        sh,
3933                        sw,
3934                        ph,
3935                        pw,
3936                        dh,
3937                        dw_dil,
3938                    });
3939                }
3940
3941                Op::Cumsum { axis, exclusive } => {
3942                    let in_id = node.inputs[0];
3943                    let in_shape = graph.node(in_id).shape.dims();
3944                    let last = (in_shape.len() - 1) as i32;
3945                    if *axis != -1 && *axis != last {
3946                        panic!("rlx-wgpu Cumsum: only last-axis wired (got axis={axis})");
3947                    }
3948                    let inner = in_shape[in_shape.len() - 1].unwrap_static() as u32;
3949                    let total: u32 = in_shape.iter().map(|d| d.unwrap_static() as u32).product();
3950                    let outer = total / inner.max(1);
3951                    let p = CumsumParams {
3952                        outer,
3953                        inner,
3954                        in_off: (arena.offset(in_id) / 4) as u32,
3955                        out_off: (arena.offset(node.id) / 4) as u32,
3956                        exclusive: if *exclusive { 1 } else { 0 },
3957                        _p0: 0,
3958                        _p1: 0,
3959                        _p2: 0,
3960                    };
3961                    schedule.push(Step::Cumsum { params: p });
3962                    let ck2 = cumsum_kernel(&dev.device);
3963                    let u = emit_uniform(std::mem::size_of::<CumsumParams>());
3964                    let bg = bind_op_output_window(&dev.device, ck2, &arena, node.id, &u);
3965                    uniforms.push(u);
3966                    bind_groups.push(bg);
3967                }
3968                Op::Fft { inverse, norm } => {
3969                    let in_id = node.inputs[0];
3970                    let in_shape = graph.node(in_id).shape.clone();
3971                    let meta = rlx_ir::fft::fft_meta(&in_shape);
3972                    let dtype = in_shape.dtype();
3973                    // The wgpu arena is f32-uniform (4 bytes/elem — see the Constant
3974                    // upload path). f64/c64 tensors get half-sized slots and are
3975                    // silently truncated to garbage; the FFT-host fallback can't
3976                    // read them back either. Reject cleanly instead (like MLX/ANE,
3977                    // which don't do f64 on the GPU). Use f32 for GPU FFT.
3978                    assert!(
3979                        matches!(dtype, rlx_ir::DType::F32),
3980                        "rlx-wgpu Op::Fft: only f32 is supported on the wgpu backend \
3981                         (the arena is f32-uniform); got {dtype:?}. Run f64/c64 FFT on CPU."
3982                    );
3983                    let use_gpu = rlx_ir::fft::gpu_fft_native_eligible(dtype, meta.n_complex)
3984                        && meta.n_complex >= 2;
3985                    let scale = norm.output_scale(meta.n_complex, *inverse) as f32;
3986                    if use_gpu {
3987                        schedule.push(Step::FftGpu {
3988                            src_off: (arena.offset(in_id) / 4) as u32,
3989                            dst_off: (arena.offset(node.id) / 4) as u32,
3990                            outer: meta.outer as u32,
3991                            n: meta.n_complex as u32,
3992                            inverse: if *inverse { 1 } else { 0 },
3993                            norm_scale: scale,
3994                        });
3995                        fft_gpu_steps.push(crate::fft_dispatch::FftGpuResources::new(
3996                            &dev.device,
3997                            &arena.buffer,
3998                        ));
3999                    } else {
4000                        schedule.push(Step::FftHost {
4001                            src_byte_off: arena.offset(in_id) as u32,
4002                            dst_byte_off: arena.offset(node.id) as u32,
4003                            outer: meta.outer as u32,
4004                            n_complex: meta.n_complex as u32,
4005                            inverse: *inverse,
4006                            norm_tag: norm.tag(),
4007                            dtype_tag: fft_dtype_tag(dtype),
4008                        });
4009                    }
4010                }
4011                Op::WelchPeaks { k, n_segments } => {
4012                    let spec_shape = graph.node(node.inputs[0]).shape.clone();
4013                    let meta = rlx_ir::audio::welch_peaks_meta(&spec_shape, *k, *n_segments)
4014                        .unwrap_or_else(|e| panic!("Op::WelchPeaks: {e}"));
4015                    let use_gpu = rlx_ir::audio::welch_peaks_gpu_native_eligible(
4016                        &spec_shape,
4017                        *k,
4018                        *n_segments,
4019                    )
4020                    .unwrap_or(false);
4021                    if use_gpu {
4022                        let p = WelchPeaksGpuParams {
4023                            spec_off: (arena.offset(node.inputs[0]) / 4) as u32,
4024                            dst_off: (arena.offset(node.id) / 4) as u32,
4025                            welch_batch: meta.welch_batch as u32,
4026                            n_fft: meta.n_fft as u32,
4027                            n_segments: meta.n_segments as u32,
4028                            k: meta.k as u32,
4029                            n_bins: meta.n_bins as u32,
4030                            _p0: 0,
4031                            _p1: 0,
4032                        };
4033                        schedule.push(Step::WelchPeaksGpu { params: p });
4034                        let wk = welch_peaks_gpu_kernel(&dev.device);
4035                        let u = emit_uniform(std::mem::size_of::<WelchPeaksGpuParams>());
4036                        let bg = bind_op_output_window(&dev.device, wk, &arena, node.id, &u);
4037                        uniforms.push(u);
4038                        bind_groups.push(bg);
4039                    } else {
4040                        schedule.push(Step::WelchPeaksHost {
4041                            spec_byte_off: arena.offset(node.inputs[0]) as u32,
4042                            dst_byte_off: arena.offset(node.id) as u32,
4043                            welch_batch: meta.welch_batch as u32,
4044                            n_fft: meta.n_fft as u32,
4045                            n_segments: meta.n_segments as u32,
4046                            k: meta.k as u32,
4047                        });
4048                    }
4049                }
4050                Op::LogMel => {
4051                    let spec_shape = graph.node(node.inputs[0]).shape.clone();
4052                    let filt_shape = graph.node(node.inputs[1]).shape.clone();
4053                    let meta = rlx_ir::audio::log_mel_meta(&spec_shape, &filt_shape)
4054                        .unwrap_or_else(|e| panic!("Op::LogMel: {e}"));
4055                    schedule.push(Step::LogMelHost {
4056                        spec_byte_off: arena.offset(node.inputs[0]) as u32,
4057                        filt_byte_off: arena.offset(node.inputs[1]) as u32,
4058                        dst_byte_off: arena.offset(node.id) as u32,
4059                        outer: meta.outer as u32,
4060                        n_fft: meta.n_fft as u32,
4061                        n_bins: meta.n_bins as u32,
4062                        n_mels: meta.n_mels as u32,
4063                    });
4064                }
4065                Op::LogMelBackward => {
4066                    let spec_shape = graph.node(node.inputs[0]).shape.clone();
4067                    let filt_shape = graph.node(node.inputs[1]).shape.clone();
4068                    let meta = rlx_ir::audio::log_mel_meta(&spec_shape, &filt_shape)
4069                        .unwrap_or_else(|e| panic!("Op::LogMelBackward: {e}"));
4070                    schedule.push(Step::LogMelBackwardHost {
4071                        spec_byte_off: arena.offset(node.inputs[0]) as u32,
4072                        filt_byte_off: arena.offset(node.inputs[1]) as u32,
4073                        dy_byte_off: arena.offset(node.inputs[2]) as u32,
4074                        dst_byte_off: arena.offset(node.id) as u32,
4075                        outer: meta.outer as u32,
4076                        n_fft: meta.n_fft as u32,
4077                        n_bins: meta.n_bins as u32,
4078                        n_mels: meta.n_mels as u32,
4079                    });
4080                }
4081                Op::SelectiveScan { state_size } => {
4082                    if *state_size > 256 {
4083                        panic!(
4084                            "rlx-wgpu SelectiveScan: state_size {} exceeds compile-time \
4085                                cap of 256 (kernel uses fixed-size private array)",
4086                            state_size
4087                        );
4088                    }
4089                    let x_id = node.inputs[0];
4090                    let dt_id = node.inputs[1];
4091                    let a_id = node.inputs[2];
4092                    let b_id = node.inputs[3];
4093                    let c_id = node.inputs[4];
4094                    let in_dims = graph.node(x_id).shape.dims();
4095                    let seq = in_dims[1].unwrap_static() as u32;
4096                    let p = SelectiveScanParams {
4097                        batch: in_dims[0].unwrap_static() as u32,
4098                        seq,
4099                        hidden: in_dims[2].unwrap_static() as u32,
4100                        state_size: *state_size as u32,
4101                        x_off: (arena.offset(x_id) / 4) as u32,
4102                        delta_off: (arena.offset(dt_id) / 4) as u32,
4103                        a_off: (arena.offset(a_id) / 4) as u32,
4104                        b_off: (arena.offset(b_id) / 4) as u32,
4105                        c_off: (arena.offset(c_id) / 4) as u32,
4106                        out_off: (arena.offset(node.id) / 4) as u32,
4107                        // PLAN L1: full-extent stride; safe under
4108                        // active-extent scaling of params.seq.
4109                        seq_stride: seq,
4110                        _p1: 0,
4111                        _p2: 0,
4112                        _p3: 0,
4113                        _p4: 0,
4114                        _p5: 0,
4115                    };
4116                    schedule.push(Step::SelectiveScan { params: p });
4117                    let ssk = selective_scan_kernel(&dev.device);
4118                    let u = emit_uniform(std::mem::size_of::<SelectiveScanParams>());
4119                    let bg = bind_op_output_window(&dev.device, ssk, &arena, node.id, &u);
4120                    uniforms.push(u);
4121                    bind_groups.push(bg);
4122                }
4123                Op::Mamba2 {
4124                    head_dim,
4125                    state_size,
4126                } => {
4127                    if *state_size > 256 {
4128                        panic!(
4129                            "rlx-wgpu Mamba2: state_size {} exceeds compile-time cap of 256",
4130                            state_size
4131                        );
4132                    }
4133                    let x_id = node.inputs[0];
4134                    let in_dims = graph.node(x_id).shape.dims(); // [B,S,H,P]
4135                    let seq = in_dims[1].unwrap_static() as u32;
4136                    let p = Mamba2Params {
4137                        batch: in_dims[0].unwrap_static() as u32,
4138                        seq,
4139                        heads: in_dims[2].unwrap_static() as u32,
4140                        head_dim: *head_dim as u32,
4141                        state_size: *state_size as u32,
4142                        x_off: (arena.offset(x_id) / 4) as u32,
4143                        dt_off: (arena.offset(node.inputs[1]) / 4) as u32,
4144                        a_off: (arena.offset(node.inputs[2]) / 4) as u32,
4145                        b_off: (arena.offset(node.inputs[3]) / 4) as u32,
4146                        c_off: (arena.offset(node.inputs[4]) / 4) as u32,
4147                        out_off: (arena.offset(node.id) / 4) as u32,
4148                        seq_stride: seq,
4149                        _p1: 0,
4150                        _p2: 0,
4151                        _p3: 0,
4152                        _p4: 0,
4153                    };
4154                    schedule.push(Step::Mamba2 { params: p });
4155                    let mk = mamba2_kernel(&dev.device);
4156                    let u = emit_uniform(std::mem::size_of::<Mamba2Params>());
4157                    let bg = bind_op_output_window(&dev.device, mk, &arena, node.id, &u);
4158                    uniforms.push(u);
4159                    bind_groups.push(bg);
4160                }
4161                Op::Gru {
4162                    hidden_size,
4163                    num_layers,
4164                    bidirectional,
4165                    carry,
4166                } => {
4167                    let x_id = node.inputs[0];
4168                    let in_dims = graph.node(x_id).shape.dims(); // [B,S,In]
4169                    let batch = in_dims[0].unwrap_static() as u32;
4170                    let seq = in_dims[1].unwrap_static() as u32;
4171                    let input_size = in_dims[2].unwrap_static() as u32;
4172                    let hidden = *hidden_size as u32;
4173                    let simple = *num_layers == 1 && !*bidirectional && !*carry;
4174                    if simple && hidden <= 256 {
4175                        let p = GruParams {
4176                            batch,
4177                            seq,
4178                            input_size,
4179                            hidden,
4180                            x_off: (arena.offset(x_id) / 4) as u32,
4181                            wih_off: (arena.offset(node.inputs[1]) / 4) as u32,
4182                            whh_off: (arena.offset(node.inputs[2]) / 4) as u32,
4183                            bih_off: (arena.offset(node.inputs[3]) / 4) as u32,
4184                            bhh_off: (arena.offset(node.inputs[4]) / 4) as u32,
4185                            out_off: (arena.offset(node.id) / 4) as u32,
4186                            seq_stride: seq,
4187                            _p1: 0,
4188                            _p2: 0,
4189                            _p3: 0,
4190                            _p4: 0,
4191                            _p5: 0,
4192                        };
4193                        schedule.push(Step::Gru { params: p });
4194                        let gk = gru_kernel(&dev.device);
4195                        let u = emit_uniform(std::mem::size_of::<GruParams>());
4196                        let bg = bind_op_output_window(&dev.device, gk, &arena, node.id, &u);
4197                        uniforms.push(u);
4198                        bind_groups.push(bg);
4199                    } else {
4200                        let h0 = if *carry {
4201                            arena.offset(node.inputs[5]) as u32
4202                        } else {
4203                            0
4204                        };
4205                        schedule.push(Step::GruHost {
4206                            x: arena.offset(x_id) as u32,
4207                            w_ih: arena.offset(node.inputs[1]) as u32,
4208                            w_hh: arena.offset(node.inputs[2]) as u32,
4209                            b_ih: arena.offset(node.inputs[3]) as u32,
4210                            b_hh: arena.offset(node.inputs[4]) as u32,
4211                            h0,
4212                            dst: arena.offset(node.id) as u32,
4213                            batch,
4214                            seq,
4215                            input_size,
4216                            hidden,
4217                            num_layers: *num_layers as u32,
4218                            bidirectional: *bidirectional,
4219                            carry: *carry,
4220                        });
4221                    }
4222                }
4223                Op::Rnn {
4224                    hidden_size,
4225                    num_layers,
4226                    bidirectional,
4227                    carry,
4228                    relu,
4229                } => {
4230                    let x_id = node.inputs[0];
4231                    let in_dims = graph.node(x_id).shape.dims();
4232                    let batch = in_dims[0].unwrap_static() as u32;
4233                    let seq = in_dims[1].unwrap_static() as u32;
4234                    let input_size = in_dims[2].unwrap_static() as u32;
4235                    let hidden = *hidden_size as u32;
4236                    let simple = *num_layers == 1 && !*bidirectional && !*carry;
4237                    if simple && hidden <= 256 {
4238                        let p = RnnParams {
4239                            batch,
4240                            seq,
4241                            input_size,
4242                            hidden,
4243                            x_off: (arena.offset(x_id) / 4) as u32,
4244                            wih_off: (arena.offset(node.inputs[1]) / 4) as u32,
4245                            whh_off: (arena.offset(node.inputs[2]) / 4) as u32,
4246                            bias_off: (arena.offset(node.inputs[3]) / 4) as u32,
4247                            out_off: (arena.offset(node.id) / 4) as u32,
4248                            seq_stride: seq,
4249                            relu: u32::from(*relu),
4250                            _p1: 0,
4251                            _p2: 0,
4252                            _p3: 0,
4253                            _p4: 0,
4254                            _p5: 0,
4255                        };
4256                        schedule.push(Step::Rnn { params: p });
4257                        let rk = rnn_kernel(&dev.device);
4258                        let u = emit_uniform(std::mem::size_of::<RnnParams>());
4259                        let bg = bind_op_output_window(&dev.device, rk, &arena, node.id, &u);
4260                        uniforms.push(u);
4261                        bind_groups.push(bg);
4262                    } else {
4263                        let h0 = if *carry {
4264                            arena.offset(node.inputs[4]) as u32
4265                        } else {
4266                            0
4267                        };
4268                        schedule.push(Step::RnnHost {
4269                            x: arena.offset(x_id) as u32,
4270                            w_ih: arena.offset(node.inputs[1]) as u32,
4271                            w_hh: arena.offset(node.inputs[2]) as u32,
4272                            bias: arena.offset(node.inputs[3]) as u32,
4273                            h0,
4274                            dst: arena.offset(node.id) as u32,
4275                            batch,
4276                            seq,
4277                            input_size,
4278                            hidden,
4279                            num_layers: *num_layers as u32,
4280                            bidirectional: *bidirectional,
4281                            carry: *carry,
4282                            relu: *relu,
4283                        });
4284                    }
4285                }
4286                Op::GatedDeltaNet {
4287                    state_size,
4288                    carry_state,
4289                } => {
4290                    if *state_size > rlx_cpu::gdn::GDN_MAX_STATE {
4291                        panic!(
4292                            "rlx-wgpu GatedDeltaNet: state_size {state_size} > {}",
4293                            rlx_cpu::gdn::GDN_MAX_STATE
4294                        );
4295                    }
4296                    let q_id = node.inputs[0];
4297                    let q_shape = &graph.node(q_id).shape;
4298                    let state_off = if *carry_state {
4299                        arena.offset(node.inputs[5])
4300                    } else {
4301                        0
4302                    };
4303                    schedule.push(Step::GatedDeltaNet {
4304                        q_byte_off: arena.offset(q_id) as u32,
4305                        k_byte_off: arena.offset(node.inputs[1]) as u32,
4306                        v_byte_off: arena.offset(node.inputs[2]) as u32,
4307                        g_byte_off: arena.offset(node.inputs[3]) as u32,
4308                        beta_byte_off: arena.offset(node.inputs[4]) as u32,
4309                        state_byte_off: state_off as u32,
4310                        dst_byte_off: arena.offset(node.id) as u32,
4311                        batch: q_shape.dim(0).unwrap_static() as u32,
4312                        seq: q_shape.dim(1).unwrap_static() as u32,
4313                        heads: q_shape.dim(2).unwrap_static() as u32,
4314                        state_size: *state_size as u32,
4315                        use_carry: *carry_state,
4316                    });
4317                    if gguf_host_pad.is_none() {
4318                        let bk = binary_kernel(&dev.device);
4319                        let u = emit_uniform(256);
4320                        gguf_host_pad = Some((
4321                            u.clone(),
4322                            bind_op_output_window(&dev.device, bk, &arena, node.id, &u),
4323                        ));
4324                    }
4325                    let (u, bg) = gguf_host_pad.as_ref().unwrap();
4326                    uniforms.push(u.clone());
4327                    bind_groups.push(bg.clone());
4328                }
4329                Op::Lstm {
4330                    hidden_size,
4331                    num_layers,
4332                    bidirectional,
4333                    carry,
4334                } => {
4335                    let x_shape = &graph.node(node.inputs[0]).shape;
4336                    let (h0, c0) = if *carry {
4337                        (
4338                            arena.offset(node.inputs[4]) as u32,
4339                            arena.offset(node.inputs[5]) as u32,
4340                        )
4341                    } else {
4342                        (0u32, 0u32)
4343                    };
4344                    schedule.push(Step::Lstm {
4345                        x_byte_off: arena.offset(node.inputs[0]) as u32,
4346                        w_ih_byte_off: arena.offset(node.inputs[1]) as u32,
4347                        w_hh_byte_off: arena.offset(node.inputs[2]) as u32,
4348                        bias_byte_off: arena.offset(node.inputs[3]) as u32,
4349                        h0_byte_off: h0,
4350                        c0_byte_off: c0,
4351                        dst_byte_off: arena.offset(node.id) as u32,
4352                        batch: x_shape.dim(0).unwrap_static() as u32,
4353                        seq: x_shape.dim(1).unwrap_static() as u32,
4354                        input_size: x_shape.dim(2).unwrap_static() as u32,
4355                        hidden: *hidden_size as u32,
4356                        num_layers: *num_layers as u32,
4357                        bidirectional: *bidirectional,
4358                        carry: *carry,
4359                    });
4360                    // Host step — keep schedule/uniform/bind_group lanes aligned.
4361                    if gguf_host_pad.is_none() {
4362                        let bk = binary_kernel(&dev.device);
4363                        let u = emit_uniform(256);
4364                        gguf_host_pad = Some((
4365                            u.clone(),
4366                            bind_op_output_window(&dev.device, bk, &arena, node.id, &u),
4367                        ));
4368                    }
4369                    let (u, bg) = gguf_host_pad.as_ref().unwrap();
4370                    uniforms.push(u.clone());
4371                    bind_groups.push(bg.clone());
4372                }
4373                Op::ConvTranspose2d {
4374                    kernel_size,
4375                    stride,
4376                    padding,
4377                    dilation,
4378                    output_padding: _,
4379                    groups,
4380                } => {
4381                    let in_shape = &graph.node(node.inputs[0]).shape;
4382                    let out_shape = &node.shape;
4383                    schedule.push(Step::ConvTranspose2d {
4384                        src_byte_off: arena.offset(node.inputs[0]) as u32,
4385                        weight_byte_off: arena.offset(node.inputs[1]) as u32,
4386                        dst_byte_off: arena.offset(node.id) as u32,
4387                        n: in_shape.dim(0).unwrap_static() as u32,
4388                        c_in: in_shape.dim(1).unwrap_static() as u32,
4389                        h: in_shape.dim(2).unwrap_static() as u32,
4390                        w_in: in_shape.dim(3).unwrap_static() as u32,
4391                        c_out: out_shape.dim(1).unwrap_static() as u32,
4392                        h_out: out_shape.dim(2).unwrap_static() as u32,
4393                        w_out: out_shape.dim(3).unwrap_static() as u32,
4394                        kh: kernel_size[0] as u32,
4395                        kw: kernel_size[1] as u32,
4396                        sh: stride[0] as u32,
4397                        sw: stride[1] as u32,
4398                        ph: padding[0] as u32,
4399                        pw: padding[1] as u32,
4400                        dh: dilation[0] as u32,
4401                        dw: dilation[1] as u32,
4402                        groups: *groups as u32,
4403                    });
4404                    // Host step: schedule-only, like `MsDeformAttnHost`/`UmapKnnHost`.
4405                    // The uniform/bind_group lanes are indexed by GPU-step count
4406                    // (`gpu_ui`), so host steps must NOT push to them.
4407                }
4408                Op::GroupNorm { num_groups, eps } => {
4409                    // NCHW: x [n,c,h,w], gamma/beta [c]. Host step (schedule-only).
4410                    let in_shape = &graph.node(node.inputs[0]).shape;
4411                    schedule.push(Step::GroupNormHost {
4412                        src_byte_off: arena.offset(node.inputs[0]) as u32,
4413                        gamma_byte_off: arena.offset(node.inputs[1]) as u32,
4414                        beta_byte_off: arena.offset(node.inputs[2]) as u32,
4415                        dst_byte_off: arena.offset(node.id) as u32,
4416                        n: in_shape.dim(0).unwrap_static() as u32,
4417                        c: in_shape.dim(1).unwrap_static() as u32,
4418                        h: in_shape.dim(2).unwrap_static() as u32,
4419                        w: in_shape.dim(3).unwrap_static() as u32,
4420                        num_groups: *num_groups as u32,
4421                        eps: *eps,
4422                    });
4423                }
4424                Op::LayerNorm2d { eps } => {
4425                    // NCHW: x [n,c,h,w], gamma/beta [c]. Host step (schedule-only).
4426                    let in_shape = &graph.node(node.inputs[0]).shape;
4427                    schedule.push(Step::LayerNorm2dHost {
4428                        src_byte_off: arena.offset(node.inputs[0]) as u32,
4429                        gamma_byte_off: arena.offset(node.inputs[1]) as u32,
4430                        beta_byte_off: arena.offset(node.inputs[2]) as u32,
4431                        dst_byte_off: arena.offset(node.id) as u32,
4432                        n: in_shape.dim(0).unwrap_static() as u32,
4433                        c: in_shape.dim(1).unwrap_static() as u32,
4434                        h: in_shape.dim(2).unwrap_static() as u32,
4435                        w: in_shape.dim(3).unwrap_static() as u32,
4436                        eps: *eps,
4437                    });
4438                }
4439                Op::ResizeNearest2x => {
4440                    // NCHW nearest 2× upsample. Host step (schedule-only).
4441                    let in_shape = &graph.node(node.inputs[0]).shape;
4442                    schedule.push(Step::ResizeNearest2xHost {
4443                        src_byte_off: arena.offset(node.inputs[0]) as u32,
4444                        dst_byte_off: arena.offset(node.id) as u32,
4445                        n: in_shape.dim(0).unwrap_static() as u32,
4446                        c: in_shape.dim(1).unwrap_static() as u32,
4447                        h: in_shape.dim(2).unwrap_static() as u32,
4448                        w: in_shape.dim(3).unwrap_static() as u32,
4449                    });
4450                }
4451                Op::Reverse { axes } => {
4452                    // Batch-general flip. Host step (schedule-only).
4453                    let in_shape = &graph.node(node.inputs[0]).shape;
4454                    let rank = in_shape.rank();
4455                    let dims: Vec<u32> = (0..rank)
4456                        .map(|i| in_shape.dim(i).unwrap_static() as u32)
4457                        .collect();
4458                    let mut rev_mask = vec![false; rank];
4459                    for &a in axes {
4460                        if a < rank {
4461                            rev_mask[a] = true;
4462                        }
4463                    }
4464                    schedule.push(Step::ReverseHost {
4465                        src_byte_off: arena.offset(node.inputs[0]) as u32,
4466                        dst_byte_off: arena.offset(node.id) as u32,
4467                        dims,
4468                        rev_mask,
4469                        elem_bytes: in_shape.dtype().size_bytes() as u32,
4470                    });
4471                }
4472                Op::ArgMax { axis, keep_dim: _ } | Op::ArgMin { axis, keep_dim: _ } => {
4473                    // ArgMax/ArgMin. Host step (schedule-only).
4474                    let in_shape = &graph.node(node.inputs[0]).shape;
4475                    let rank = in_shape.rank();
4476                    let outer: usize = (0..*axis)
4477                        .map(|i| in_shape.dim(i).unwrap_static())
4478                        .product::<usize>()
4479                        .max(1);
4480                    let reduced = in_shape.dim(*axis).unwrap_static();
4481                    let inner: usize = (*axis + 1..rank)
4482                        .map(|i| in_shape.dim(i).unwrap_static())
4483                        .product::<usize>()
4484                        .max(1);
4485                    schedule.push(Step::ArgReduceHost {
4486                        src_byte_off: arena.offset(node.inputs[0]) as u32,
4487                        dst_byte_off: arena.offset(node.id) as u32,
4488                        outer: outer as u32,
4489                        reduced: reduced as u32,
4490                        inner: inner as u32,
4491                        is_max: matches!(node.op, Op::ArgMax { .. }),
4492                    });
4493                }
4494                Op::Scan {
4495                    body,
4496                    length,
4497                    save_trajectory,
4498                    num_bcast,
4499                    num_xs,
4500                    ..
4501                } => {
4502                    // Host fallback (no unified memory): compile the body once,
4503                    // loop it on the CPU against a readback of the arena span.
4504                    let nb = *num_bcast as usize;
4505                    let nx = *num_xs as usize;
4506                    let plan = rlx_cpu::thunk::compile_scan_body(body, nb, nx);
4507                    let bcast_outer: Vec<(usize, usize)> = (0..nb)
4508                        .map(|i| {
4509                            let id = node.inputs[1 + i];
4510                            (arena.offset(id), graph.node(id).shape.size_bytes().unwrap())
4511                        })
4512                        .collect();
4513                    let xs_outer: Vec<(usize, usize)> = (0..nx)
4514                        .map(|i| {
4515                            let id = node.inputs[1 + nb + i];
4516                            let total = graph.node(id).shape.size_bytes().unwrap();
4517                            (arena.offset(id), total / *length as usize)
4518                        })
4519                        .collect();
4520                    schedule.push(Step::ScanHost {
4521                        plan: std::sync::Arc::new(plan),
4522                        outer_init_off: arena.offset(node.inputs[0]),
4523                        outer_final_off: arena.offset(node.id),
4524                        length: *length,
4525                        save_trajectory: *save_trajectory,
4526                        xs_outer,
4527                        bcast_outer,
4528                    });
4529                }
4530                Op::Custom { name, attrs, .. } => match name.as_str() {
4531                    "llada2.group_limited_gate" => {
4532                        let sig_id = node.inputs[0];
4533                        let route_id = node.inputs[1];
4534                        let n_elems = graph.node(sig_id).shape.num_elements().unwrap() as u32;
4535                        let mut attr_buf = [0u8; 20];
4536                        let n = attrs.len().min(20);
4537                        attr_buf[..n].copy_from_slice(&attrs[..n]);
4538                        schedule.push(Step::Llada2GroupLimitedGate {
4539                            sig_byte_off: arena.offset(sig_id) as u32,
4540                            route_byte_off: arena.offset(route_id) as u32,
4541                            out_byte_off: arena.offset(node.id) as u32,
4542                            n_elems,
4543                            attrs: attr_buf,
4544                        });
4545                    }
4546                    "umap.knn" => {
4547                        let pw_id = node.inputs[0];
4548                        let pw_shape = graph.node(pw_id).shape.dims();
4549                        let n = pw_shape[0].unwrap_static() as u32;
4550                        let k = if attrs.len() >= 4 {
4551                            u32::from_le_bytes(attrs[..4].try_into().unwrap())
4552                        } else {
4553                            panic!("rlx-wgpu: umap.knn attrs missing k");
4554                        };
4555                        let pw_off = arena.offset(pw_id) as u32;
4556                        let out_off = arena.offset(node.id) as u32;
4557                        if n as usize >= crate::umap_knn_host::UMAP_KNN_GPU_MIN_N {
4558                            let p = UmapKnnParams {
4559                                n,
4560                                k,
4561                                pw_off: pw_off / 4,
4562                                out_off: out_off / 4,
4563                                _p0: 0,
4564                                _p1: 0,
4565                                _p2: 0,
4566                            };
4567                            schedule.push(Step::UmapKnn { params: p });
4568                            let uk = umap_knn_kernel(&dev.device);
4569                            let u = emit_uniform(std::mem::size_of::<UmapKnnParams>());
4570                            let bg = bind_op_output_window(&dev.device, uk, &arena, node.id, &u);
4571                            uniforms.push(u);
4572                            bind_groups.push(bg);
4573                        } else {
4574                            schedule.push(Step::UmapKnnHost {
4575                                pairwise_byte_off: pw_off,
4576                                out_byte_off: out_off,
4577                                n,
4578                                k,
4579                            });
4580                        }
4581                    }
4582                    "gdino.ms_deform_attn" => {
4583                        let in_offs: Vec<(u32, u32)> = node
4584                            .inputs
4585                            .iter()
4586                            .map(|&id| {
4587                                let bytes = graph.node(id).shape.num_elements().unwrap() * 4;
4588                                (arena.offset(id) as u32, bytes as u32)
4589                            })
4590                            .collect();
4591                        let out_bytes = (node.shape.num_elements().unwrap() * 4) as u32;
4592                        schedule.push(Step::MsDeformAttnHost {
4593                            in_offs,
4594                            out_byte_off: arena.offset(node.id) as u32,
4595                            out_bytes,
4596                            attrs: attrs.clone(),
4597                        });
4598                    }
4599                    other => panic!("rlx-wgpu: unsupported Op::Custom('{other}')"),
4600                },
4601                Op::GroupedMatMul => {
4602                    // Inputs: input [M, K], weight [E, K, N], expert_idx [M]
4603                    let in_id = node.inputs[0];
4604                    let w_id = node.inputs[1];
4605                    let idx_id = node.inputs[2];
4606                    let in_dims = graph.node(in_id).shape.dims();
4607                    let w_dims = graph.node(w_id).shape.dims();
4608                    let m = in_dims[0].unwrap_static() as u32;
4609                    let k = in_dims[1].unwrap_static() as u32;
4610                    let n = w_dims[2].unwrap_static() as u32;
4611                    let ne = w_dims[0].unwrap_static() as u32;
4612                    let p = GroupedMatmulParams {
4613                        m,
4614                        k,
4615                        n,
4616                        num_experts: ne,
4617                        in_off: (arena.offset(in_id) / 4) as u32,
4618                        w_off: (arena.offset(w_id) / 4) as u32,
4619                        idx_off: (arena.offset(idx_id) / 4) as u32,
4620                        out_off: (arena.offset(node.id) / 4) as u32,
4621                    };
4622                    schedule.push(Step::GroupedMatmul { params: p });
4623                    let gk = grouped_matmul_kernel(&dev.device);
4624                    let u = emit_uniform(std::mem::size_of::<GroupedMatmulParams>());
4625                    let bg = bind_op_output_window(&dev.device, gk, &arena, node.id, &u);
4626                    uniforms.push(u);
4627                    bind_groups.push(bg);
4628                }
4629                Op::DequantGroupedMatMul { scheme } => {
4630                    let in_id = node.inputs[0];
4631                    let w_id = node.inputs[1];
4632                    let idx_id = node.inputs[2];
4633                    let in_dims = graph.node(in_id).shape.dims();
4634                    let out_dims = node.shape.dims();
4635                    let m = in_dims[0].unwrap_static() as u32;
4636                    let k = in_dims[1].unwrap_static() as u32;
4637                    let n = out_dims[out_dims.len() - 1].unwrap_static() as u32;
4638                    let block_elems = scheme.gguf_block_size() as usize;
4639                    let block_bytes = scheme.gguf_block_bytes() as usize;
4640                    let slab_bytes = (k as usize * n as usize) / block_elems * block_bytes;
4641                    let total_bytes = graph.node(w_id).shape.num_elements().unwrap();
4642                    let ne = (total_bytes / slab_bytes.max(1)) as u32;
4643                    schedule.push(Step::DequantGroupedMatmulGguf {
4644                        m,
4645                        k,
4646                        n,
4647                        num_experts: ne,
4648                        scheme_id: crate::gguf_host::gguf_scheme_id(*scheme),
4649                        x_byte_off: arena.offset(in_id) as u64,
4650                        w_byte_off: arena.offset(w_id) as u64,
4651                        idx_byte_off: arena.offset(idx_id) as u64,
4652                        out_byte_off: arena.offset(node.id) as u64,
4653                    });
4654                    // Host step (builds its own bind groups): schedule-only, like
4655                    // `GroupNorm`. The uniform/bind_group lanes are indexed by
4656                    // GPU-step count (host steps skipped), so pushing here would
4657                    // shift every later GPU op's lane. Do NOT push.
4658                }
4659                Op::TopK { k } => {
4660                    let in_id = node.inputs[0];
4661                    let in_dims = graph.node(in_id).shape.dims();
4662                    let inner = in_dims.last().unwrap().unwrap_static() as u32;
4663                    let outer: u32 = in_dims[..in_dims.len() - 1]
4664                        .iter()
4665                        .map(|d| d.unwrap_static() as u32)
4666                        .product::<u32>()
4667                        .max(1);
4668                    let p = TopKParams {
4669                        outer,
4670                        inner,
4671                        k: *k as u32,
4672                        in_off: (arena.offset(in_id) / 4) as u32,
4673                        out_off: (arena.offset(node.id) / 4) as u32,
4674                        _p0: 0,
4675                        _p1: 0,
4676                        _p2: 0,
4677                    };
4678                    schedule.push(Step::TopK { params: p });
4679                    let tk = topk_kernel(&dev.device);
4680                    let u = emit_uniform(std::mem::size_of::<TopKParams>());
4681                    let bg = bind_op_output_window(&dev.device, tk, &arena, node.id, &u);
4682                    uniforms.push(u);
4683                    bind_groups.push(bg);
4684                }
4685                Op::ScatterAdd => {
4686                    // Inputs: updates [num_updates, trailing], indices [num_updates].
4687                    // Output: [out_dim, trailing]. Implemented as two phases:
4688                    //   1. Zero `out_dim * trailing` slots.
4689                    //   2. CAS-loop atomic-accumulate `num_updates * trailing` updates.
4690                    let upd_id = node.inputs[0];
4691                    let idx_id = node.inputs[1];
4692                    let upd_dims = graph.node(upd_id).shape.dims();
4693                    let out_dims = node.shape.dims();
4694                    let num_updates = upd_dims[0].unwrap_static() as u32;
4695                    let trailing: u32 = upd_dims
4696                        .iter()
4697                        .skip(1)
4698                        .map(|d| d.unwrap_static() as u32)
4699                        .product::<u32>()
4700                        .max(1);
4701                    let out_dim = out_dims[0].unwrap_static() as u32;
4702                    let out_total = out_dim * trailing;
4703
4704                    let common = ScatterAddParams {
4705                        op: 0,
4706                        out_off: (arena.offset(node.id) / 4) as u32,
4707                        upd_off: (arena.offset(upd_id) / 4) as u32,
4708                        idx_off: (arena.offset(idx_id) / 4) as u32,
4709                        out_total,
4710                        num_updates,
4711                        trailing,
4712                        out_dim,
4713                    };
4714                    let sk = scatter_add_kernel(&dev.device);
4715
4716                    // Phase 0: zero.
4717                    schedule.push(Step::ScatterAdd { params: common });
4718                    let u0 = emit_uniform(std::mem::size_of::<ScatterAddParams>());
4719                    let bg0 = bind_op_output_window(&dev.device, sk, &arena, node.id, &u0);
4720                    uniforms.push(u0);
4721                    bind_groups.push(bg0);
4722
4723                    // Phase 1: accumulate.
4724                    let mut acc = common;
4725                    acc.op = 1;
4726                    schedule.push(Step::ScatterAdd { params: acc });
4727                    let u1 = emit_uniform(std::mem::size_of::<ScatterAddParams>());
4728                    let bg1 = bind_op_output_window(&dev.device, sk, &arena, node.id, &u1);
4729                    uniforms.push(u1);
4730                    bind_groups.push(bg1);
4731                }
4732                Op::FusedResidualLN { has_bias, eps } => {
4733                    // Inputs: [x, residual, [bias], gamma, beta].
4734                    let x_id = node.inputs[0];
4735                    let r_id = node.inputs[1];
4736                    let (bias_id, g_id, b_id) = if *has_bias {
4737                        (node.inputs[2], node.inputs[3], node.inputs[4])
4738                    } else {
4739                        (x_id, node.inputs[2], node.inputs[3]) // bias unused
4740                    };
4741                    let in_dims = node.shape.dims();
4742                    let inner = in_dims[in_dims.len() - 1].unwrap_static() as u32;
4743                    let total: u32 = in_dims.iter().map(|d| d.unwrap_static() as u32).product();
4744                    let outer = total / inner.max(1);
4745                    let p = FusedResidualLnParams {
4746                        outer,
4747                        inner,
4748                        in_off: (arena.offset(x_id) / 4) as u32,
4749                        residual_off: (arena.offset(r_id) / 4) as u32,
4750                        bias_off: (arena.offset(bias_id) / 4) as u32,
4751                        gamma_off: (arena.offset(g_id) / 4) as u32,
4752                        beta_off: (arena.offset(b_id) / 4) as u32,
4753                        out_off: (arena.offset(node.id) / 4) as u32,
4754                        eps_bits: eps.to_bits(),
4755                        has_bias: if *has_bias { 1 } else { 0 },
4756                        _p0: 0,
4757                        _p1: 0,
4758                    };
4759                    schedule.push(Step::FusedResidualLn { params: p });
4760                    let frk = fused_residual_ln_kernel(&dev.device);
4761                    let u = emit_uniform(std::mem::size_of::<FusedResidualLnParams>());
4762                    let bg = bind_op_output_window(&dev.device, frk, &arena, node.id, &u);
4763                    uniforms.push(u);
4764                    bind_groups.push(bg);
4765                }
4766                Op::FusedResidualRmsNorm { has_bias, eps } => {
4767                    let x_id = node.inputs[0];
4768                    let r_id = node.inputs[1];
4769                    let (bias_id, g_id, b_id) = if *has_bias {
4770                        (node.inputs[2], node.inputs[3], node.inputs[4])
4771                    } else {
4772                        (x_id, node.inputs[2], node.inputs[3])
4773                    };
4774                    let in_dims = node.shape.dims();
4775                    let inner = in_dims[in_dims.len() - 1].unwrap_static() as u32;
4776                    let total: u32 = in_dims.iter().map(|d| d.unwrap_static() as u32).product();
4777                    let outer = total / inner.max(1);
4778                    let p = FusedResidualRmsNormParams {
4779                        outer,
4780                        inner,
4781                        in_off: (arena.offset(x_id) / 4) as u32,
4782                        residual_off: (arena.offset(r_id) / 4) as u32,
4783                        bias_off: (arena.offset(bias_id) / 4) as u32,
4784                        gamma_off: (arena.offset(g_id) / 4) as u32,
4785                        beta_off: (arena.offset(b_id) / 4) as u32,
4786                        out_off: (arena.offset(node.id) / 4) as u32,
4787                        eps_bits: eps.to_bits(),
4788                        has_bias: if *has_bias { 1 } else { 0 },
4789                        _p0: 0,
4790                        _p1: 0,
4791                    };
4792                    schedule.push(Step::FusedResidualRmsNorm { params: p });
4793                    let frk = fused_residual_rms_norm_kernel(&dev.device);
4794                    let u = emit_uniform(std::mem::size_of::<FusedResidualRmsNormParams>());
4795                    let bg = bind_op_output_window(&dev.device, frk, &arena, node.id, &u);
4796                    uniforms.push(u);
4797                    bind_groups.push(bg);
4798                }
4799                Op::DequantMatMul { scheme } => {
4800                    use rlx_ir::QuantScheme;
4801                    let x_id = node.inputs[0];
4802                    let w_id = node.inputs[1];
4803                    // Rank-agnostic GEMM dims: `n` = last output dim, `m` = the
4804                    // product of the leading output dims (e.g. batch·seq), `k` =
4805                    // last input dim. A 2D-only `out_dims[1]` read collapses a 3D
4806                    // decode output `[1, 1, hidden]` to `n = 1` (the seq axis),
4807                    // which then mis-sizes the weight window. Mirrors
4808                    // `gguf_gpu::dequant_gguf_scratch_bytes`.
4809                    let out_total = node.shape.num_elements().unwrap_or(0) as u32;
4810                    let n = node.shape.dim(node.shape.rank() - 1).unwrap_static() as u32;
4811                    let m = out_total / n.max(1);
4812                    let x_total = graph.node(x_id).shape.num_elements().unwrap_or(0) as u32;
4813                    let k = x_total / m.max(1);
4814                    if scheme.is_gguf() {
4815                        schedule.push(Step::DequantMatmulGguf {
4816                            m,
4817                            k,
4818                            n,
4819                            scheme_id: crate::gguf_host::gguf_scheme_id(*scheme),
4820                            x_byte_off: arena.offset(x_id) as u64,
4821                            w_byte_off: arena.offset(w_id) as u64,
4822                            out_byte_off: arena.offset(node.id) as u64,
4823                        });
4824                        // Host step (`run_dequant_matmul_gguf_gpu` builds its own
4825                        // bind groups): schedule-only, like `GroupNorm`. The
4826                        // uniform/bind_group lanes are indexed by GPU-step count
4827                        // (`gpu_ui`/`gpu_bi`, host steps skipped), so pushing here
4828                        // would shift every later GPU op's lane — fatal in the
4829                        // packed decode graph where GGUF ops interleave with
4830                        // RmsNorm/RoPE/Attention/SwiGLU. Do NOT push.
4831                    } else {
4832                        let (block_size, scheme_id) = match scheme {
4833                            QuantScheme::Int8Block { block_size } => (*block_size, 0u32),
4834                            QuantScheme::Int8BlockAsym { block_size } => (*block_size, 1u32),
4835                            QuantScheme::Int4Block { block_size } => (*block_size, 2u32),
4836                            QuantScheme::Fp8E4m3 => (1, 3u32),
4837                            QuantScheme::Fp8E5m2 => (1, 4u32),
4838                            QuantScheme::Nvfp4Block => (rlx_ir::NVFP4_GROUP_SIZE as u32, 5u32),
4839                            other => panic!("rlx-wgpu DequantMatMul: unsupported scheme {other:?}"),
4840                        };
4841                        let scale_id = node.inputs[2];
4842                        let zp_id = node.inputs[3];
4843                        let p = DequantMatmulParams {
4844                            m,
4845                            k,
4846                            n,
4847                            block_size,
4848                            scheme_id,
4849                            x_off: (arena.offset(x_id) / 4) as u32,
4850                            w_off: (arena.offset(w_id) / 4) as u32,
4851                            scale_off: (arena.offset(scale_id) / 4) as u32,
4852                            zp_off: (arena.offset(zp_id) / 4) as u32,
4853                            out_off: (arena.offset(node.id) / 4) as u32,
4854                            _p0: 0,
4855                            _p1: 0,
4856                        };
4857                        schedule.push(Step::DequantMatmul { params: p });
4858                        let dk = dequant_matmul_kernel(&dev.device);
4859                        let u = emit_uniform(std::mem::size_of::<DequantMatmulParams>());
4860                        let bg = bind_op_output_window(&dev.device, dk, &arena, node.id, &u);
4861                        uniforms.push(u);
4862                        bind_groups.push(bg);
4863                    }
4864                }
4865                Op::RmsNormBackwardInput { eps, .. }
4866                | Op::RmsNormBackwardGamma { eps, .. }
4867                | Op::RmsNormBackwardBeta { eps, .. } => {
4868                    let x_shape = &graph.node(node.inputs[0]).shape;
4869                    let h = x_shape.dim(x_shape.rank() - 1).unwrap_static() as u32;
4870                    let rows = (x_shape.num_elements().unwrap() / h.max(1) as usize) as u32;
4871                    let foff = |i: usize| (arena.offset(node.inputs[i]) / 4) as u32;
4872                    let wrt = match &node.op {
4873                        Op::RmsNormBackwardInput { .. } => 0u32,
4874                        Op::RmsNormBackwardGamma { .. } => 1u32,
4875                        Op::RmsNormBackwardBeta { .. } => 2u32,
4876                        _ => unreachable!(),
4877                    };
4878                    let p = RmsNormBwdParams {
4879                        outer: rows,
4880                        inner: h,
4881                        x_off: foff(0),
4882                        gamma_off: foff(1),
4883                        beta_off: foff(2),
4884                        dy_off: foff(3),
4885                        out_off: (arena.offset(node.id) / 4) as u32,
4886                        eps_bits: eps.to_bits(),
4887                        wrt,
4888                    };
4889                    let rk = if wrt == 0 {
4890                        rms_norm_backward_kernel(&dev.device)
4891                    } else {
4892                        rms_norm_backward_param_kernel(&dev.device)
4893                    };
4894                    let u = emit_uniform(std::mem::size_of::<RmsNormBwdParams>());
4895                    let bg = bind_op_output_window(&dev.device, rk, &arena, node.id, &u);
4896                    match &node.op {
4897                        Op::RmsNormBackwardInput { .. } => {
4898                            schedule.push(Step::RmsNormBackwardInput { params: p });
4899                        }
4900                        Op::RmsNormBackwardGamma { .. } => {
4901                            schedule.push(Step::RmsNormBackwardGamma { params: p });
4902                        }
4903                        Op::RmsNormBackwardBeta { .. } => {
4904                            schedule.push(Step::RmsNormBackwardBeta { params: p });
4905                        }
4906                        _ => unreachable!(),
4907                    }
4908                    uniforms.push(u);
4909                    bind_groups.push(bg);
4910                }
4911                Op::LayerNormBackwardInput { eps, .. } => {
4912                    let x_shape = &graph.node(node.inputs[0]).shape;
4913                    let h = x_shape.dim(x_shape.rank() - 1).unwrap_static() as u32;
4914                    let rows = (x_shape.num_elements().unwrap() / h.max(1) as usize) as u32;
4915                    let p = LayerNormBwdParams {
4916                        outer: rows,
4917                        inner: h,
4918                        x_off: (arena.offset(node.inputs[0]) / 4) as u32,
4919                        gamma_off: (arena.offset(node.inputs[1]) / 4) as u32,
4920                        dy_off: (arena.offset(node.inputs[2]) / 4) as u32,
4921                        out_off: (arena.offset(node.id) / 4) as u32,
4922                        eps_bits: eps.to_bits(),
4923                        scratch_off: 0,
4924                    };
4925                    let rk = layer_norm_backward_input_kernel(&dev.device);
4926                    let u = emit_uniform(std::mem::size_of::<LayerNormBwdParams>());
4927                    let bg = bind_op_output_window(&dev.device, rk, &arena, node.id, &u);
4928                    schedule.push(Step::LayerNormBackwardInput { params: p });
4929                    uniforms.push(u);
4930                    bind_groups.push(bg);
4931                }
4932                Op::LayerNormBackwardGamma { eps, .. } => {
4933                    // Inputs: [x, dy] — gamma_off is unused for this op.
4934                    // Emit two steps: a multi-workgroup partial that
4935                    // writes per-chunk dgamma to the tail scratch zone,
4936                    // and a single-workgroup reduce that sums chunks
4937                    // into the final dgamma slot.
4938                    let x_shape = &graph.node(node.inputs[0]).shape;
4939                    let h = x_shape.dim(x_shape.rank() - 1).unwrap_static() as u32;
4940                    let rows = (x_shape.num_elements().unwrap() / h.max(1) as usize) as u32;
4941                    const ROWS_PER_WG: u32 = 16;
4942                    let num_workgroups = rows.div_ceil(ROWS_PER_WG.max(1));
4943                    let scratch_off_words = (arena.scratch_off / 4) as u32;
4944                    let partial_params = LayerNormBwdParams {
4945                        outer: rows,
4946                        inner: h,
4947                        x_off: (arena.offset(node.inputs[0]) / 4) as u32,
4948                        gamma_off: 0,
4949                        dy_off: (arena.offset(node.inputs[1]) / 4) as u32,
4950                        out_off: 0, // unused by the partial kernel
4951                        eps_bits: eps.to_bits(),
4952                        scratch_off: scratch_off_words,
4953                    };
4954                    let reduce_params = LayerNormBwdParams {
4955                        // `outer` for the reduce kernel carries the
4956                        // number of partial chunks we just emitted.
4957                        outer: num_workgroups,
4958                        inner: h,
4959                        x_off: 0,
4960                        gamma_off: 0,
4961                        dy_off: 0,
4962                        out_off: (arena.offset(node.id) / 4) as u32,
4963                        eps_bits: eps.to_bits(),
4964                        scratch_off: scratch_off_words,
4965                    };
4966                    let p_k = layer_norm_backward_gamma_partial_kernel(&dev.device);
4967                    let r_k = layer_norm_backward_gamma_reduce_kernel(&dev.device);
4968                    let p_u = emit_uniform(std::mem::size_of::<LayerNormBwdParams>());
4969                    let r_u = emit_uniform(std::mem::size_of::<LayerNormBwdParams>());
4970                    let p_bg = bind_op_output_window(&dev.device, p_k, &arena, node.id, &p_u);
4971                    let r_bg = bind_op_output_window(&dev.device, r_k, &arena, node.id, &r_u);
4972                    schedule.push(Step::LayerNormBackwardGammaPartial {
4973                        params: partial_params,
4974                        num_workgroups,
4975                    });
4976                    schedule.push(Step::LayerNormBackwardGammaReduce {
4977                        params: reduce_params,
4978                    });
4979                    uniforms.push(p_u);
4980                    uniforms.push(r_u);
4981                    bind_groups.push(p_bg);
4982                    bind_groups.push(r_bg);
4983                }
4984                Op::RopeBackward { head_dim, n_rot } => {
4985                    let dy_shape = &graph.node(node.inputs[0]).shape;
4986                    let (batch, seq, hidden) = if dy_shape.rank() >= 3 {
4987                        (
4988                            dy_shape.dim(0).unwrap_static() as u32,
4989                            dy_shape.dim(1).unwrap_static() as u32,
4990                            dy_shape.dim(2).unwrap_static() as u32,
4991                        )
4992                    } else {
4993                        (
4994                            1,
4995                            dy_shape.dim(0).unwrap_static() as u32,
4996                            dy_shape.dim(1).unwrap_static() as u32,
4997                        )
4998                    };
4999                    let cos_len = graph.node(node.inputs[1]).shape.num_elements().unwrap() as u32;
5000                    let p = RopeBwdParams {
5001                        batch,
5002                        seq,
5003                        hidden,
5004                        head_dim: *head_dim as u32,
5005                        n_rot: *n_rot as u32,
5006                        dy_off: (arena.offset(node.inputs[0]) / 4) as u32,
5007                        cos_off: (arena.offset(node.inputs[1]) / 4) as u32,
5008                        sin_off: (arena.offset(node.inputs[2]) / 4) as u32,
5009                        dx_off: (arena.offset(node.id) / 4) as u32,
5010                        cos_len,
5011                    };
5012                    let rk = rope_backward_kernel(&dev.device);
5013                    let u = emit_uniform(std::mem::size_of::<RopeBwdParams>());
5014                    let bg = bind_op_output_window(&dev.device, rk, &arena, node.id, &u);
5015                    schedule.push(Step::RopeBackward { params: p });
5016                    uniforms.push(u);
5017                    bind_groups.push(bg);
5018                }
5019                Op::CumsumBackward { exclusive, .. } => {
5020                    let dy_shape = &graph.node(node.inputs[0]).shape;
5021                    let cols = dy_shape.dim(dy_shape.rank() - 1).unwrap_static() as u32;
5022                    let rows = (dy_shape.num_elements().unwrap() / cols.max(1) as usize) as u32;
5023                    let p = CumsumBwdParams {
5024                        outer: rows,
5025                        inner: cols,
5026                        dy_off: (arena.offset(node.inputs[0]) / 4) as u32,
5027                        dx_off: (arena.offset(node.id) / 4) as u32,
5028                        exclusive: if *exclusive { 1 } else { 0 },
5029                        _p0: 0,
5030                        _p1: 0,
5031                        _p2: 0,
5032                    };
5033                    let ck = cumsum_backward_kernel(&dev.device);
5034                    let u = emit_uniform(std::mem::size_of::<CumsumBwdParams>());
5035                    let bg = bind_op_output_window(&dev.device, ck, &arena, node.id, &u);
5036                    schedule.push(Step::CumsumBackward { params: p });
5037                    uniforms.push(u);
5038                    bind_groups.push(bg);
5039                }
5040                Op::GatherBackward { .. } => {
5041                    let dy_shape = &graph.node(node.inputs[0]).shape;
5042                    let idx_shape = &graph.node(node.inputs[1]).shape;
5043                    let out_shape = &node.shape;
5044                    let rank = out_shape.rank();
5045                    let axis = match &node.op {
5046                        Op::GatherBackward { axis } => *axis,
5047                        _ => 0,
5048                    };
5049                    let axis_u = if axis < 0 {
5050                        (rank as i32 + axis) as usize
5051                    } else {
5052                        axis as usize
5053                    };
5054                    let outer: usize = (0..axis_u)
5055                        .map(|i| dy_shape.dim(i).unwrap_static())
5056                        .product::<usize>()
5057                        .max(1);
5058                    let num_idx = idx_shape.dim(axis_u).unwrap_static();
5059                    let trailing: usize = (axis_u + 1..dy_shape.rank())
5060                        .map(|i| dy_shape.dim(i).unwrap_static())
5061                        .product::<usize>()
5062                        .max(1);
5063                    let axis_dim = out_shape.dim(axis_u).unwrap_static();
5064                    let p = GatherBwdParams {
5065                        outer: outer as u32,
5066                        axis_dim: axis_dim as u32,
5067                        num_idx: num_idx as u32,
5068                        trailing: trailing as u32,
5069                        dy_off: (arena.offset(node.inputs[0]) / 4) as u32,
5070                        idx_off: (arena.offset(node.inputs[1]) / 4) as u32,
5071                        dst_off: (arena.offset(node.id) / 4) as u32,
5072                        _p0: 0,
5073                    };
5074                    let zk = gather_backward_zero_kernel(&dev.device);
5075                    let u = emit_uniform(std::mem::size_of::<GatherBwdParams>());
5076                    let bg = bind_op_output_window(&dev.device, zk, &arena, node.id, &u);
5077                    schedule.push(Step::GatherBackward { params: p });
5078                    uniforms.push(u);
5079                    bind_groups.push(bg);
5080                }
5081                #[cfg(feature = "splat")]
5082                Op::GaussianSplatRender {
5083                    width,
5084                    height,
5085                    tile_size,
5086                    radius_scale,
5087                    alpha_cutoff,
5088                    max_splat_steps,
5089                    transmittance_threshold,
5090                    max_list_entries,
5091                } => {
5092                    let elem_len = |id: NodeId| -> u32 {
5093                        graph.node(id).shape.num_elements().unwrap_or(0) as u32
5094                    };
5095                    schedule.push(Step::GaussianSplatRender {
5096                        positions_byte_off: arena.offset(node.inputs[0]) as u32,
5097                        positions_len: elem_len(node.inputs[0]),
5098                        scales_byte_off: arena.offset(node.inputs[1]) as u32,
5099                        scales_len: elem_len(node.inputs[1]),
5100                        rotations_byte_off: arena.offset(node.inputs[2]) as u32,
5101                        rotations_len: elem_len(node.inputs[2]),
5102                        opacities_byte_off: arena.offset(node.inputs[3]) as u32,
5103                        opacities_len: elem_len(node.inputs[3]),
5104                        colors_byte_off: arena.offset(node.inputs[4]) as u32,
5105                        colors_len: elem_len(node.inputs[4]),
5106                        sh_coeffs_byte_off: arena.offset(node.inputs[5]) as u32,
5107                        sh_coeffs_len: elem_len(node.inputs[5]),
5108                        meta_byte_off: arena.offset(node.inputs[6]) as u32,
5109                        dst_byte_off: arena.offset(node.id) as u32,
5110                        dst_len: node.shape.num_elements().unwrap_or(0) as u32,
5111                        width: *width,
5112                        height: *height,
5113                        tile_size: *tile_size,
5114                        radius_scale: *radius_scale,
5115                        alpha_cutoff: *alpha_cutoff,
5116                        max_splat_steps: *max_splat_steps,
5117                        transmittance_threshold: *transmittance_threshold,
5118                        max_list_entries: *max_list_entries,
5119                    });
5120                }
5121
5122                #[cfg(feature = "splat")]
5123                Op::GaussianSplatRenderBackward {
5124                    width,
5125                    height,
5126                    tile_size,
5127                    radius_scale,
5128                    alpha_cutoff,
5129                    max_splat_steps,
5130                    transmittance_threshold,
5131                    max_list_entries,
5132                    loss_grad_clip,
5133                    sh_band,
5134                    max_anisotropy,
5135                } => {
5136                    let elem_len = |id: NodeId| -> u32 {
5137                        graph.node(id).shape.num_elements().unwrap_or(0) as u32
5138                    };
5139                    schedule.push(Step::GaussianSplatRenderBackward {
5140                        positions_byte_off: arena.offset(node.inputs[0]) as u32,
5141                        positions_len: elem_len(node.inputs[0]),
5142                        scales_byte_off: arena.offset(node.inputs[1]) as u32,
5143                        scales_len: elem_len(node.inputs[1]),
5144                        rotations_byte_off: arena.offset(node.inputs[2]) as u32,
5145                        rotations_len: elem_len(node.inputs[2]),
5146                        opacities_byte_off: arena.offset(node.inputs[3]) as u32,
5147                        opacities_len: elem_len(node.inputs[3]),
5148                        colors_byte_off: arena.offset(node.inputs[4]) as u32,
5149                        colors_len: elem_len(node.inputs[4]),
5150                        sh_coeffs_byte_off: arena.offset(node.inputs[5]) as u32,
5151                        sh_coeffs_len: elem_len(node.inputs[5]),
5152                        meta_byte_off: arena.offset(node.inputs[6]) as u32,
5153                        d_loss_byte_off: arena.offset(node.inputs[7]) as u32,
5154                        d_loss_len: elem_len(node.inputs[7]),
5155                        packed_byte_off: arena.offset(node.id) as u32,
5156                        packed_len: node.shape.num_elements().unwrap_or(0) as u32,
5157                        width: *width,
5158                        height: *height,
5159                        tile_size: *tile_size,
5160                        radius_scale: *radius_scale,
5161                        alpha_cutoff: *alpha_cutoff,
5162                        max_splat_steps: *max_splat_steps,
5163                        transmittance_threshold: *transmittance_threshold,
5164                        max_list_entries: *max_list_entries,
5165                        loss_grad_clip: *loss_grad_clip,
5166                        sh_band: *sh_band,
5167                        max_anisotropy: *max_anisotropy,
5168                    });
5169                }
5170
5171                #[cfg(feature = "splat")]
5172                Op::GaussianSplatPrepare {
5173                    width,
5174                    height,
5175                    tile_size,
5176                    radius_scale,
5177                    alpha_cutoff,
5178                    max_splat_steps,
5179                    transmittance_threshold,
5180                    max_list_entries,
5181                } => {
5182                    let elem_len = |id: NodeId| -> u32 {
5183                        graph.node(id).shape.num_elements().unwrap_or(0) as u32
5184                    };
5185                    schedule.push(Step::GaussianSplatPrepare {
5186                        positions_byte_off: arena.offset(node.inputs[0]) as u32,
5187                        positions_len: elem_len(node.inputs[0]),
5188                        scales_byte_off: arena.offset(node.inputs[1]) as u32,
5189                        scales_len: elem_len(node.inputs[1]),
5190                        rotations_byte_off: arena.offset(node.inputs[2]) as u32,
5191                        rotations_len: elem_len(node.inputs[2]),
5192                        opacities_byte_off: arena.offset(node.inputs[3]) as u32,
5193                        opacities_len: elem_len(node.inputs[3]),
5194                        colors_byte_off: arena.offset(node.inputs[4]) as u32,
5195                        colors_len: elem_len(node.inputs[4]),
5196                        sh_coeffs_byte_off: arena.offset(node.inputs[5]) as u32,
5197                        sh_coeffs_len: elem_len(node.inputs[5]),
5198                        meta_byte_off: arena.offset(node.inputs[6]) as u32,
5199                        meta_len: elem_len(node.inputs[6]),
5200                        prep_byte_off: arena.offset(node.id) as u32,
5201                        prep_len: node.shape.num_elements().unwrap_or(0) as u32,
5202                        width: *width,
5203                        height: *height,
5204                        tile_size: *tile_size,
5205                        radius_scale: *radius_scale,
5206                        alpha_cutoff: *alpha_cutoff,
5207                        max_splat_steps: *max_splat_steps,
5208                        transmittance_threshold: *transmittance_threshold,
5209                        max_list_entries: *max_list_entries,
5210                    });
5211                }
5212
5213                #[cfg(feature = "splat")]
5214                Op::GaussianSplatRasterize {
5215                    width,
5216                    height,
5217                    tile_size,
5218                    alpha_cutoff,
5219                    max_splat_steps,
5220                    transmittance_threshold,
5221                    max_list_entries,
5222                } => {
5223                    let elem_len = |id: NodeId| -> u32 {
5224                        graph.node(id).shape.num_elements().unwrap_or(0) as u32
5225                    };
5226                    let prep_id = node.inputs[0];
5227                    let count = match &graph.node(prep_id).op {
5228                        rlx_ir::Op::GaussianSplatPrepare { .. } => {
5229                            elem_len(graph.node(prep_id).inputs[0]) / 3
5230                        }
5231                        _ => 1,
5232                    };
5233                    schedule.push(Step::GaussianSplatRasterize {
5234                        prep_byte_off: arena.offset(prep_id) as u32,
5235                        prep_len: elem_len(prep_id),
5236                        meta_byte_off: arena.offset(node.inputs[1]) as u32,
5237                        meta_len: elem_len(node.inputs[1]),
5238                        dst_byte_off: arena.offset(node.id) as u32,
5239                        dst_len: node.shape.num_elements().unwrap_or(0) as u32,
5240                        count,
5241                        width: *width,
5242                        height: *height,
5243                        tile_size: *tile_size,
5244                        alpha_cutoff: *alpha_cutoff,
5245                        max_splat_steps: *max_splat_steps,
5246                        transmittance_threshold: *transmittance_threshold,
5247                        max_list_entries: *max_list_entries,
5248                    });
5249                }
5250
5251                Op::If { .. } | Op::While { .. } => {
5252                    // Should be unreachable: unfuse.rs inlines both branches
5253                    // (If) or unrolls max_iterations (While) into the parent
5254                    // graph using primitive ops + Where for the gating. If
5255                    // we hit this arm, the unfusion pass has a gap.
5256                    panic!(
5257                        "rlx-wgpu: Op::If/While leaked past unfusion pass — \
5258                            check unfuse.rs::expand_if / expand_while"
5259                    );
5260                }
5261                Op::RngNormal {
5262                    mean,
5263                    scale,
5264                    key,
5265                    op_seed,
5266                } => {
5267                    let len = node.shape.num_elements().unwrap_or(0) as u32;
5268                    schedule.push(Step::RngNormalHost {
5269                        dst_byte_off: arena.offset(node.id) as u32,
5270                        len,
5271                        mean: *mean,
5272                        scale: *scale,
5273                        key: *key,
5274                        op_seed: *op_seed,
5275                    });
5276                }
5277                Op::RngUniform {
5278                    low,
5279                    high,
5280                    key,
5281                    op_seed,
5282                } => {
5283                    let len = node.shape.num_elements().unwrap_or(0) as u32;
5284                    schedule.push(Step::RngUniformHost {
5285                        dst_byte_off: arena.offset(node.id) as u32,
5286                        len,
5287                        low: *low,
5288                        high: *high,
5289                        key: *key,
5290                        op_seed: *op_seed,
5291                    });
5292                }
5293                // Standalone nearest 2× upsample: the region-marking pass wraps
5294                // a bare `Op::ResizeNearest2x` into a single-step TransformRegion.
5295                // Route that to the host resize helper (mirrors the bare arm).
5296                Op::TransformRegion { steps, .. }
5297                    if steps.len() == 1
5298                        && matches!(
5299                            steps[0],
5300                            rlx_ir::op::TransformStep::ResizeNearest2x(
5301                                rlx_ir::op::ChainOperand::Input(0)
5302                            )
5303                        ) =>
5304                {
5305                    let in_shape = &graph.node(node.inputs[0]).shape;
5306                    schedule.push(Step::ResizeNearest2xHost {
5307                        src_byte_off: arena.offset(node.inputs[0]) as u32,
5308                        dst_byte_off: arena.offset(node.id) as u32,
5309                        n: in_shape.dim(0).unwrap_static() as u32,
5310                        c: in_shape.dim(1).unwrap_static() as u32,
5311                        h: in_shape.dim(2).unwrap_static() as u32,
5312                        w: in_shape.dim(3).unwrap_static() as u32,
5313                    });
5314                }
5315                other => panic!(
5316                    "rlx-wgpu: op {other:?} not yet lowered (v2 covers Matmul, \
5317                     Binary, Compare, Activation, Where — fall back to CPU/Metal/MLX)"
5318                ),
5319            }
5320        }
5321
5322        if rlx_ir::env::flag("RLX_WGPU_SCHEDULE") || rlx_ir::env::flag("RLX_DISPATCH_REPORT") {
5323            let mut counts: std::collections::BTreeMap<&'static str, usize> =
5324                std::collections::BTreeMap::new();
5325            let mut fft_gpu = 0usize;
5326            let mut fft_host = 0usize;
5327            for s in &schedule {
5328                *counts.entry(step_name(s)).or_insert(0) += 1;
5329                match s {
5330                    Step::FftGpu { .. } => fft_gpu += 1,
5331                    Step::FftHost { .. } => fft_host += 1,
5332                    _ => {}
5333                }
5334            }
5335            let arena_mb = arena.size as f64 / (1u64 << 20) as f64;
5336            eprintln!(
5337                "[rlx-wgpu] schedule: {} steps, arena={arena_mb:.1} MiB, fft_gpu={fft_gpu}, fft_host={fft_host}",
5338                schedule.len()
5339            );
5340            for (n, c) in &counts {
5341                eprintln!("    {c:>4} × {n}");
5342            }
5343        }
5344
5345        let coop_f16_vk = schedule_uses_coop_f16_vk(&schedule);
5346
5347        Self {
5348            graph,
5349            arena,
5350            dequant_scratch_off,
5351            schedule,
5352            input_offsets,
5353            param_offsets,
5354            uniforms,
5355            bind_groups,
5356            meta_buffers,
5357            unresolved: None,
5358            last_binding: None,
5359            pending_params: HashMap::new(),
5360            pending_param_bytes: HashMap::new(),
5361            active_extent: None,
5362            uniforms_active_extent: None,
5363            input_staging_hashes: HashMap::new(),
5364            coop_f16_vk,
5365            coop_f16_b_param,
5366            coop_f16_vk_wide_b: HashSet::new(),
5367            coop_f16_vk_wide_bind_groups,
5368            coop_f16_host_activations,
5369            stashed_params: HashMap::new(),
5370            readback_staging: None,
5371            tiny_readback: None,
5372            dispatch_only: false,
5373            fft_gpu_steps,
5374            gpu_handles: HashMap::new(),
5375            gpu_handle_feeds: HashMap::new(),
5376            gpu_handle_resident: HashSet::new(),
5377            pending_read_indices: None,
5378            rng,
5379        }
5380    }
5381}