Skip to main content

ferric_tensor/
lib.rs

1//! Ferric L2 — a general N-dimensional tensor runtime on the GPU fabric.
2//!
3//! This is the substrate the whole ecosystem is meant to stand on: not fixed-shape, hand-fused
4//! kernels for one architecture, but a real tensor with **arbitrary rank, strided views, and
5//! broadcasting**, plus **general** elementwise ops, **general** reductions over any axes, and
6//! **batched** matmul. The transformer kernels in `ferric-core` become fused fast-paths of this.
7//!
8//! Design: eager execution, tensors are `Arc`-shared f32 buffers described by (shape, strides,
9//! offset). Views (`reshape`/`permute`/`transpose`/`broadcast_to`) are zero-copy stride tricks;
10//! `contiguous()` materializes. One general strided kernel powers elementwise + broadcasting; a
11//! segmented kernel powers reductions; a batched kernel powers matmul. Validated against a strided
12//! CPU reference on general shapes (broadcasting, non-contiguous inputs, arbitrary reduction axes).
13//!
14//! Next fabric layers (in progress): dtypes (f16/bf16/int), autograd tape for training, op fusion,
15//! and the heterogeneous scheduler.
16
17use ferric_core::Context;
18use std::sync::Arc;
19use wgpu::util::DeviceExt;
20
21pub mod autograd; // reverse-mode autodiff (training)
22pub mod cpu; // strided CPU reference (validation source of truth)
23pub mod dtype; // f16/bf16 half-precision storage + on-device dequant
24pub mod fuse; // kernel fusion via runtime WGSL codegen (the optimizing-compiler seed)
25pub mod nn; // transformer blocks expressed on the general runtime
26pub mod optim; // optimizers (Adam)
27#[cfg(not(target_arch = "wasm32"))]
28pub mod sched; // L7 heterogeneous scheduler (GPU + CPU as one fabric)
29#[cfg(not(target_arch = "wasm32"))]
30pub mod ws; // WebSocket bridge so a browser tab is a scheduler device
31pub use autograd::Var;
32pub use dtype::{DType, Half, QRow, QTensor, Ternary};
33pub use optim::Adam;
34
35/// A general N-D f32 tensor: an Arc-shared device buffer viewed through (shape, strides, offset).
36#[derive(Clone)]
37pub struct Tensor {
38    ctx: Arc<Context>,
39    buf: Arc<wgpu::Buffer>,
40    pub shape: Vec<usize>,
41    pub strides: Vec<usize>, // element strides (row-major by default)
42    offset: usize,
43}
44
45fn contig_strides(shape: &[usize]) -> Vec<usize> {
46    let mut s = vec![1usize; shape.len()];
47    for i in (0..shape.len().saturating_sub(1)).rev() {
48        s[i] = s[i + 1] * shape[i + 1];
49    }
50    s
51}
52fn numel(shape: &[usize]) -> usize { shape.iter().product() }
53
54/// Broadcast two shapes NumPy-style (right-aligned). Returns the result shape.
55fn broadcast_shapes(a: &[usize], b: &[usize]) -> Vec<usize> {
56    let r = a.len().max(b.len());
57    let mut out = vec![0usize; r];
58    for i in 0..r {
59        let da = if i + a.len() >= r { a[i + a.len() - r] } else { 1 };
60        let db = if i + b.len() >= r { b[i + b.len() - r] } else { 1 };
61        assert!(da == db || da == 1 || db == 1, "shapes {a:?} and {b:?} not broadcastable at dim {i}");
62        out[i] = da.max(db);
63    }
64    out
65}
66
67impl Tensor {
68    pub fn numel(&self) -> usize { numel(&self.shape) }
69    pub fn rank(&self) -> usize { self.shape.len() }
70    pub fn is_contiguous(&self) -> bool { self.strides == contig_strides(&self.shape) && self.offset == 0 }
71
72    // ---- construction / io ----
73    pub fn from_vec(ctx: &Arc<Context>, data: &[f32], shape: &[usize]) -> Tensor {
74        assert_eq!(data.len(), numel(shape), "data len != shape product");
75        let buf = ctx.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
76            label: Some("tensor"),
77            contents: bytemuck::cast_slice(data),
78            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
79        });
80        Tensor { ctx: ctx.clone(), buf: Arc::new(buf), shape: shape.to_vec(), strides: contig_strides(shape), offset: 0 }
81    }
82    pub fn zeros(ctx: &Arc<Context>, shape: &[usize]) -> Tensor { Self::from_vec(ctx, &vec![0.0; numel(shape)], shape) }
83    /// Wrap a freshly-computed contiguous device buffer as a tensor (crate-internal).
84    pub(crate) fn from_parts(ctx: &Arc<Context>, buf: wgpu::Buffer, shape: Vec<usize>) -> Tensor {
85        let strides = contig_strides(&shape);
86        Tensor { ctx: ctx.clone(), buf: Arc::new(buf), shape, strides, offset: 0 }
87    }
88
89    /// Materialize (contiguous) and read back to host in logical row-major order.
90    pub async fn to_vec(&self) -> Vec<f32> {
91        let c = self.contiguous();
92        readback(&c.ctx, &c.buf, c.numel()).await
93    }
94
95    // ---- zero-copy views ----
96    pub fn reshape(&self, shape: &[usize]) -> Tensor {
97        assert_eq!(numel(shape), self.numel(), "reshape changes numel");
98        let c = self.contiguous();
99        Tensor { ctx: c.ctx, buf: c.buf, strides: contig_strides(shape), shape: shape.to_vec(), offset: 0 }
100    }
101    pub fn permute(&self, perm: &[usize]) -> Tensor {
102        assert_eq!(perm.len(), self.rank(), "permute rank mismatch");
103        Tensor {
104            ctx: self.ctx.clone(), buf: self.buf.clone(), offset: self.offset,
105            shape: perm.iter().map(|&p| self.shape[p]).collect(),
106            strides: perm.iter().map(|&p| self.strides[p]).collect(),
107        }
108    }
109    pub fn transpose(&self, a: usize, b: usize) -> Tensor {
110        let mut p: Vec<usize> = (0..self.rank()).collect();
111        p.swap(a, b);
112        self.permute(&p)
113    }
114    /// Broadcast to a larger shape (right-aligned); broadcast dims get stride 0.
115    pub fn broadcast_to(&self, shape: &[usize]) -> Tensor {
116        let r = shape.len();
117        assert!(r >= self.rank(), "cannot broadcast to fewer dims");
118        let mut strides = vec![0usize; r];
119        for i in 0..self.rank() {
120            let (si, di) = (self.rank() - 1 - i, r - 1 - i);
121            if self.shape[si] == shape[di] {
122                strides[di] = self.strides[si];
123            } else {
124                assert_eq!(self.shape[si], 1, "cannot broadcast dim {} of {:?} to {:?}", si, self.shape, shape);
125                strides[di] = 0;
126            }
127        }
128        Tensor { ctx: self.ctx.clone(), buf: self.buf.clone(), shape: shape.to_vec(), strides, offset: self.offset }
129    }
130
131    /// Materialize a (possibly strided/broadcast) view into a fresh contiguous buffer.
132    pub fn contiguous(&self) -> Tensor {
133        if self.is_contiguous() {
134            return self.clone();
135        }
136        let n = self.numel();
137        let out = empty(&self.ctx, n);
138        // info: [rank, n, offset, shape..., strides...]
139        let mut info = vec![self.rank() as u32, n as u32, self.offset as u32];
140        info.extend(self.shape.iter().map(|&x| x as u32));
141        info.extend(self.strides.iter().map(|&x| x as u32));
142        run(&self.ctx, GATHER_WGSL, "gather", &[&self.buf, &out, &u32buf(&self.ctx, &info)], groups(n));
143        Tensor { ctx: self.ctx.clone(), buf: Arc::new(out), shape: self.shape.clone(), strides: contig_strides(&self.shape), offset: 0 }
144    }
145
146    // ---- general broadcasting elementwise ----
147    fn binary(&self, other: &Tensor, op: u32) -> Tensor {
148        let shape = broadcast_shapes(&self.shape, &other.shape);
149        let a = self.broadcast_to(&shape);
150        let b = other.broadcast_to(&shape);
151        let n = numel(&shape);
152        let out = empty(&self.ctx, n);
153        // info: [rank, op, n, offA, offB, shape..., aStr..., bStr...]
154        let mut info = vec![shape.len() as u32, op, n as u32, a.offset as u32, b.offset as u32];
155        info.extend(shape.iter().map(|&x| x as u32));
156        info.extend(a.strides.iter().map(|&x| x as u32));
157        info.extend(b.strides.iter().map(|&x| x as u32));
158        run(&self.ctx, BINARY_WGSL, "binary", &[&a.buf, &b.buf, &out, &u32buf(&self.ctx, &info)], groups(n));
159        Tensor { ctx: self.ctx.clone(), buf: Arc::new(out), shape: shape.clone(), strides: contig_strides(&shape), offset: 0 }
160    }
161    pub fn add(&self, o: &Tensor) -> Tensor { self.binary(o, 0) }
162    pub fn sub(&self, o: &Tensor) -> Tensor { self.binary(o, 1) }
163    pub fn mul(&self, o: &Tensor) -> Tensor { self.binary(o, 2) }
164    pub fn div(&self, o: &Tensor) -> Tensor { self.binary(o, 3) }
165    pub fn maximum(&self, o: &Tensor) -> Tensor { self.binary(o, 4) }
166
167    fn unary(&self, op: u32) -> Tensor {
168        let c = self.contiguous();
169        let n = c.numel();
170        let out = empty(&self.ctx, n);
171        run(&self.ctx, UNARY_WGSL, "unary", &[&c.buf, &out, &u32buf(&self.ctx, &[op, n as u32])], groups(n));
172        Tensor { ctx: self.ctx.clone(), buf: Arc::new(out), shape: c.shape, strides: c.strides, offset: 0 }
173    }
174    pub fn exp(&self) -> Tensor { self.unary(0) }
175    pub fn neg(&self) -> Tensor { self.unary(1) }
176    pub fn relu(&self) -> Tensor { self.unary(2) }
177    pub fn sqrt(&self) -> Tensor { self.unary(3) }
178    pub fn relu_mask(&self) -> Tensor { self.unary(4) } // 1 where x>0 else 0 (relu' )
179    pub fn abs(&self) -> Tensor { self.unary(5) }
180    pub fn sigmoid(&self) -> Tensor { self.unary(6) }
181    pub fn silu(&self) -> Tensor { self.unary(7) }
182    pub fn gelu(&self) -> Tensor { self.unary(8) }
183    pub fn log(&self) -> Tensor { self.unary(9) }
184    pub fn relu2(&self) -> Tensor { self.unary(10) } // ReLU² (BitNet FFN)
185    pub fn scalar(&self, s: f32) -> Tensor { Tensor::from_vec(&self.ctx, &[s], &[1]) }
186
187    // ---- fused transformer fast-paths (same result as composing primitives, fewer dispatches) ----
188    /// Softmax over `axis` (fused: per-row max/exp/sum/div in one kernel).
189    pub fn softmax(&self, axis: usize) -> Tensor {
190        let r = self.rank();
191        let mut perm: Vec<usize> = (0..r).collect();
192        perm.remove(axis);
193        perm.push(axis);
194        let p = self.permute(&perm).contiguous();
195        let d = p.shape[r - 1];
196        let rows = p.numel() / d;
197        let out = empty(&self.ctx, p.numel());
198        run(&self.ctx, SOFTMAX_WGSL, "softmax", &[p.buf.as_ref(), &out, &u32buf(&self.ctx, &[rows as u32, d as u32])], groups(rows));
199        let sm = Tensor::from_parts(&self.ctx, out, p.shape.clone());
200        let mut inv = vec![0usize; r];
201        for (i, &pp) in perm.iter().enumerate() { inv[pp] = i; }
202        sm.permute(&inv).contiguous()
203    }
204    /// RMSNorm over the last dim: x/sqrt(mean(x²)+eps)·weight (fused).
205    pub fn rmsnorm(&self, weight: &Tensor, eps: f32) -> Tensor {
206        let c = self.contiguous();
207        let d = *c.shape.last().unwrap();
208        let rows = c.numel() / d;
209        let out = empty(&self.ctx, c.numel());
210        run(&self.ctx, RMSNORM_WGSL, "rmsnorm", &[c.buf.as_ref(), weight.contiguous().buf.as_ref(), &out, &u32buf(&self.ctx, &[rows as u32, d as u32, eps.to_bits()])], groups(rows));
211        Tensor::from_parts(&self.ctx, out, c.shape.clone())
212    }
213    /// Rotary position embedding (NeoX rotate-half) on a [T, n_heads·head_dim] tensor.
214    pub fn rope(&self, n_heads: usize, head_dim: usize, base: f32, offset: usize) -> Tensor {
215        let c = self.contiguous();
216        let t = c.numel() / (n_heads * head_dim);
217        let out = empty(&self.ctx, c.numel());
218        run(&self.ctx, ROPE_WGSL, "rope", &[c.buf.as_ref(), &out, &u32buf(&self.ctx, &[t as u32, n_heads as u32, head_dim as u32, base.to_bits(), offset as u32])], groups(t * n_heads));
219        Tensor::from_parts(&self.ctx, out, c.shape.clone())
220    }
221    /// 3D rotary position embedding (V-JEPA 2): head_dim split into 3 groups (temporal/height/width),
222    /// each rotated by the token's coordinate along that axis. self is [T, n_heads·head_dim], T=gt·gh·gw.
223    pub fn rope_3d(&self, n_heads: usize, head_dim: usize, base: f32, gt: usize, gh: usize, gw: usize) -> Tensor {
224        let c = self.contiguous();
225        let t = c.numel() / (n_heads * head_dim);
226        assert_eq!(t, gt * gh * gw, "T must equal gt·gh·gw");
227        assert_eq!(head_dim % 6, 0, "head_dim must be divisible by 6 for 3D RoPE");
228        let out = empty(&self.ctx, c.numel());
229        run(&self.ctx, ROPE_3D_WGSL, "rope3d", &[c.buf.as_ref(), &out, &u32buf(&self.ctx, &[t as u32, n_heads as u32, head_dim as u32, gt as u32, gh as u32, gw as u32, base.to_bits()])], groups(t * n_heads));
230        Tensor::from_parts(&self.ctx, out, c.shape.clone())
231    }
232
233    /// Causal depthwise conv1d — the LFM2 / Liquid AI short-conv mixer. self is [T, C] (sequence ×
234    /// channels), weight is [C, L] (per-channel kernel of length L). Causal: out[t] sees only t-L+1..t.
235    pub fn depthwise_conv1d_causal(&self, weight: &Tensor, l: usize) -> Tensor {
236        let c = self.contiguous();
237        let (t, ch) = (c.shape[0], c.shape[1]);
238        let out = empty(&self.ctx, t * ch);
239        run(&self.ctx, CONV1D_WGSL, "conv1d", &[c.buf.as_ref(), weight.contiguous().buf.as_ref(), &out, &u32buf(&self.ctx, &[t as u32, ch as u32, l as u32, 0])], groups(t * ch));
240        Tensor::from_parts(&self.ctx, out, vec![t, ch])
241    }
242
243    /// y = x·Wᵀ where x is [rows,in] and W is stored [out,in] (HF linear convention) — computed
244    /// directly, without materializing Wᵀ. Essential for big tied LM heads (avoids a huge transpose).
245    pub fn matmul_bt(&self, w: &Tensor) -> Tensor {
246        let x = self.contiguous();
247        assert_eq!(x.rank(), 2, "matmul_bt is 2D");
248        let (rows, inn) = (x.shape[0], x.shape[1]);
249        let wc = w.contiguous();
250        let out_f = wc.shape[0];
251        assert_eq!(inn, wc.shape[1], "inner dims mismatch");
252        let out = empty(&self.ctx, rows * out_f);
253        run(&self.ctx, MATMUL_BT_WGSL, "matmul_bt", &[x.buf.as_ref(), wc.buf.as_ref(), &out, &u32buf(&self.ctx, &[rows as u32, out_f as u32, inn as u32])], groups(rows * out_f));
254        Tensor::from_parts(&self.ctx, out, vec![rows, out_f])
255    }
256
257    /// y = act(x·Wᵀ) — a linear projection with the activation fused into the matmul epilogue (one
258    /// kernel, no intermediate). act: 0 identity, 1 relu, 2 silu, 3 gelu, 4 sigmoid. Every gated FFN
259    /// (silu(x·Wgateᵀ)) and every relu/gelu MLP hidden layer collapses to a single dispatch.
260    pub fn matmul_bt_act(&self, w: &Tensor, act: u32) -> Tensor {
261        let x = self.contiguous();
262        assert_eq!(x.rank(), 2, "matmul_bt_act is 2D");
263        let (rows, inn) = (x.shape[0], x.shape[1]);
264        let wc = w.contiguous();
265        let out_f = wc.shape[0];
266        let out = empty(&self.ctx, rows * out_f);
267        run(&self.ctx, MATMUL_BT_ACT_WGSL, "matmul_bt_act", &[x.buf.as_ref(), wc.buf.as_ref(), &out, &u32buf(&self.ctx, &[rows as u32, out_f as u32, inn as u32, act])], groups(rows * out_f));
268        Tensor::from_parts(&self.ctx, out, vec![rows, out_f])
269    }
270
271    /// Row gather (embedding lookup): self is a [vocab, d] table; returns [idx.len(), d].
272    pub fn gather_rows(&self, idx: &[u32]) -> Tensor {
273        let d = *self.shape.last().unwrap();
274        let c = self.contiguous();
275        let out = empty(&self.ctx, idx.len() * d);
276        let idxbuf = u32buf(&self.ctx, idx);
277        run(&self.ctx, GATHER_ROWS_WGSL, "gather_rows", &[c.buf.as_ref(), &idxbuf, &out, &u32buf(&self.ctx, &[idx.len() as u32, d as u32])], groups(idx.len() * d));
278        Tensor::from_parts(&self.ctx, out, vec![idx.len(), d])
279    }
280    pub(crate) fn ctx_arc(&self) -> Arc<Context> { self.ctx.clone() }
281
282    // ---- general reduction over arbitrary axes ----
283    fn reduce(&self, axes: &[usize], op: u32, keepdim: bool) -> Tensor {
284        let mut ax: Vec<usize> = axes.to_vec();
285        ax.sort_unstable();
286        ax.dedup();
287        let keep: Vec<usize> = (0..self.rank()).filter(|d| !ax.contains(d)).collect();
288        // permute reduced axes to the end, materialize → [outer, red]
289        let perm: Vec<usize> = keep.iter().chain(ax.iter()).copied().collect();
290        let moved = self.permute(&perm).contiguous();
291        let red: usize = ax.iter().map(|&d| self.shape[d]).product();
292        let outer: usize = moved.numel() / red.max(1);
293        let out = empty(&self.ctx, outer);
294        run(&self.ctx, REDUCE_WGSL, "reduce", &[&moved.buf, &out, &u32buf(&self.ctx, &[outer as u32, red as u32, op])], groups(outer));
295        let mut oshape: Vec<usize> = keep.iter().map(|&d| self.shape[d]).collect();
296        if keepdim {
297            oshape = (0..self.rank()).map(|d| if ax.contains(&d) { 1 } else { self.shape[d] }).collect();
298        }
299        if oshape.is_empty() { oshape.push(1); }
300        Tensor { ctx: self.ctx.clone(), buf: Arc::new(out), strides: contig_strides(&oshape), shape: oshape, offset: 0 }
301    }
302    pub fn sum(&self, axes: &[usize], keepdim: bool) -> Tensor { self.reduce(axes, 0, keepdim) }
303    pub fn max(&self, axes: &[usize], keepdim: bool) -> Tensor { self.reduce(axes, 1, keepdim) }
304    pub fn mean(&self, axes: &[usize], keepdim: bool) -> Tensor {
305        let n: usize = axes.iter().map(|&d| self.shape[d]).product();
306        let s = self.sum(axes, keepdim);
307        let inv = Tensor::from_vec(&self.ctx, &[1.0 / n as f32], &[1]);
308        s.mul(&inv)
309    }
310
311    // ---- batched matmul: [..., m, k] x [..., k, n] -> [..., m, n], batch dims broadcast ----
312    pub fn matmul(&self, other: &Tensor) -> Tensor {
313        let (ra, rb) = (self.rank(), other.rank());
314        assert!(ra >= 2 && rb >= 2, "matmul needs rank >= 2");
315        let (m, ka) = (self.shape[ra - 2], self.shape[ra - 1]);
316        let (kb, n) = (other.shape[rb - 2], other.shape[rb - 1]);
317        assert_eq!(ka, kb, "matmul inner dims {ka} != {kb}");
318        let batch_a = &self.shape[..ra - 2];
319        let batch_b = &other.shape[..rb - 2];
320        let batch = broadcast_shapes(batch_a, batch_b);
321        let bn: usize = numel(&batch);
322        let a_full: Vec<usize> = batch.iter().chain([m, ka].iter()).copied().collect();
323        let b_full: Vec<usize> = batch.iter().chain([kb, n].iter()).copied().collect();
324        let a = self.broadcast_to(&a_full).contiguous();
325        let b = other.broadcast_to(&b_full).contiguous();
326        let out = empty(&self.ctx, bn * m * n);
327        // Pick the GEMM kernel by what the autotuner measured fastest for this shape+device (naive vs
328        // register-blocked tiled). No single kernel wins on every GPU, so we select by measurement:
329        // on M5 Max/Metal that's naive (~587 GFLOP/s); on GPUs where tiling wins, it's tiled. Untuned
330        // shapes default to naive (never a regression). See `autotune_matmul` + docs/SOTA.md #1/#6.
331        let use_tiled = bn == 1 && gemm_choice(m, ka, n) == Gemm::Tiled;
332        if use_tiled {
333            let (gx, gy) = ((n as u32).div_ceil(64), (m as u32).div_ceil(64));
334            run(&self.ctx, TILED_MATMUL_WGSL, "mm_tiled", &[&a.buf, &b.buf, &out, &u32buf(&self.ctx, &[m as u32, ka as u32, n as u32, 0])], (gx, gy, 1));
335        } else {
336            run(&self.ctx, MATMUL_WGSL, "bmm", &[&a.buf, &b.buf, &out, &u32buf(&self.ctx, &[bn as u32, m as u32, ka as u32, n as u32])], groups(bn * m * n));
337        }
338        let oshape: Vec<usize> = batch.iter().chain([m, n].iter()).copied().collect();
339        Tensor { ctx: self.ctx.clone(), buf: Arc::new(out), strides: contig_strides(&oshape), shape: oshape, offset: 0 }
340    }
341
342    /// Autotune the GEMM kernel for this shape on this device: time naive vs tiled and cache the
343    /// winner (keyed by shape bucket). Subsequent `matmul`s of the same bucket use it. Returns the
344    /// choice. This is how GEMM stays fast *portably* — the winner differs across GPUs.
345    #[cfg(not(target_arch = "wasm32"))]
346    pub async fn autotune_matmul(&self, other: &Tensor) -> &'static str {
347        let (m, ka) = (self.shape[self.rank() - 2], self.shape[self.rank() - 1]);
348        let n = other.shape[other.rank() - 1];
349        let time = |f: &dyn Fn() -> Tensor| {
350            let t0 = std::time::Instant::now();
351            for _ in 0..8 { let _ = pollster::block_on(f().to_vec()); }
352            t0.elapsed()
353        };
354        let naive = time(&|| self.matmul_naive(other));
355        let tiled = time(&|| self.matmul_tiled(other));
356        let win = if tiled < naive { Gemm::Tiled } else { Gemm::Naive };
357        GEMM_CACHE.with(|c| c.borrow_mut().insert(gemm_bucket(m, ka, n), win));
358        if win == Gemm::Tiled { "tiled" } else { "naive" }
359    }
360
361    /// Force the register-blocked tiled 2D GEMM (for benchmarking / large matmuls).
362    pub fn matmul_tiled(&self, other: &Tensor) -> Tensor {
363        let (m, ka) = (self.shape[self.rank() - 2], self.shape[self.rank() - 1]);
364        let n = other.shape[other.rank() - 1];
365        let (a, b) = (self.contiguous(), other.contiguous());
366        let out = empty(&self.ctx, m * n);
367        let (gx, gy) = ((n as u32).div_ceil(64), (m as u32).div_ceil(64));
368        run(&self.ctx, TILED_MATMUL_WGSL, "mm_tiled", &[&a.buf, &b.buf, &out, &u32buf(&self.ctx, &[m as u32, ka as u32, n as u32, 0])], (gx, gy, 1));
369        Tensor::from_parts(&self.ctx, out, vec![m, n])
370    }
371
372    /// The naive (non-tiled) matmul, kept for benchmarking the tiled fast-path against.
373    pub fn matmul_naive(&self, other: &Tensor) -> Tensor {
374        let (ra, rb) = (self.rank(), other.rank());
375        let (m, ka) = (self.shape[ra - 2], self.shape[ra - 1]);
376        let n = other.shape[rb - 1];
377        let a = self.contiguous();
378        let b = other.contiguous();
379        let out = empty(&self.ctx, m * n);
380        run(&self.ctx, MATMUL_WGSL, "bmm", &[&a.buf, &b.buf, &out, &u32buf(&self.ctx, &[1, m as u32, ka as u32, n as u32])], groups(m * n));
381        Tensor::from_parts(&self.ctx, out, vec![m, n])
382    }
383}
384
385// ---------- device plumbing (uses ferric-core Context's public device/queue) ----------
386fn empty(ctx: &Context, n: usize) -> wgpu::Buffer {
387    ctx.device.create_buffer(&wgpu::BufferDescriptor {
388        label: Some("t"), size: (n.max(1) * 4) as u64,
389        usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
390        mapped_at_creation: false,
391    })
392}
393fn u32buf(ctx: &Context, data: &[u32]) -> wgpu::Buffer {
394    ctx.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
395        label: Some("info"), contents: bytemuck::cast_slice(data),
396        usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
397    })
398}
399pub(crate) fn unibuf(ctx: &Context, data: &[u32]) -> wgpu::Buffer {
400    ctx.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
401        label: Some("uinfo"), contents: bytemuck::cast_slice(data),
402        usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
403    })
404}
405fn groups(n: usize) -> (u32, u32, u32) { (((n as u32) + 63) / 64, 1, 1) }
406
407// Compile each WGSL kernel's pipeline ONCE and reuse it — recompiling every dispatch (as before)
408// dominated runtime for real workloads. Keyed by the kernel's &'static str address (stable per
409// kernel); assumes one device per thread, which holds for all Ferric usage. Every SOTA runtime
410// caches compiled kernels; this is the single biggest per-op overhead removed.
411thread_local! {
412    static PIPELINES: std::cell::RefCell<std::collections::HashMap<(usize, u64), wgpu::ComputePipeline>> =
413        std::cell::RefCell::new(std::collections::HashMap::new());
414    // Autotuner: shape-bucket → measured-fastest GEMM kernel for this device.
415    static GEMM_CACHE: std::cell::RefCell<std::collections::HashMap<(u32, u32, u32), Gemm>> =
416        std::cell::RefCell::new(std::collections::HashMap::new());
417}
418#[derive(Clone, Copy, PartialEq)]
419enum Gemm { Naive, Tiled }
420fn gemm_bucket(m: usize, k: usize, n: usize) -> (u32, u32, u32) {
421    let b = |x: usize| -> u32 { if x <= 128 { 128 } else if x <= 256 { 256 } else if x <= 512 { 512 } else { 1024 } };
422    (b(m), b(k), b(n))
423}
424fn gemm_choice(m: usize, k: usize, n: usize) -> Gemm {
425    GEMM_CACHE.with(|c| c.borrow().get(&gemm_bucket(m, k, n)).copied()).unwrap_or(Gemm::Naive)
426}
427fn pipeline_for(ctx: &Context, wgsl: &str, label: &str) -> wgpu::ComputePipeline {
428    // key by (device, content-hash): caches dynamically-generated fusion shaders too, and stays
429    // correct across multiple GPUs (a device-A pipeline is never reused on device B).
430    use std::hash::{Hash, Hasher};
431    let mut h = std::collections::hash_map::DefaultHasher::new();
432    wgsl.hash(&mut h);
433    let key = ((&ctx.device as *const wgpu::Device) as usize, h.finish());
434    PIPELINES.with(|c| {
435        c.borrow_mut().entry(key).or_insert_with(|| {
436            let module = ctx.device.create_shader_module(wgpu::ShaderModuleDescriptor {
437                label: Some(label), source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(wgsl)),
438            });
439            ctx.device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
440                label: Some(label), layout: None, module: &module, entry_point: Some("main"),
441                compilation_options: Default::default(), cache: None,
442            })
443        }).clone()
444    })
445}
446fn run(ctx: &Context, wgsl: &str, label: &str, binds: &[&wgpu::Buffer], g: (u32, u32, u32)) {
447    let pipe = pipeline_for(ctx, wgsl, label);
448    let entries: Vec<wgpu::BindGroupEntry> = binds.iter().enumerate()
449        .map(|(i, b)| wgpu::BindGroupEntry { binding: i as u32, resource: b.as_entire_binding() }).collect();
450    let bg = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
451        label: Some(label), layout: &pipe.get_bind_group_layout(0), entries: &entries,
452    });
453    let mut enc = ctx.device.create_command_encoder(&Default::default());
454    {
455        let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some(label), timestamp_writes: None });
456        pass.set_pipeline(&pipe);
457        pass.set_bind_group(0, &bg, &[]);
458        pass.dispatch_workgroups(g.0, g.1, g.2);
459    }
460    ctx.queue.submit([enc.finish()]);
461}
462async fn readback(ctx: &Context, buf: &wgpu::Buffer, n: usize) -> Vec<f32> {
463    let bytes = (n * 4) as u64;
464    let staging = ctx.device.create_buffer(&wgpu::BufferDescriptor {
465        label: Some("staging"), size: bytes, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false,
466    });
467    let mut enc = ctx.device.create_command_encoder(&Default::default());
468    enc.copy_buffer_to_buffer(buf, 0, &staging, 0, bytes);
469    ctx.queue.submit([enc.finish()]);
470    let (tx, rx) = flume::bounded(1);
471    staging.slice(..).map_async(wgpu::MapMode::Read, move |r| { let _ = tx.send(r); });
472    let _ = ctx.device.poll(wgpu::PollType::wait_indefinitely());
473    rx.recv_async().await.unwrap().unwrap();
474    let data = staging.slice(..).get_mapped_range().unwrap();
475    let out = bytemuck::cast_slice(&data).to_vec();
476    drop(data);
477    staging.unmap();
478    out
479}
480
481// ---------- general kernels ----------
482// row-major decode of a linear output index into per-input strided offsets.
483const BINARY_WGSL: &str = r#"
484@group(0) @binding(0) var<storage,read>        a: array<f32>;
485@group(0) @binding(1) var<storage,read>        b: array<f32>;
486@group(0) @binding(2) var<storage,read_write>  out: array<f32>;
487@group(0) @binding(3) var<storage,read>        info: array<u32>; // rank,op,n,offA,offB,shape[r],aStr[r],bStr[r]
488@compute @workgroup_size(64)
489fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
490    let i = gid.x; let rank = info[0]; let op = info[1]; let n = info[2];
491    if (i >= n) { return; }
492    var ia = info[3]; var ib = info[4]; var rem = i;
493    for (var dd: u32 = 0u; dd < rank; dd = dd + 1u) {
494        let d = rank - 1u - dd;
495        let sz = info[5u + d];
496        let idx = rem % sz; rem = rem / sz;
497        ia = ia + idx * info[5u + rank + d];
498        ib = ib + idx * info[5u + 2u * rank + d];
499    }
500    let x = a[ia]; let y = b[ib];
501    var r: f32 = 0.0;
502    switch (op) {
503        case 0u: { r = x + y; }
504        case 1u: { r = x - y; }
505        case 2u: { r = x * y; }
506        case 3u: { r = x / y; }
507        case 4u: { r = max(x, y); }
508        default: { r = x + y; }
509    }
510    out[i] = r;
511}
512"#;
513
514const UNARY_WGSL: &str = r#"
515@group(0) @binding(0) var<storage,read>        x: array<f32>;
516@group(0) @binding(1) var<storage,read_write>  out: array<f32>;
517@group(0) @binding(2) var<storage,read>        info: array<u32>; // op, n
518@compute @workgroup_size(64)
519fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
520    let i = gid.x; if (i >= info[1]) { return; }
521    let v = x[i]; var r: f32 = v;
522    switch (info[0]) {
523        case 0u: { r = exp(v); }
524        case 1u: { r = -v; }
525        case 2u: { r = max(v, 0.0); }
526        case 3u: { r = sqrt(v); }
527        case 4u: { if (v > 0.0) { r = 1.0; } else { r = 0.0; } }
528        case 5u: { r = abs(v); }
529        case 6u: { r = 1.0 / (1.0 + exp(-v)); }
530        case 7u: { r = v / (1.0 + exp(-v)); }
531        case 8u: {
532            let t = 1.0 / (1.0 + 0.3275911 * abs(v * 0.7071067811865476));
533            let e = 1.0 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * exp(-(v * 0.7071067811865476) * (v * 0.7071067811865476));
534            let erf = select(-e, e, v >= 0.0);
535            r = 0.5 * v * (1.0 + erf);
536        }
537        case 9u: { r = log(v); }
538        case 10u: { let z = max(v, 0.0); r = z * z; } // ReLU² (BitNet FFN)
539        default: { r = v; }
540    }
541    out[i] = r;
542}
543"#;
544
545const GATHER_WGSL: &str = r#"
546@group(0) @binding(0) var<storage,read>        x: array<f32>;
547@group(0) @binding(1) var<storage,read_write>  out: array<f32>;
548@group(0) @binding(2) var<storage,read>        info: array<u32>; // rank,n,offset,shape[r],strides[r]
549@compute @workgroup_size(64)
550fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
551    let i = gid.x; let rank = info[0]; let n = info[1];
552    if (i >= n) { return; }
553    var src = info[2]; var rem = i;
554    for (var dd: u32 = 0u; dd < rank; dd = dd + 1u) {
555        let d = rank - 1u - dd;
556        let sz = info[3u + d];
557        let idx = rem % sz; rem = rem / sz;
558        src = src + idx * info[3u + rank + d];
559    }
560    out[i] = x[src];
561}
562"#;
563
564const REDUCE_WGSL: &str = r#"
565@group(0) @binding(0) var<storage,read>        x: array<f32>;   // [outer, red] contiguous
566@group(0) @binding(1) var<storage,read_write>  out: array<f32>; // [outer]
567@group(0) @binding(2) var<storage,read>        info: array<u32>; // outer, red, op(0=sum,1=max)
568@compute @workgroup_size(64)
569fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
570    let i = gid.x; let outer = info[0]; let red = info[1]; let op = info[2];
571    if (i >= outer) { return; }
572    let base = i * red;
573    if (op == 1u) {
574        var acc = x[base];
575        for (var j: u32 = 1u; j < red; j = j + 1u) { acc = max(acc, x[base + j]); }
576        out[i] = acc;
577    } else {
578        var acc = 0.0;
579        for (var j: u32 = 0u; j < red; j = j + 1u) { acc = acc + x[base + j]; }
580        out[i] = acc;
581    }
582}
583"#;
584
585const SOFTMAX_WGSL: &str = r#"
586@group(0) @binding(0) var<storage,read>        x: array<f32>;
587@group(0) @binding(1) var<storage,read_write>  out: array<f32>;
588@group(0) @binding(2) var<storage,read>        info: array<u32>; // rows, d
589@compute @workgroup_size(64)
590fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
591    let row = gid.x; let rows = info[0]; let d = info[1];
592    if (row >= rows) { return; }
593    let base = row * d;
594    var mx = x[base];
595    for (var j: u32 = 1u; j < d; j = j + 1u) { mx = max(mx, x[base + j]); }
596    var sum = 0.0;
597    for (var j: u32 = 0u; j < d; j = j + 1u) { let e = exp(x[base + j] - mx); out[base + j] = e; sum = sum + e; }
598    let inv = 1.0 / sum;
599    for (var j: u32 = 0u; j < d; j = j + 1u) { out[base + j] = out[base + j] * inv; }
600}
601"#;
602
603const RMSNORM_WGSL: &str = r#"
604@group(0) @binding(0) var<storage,read>        x: array<f32>;
605@group(0) @binding(1) var<storage,read>        weight: array<f32>;
606@group(0) @binding(2) var<storage,read_write>  out: array<f32>;
607@group(0) @binding(3) var<storage,read>        info: array<u32>; // rows, d, bitcast(eps)
608@compute @workgroup_size(64)
609fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
610    let row = gid.x; let rows = info[0]; let d = info[1]; let eps = bitcast<f32>(info[2]);
611    if (row >= rows) { return; }
612    let base = row * d;
613    var ms = 0.0;
614    for (var j: u32 = 0u; j < d; j = j + 1u) { let v = x[base + j]; ms = ms + v * v; }
615    let inv = 1.0 / sqrt(ms / f32(d) + eps);
616    for (var j: u32 = 0u; j < d; j = j + 1u) { out[base + j] = x[base + j] * inv * weight[j]; }
617}
618"#;
619
620const ROPE_WGSL: &str = r#"
621@group(0) @binding(0) var<storage,read>        x: array<f32>;
622@group(0) @binding(1) var<storage,read_write>  out: array<f32>;
623@group(0) @binding(2) var<storage,read>        info: array<u32>; // t, h, dh, bitcast(base), offset
624@compute @workgroup_size(64)
625fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
626    let t = info[0]; let h = info[1]; let dh = info[2]; let base = bitcast<f32>(info[3]); let off = info[4];
627    let id = gid.x; if (id >= t * h) { return; }
628    let i = id / h; let head = id % h; let half = dh / 2u;
629    let o = (i * h + head) * dh; let lb = log(base);
630    for (var c: u32 = 0u; c < half; c = c + 1u) {
631        let inv = exp(-2.0 * f32(c) / f32(dh) * lb);
632        let ang = f32(i + off) * inv; let cs = cos(ang); let sn = sin(ang);
633        let x1 = x[o + c]; let x2 = x[o + c + half];
634        out[o + c] = x1 * cs - x2 * sn;
635        out[o + c + half] = x2 * cs + x1 * sn;
636    }
637}
638"#;
639
640const ROPE_3D_WGSL: &str = r#"
641@group(0) @binding(0) var<storage,read>        x: array<f32>;
642@group(0) @binding(1) var<storage,read_write>  out: array<f32>;
643@group(0) @binding(2) var<storage,read>        info: array<u32>; // T, H, dh, gt, gh, gw, bitcast(base)
644@compute @workgroup_size(64)
645fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
646    let tt = info[0]; let h = info[1]; let dh = info[2];
647    let gt = info[3]; let gh = info[4]; let gw = info[5]; let base = bitcast<f32>(info[6]);
648    let id = gid.x; if (id >= tt * h) { return; }
649    let t = id / h; let head = id % h;
650    var co = array<u32, 3>(t / (gh * gw), (t / gw) % gh, t % gw); // (it, ih, iw)
651    let g = dh / 3u; let half = g / 2u; let lb = log(base);
652    for (var gi: u32 = 0u; gi < 3u; gi = gi + 1u) {
653        let coord = f32(co[gi]);
654        let off = (t * h + head) * dh + gi * g;
655        for (var c: u32 = 0u; c < half; c = c + 1u) {
656            let inv = exp(-2.0 * f32(c) / f32(g) * lb);
657            let ang = coord * inv; let cs = cos(ang); let sn = sin(ang);
658            let x1 = x[off + c]; let x2 = x[off + c + half];
659            out[off + c] = x1 * cs - x2 * sn;
660            out[off + c + half] = x2 * cs + x1 * sn;
661        }
662    }
663}
664"#;
665
666const CONV1D_WGSL: &str = r#"
667@group(0) @binding(0) var<storage,read>        x: array<f32>;      // [T, C]
668@group(0) @binding(1) var<storage,read>        w: array<f32>;      // [C, L]
669@group(0) @binding(2) var<storage,read_write>  out: array<f32>;    // [T, C]
670@group(0) @binding(3) var<storage,read>        info: array<u32>;   // T, C, L
671@compute @workgroup_size(64)
672fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
673    let idx = gid.x; let t = info[0]; let ch = info[1]; let l = info[2];
674    if (idx >= t * ch) { return; }
675    let row = idx / ch; let c = idx % ch;
676    var acc = 0.0;
677    for (var k: u32 = 0u; k < l; k = k + 1u) {
678        // causal: source position = row - (L-1) + k
679        let off = i32(row) - i32(l) + 1 + i32(k);
680        if (off >= 0) { acc = acc + w[c * l + k] * x[u32(off) * ch + c]; }
681    }
682    out[idx] = acc;
683}
684"#;
685
686const MATMUL_BT_ACT_WGSL: &str = r#"
687@group(0) @binding(0) var<storage,read>        x: array<f32>;    // [rows, in]
688@group(0) @binding(1) var<storage,read>        w: array<f32>;    // [out, in]
689@group(0) @binding(2) var<storage,read_write>  out: array<f32>;  // [rows, out]
690@group(0) @binding(3) var<storage,read>        info: array<u32>; // rows, out, in, act
691fn act(v: f32, a: u32) -> f32 {
692    switch (a) {
693        case 1u: { return max(v, 0.0); }
694        case 2u: { return v / (1.0 + exp(-v)); }
695        case 3u: {
696            let t = 1.0 / (1.0 + 0.3275911 * abs(v * 0.7071067811865476));
697            let e = 1.0 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * exp(-(v * 0.7071067811865476) * (v * 0.7071067811865476));
698            let erf = select(-e, e, v >= 0.0);
699            return 0.5 * v * (1.0 + erf);
700        }
701        case 4u: { return 1.0 / (1.0 + exp(-v)); }
702        default: { return v; }
703    }
704}
705@compute @workgroup_size(64)
706fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
707    let idx = gid.x; let rows = info[0]; let o_dim = info[1]; let in_dim = info[2];
708    if (idx >= rows * o_dim) { return; }
709    let o = idx % o_dim; let r = idx / o_dim;
710    var acc = 0.0;
711    for (var c: u32 = 0u; c < in_dim; c = c + 1u) { acc = acc + x[r * in_dim + c] * w[o * in_dim + c]; }
712    out[idx] = act(acc, info[3]);
713}
714"#;
715
716const MATMUL_BT_WGSL: &str = r#"
717@group(0) @binding(0) var<storage,read>        x: array<f32>;    // [rows, in]
718@group(0) @binding(1) var<storage,read>        w: array<f32>;    // [out, in]  (HF layout)
719@group(0) @binding(2) var<storage,read_write>  out: array<f32>;  // [rows, out]
720@group(0) @binding(3) var<storage,read>        info: array<u32>; // rows, out, in
721@compute @workgroup_size(64)
722fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
723    let idx = gid.x; let rows = info[0]; let o_dim = info[1]; let in_dim = info[2];
724    if (idx >= rows * o_dim) { return; }
725    let o = idx % o_dim; let r = idx / o_dim;
726    var acc = 0.0;
727    for (var c: u32 = 0u; c < in_dim; c = c + 1u) { acc = acc + x[r * in_dim + c] * w[o * in_dim + c]; }
728    out[idx] = acc;
729}
730"#;
731
732const GATHER_ROWS_WGSL: &str = r#"
733@group(0) @binding(0) var<storage,read>        table: array<f32>;
734@group(0) @binding(1) var<storage,read>        idx: array<u32>;
735@group(0) @binding(2) var<storage,read_write>  out: array<f32>;
736@group(0) @binding(3) var<storage,read>        info: array<u32>; // n, d
737@compute @workgroup_size(64)
738fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
739    let n = info[0]; let d = info[1]; let t = gid.x;
740    if (t >= n * d) { return; }
741    let i = t / d; let j = t % d;
742    out[i * d + j] = table[idx[i] * d + j];
743}
744"#;
745
746// High-intensity register-blocked GEMM: an 8×8 workgroup (64 threads) computes a 64×64 output tile,
747// EACH THREAD an 8×8 micro-tile held in 64 registers. Per K-step every thread loads 8 A-values and
748// 8 B-values from shared memory and does 64 FMAs — arithmetic intensity 4× the 4×4 version, which is
749// what lifts a WebGPU GEMM toward the >1 TFLOP tier (vs. a cache-friendly naive kernel). BM=BN=64,
750// BK=8, TM=TN=8, 64 threads.
751const TILED_MATMUL_WGSL: &str = r#"
752@group(0) @binding(0) var<storage,read>        a: array<f32>;   // [M,K]
753@group(0) @binding(1) var<storage,read>        b: array<f32>;   // [K,N]
754@group(0) @binding(2) var<storage,read_write>  out: array<f32>; // [M,N]
755@group(0) @binding(3) var<storage,read>        info: array<u32>; // M,K,N
756var<workgroup> As: array<f32, 512>; // 64×8
757var<workgroup> Bs: array<f32, 512>; // 8×64
758@compute @workgroup_size(8, 8, 1)
759fn main(@builtin(local_invocation_id) lid: vec3<u32>, @builtin(workgroup_id) wid: vec3<u32>) {
760    let m = info[0]; let k = info[1]; let n = info[2];
761    let row0 = wid.y * 64u; let col0 = wid.x * 64u;
762    let li = lid.y * 8u + lid.x;             // 0..63
763    let tr = lid.y * 8u; let tc = lid.x * 8u; // this thread's 8×8 micro-tile origin within the 64×64 tile
764    var acc: array<f32, 64>;
765    for (var i = 0u; i < 64u; i++) { acc[i] = 0.0; }
766    let ntiles = (k + 7u) / 8u;
767    for (var t = 0u; t < ntiles; t++) {
768        // stage A[64×8] and B[8×64] into shared memory (64 threads × 8 elems each)
769        for (var e = 0u; e < 8u; e++) {
770            let ia = li + e * 64u; let ar = ia / 8u; let ak = ia % 8u;
771            let gr = row0 + ar; let gk = t * 8u + ak;
772            As[ia] = select(0.0, a[gr * k + gk], gr < m && gk < k);
773            let bk = ia / 64u; let bc = ia % 64u;
774            let gk2 = t * 8u + bk; let gc = col0 + bc;
775            Bs[ia] = select(0.0, b[gk2 * n + gc], gk2 < k && gc < n);
776        }
777        workgroupBarrier();
778        for (var kk = 0u; kk < 8u; kk++) {
779            var ra: array<f32, 8>; var rb: array<f32, 8>;
780            for (var i = 0u; i < 8u; i++) { ra[i] = As[(tr + i) * 8u + kk]; rb[i] = Bs[kk * 64u + tc + i]; }
781            for (var i = 0u; i < 8u; i++) { for (var j = 0u; j < 8u; j++) { acc[i * 8u + j] = acc[i * 8u + j] + ra[i] * rb[j]; } }
782        }
783        workgroupBarrier();
784    }
785    for (var i = 0u; i < 8u; i++) {
786        for (var j = 0u; j < 8u; j++) {
787            let r = row0 + tr + i; let c = col0 + tc + j;
788            if (r < m && c < n) { out[r * n + c] = acc[i * 8u + j]; }
789        }
790    }
791}
792"#;
793
794const MATMUL_WGSL: &str = r#"
795@group(0) @binding(0) var<storage,read>        a: array<f32>;   // [batch, m, k]
796@group(0) @binding(1) var<storage,read>        b: array<f32>;   // [batch, k, n]
797@group(0) @binding(2) var<storage,read_write>  out: array<f32>; // [batch, m, n]
798@group(0) @binding(3) var<storage,read>        info: array<u32>; // batch, m, k, n
799@compute @workgroup_size(64)
800fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
801    let idx = gid.x; let batch = info[0]; let m = info[1]; let k = info[2]; let n = info[3];
802    if (idx >= batch * m * n) { return; }
803    let j = idx % n; let i = (idx / n) % m; let bt = idx / (m * n);
804    let ao = bt * m * k + i * k; let bo = bt * k * n;
805    var acc = 0.0;
806    for (var l: u32 = 0u; l < k; l = l + 1u) { acc = acc + a[ao + l] * b[bo + l * n + j]; }
807    out[idx] = acc;
808}
809"#;