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