Skip to main content

inferencelayer/
encoder.rs

1//! Encoder GPU kernels (WGSL) — the stateless full-sequence compute path for embedding models.
2//!
3//! Distinct from the decoder kernels in `forward.rs` by design: encoders are single-shot over a
4//! known ragged batch (no KV pool, no block tables, no ring buffers), weights are f16/f32 (never
5//! Q4 — encoder models are small and precision-sensitive), and attention is a mask MODE
6//! (bidirectional / causal / local-window) over exact `seq_starts` ranges rather than paged
7//! causal decode. Every kernel here is parity-tested against the `encoder_cpu` oracle
8//! (tolerance-gated, never bitwise — see `tests/encoder_kernels.rs`).
9
10use crate::GpuCtx;
11use crate::encoder_weights::{Act, MaskKind};
12use crate::forward::ShaderModuleTuned as _;
13use crate::forward::{make_bg, pipeline, uni};
14use anyhow::Result;
15
16/// GEMM epilogue activation codes (uniform `flags >> 8`); `0` = none.
17pub(crate) fn act_code(act: Option<Act>) -> u32 {
18    match act {
19        None => 0,
20        Some(Act::GeluErf) => 1,
21        Some(Act::GeluTanh) => 2,
22        Some(Act::Silu) => 3,
23        Some(Act::Tanh) => 4,
24        Some(Act::Relu) => 5,
25    }
26}
27
28/// WGSL epilogue shared by both GEMM variants: A&S 7.1.26 erf (no WGSL intrinsic exists; the CPU
29/// oracle uses exact `erff`, the difference sits inside the parity tolerance band).
30const ACT_FNS: &str = r#"
31fn erf_as(x: f32) -> f32 {
32    let s = sign(x);
33    let a = abs(x);
34    let t = 1.0 / (1.0 + 0.3275911 * a);
35    let y = 1.0 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t
36        + 0.254829592) * t * exp(-a * a);
37    return s * y;
38}
39fn apply_act(v: f32, code: u32) -> f32 {
40    switch code {
41        case 1u: { return 0.5 * v * (1.0 + erf_as(v * 0.70710678)); }
42        // tanh-GELU. The argument is CLAMPED, exactly as the decoder's kernels already do
43        // (forward.rs / lib.rs) — WGSL's `tanh` overflows to NaN on some backends for |arg| >~ 88,
44        // because a naive (e^2x - 1)/(e^2x + 1) expansion divides inf by inf. `arg` reaches 105 at a
45        // pre-activation of only 13.8, which the text encoders never hit and a ViT's outlier tokens
46        // hit on the first block. tanh is ±1 to well inside f32 epsilon by |arg| = 20, so this is
47        // EXACT, not an approximation.
48        case 2u: {
49            let a = clamp(0.7978845608 * (v + 0.044715 * v * v * v), -20.0, 20.0);
50            return 0.5 * v * (1.0 + tanh(a));
51        }
52        case 3u: { return v / (1.0 + exp(-v)); }
53        case 4u: { return tanh(v); }
54        case 5u: { return max(v, 0.0); }
55        default: { return v; }
56    }
57}
58"#;
59
60/// Tiled GEMM `y[m,n] = act(x[m,k] · Wᵀ + bias)`, `W` row-major `[n,k]` (HF Linear layout) in
61/// f16 or f32. 16×16 output tile, 16-deep shared K-tiles, f32 accumulate.
62pub(crate) fn enc_gemm_src(f16: bool) -> String {
63    let (enable, wty) = if f16 {
64        ("enable f16;", "f16")
65    } else {
66        ("", "f32")
67    };
68    format!(
69        r#"{enable}
70struct Meta {{ m: u32, n: u32, k: u32, flags: u32 }}
71@group(0) @binding(0) var<storage, read> x: array<f32>;
72@group(0) @binding(1) var<storage, read> w: array<{wty}>;
73@group(0) @binding(2) var<storage, read> bias: array<f32>;
74@group(0) @binding(3) var<storage, read_write> y: array<f32>;
75@group(0) @binding(4) var<uniform> mt: Meta;
76var<workgroup> xt: array<f32, 256>;
77var<workgroup> wt: array<f32, 256>;
78{ACT_FNS}
79@compute @workgroup_size(16, 16)
80fn main(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_id) li: vec3<u32>) {{
81    let row = wg.y * 16u + li.y;
82    let col = wg.x * 16u + li.x;
83    var acc = 0.0;
84    let ktiles = (mt.k + 15u) / 16u;
85    for (var kb = 0u; kb < ktiles; kb++) {{
86        let kx = kb * 16u + li.x;
87        var xv = 0.0;
88        if (row < mt.m && kx < mt.k) {{ xv = x[row * mt.k + kx]; }}
89        xt[li.y * 16u + li.x] = xv;
90        let kw = kb * 16u + li.y;
91        var wv = 0.0;
92        if (col < mt.n && kw < mt.k) {{ wv = f32(w[col * mt.k + kw]); }}
93        wt[li.y * 16u + li.x] = wv;
94        workgroupBarrier();
95        for (var kk = 0u; kk < 16u; kk++) {{
96            acc += xt[li.y * 16u + kk] * wt[kk * 16u + li.x];
97        }}
98        workgroupBarrier();
99    }}
100    if (row < mt.m && col < mt.n) {{
101        if ((mt.flags & 1u) != 0u) {{ acc += bias[col]; }}
102        y[row * mt.n + col] = apply_act(acc, mt.flags >> 8u);
103    }}
104}}
105"#
106    )
107}
108
109/// **GEMM v2** — the same contract as [`enc_gemm_src`] (`y[m,n] = act(x[m,k] · Wᵀ + bias)`, same
110/// bindings, same `Meta`), register-blocked.
111///
112/// v1 gives each thread ONE output and reads two shared values per MAC — 0.5 MAC/read, which is why
113/// it measured ~160 GFLOP/s on an M4 Max whose shader ALUs can do ~14 TFLOP/s. Every production
114/// encoder shape (M ∈ {64,192,512,2300} × K/N ∈ {384..3072}) is ALU/occupancy-bound, not
115/// weight-bandwidth-bound, so the lever is arithmetic intensity per shared read, not bandwidth
116/// tricks.
117///
118/// Here a 16×16 workgroup (256 threads) computes a `BM × BN` tile, so each thread owns a
119/// `TM × 4` register block:
120///
121/// ```text
122///   per k-step:  a = vec_TM(xs[row_i][kk])      TM reads
123///                b = vec4  (ws[kk][col_0..3])    4 reads
124///                acc_i += a[i] * b               TM×4 MACs
125/// ```
126///
127/// wide (BM=64): 16 MACs / 8 reads = **2.0 MAC/read** — 4× v1.
128/// skinny (BM=32): 8 MACs / 6 reads = 1.33 MAC/read, but **twice the workgroups** at small M,
129/// which is what a 64-token BioLORD call actually needs (M=64,N=768 → 12 workgroups under wide,
130/// 24 under skinny, on a ~40-core GPU).
131///
132/// Both stage cooperatively and **coalesced**: `x` is `[m,k]` and `w` is `[n,k]` row-major, so
133/// consecutive threads read consecutive `k` — `ws` is written TRANSPOSED into shared so the inner
134/// loop broadcasts one `ws` row across the 16 column-threads. The k-loop body is emitted UNROLLED
135/// from Rust and the accumulators are named `vec4`s, never a dynamically-indexed array: an indexed
136/// private array lands in DRAM scratch (the engine's "4.2× GEMM lesson", `forward.rs`).
137///
138/// f16 weights are storage-only (f32 x, f32 accumulate) — the precision contract of v1, so every
139/// parity tolerance carries over unchanged.
140pub fn enc_gemm2_src(f16: bool, bm: usize, bn: usize) -> String {
141    let (enable, wty) = if f16 {
142        ("enable f16;", "f16")
143    } else {
144        ("", "f32")
145    };
146    const BK: usize = 16;
147    let tm = bm / 16; // rows per thread
148    let cpt = bn / 16; // COLUMNS per thread: 4 → a vec4 block, 2 → a vec2
149    let x_iters = bm * BK / 256; // cooperative-load rounds for the x tile
150    let w_iters = bn / 16; // ditto for w (16 threads cover 16 columns per round)
151    let bn_ = bn;
152    debug_assert!(tm >= 1 && bm.is_multiple_of(16) && x_iters * 256 == bm * BK);
153    debug_assert!(
154        matches!(cpt, 2 | 4 | 8),
155        "cols/thread must be 2, 4 or 8, got {cpt}"
156    );
157
158    // Two emitters. `cpt <= 4` is the ORIGINAL, emitted byte-for-byte as before (a test pins the
159    // (64,64) kernel's structure, and the tiles it serves are unchanged). `cpt == 8` is the wide
160    // tile: two vec4 column blocks per row, with the `xs` read HOISTED so one shared read feeds both
161    // — that hoist is the entire point, since it is what lifts arithmetic intensity from 2 to 4.
162    let (inner, acc_decl, epi) = if cpt <= 4 {
163        let mut inner = String::new();
164        for kk in 0..BK {
165            let comps = (0..cpt)
166                .map(|j| format!("ws[{kk}u * {bn_}u + col0 + {j}u]"))
167                .collect::<Vec<_>>()
168                .join(", ");
169            inner.push_str(&format!("        {{ let b = vec{cpt}<f32>({comps});\n"));
170            for i in 0..tm {
171                inner.push_str(&format!(
172                    "          acc{i} += xs[(row0 + {i}u) * {BK}u + {kk}u] * b;\n"
173                ));
174            }
175            inner.push_str("        }\n");
176        }
177        let acc_decl = (0..tm)
178            .map(|i| format!("    var acc{i} = vec{cpt}<f32>(0.0);"))
179            .collect::<Vec<_>>()
180            .join("\n");
181        let mut epi = String::new();
182        for i in 0..tm {
183            epi.push_str(&format!(
184                "    {{ let r = mrow + {i}u; if (r < mt.m) {{\n        let acc = acc{i};\n"
185            ));
186            for j in 0..cpt {
187                epi.push_str(&format!(
188                    "        {{ let c = ncol + {j}u; if (c < mt.n) {{ var v = acc[{j}u]; \
189                     if ((mt.flags & 1u) != 0u) {{ v += bias[c]; }} \
190                     y[r * mt.n + c] = apply_act(v, mt.flags >> 8u); }} }}\n"
191                ));
192            }
193            epi.push_str("    } }\n");
194        }
195        (inner, acc_decl, epi)
196    } else {
197        // TM rows × 2 vec4 blocks. Per k-step: TM shared reads of `xs` + 8 of `ws` = TM+8, feeding
198        // TM×8 FMAs. At TM=8 that is 64 FMAs from 16 reads — intensity 4, against 2 for (64,64).
199        const NV: usize = 2; // vec4 blocks per row
200        let mut inner = String::new();
201        for kk in 0..BK {
202            for b in 0..NV {
203                let comps = (0..4)
204                    .map(|j| format!("ws[{kk}u * {bn_}u + col0 + {}u]", b * 4 + j))
205                    .collect::<Vec<_>>()
206                    .join(", ");
207                inner.push_str(&format!("        let b{b}_{kk} = vec4<f32>({comps});\n"));
208            }
209            for i in 0..tm {
210                // ONE shared read of x, reused by both column blocks.
211                inner.push_str(&format!(
212                    "        let a{i}_{kk} = xs[(row0 + {i}u) * {BK}u + {kk}u];\n"
213                ));
214                for b in 0..NV {
215                    inner.push_str(&format!("        acc{i}_{b} += a{i}_{kk} * b{b}_{kk};\n"));
216                }
217            }
218        }
219        let acc_decl = (0..tm)
220            .flat_map(|i| (0..NV).map(move |b| format!("    var acc{i}_{b} = vec4<f32>(0.0);")))
221            .collect::<Vec<_>>()
222            .join("\n");
223        let mut epi = String::new();
224        for i in 0..tm {
225            epi.push_str(&format!("    {{ let r = mrow + {i}u; if (r < mt.m) {{\n"));
226            for b in 0..NV {
227                for j in 0..4 {
228                    let c = b * 4 + j;
229                    epi.push_str(&format!(
230                        "        {{ let c = ncol + {c}u; if (c < mt.n) {{ var v = acc{i}_{b}[{j}u]; \
231                         if ((mt.flags & 1u) != 0u) {{ v += bias[c]; }} \
232                         y[r * mt.n + c] = apply_act(v, mt.flags >> 8u); }} }}\n"
233                    ));
234                }
235            }
236            epi.push_str("    } }\n");
237        }
238        (inner, acc_decl, epi)
239    };
240
241    format!(
242        r#"{enable}
243struct Meta {{ m: u32, n: u32, k: u32, flags: u32 }}
244@group(0) @binding(0) var<storage, read> x: array<f32>;
245@group(0) @binding(1) var<storage, read> w: array<{wty}>;
246@group(0) @binding(2) var<storage, read> bias: array<f32>;
247@group(0) @binding(3) var<storage, read_write> y: array<f32>;
248@group(0) @binding(4) var<uniform> mt: Meta;
249var<workgroup> xs: array<f32, {xs_len}>;   // [BM][BK]
250var<workgroup> ws: array<f32, {ws_len}>;   // [BK][BN], transposed at stage
251{ACT_FNS}
252@compute @workgroup_size(16, 16)
253fn main(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_id) li: vec3<u32>) {{
254    let t = li.y * 16u + li.x;              // 0..255
255    let mbase = wg.y * {bm}u;               // first row of this tile
256    let nbase = wg.x * {bn_}u;              // first col of this tile
257    let row0 = li.y * {tm}u;                // thread's rows, tile-local
258    let col0 = li.x * {cpt}u;               // thread's cols, tile-local
259    let mrow = mbase + row0;
260    let ncol = nbase + col0;
261{acc_decl}
262
263    let ktiles = (mt.k + {BK}u - 1u) / {BK}u;
264    for (var kb = 0u; kb < ktiles; kb++) {{
265        let koff = kb * {BK}u;
266        // Stage x[BM][BK]: consecutive threads read consecutive k (coalesced; x is [m,k]).
267        for (var i = 0u; i < {x_iters}u; i++) {{
268            let r = i * 16u + t / {BK}u;
269            let c = t % {BK}u;
270            let gr = mbase + r;
271            let gc = koff + c;
272            var v = 0.0;
273            if (gr < mt.m && gc < mt.k) {{ v = x[gr * mt.k + gc]; }}
274            xs[r * {BK}u + c] = v;
275        }}
276        // Stage w[BN][BK] TRANSPOSED into ws[BK][BN]: consecutive threads read consecutive k of
277        // one output column (w is [n,k]), so the global reads coalesce and the transpose happens
278        // in shared, where it is free.
279        for (var i = 0u; i < {w_iters}u; i++) {{
280            let c = i * 16u + t / {BK}u;
281            let kk = t % {BK}u;
282            let gc = nbase + c;
283            let gk = koff + kk;
284            var v = 0.0;
285            if (gc < mt.n && gk < mt.k) {{ v = f32(w[gc * mt.k + gk]); }}
286            ws[kk * {bn_}u + c] = v;
287        }}
288        workgroupBarrier();
289{inner}
290        workgroupBarrier();
291    }}
292{epi}}}
293"#,
294        xs_len = bm * BK,
295        ws_len = BK * bn,
296    )
297}
298
299/// GEMM v3 — vec4 shared memory, on a PRE-TRANSPOSED weight.
300///
301/// v2 stalls at ~1.3 TF/s because its inner loop issues 16 SCALAR threadgroup reads per k-step (8 of
302/// `xs`, 8 of `ws`) to feed 64 FMAs. Threadgroup bandwidth is not the wall — the *instruction* count
303/// is. Reading `vec4`s instead cuts those 16 loads to 4.
304///
305/// That is impossible with v2's weight layout. `w` is `[n, k]` (HF's Linear layout), so the eight
306/// columns one thread needs at a fixed `k` are `k` floats apart — never a vec4, and never coalesced.
307/// The fix costs nothing at runtime: transpose the weight to `[k, n]` ONCE, when it is uploaded. Then
308/// a fixed `k` is a contiguous run of columns, and BOTH the global load and the shared load become
309/// vec4. (v2 stays exactly as it was — its kernels are pinned by tests and shipped models depend on
310/// them. This is additive.)
311///
312/// Layout, per 256-thread workgroup, `bm × bn` output tile, `BK`-deep:
313///   `xs: array<vec4<f32>, bm·BK/4>`   `x[m,k]`, contiguous in k  → vec4 along k
314///   `ws: array<vec4<f32>, BK·bn/4>`   `wT[k,n]`, contiguous in n → vec4 along n
315/// Each thread owns `TM = bm/16` rows × `CPT = bn/16` cols, accumulating in `vec4`s that are NAMED,
316/// never a dynamically-indexed private array (the engine's 4.2× lesson).
317pub fn enc_gemm3_src(f16: bool, bm: usize, bn: usize, bk: usize) -> String {
318    let (enable, wty) = if f16 {
319        ("enable f16;", "f16")
320    } else {
321        ("", "f32")
322    };
323    let tm = bm / 16;
324    let cpt = bn / 16;
325    let nv = cpt / 4; // vec4 column blocks per thread
326    let kq = bk / 4; // k-quads per k-tile
327    assert!(cpt.is_multiple_of(4) && bk.is_multiple_of(4) && bm.is_multiple_of(16));
328    let bnq = bn / 4; // column-quads across the tile
329    let xs_len = bm * kq; // vec4s
330    let ws_len = bk * bnq; // vec4s
331    let x_rounds = xs_len.div_ceil(256);
332    let w_rounds = ws_len.div_ceil(256);
333
334    let acc_decl = (0..tm)
335        .flat_map(|i| (0..nv).map(move |b| format!("    var acc{i}_{b} = vec4<f32>(0.0);")))
336        .collect::<Vec<_>>()
337        .join("\n");
338
339    // Inner product, fully unrolled. Per k-quad: TM vec4 loads of xs + 4·NV vec4 loads of ws,
340    // feeding 4·TM·CPT FMAs.
341    let mut inner = String::new();
342    for q in 0..kq {
343        for i in 0..tm {
344            inner.push_str(&format!(
345                "        let a{q}_{i} = xs[(row0 + {i}u) * {kq}u + {q}u];\n"
346            ));
347        }
348        for kk in 0..4 {
349            for b in 0..nv {
350                inner.push_str(&format!(
351                    "        let w{q}_{kk}_{b} = ws[({}u) * {bnq}u + colq + {b}u];\n",
352                    q * 4 + kk
353                ));
354            }
355            for i in 0..tm {
356                for b in 0..nv {
357                    inner.push_str(&format!(
358                        "        acc{i}_{b} += a{q}_{i}[{kk}u] * w{q}_{kk}_{b};\n"
359                    ));
360                }
361            }
362        }
363    }
364
365    let mut epi = String::new();
366    for i in 0..tm {
367        epi.push_str(&format!("    {{ let r = mrow + {i}u; if (r < mt.m) {{\n"));
368        for b in 0..nv {
369            for j in 0..4 {
370                let c = b * 4 + j;
371                epi.push_str(&format!(
372                    "        {{ let c = ncol + {c}u; if (c < mt.n) {{ var v = acc{i}_{b}[{j}u]; \
373                     if ((mt.flags & 1u) != 0u) {{ v += bias[c]; }} \
374                     y[r * mt.n + c] = apply_act(v, mt.flags >> 8u); }} }}\n"
375                ));
376            }
377        }
378        epi.push_str("    } }\n");
379    }
380
381    format!(
382        r#"{enable}
383struct Meta {{ m: u32, n: u32, k: u32, flags: u32 }}
384@group(0) @binding(0) var<storage, read> x:  array<f32>;      // [m, k]
385@group(0) @binding(1) var<storage, read> w:  array<{wty}>;    // [k, n]  -- TRANSPOSED at upload
386@group(0) @binding(2) var<storage, read> bias: array<f32>;
387@group(0) @binding(3) var<storage, read_write> y: array<f32>;
388@group(0) @binding(4) var<uniform> mt: Meta;
389var<workgroup> xs: array<vec4<f32>, {xs_len}>;   // [bm][BK/4]
390var<workgroup> ws: array<vec4<f32>, {ws_len}>;   // [BK][bn/4]
391{ACT_FNS}
392@compute @workgroup_size(16, 16)
393fn main(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_id) li: vec3<u32>) {{
394    let t = li.y * 16u + li.x;
395    let mbase = wg.y * {bm}u;
396    let nbase = wg.x * {bn}u;
397    let row0 = li.y * {tm}u;
398    let colq = li.x * {nv}u;              // this thread's first COLUMN-QUAD in the tile
399    let mrow = mbase + row0;
400    let ncol = nbase + colq * 4u;
401{acc_decl}
402
403    let ktiles = (mt.k + {bk}u - 1u) / {bk}u;
404    for (var kb = 0u; kb < ktiles; kb++) {{
405        let koff = kb * {bk}u;
406        // Stage x as vec4 along k (x is [m,k]: contiguous, and consecutive threads take consecutive
407        // k-quads of a row, so the warp's reads are contiguous too).
408        for (var i = 0u; i < {x_rounds}u; i++) {{
409            let idx = i * 256u + t;
410            if (idx < {xs_len}u) {{
411                let r = idx / {kq}u;
412                let q = idx % {kq}u;
413                let gr = mbase + r;
414                let gk = koff + q * 4u;
415                var v = vec4<f32>(0.0);
416                if (gr < mt.m) {{
417                    if (gk + 0u < mt.k) {{ v.x = x[gr * mt.k + gk + 0u]; }}
418                    if (gk + 1u < mt.k) {{ v.y = x[gr * mt.k + gk + 1u]; }}
419                    if (gk + 2u < mt.k) {{ v.z = x[gr * mt.k + gk + 2u]; }}
420                    if (gk + 3u < mt.k) {{ v.w = x[gr * mt.k + gk + 3u]; }}
421                }}
422                xs[r * {kq}u + q] = v;
423            }}
424        }}
425        // Stage wT as vec4 along n. THIS is what the transpose bought: at a fixed k the columns are
426        // contiguous, so one thread loads four of them in one go and consecutive threads load the
427        // next four.
428        for (var i = 0u; i < {w_rounds}u; i++) {{
429            let idx = i * 256u + t;
430            if (idx < {ws_len}u) {{
431                let kk = idx / {bnq}u;
432                let cq = idx % {bnq}u;
433                let gk = koff + kk;
434                let gc = nbase + cq * 4u;
435                var v = vec4<f32>(0.0);
436                if (gk < mt.k) {{
437                    if (gc + 0u < mt.n) {{ v.x = f32(w[gk * mt.n + gc + 0u]); }}
438                    if (gc + 1u < mt.n) {{ v.y = f32(w[gk * mt.n + gc + 1u]); }}
439                    if (gc + 2u < mt.n) {{ v.z = f32(w[gk * mt.n + gc + 2u]); }}
440                    if (gc + 3u < mt.n) {{ v.w = f32(w[gk * mt.n + gc + 3u]); }}
441                }}
442                ws[kk * {bnq}u + cq] = v;
443            }}
444        }}
445        workgroupBarrier();
446{inner}
447        workgroupBarrier();
448    }}
449{epi}}}
450"#
451    )
452}
453
454/// GEMM v3-f16a — the v3 structure with F16 ARITHMETIC (the P7 contingency): x is converted to
455/// f16 ONCE at staging, the weight is stored f16 `[k,n]`-transposed, and the inner FMAs and
456/// accumulators are f16 — Apple's shader ALUs run f16 at twice the f32 rate, which is the whole
457/// remaining edge MPSGraph holds at the small/mid shapes. The EPILOGUE is f32 (bias, activation,
458/// store), so downstream buffers keep their type.
459///
460/// This is NOT parity-class, but the f16 PARTIALS now span only ONE k-tile (BK steps): each
461/// tile's vec4<f16> accumulator folds into a persistent vec4<f32> accumulator at the tile
462/// boundary, so drift is bounded by BK, not k — measured 9.4e-3 abs at k=128 in the Volta lab
463/// versus percent-class error for full-k f16 accumulation at k=6144. Still OPT-IN per load
464/// (`OSFKB_ENC_F16A=1`), guarded by the cosine gate (`tests/enc_f16a.rs`, ≥ 0.999 vs f32) —
465/// and it requires SHADER_F16, falling back silently to f32 v3 where the adapter lacks it.
466/// V100 (Vulkan): 11.1–12.8 TF/s at GLM-tower shapes vs 6.3–9.1 for f32 v3 — Volta HFMA2 is
467/// reachable through portable WGSL (tests/gemm_volta_lab.rs).
468pub fn enc_gemm3_f16a_src(bm: usize, bn: usize, bk: usize) -> String {
469    let tm = bm / 16;
470    let cpt = bn / 16;
471    let nv = cpt / 4;
472    let kq = bk / 4;
473    assert!(cpt.is_multiple_of(4) && bk.is_multiple_of(4) && bm.is_multiple_of(16));
474    let bnq = bn / 4;
475    let xs_len = bm * kq;
476    let ws_len = bk * bnq;
477    let x_rounds = xs_len.div_ceil(256);
478    let w_rounds = ws_len.div_ceil(256);
479
480    let acc_decl = (0..tm)
481        .flat_map(|i| (0..nv).map(move |b| format!("    var acc{i}_{b} = vec4<f32>(0.0);")))
482        .collect::<Vec<_>>()
483        .join("\n");
484    // Per-tile f16 partials + the f32 fold at the tile boundary (the drift bound).
485    let hacc_decl = (0..tm)
486        .flat_map(|i| (0..nv).map(move |b| format!("        var h{i}_{b} = vec4<f16>(0.0);")))
487        .collect::<Vec<_>>()
488        .join("\n");
489    let fold = (0..tm)
490        .flat_map(|i| (0..nv).map(move |b| format!("        acc{i}_{b} += vec4<f32>(h{i}_{b});")))
491        .collect::<Vec<_>>()
492        .join("\n");
493
494    let mut inner = String::new();
495    for q in 0..kq {
496        for i in 0..tm {
497            inner.push_str(&format!(
498                "        let a{q}_{i} = xs[(row0 + {i}u) * {kq}u + {q}u];\n"
499            ));
500        }
501        for kk in 0..4 {
502            for b in 0..nv {
503                inner.push_str(&format!(
504                    "        let w{q}_{kk}_{b} = ws[({}u) * {bnq}u + colq + {b}u];\n",
505                    q * 4 + kk
506                ));
507            }
508            for i in 0..tm {
509                for b in 0..nv {
510                    inner.push_str(&format!(
511                        "        h{i}_{b} = fma(vec4<f16>(a{q}_{i}[{kk}u]), w{q}_{kk}_{b}, h{i}_{b});\n"
512                    ));
513                }
514            }
515        }
516    }
517
518    let mut epi = String::new();
519    for i in 0..tm {
520        epi.push_str(&format!("    {{ let r = mrow + {i}u; if (r < mt.m) {{\n"));
521        for b in 0..nv {
522            for j in 0..4 {
523                let c = b * 4 + j;
524                epi.push_str(&format!(
525                    "        {{ let c = ncol + {c}u; if (c < mt.n) {{ var v = acc{i}_{b}[{j}u]; \
526                     if ((mt.flags & 1u) != 0u) {{ v += bias[c]; }} \
527                     y[r * mt.n + c] = apply_act(v, mt.flags >> 8u); }} }}\n"
528                ));
529            }
530        }
531        epi.push_str("    } }\n");
532    }
533
534    format!(
535        r#"enable f16;
536struct Meta {{ m: u32, n: u32, k: u32, flags: u32 }}
537@group(0) @binding(0) var<storage, read> x:  array<f32>;          // [m, k]
538@group(0) @binding(1) var<storage, read> w:  array<vec4<f16>>;    // [k, n/4] -- TRANSPOSED f16
539@group(0) @binding(2) var<storage, read> bias: array<f32>;
540@group(0) @binding(3) var<storage, read_write> y: array<f32>;
541@group(0) @binding(4) var<uniform> mt: Meta;
542var<workgroup> xs: array<vec4<f16>, {xs_len}>;   // [bm][BK/4], converted once at stage
543var<workgroup> ws: array<vec4<f16>, {ws_len}>;   // [BK][bn/4]
544{ACT_FNS}
545@compute @workgroup_size(16, 16)
546fn main(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_id) li: vec3<u32>) {{
547    let t = li.y * 16u + li.x;
548    let mbase = wg.y * {bm}u;
549    let nbase = wg.x * {bn}u;
550    let row0 = li.y * {tm}u;
551    let colq = li.x * {nv}u;
552    let mrow = mbase + row0;
553    let ncol = nbase + colq * 4u;
554{acc_decl}
555
556    let ktiles = (mt.k + {bk}u - 1u) / {bk}u;
557    for (var kb = 0u; kb < ktiles; kb++) {{
558        let koff = kb * {bk}u;
559        for (var i = 0u; i < {x_rounds}u; i++) {{
560            let idx = i * 256u + t;
561            if (idx < {xs_len}u) {{
562                let r = idx / {kq}u;
563                let q = idx % {kq}u;
564                let gr = mbase + r;
565                let gk = koff + q * 4u;
566                var v = vec4<f32>(0.0);
567                if (gr < mt.m) {{
568                    if (gk + 0u < mt.k) {{ v.x = x[gr * mt.k + gk + 0u]; }}
569                    if (gk + 1u < mt.k) {{ v.y = x[gr * mt.k + gk + 1u]; }}
570                    if (gk + 2u < mt.k) {{ v.z = x[gr * mt.k + gk + 2u]; }}
571                    if (gk + 3u < mt.k) {{ v.w = x[gr * mt.k + gk + 3u]; }}
572                }}
573                xs[idx] = vec4<f16>(v);
574            }}
575        }}
576        for (var i = 0u; i < {w_rounds}u; i++) {{
577            let idx = i * 256u + t;
578            if (idx < {ws_len}u) {{
579                let kk = idx / {bnq}u;
580                let cq = idx % {bnq}u;
581                let gk = koff + kk;
582                var v = vec4<f16>(0.0);
583                if (gk < mt.k && (nbase + cq * 4u) < mt.n) {{ v = w[(gk * mt.n) / 4u + nbase / 4u + cq]; }}
584                ws[kk * {bnq}u + cq] = v;
585            }}
586        }}
587        workgroupBarrier();
588{hacc_decl}
589{inner}
590{fold}
591        workgroupBarrier();
592    }}
593{epi}}}
594"#
595    )
596}
597
598/// GEMM v4 — HARDWARE MATRIX UNITS (`EXPERIMENTAL_COOPERATIVE_MATRIX`).
599///
600/// Apple `simdgroup_matrix` on Metal, NVIDIA/AMD tensor cores through `VK_KHR_cooperative_matrix` on
601/// Vulkan — one WGSL kernel, both. Adapters without them keep [`enc_gemm3_src`]: this ACCELERATES, it
602/// never gates.
603///
604/// ## The two mistakes the first version made, because they are the ones to avoid
605///
606/// It ran at HALF of v3's speed, and the matrix units were not at fault — the tile was:
607///
608/// 1. **It staged the output tile in shared memory** (to bounds-check the edges) — 16 KB of Metal's
609///    32 KB budget, so exactly ONE workgroup was resident per core and nothing hid any latency.
610/// 2. **Its 64×64 tile had HALF the arithmetic intensity of v3's 128×128**: 8 KB staged per 131k
611///    FLOPs (16 FLOP/byte) against v3's 16 KB per 524k (32 FLOP/byte). The scalar kernel was handed
612///    the better tile and unsurprisingly won.
613///
614/// Both are fixed here, and the fix for (1) is a trick the coop API gives away: **bias is folded into
615/// the accumulator's INITIAL VALUE**. `acc` starts as an 8×8 tile loaded from a `[8, n]` broadcast of
616/// the bias vector (built once at upload), so `coopMultiplyAdd` accumulates on top of it and the
617/// result can be stored STRAIGHT to `y` — no staging array, no epilogue pass, no shared memory beyond
618/// the operands.
619///
620/// ## Shape
621///
622/// 128×128 output tile, 256 threads = 8 subgroups in a 2×4 grid, each owning 64 rows × 32 cols as
623/// 8×4 = 32 accumulators of 8×8. A coop matrix lives ACROSS the subgroup's lanes, so those 32
624/// accumulators cost ~64 f32 per lane — the register wall that makes a 128×128 SCALAR tile spill
625/// simply is not there, which is the whole point of the matrix units.
626///
627/// Shared: `xs` 8 KB + `ws` 8 KB = 16 KB. `w` is `[k, n]` (pre-transposed at upload), the same layout
628/// v3 needs, so B tiles load row-major with an `n` stride at no extra cost.
629///
630/// Activation is NOT fused (a coop matrix has no elementwise ops worth the name yet); the caller runs
631/// a separate elementwise pass, which is a rounding error next to the GEMM.
632///
633/// Requires `m % 8 == 0 && n % 8 == 0` — `coopStore` does not bounds-check, and a partial 8×8 block
634/// would write outside `y`. The caller checks and falls back to v3 otherwise; every shape in the
635/// vision tower satisfies it (912 = 114·8).
636/// GEMM v4-f16 — the matrix units with F16 OPERANDS and F32 ACCUMULATION, the `8x8x8
637/// ab=F16 cr=F32` configuration this adapter reports (`cooperative_matrix_properties`). This
638/// is the configuration MPSGraph's fast GEMMs live on, and it is PRECISION-SUPERIOR to the
639/// f16a WGSL band: inputs quantize to f16 once, every accumulation is f32. A/B stage as f16
640/// (halving the shared-memory footprint v4 pays), the C/bias path stays f32 end to end.
641/// Same 128×128 tile, same bias-in-accumulator trick, same "accelerates, never gates"
642/// contract — adapters without the feature (or without the mixed-type config) keep v3.
643pub fn enc_gemm4_f16_src(bm: usize, bn: usize, bk: usize) -> String {
644    enc_gemm4_typed_src(bm, bn, bk, true)
645}
646
647pub fn enc_gemm4_src(bm: usize, bn: usize, bk: usize) -> String {
648    enc_gemm4_typed_src(bm, bn, bk, false)
649}
650
651/// v4-f16 over an f16-STORED `[k,n]` weight (the f16a upload layout) — w binds as
652/// `array<f16>`, no conversion at stage; x still converts once.
653pub fn enc_gemm4_f16w_src(bm: usize, bn: usize, bk: usize) -> String {
654    let src = enc_gemm4_typed_src(bm, bn, bk, true)
655        .replace(
656            "@group(0) @binding(1) var<storage, read> w:     array<f32>;",
657            "@group(0) @binding(1) var<storage, read> w:     array<f16>;",
658        )
659        // …and the staging read widens explicitly (the guard var is f32).
660        .replace(
661            "{ v = w[gk * mt.n + gc]; }",
662            "{ v = f32(w[gk * mt.n + gc]); }",
663        );
664    assert!(
665        src.contains("array<f16>;") && src.contains("f32(w[gk"),
666        "v4 w-binding/stage drifted"
667    );
668    src
669}
670
671fn enc_gemm4_typed_src(bm: usize, bn: usize, bk: usize, ab_f16: bool) -> String {
672    assert!(bm.is_multiple_of(8) && bn.is_multiple_of(8) && bk.is_multiple_of(8));
673    const SG_ROWS: usize = 2; // subgroup grid
674    const SG_COLS: usize = 4;
675    let nsg = SG_ROWS * SG_COLS;
676    let threads = nsg * 32;
677    let rows_per_sg = bm / SG_ROWS; // 64
678    let cols_per_sg = bn / SG_COLS; // 32
679    let ai = rows_per_sg / 8; // 8 accumulators down
680    let aj = cols_per_sg / 8; // 4 across
681    let ksteps = bk / 8;
682
683    // Accumulators START as the bias tile: `bias8` is the bias vector broadcast to 8 rows, so an 8×8
684    // C-tile loaded from it has the bias in every row — exactly what the epilogue would have added.
685    let abty = if ab_f16 { "f16" } else { "f32" };
686    let f16_enable = if ab_f16 { "enable f16;\n" } else { "" };
687    let acc_decl = (0..ai)
688        .flat_map(|i| {
689            (0..aj).map(move |j| {
690                format!(
691                    "    var acc{i}_{j} = coopLoadT<coop_mat8x8<f32, C>>(&bias8[nbase + qc * {cols_per_sg}u + {}u], mt.n);",
692                    j * 8
693                )
694            })
695        })
696        .collect::<Vec<_>>()
697        .join("\n");
698
699    let mut inner = String::new();
700    for kk in 0..ksteps {
701        for i in 0..ai {
702            inner.push_str(&format!(
703                "            let a{kk}_{i} = coopLoadT<coop_mat8x8<{abty}, A>>(&xs[(qr * {rows_per_sg}u + {}u) * {bk}u + {}u], {bk}u);\n",
704                i * 8, kk * 8
705            ));
706        }
707        for j in 0..aj {
708            inner.push_str(&format!(
709                "            let b{kk}_{j} = coopLoadT<coop_mat8x8<{abty}, B>>(&ws[{}u * {bn}u + qc * {cols_per_sg}u + {}u], {bn}u);\n",
710                kk * 8, j * 8
711            ));
712        }
713        for i in 0..ai {
714            for j in 0..aj {
715                inner.push_str(&format!(
716                    "            acc{i}_{j} = coopMultiplyAdd(a{kk}_{i}, b{kk}_{j}, acc{i}_{j});\n"
717                ));
718            }
719        }
720    }
721
722    // Store straight to y. Guarded per 8×8 block; m and n are multiples of 8 (checked by the caller),
723    // so a block is either wholly inside or wholly outside.
724    let mut store = String::new();
725    for i in 0..ai {
726        for j in 0..aj {
727            store.push_str(&format!(
728                "    {{ let r = mbase + qr * {rows_per_sg}u + {}u; let c = nbase + qc * {cols_per_sg}u + {}u;\n      \
729                 if (r < mt.m && c < mt.n) {{ coopStoreT(acc{i}_{j}, &y[r * mt.n + c], mt.n); }} }}\n",
730                i * 8, j * 8
731            ));
732        }
733    }
734
735    let xs_len = bm * bk;
736    let ws_len = bk * bn;
737    let x_rounds = xs_len.div_ceil(threads);
738    let w_rounds = ws_len.div_ceil(threads);
739
740    format!(
741        r#"{f16_enable}enable wgpu_cooperative_matrix;
742struct Meta {{ m: u32, n: u32, k: u32, flags: u32 }}
743@group(0) @binding(0) var<storage, read> x:     array<f32>;   // [m, k]
744@group(0) @binding(1) var<storage, read> w:     array<f32>;   // [k, n]  -- TRANSPOSED at upload
745@group(0) @binding(2) var<storage, read> bias8: array<f32>;   // [8, n]  -- bias broadcast to 8 rows
746@group(0) @binding(3) var<storage, read_write> y: array<f32>; // [m, n]
747@group(0) @binding(4) var<uniform> mt: Meta;
748var<workgroup> xs: array<{abty}, {xs_len}>;   // [bm][bk]
749var<workgroup> ws: array<{abty}, {ws_len}>;   // [bk][bn]
750@compute @workgroup_size({threads})
751fn main(
752    @builtin(workgroup_id) wg: vec3<u32>,
753    @builtin(local_invocation_index) t: u32,
754    @builtin(subgroup_id) sg: u32,
755) {{
756    let mbase = wg.y * {bm}u;
757    let nbase = wg.x * {bn}u;
758    let qr = sg / {SG_COLS}u;
759    let qc = sg % {SG_COLS}u;
760{acc_decl}
761
762    let ktiles = (mt.k + {bk}u - 1u) / {bk}u;
763    for (var kb = 0u; kb < ktiles; kb++) {{
764        let koff = kb * {bk}u;
765        for (var i = 0u; i < {x_rounds}u; i++) {{
766            let idx = i * {threads}u + t;
767            if (idx < {xs_len}u) {{
768                let r = idx / {bk}u;
769                let c = idx % {bk}u;
770                let gr = mbase + r;
771                let gc = koff + c;
772                var v = 0.0;
773                if (gr < mt.m && gc < mt.k) {{ v = x[gr * mt.k + gc]; }}
774                xs[idx] = {abty}(v);
775            }}
776        }}
777        for (var i = 0u; i < {w_rounds}u; i++) {{
778            let idx = i * {threads}u + t;
779            if (idx < {ws_len}u) {{
780                let kk = idx / {bn}u;
781                let c = idx % {bn}u;
782                let gk = koff + kk;
783                let gc = nbase + c;
784                var v = 0.0;
785                if (gk < mt.k && gc < mt.n) {{ v = w[gk * mt.n + gc]; }}
786                ws[idx] = {abty}(v);
787            }}
788        }}
789        workgroupBarrier();
790{inner}
791        workgroupBarrier();
792    }}
793
794{store}}}
795"#
796    )
797}
798
799/// GEMM v3 SPLIT-K — the occupancy answer for the mid-band widths (n = 768/1024-class) at
800/// SMALL m, where no tile has enough output blocks to fill the GPU (m=64 × n=768 at (16,64) is
801/// 48 workgroups on a ~40-core machine). The reduction dimension supplies the missing
802/// parallelism: `grid.z` chunks each own a contiguous run of k-tiles and write RAW partials
803/// (`y_part[c][m,n]`, no bias, no activation); [`ENC_GEMM3_SK_REDUCE`] then folds the chunks in
804/// fixed order and applies the epilogue. Chunk count is `num_workgroups.z` (one pipeline serves
805/// any split), the tile is the same vec4/`[k,n]`-transposed body as [`enc_gemm3_src`], and a
806/// chunk whose window falls past `k` writes zeros — never stale scratch.
807pub fn enc_gemm3_sk_src(f16: bool, bm: usize, bn: usize, bk: usize) -> String {
808    gemm3_sk_patch(enc_gemm3_src(f16, bm, bn, bk), bk)
809}
810
811/// The f16-ARITHMETIC split-K partial: the same patch over [`enc_gemm3_f16a_src`] — its
812/// epilogue already stores `f32(acc)`, so the partials stay f32 and the SAME reduce kernel
813/// serves both precisions.
814pub fn enc_gemm3_sk_f16a_src(bm: usize, bn: usize, bk: usize) -> String {
815    gemm3_sk_patch(enc_gemm3_f16a_src(bm, bn, bk), bk)
816}
817
818/// The split-K source surgery, shared by the f32 and f16a bases (fragments asserted so drift
819/// in either generator is caught at pipeline build).
820fn gemm3_sk_patch(base: String, bk: usize) -> String {
821    // Reuse the v3 body: patch the entry to take the chunk id, window the k-loop, and store to
822    // the partials buffer without the epilogue. Assert each fragment so v3 drift is caught.
823    let frags = [
824        "fn main(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_id) li: vec3<u32>) {",
825        "    let ktiles = (mt.k + ",
826        "@group(0) @binding(3) var<storage, read_write> y: array<f32>;",
827    ];
828    for f in frags {
829        assert!(base.contains(f), "v3 source drifted: {f}");
830    }
831    let src = base
832        .replace(
833            "fn main(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_id) li: vec3<u32>) {",
834            "fn main(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_id) li: vec3<u32>,\n        @builtin(num_workgroups) nwg: vec3<u32>) {",
835        )
836        .replace(
837            "@group(0) @binding(3) var<storage, read_write> y: array<f32>;",
838            "@group(0) @binding(3) var<storage, read_write> y: array<f32>; // [nz, m, n] partials",
839        )
840        // The raw-partial epilogue drops every bias read; naga would then PRUNE the binding and
841        // renumber the auto layout. Keep it referenced behind an impossible flag value.
842        .replace(
843            "    let t = li.y * 16u + li.x;",
844            "    let t = li.y * 16u + li.x;\n    if (mt.flags == 0xFFFFFFFFu) { y[0] = bias[0]; } // keep bias bound (never true)",
845        );
846    // Window the k-loop to this chunk: tiles [c·per, c·per+per) of the global tile count.
847    let (patched, klooped) = {
848        let needle = format!(
849            "    let ktiles = (mt.k + {bk}u - 1u) / {bk}u;\n    for (var kb = 0u; kb < ktiles; kb++) {{\n        let koff = kb * {bk}u;"
850        );
851        let replacement = format!(
852            "    let ktiles = (mt.k + {bk}u - 1u) / {bk}u;\n    let per = (ktiles + nwg.z - 1u) / nwg.z;\n    let kb0 = wg.z * per;\n    let kb1 = min(kb0 + per, ktiles);\n    for (var kb = kb0; kb < kb1; kb++) {{\n        let koff = kb * {bk}u;"
853        );
854        let ok = src.contains(&needle);
855        (src.replace(&needle, &replacement), ok)
856    };
857    assert!(klooped, "v3 k-loop shape drifted");
858    // Epilogue: RAW partial store (no bias/act), offset by the chunk plane.
859    let mut out = patched;
860    assert!(
861        out.contains("var v = acc") || out.contains("var v = f32(acc"),
862        "v3/f16a epilogue drifted"
863    );
864    // The v3 epilogue lines look like: `... var v = accI_B[Ju]; if bias...; y[r*n+c] = apply_act(...)`.
865    // Rewrite them wholesale: strip bias/act, add the plane offset.
866    out = rewrite_v3_epilogue_for_sk(&out);
867    out
868}
869
870/// The epilogue rewrite for [`enc_gemm3_sk_src`], kept separate so its regex-free string surgery
871/// stays inspectable: every `var v = accX; if bias v += bias; y[idx] = apply_act(v, …)` becomes
872/// `y[plane + idx] = accX;` with `plane = wg.z · mt.m · mt.n`.
873fn rewrite_v3_epilogue_for_sk(src: &str) -> String {
874    let mut out = String::with_capacity(src.len());
875    for line in src.lines() {
876        let t = line.trim_start();
877        if let Some(rest) = t.strip_prefix("{ let c = ncol + ") {
878            // `{ let c = ncol + Ju; if (c < mt.n) { var v = accI_B[Ju]; if ((mt.flags & 1u) != 0u) { v += bias[c]; } y[r * mt.n + c] = apply_act(v, mt.flags >> 8u); } }`
879            let j = rest.split("u;").next().expect("column offset");
880            let acc = rest
881                .split("var v = ")
882                .nth(1)
883                .and_then(|s| s.split(';').next())
884                .expect("accumulator expr");
885            let indent = &line[..line.len() - t.len()];
886            out.push_str(&format!(
887                "{indent}{{ let c = ncol + {j}u; if (c < mt.n) {{ y[wg.z * mt.m * mt.n + r * mt.n + c] = {acc}; }} }}\n"
888            ));
889        } else {
890            out.push_str(line);
891            out.push('\n');
892        }
893    }
894    out
895}
896
897/// The reduce half of split-K: `y[i] = act(Σ_c part[c·m·n + i] (+ bias))`, one thread per output
898/// element, chunk loop in FIXED ascending order (deterministic — the accumulation order never
899/// depends on the grid). Meta: (m, n, flags, sk); flags reuse the GEMM encoding.
900fn enc_gemm3_sk_reduce_src() -> String {
901    format!(
902        r#"
903struct Meta {{ m: u32, n: u32, flags: u32, sk: u32 }}
904@group(0) @binding(0) var<storage, read> part: array<f32>;
905@group(0) @binding(1) var<storage, read> bias: array<f32>;
906@group(0) @binding(2) var<storage, read_write> y: array<f32>;
907@group(0) @binding(3) var<uniform> mt: Meta;
908{ACT_FNS}
909@compute @workgroup_size(256)
910fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
911    let i = gid.x;
912    let total = mt.m * mt.n;
913    if (i >= total) {{ return; }}
914    var acc = 0.0;
915    for (var c = 0u; c < mt.sk; c++) {{ acc += part[c * total + i]; }}
916    if ((mt.flags & 1u) != 0u) {{ acc += bias[i % mt.n]; }}
917    y[i] = apply_act(acc, mt.flags >> 8u);
918}}
919"#
920    )
921}
922
923/// The tile space `(bm, bn)`, ordered by ARITHMETIC INTENSITY — best first.
924///
925/// Each thread owns `TM = bm/16` rows × `CPT = bn/16` cols and, per k-step, does `TM×CPT` MACs out
926/// of `TM + CPT` shared reads:
927///
928/// ```text
929///   (128,128) 64/16 = 4.00   (64,64) 16/8 = 2.00   (32,32) 4/4 = 1.00
930///   (128,64)  32/12 = 2.67   (64,32)  8/6 = 1.33   (16,64) 4/5 = 0.80
931///   (64,128)  32/12 = 2.67   (32,64)  8/6 = 1.33   (16,32) 2/3 = 0.67
932/// ```
933///
934/// Intensity TRADES against the grid: halving `bn` doubles the workgroups. Worth it only while the
935/// GPU is still idle — which is what [`gemm2_tile`] decides.
936pub(crate) const GEMM2_TILES: [(usize, usize); 9] = [
937    // Wide tiles (256 threads, 8 rows × 8 cols per thread). Intensity 4.0 — DOUBLE the (64,64) that
938    // used to head this table, and the reason is the only lever WGSL leaves us: portable register
939    // blocking. `(64,64)` does 16 FMAs per 8 shared reads and is therefore shared-memory-bound, not
940    // ALU-bound, which is why it stalls at ~1.3 TF/s while MPSGraph's simdgroup_matrix does 4-8.
941    // Doubling the block halves the shared traffic per FMA. Costs 64 accumulator registers, so it is
942    // gated on the grid still filling the GPU (below) and measured, never assumed.
943    (128, 128),
944    (128, 64),
945    (64, 128),
946    (64, 64),
947    (32, 64),
948    (64, 32),
949    (32, 32),
950    (16, 64),
951    (16, 32),
952];
953
954/// Workgroups below which an M4-class GPU is simply idle. The GEMM's throughput tracks the GRID,
955/// not the shape:
956///
957/// ```text
958///   crossenc qkv      6×3  =   18 WGs ->  428 GF/s
959///   biolord  qkv     12×2  =   24 WGs ->  746
960///   crossenc mlp-up  24×3  =   72 WGs -> 1253
961///   biolord  mlp-up  48×2  =   96 WGs -> 1594
962///   deberta  qkv     12×8  =   96 WGs -> 1668
963///   gliner   span-up 32×36 = 1152 WGs -> 1797
964/// ```
965const GEMM2_MIN_WGS: usize = 96;
966
967/// The row-tile height for an `[m, n]` output: the LARGEST tile (best register blocking) whose grid
968/// still fills the GPU, else the smallest.
969///
970/// Selecting on `m` alone — which is what this did — misses the actual constraint. Both endpoints in
971/// the production request path have a SMALL `n`: `/score`'s qkv is 384 wide, so `n/BN` is 6 column
972/// tiles no matter how big `m` is, and a 64-row tile then leaves an 18-workgroup grid on a ~40-core
973/// GPU. It is not the arithmetic; it is that most of the machine is idle.
974pub fn gemm2_tile(m: usize, n: usize) -> (usize, usize) {
975    // Lab knob: `OSFKB_ENC_TILE=128x64` forces one tile for every shape. The tile space trades
976    // arithmetic intensity against BOTH grid occupancy and register pressure, and the second of those
977    // is invisible from the source — the only way to find the knee is to sweep it.
978    if let Ok(v) = std::env::var("OSFKB_ENC_TILE")
979        && let Some((a, b)) = v.split_once('x')
980        && let (Ok(a), Ok(b)) = (a.parse(), b.parse())
981        && GEMM2_TILES.contains(&(a, b))
982    {
983        return (a, b);
984    }
985    let wgs = |(bm, bn): (usize, usize)| n.div_ceil(bn) * m.div_ceil(bm);
986    GEMM2_TILES
987        .into_iter()
988        .find(|&t| wgs(t) >= GEMM2_MIN_WGS)
989        // Nothing fills the GPU: take whichever tile gets CLOSEST. Reversed so that on a tie the
990        // higher-intensity tile (earlier in the table) wins.
991        .unwrap_or_else(|| {
992            GEMM2_TILES
993                .into_iter()
994                .rev()
995                .max_by_key(|&t| wgs(t))
996                .expect("non-empty tile table")
997        })
998}
999
1000/// Index of a tile height in the pipeline/bind-group triple.
1001pub(crate) fn gemm2_tier(tile: (usize, usize)) -> usize {
1002    GEMM2_TILES
1003        .iter()
1004        .position(|&t| t == tile)
1005        .expect("tile came from GEMM2_TILES")
1006}
1007/// `OSFKB_ENC_GEMM2=0` falls back to the v1 kernel — the escape hatch if v2 ever misbehaves on an
1008/// adapter we cannot test here.
1009pub(crate) fn gemm2_enabled() -> bool {
1010    !matches!(
1011        std::env::var("OSFKB_ENC_GEMM2").ok().as_deref(),
1012        Some("0") | Some("off")
1013    )
1014}
1015
1016/// The v3 (vec4-shared, `[k,n]`-transposed-weight) tile space for the TEXT encoders, best
1017/// arithmetic intensity first. `bk` rides each tile: shallow (8) for the occupancy-bound small
1018/// tiles, 16 where the tile is already wide (the `gemm3_bench_text_shapes` sweep's winners:
1019/// m=64 shapes → 32×64×8, m=192 → 64×64×16-class, m=512 → 64×128×16).
1020pub(crate) const GEMM3_TILES: [(usize, usize, usize); 5] = [
1021    (128, 128, 16),
1022    (64, 128, 16),
1023    (64, 64, 16),
1024    (32, 64, 8),
1025    (16, 64, 8),
1026];
1027
1028/// Workgroup floor for the v3 tile choice. LOWER than v2's 96: the vec4 kernel wins on
1029/// instruction count, so trading a little occupancy for a fatter tile pays earlier. The floor
1030/// alone is not the whole rule — see [`gemm3_tile`]'s ≥3-row-tiles admission: crossenc qkv3's
1031/// measured best tile (64,64,16) runs at 54 WGs and a hard 64 floor excluded it (the
1032/// `OSFKB_ENC_TS` profile caught the pick running 1.33 TF/s where the sweep proved 2.3), while
1033/// a flat 48 floor regressed /embed +17% (it admits m-tall tiles at m=64 AND disables the
1034/// split-K arm). `OSFKB_ENC_G3_MINWGS` overrides the floor for lab sweeps.
1035fn gemm3_min_wgs() -> usize {
1036    static V: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
1037    *V.get_or_init(|| {
1038        std::env::var("OSFKB_ENC_G3_MINWGS")
1039            .ok()
1040            .and_then(|v| v.parse().ok())
1041            .unwrap_or(64)
1042    })
1043}
1044
1045/// The v3 tile for an `[m, n]` output — the largest tile whose grid still clears the floor,
1046/// else whichever gets closest (ties to the higher-intensity tile).
1047///
1048/// A tile is also admitted just BELOW the floor (≥ 48 WGs) when `m` spans at least three of its
1049/// row-tiles: measured, (192,1152) wants (64,64,16) at 54 WGs (2307 GF/s vs 1.33 TF/s for the
1050/// floor-strict pick), while at m=64 the same relaxation admits m-tall tiles that LOSE — the
1051/// ≥3-row-tiles condition separates exactly those two cases (and keeps the split-K arm's
1052/// boundary, which uses the plain floor, untouched).
1053/// The tile-admission predicate: the plain floor, OR just below it (≥ 48 WGs) for an
1054/// INTENSITY-RICH tile (`bm ≥ 64`) that `m` spans at least three times. Shared by
1055/// [`gemm3_tile`] AND the split-K plain-vs-pair branch — the `OSFKB_ENC_TS` profile caught
1056/// them disagreeing (GLiNER's thirty n=768 GEMMs took the split-K arm at t≈230 where plain
1057/// (64,64) wins all five paired rounds). The `bm ≥ 64` term is measured too: without it the
1058/// m=64 (16,64)@48 case steals the split-K arm and /embed regresses ~+13%.
1059pub(crate) fn gemm3_grid_ok(m: usize, tile: (usize, usize, usize), wgs: usize) -> bool {
1060    wgs >= gemm3_min_wgs() || (wgs >= 48 && tile.0 >= 64 && m / tile.0 >= 3)
1061}
1062
1063pub(crate) fn gemm3_tile(m: usize, n: usize) -> (usize, usize, usize) {
1064    let wgs = |(bm, bn, _): (usize, usize, usize)| n.div_ceil(bn) * m.div_ceil(bm);
1065    let mut pick = GEMM3_TILES
1066        .into_iter()
1067        .find(|&t| gemm3_grid_ok(m, t, wgs(t)))
1068        .unwrap_or_else(|| {
1069            GEMM3_TILES
1070                .into_iter()
1071                .rev()
1072                .max_by_key(|&t| wgs(t))
1073                .expect("non-empty tile table")
1074        });
1075    // DEMOTION (measured, `tile_check` ladders at every production text shape): a FAT tile
1076    // (bm ≥ 64) admitted under ~80 workgroups (< ~2/core) loses to the next-thinner tile
1077    // whenever that one reaches ≥ 128 WGs (≥ ~3.2/core) — crossenc up (64,64,16)@72 → (32,64,8)
1078    // @144 is +26%, gliner qkv3 @72 → @144 +12%, deberta qkv3 (128,128,16)@72 → (64,128,16)@144
1079    // is +83%. When the thinner grid does NOT reach 128, the fat tile keeps winning (crossenc
1080    // qkv3 (64,64,16)@54 beats (32,64,8)@108 by 16%) — occupancy only buys latency hiding once
1081    // there is enough of it, and below that the intensity ladder rules.
1082    // `OSFKB_ENC_G3_DEMOTE=0` pins the pre-demotion pick (read per call, deliberately not
1083    // latched — one process can A/B both arms; ~40 reads per encode is noise next to a GEMM).
1084    if std::env::var("OSFKB_ENC_G3_DEMOTE").ok().as_deref() == Some("0") {
1085        return pick;
1086    }
1087    let mut idx = gemm3_tier(pick);
1088    while pick.0 >= 64 && wgs(pick) < 80 && idx + 1 < GEMM3_TILES.len() {
1089        let next = GEMM3_TILES[idx + 1];
1090        if wgs(next) >= 128 {
1091            pick = next;
1092            idx += 1;
1093        } else {
1094            break;
1095        }
1096    }
1097    pick
1098}
1099
1100/// Index of a v3 tile in the pipeline/bind-group set.
1101pub(crate) fn gemm3_tier(tile: (usize, usize, usize)) -> usize {
1102    GEMM3_TILES
1103        .iter()
1104        .position(|&t| t == tile)
1105        .expect("tile came from GEMM3_TILES")
1106}
1107
1108/// Whether a plan step of output width `n` runs GEMM v3. The predicate is decided at LOAD (it
1109/// selects the weight's upload layout — `[k,n]` transposed — as well as the replay kernel), so
1110/// it keys on `n` alone. Two bands, both from `gemm3_bench_text_shapes` on the M4 Max:
1111///
1112/// * `n ≥ 1152` — the fused-QKV / MLP-up widths: v3 wins at every measured `m` (1.2–3.1×).
1113/// * `n ≤ 512` — the MiniLM-class o/down projections: v3 wins there too (crossenc o
1114///   1106 → 1345 GF/s, down 1176 → 1403 at m=192; the tiny `n` starves ANY tile of columns, so
1115///   the vec4 instruction-count win is all that's left to compete on).
1116///
1117/// The band BETWEEN (n = 768: BioLORD/DeBERTa o/down) stays v2 — v3 measured 0.91–0.94× at
1118/// m=64 there, and m is unknown at load. `OSFKB_ENC_GEMM3=0` pins v2 everywhere.
1119pub(crate) fn gemm3_eligible(n: usize) -> bool {
1120    (n >= 1152 || n <= 512)
1121        && gemm2_enabled() // OSFKB_ENC_GEMM2=0 is the deep escape hatch: EVERYTHING reverts to v1
1122        && !matches!(
1123            std::env::var("OSFKB_ENC_GEMM3").ok().as_deref(),
1124            Some("0") | Some("off")
1125        )
1126}
1127
1128/// The MID-band widths (BioLORD/DeBERTa o/down at n=768, Qwen3-embed at 1024): v3 with an
1129/// m-dependent arm. Weights upload `[k,n]`-transposed; the REPLAY picks plain v3 when the grid
1130/// fills the GPU (m=512: o 1.47×, down 2.02× over v2) and the split-K pair when it does not
1131/// (m=64 plain v3 measured 0.88–0.97× — the k-split restores the missing workgroups).
1132/// `OSFKB_ENC_GEMM3_SK=0` pins these widths on v2.
1133pub(crate) fn gemm3_sk_band(n: usize) -> bool {
1134    n > 512
1135        && n < 1152
1136        && n.is_multiple_of(64)
1137        && gemm2_enabled()
1138        && !matches!(
1139            std::env::var("OSFKB_ENC_GEMM3").ok().as_deref(),
1140            Some("0") | Some("off")
1141        )
1142        && std::env::var("OSFKB_ENC_GEMM3_SK").ok().as_deref() != Some("0")
1143}
1144
1145/// The SMALL-n band's split-K arm (`OSFKB_ENC_SK_SMALLN=0` reverts to plain v3): /score's
1146/// n=384 o/down bucket — 42% of that plan by the `OSFKB_ENC_TS` timestamps — tops out at
1147/// 72 workgroups under its best plain tile at m=192. The lab (`gemm3_smalln_bench`) measured
1148/// the pair 890 → 1338 GF/s on o (+50%) and 1398 → 1664 on down (+19%) there, while at
1149/// m=232 (90 WGs, the GLiNER shapes) plain KEEPS winning (1493 vs 1267) — so this only
1150/// selects the weight-side machinery; the replay decides per dispatch at the ~80-WG knee.
1151/// Honors the mid band's master switch too: `OSFKB_ENC_GEMM3_SK=0` pins plain everywhere.
1152pub(crate) fn gemm3_smalln_sk(n: usize) -> bool {
1153    n <= 512
1154        && n.is_multiple_of(64)
1155        && std::env::var("OSFKB_ENC_SK_SMALLN").ok().as_deref() != Some("0")
1156        && std::env::var("OSFKB_ENC_GEMM3_SK").ok().as_deref() != Some("0")
1157}
1158
1159/// `OSFKB_ENC_F16A=1`: run the transposed-eligible GEMM steps with F16 ARITHMETIC (see
1160/// [`enc_gemm3_f16a_src`]). Read per LOAD, deliberately not latched — the cosine gate builds
1161/// one encoder each way in one process. Requires SHADER_F16 at the call site (the caller
1162/// falls back to f32 v3 silently where absent — the flag then changes nothing).
1163pub(crate) fn f16a_enabled() -> bool {
1164    std::env::var("OSFKB_ENC_F16A").ok().as_deref() == Some("1")
1165}
1166
1167/// Split-K chunk count for a weight of depth `k` — static per step (the reduce meta bakes it);
1168/// the replay only chooses plain-vs-split, never the split width.
1169pub(crate) fn gemm3_sk_chunks(k: usize) -> u32 {
1170    if k >= 2048 { 4 } else { 2 }
1171}
1172
1173/// REPLAY-dynamic split plan for the split-K pair: `(use 32-row tile, split width)`. The
1174/// reduce meta's `sk` word is rewritten per encode (next to its `m`), so both track the token
1175/// count. Measured (`gemm3_smalln_bench` + the `sk_tile_lab` ladders, 9/9 quiet-window
1176/// shapes): the pair wants its TOTAL grid at ~128–160 workgroups, and the 32-ROW partial tile
1177/// beats the 16-row one wherever its grid still reaches ≥ 96 WGs at that width — twice the
1178/// per-thread work halves the reduce planes and the staging barriers: o (192,384,384)
1179/// 985 → 1485 GF/s (+51%), down +32%, mid-band (64,768,768) 1044 → 1660 (+59%),
1180/// (64,768,3072) +51%. Below 96 WGs the 16-row tile keeps the occupancy (m=32: 1393 vs 1341).
1181/// Deep-k steps keep the mid band's measured width floor ([`gemm3_sk_chunks`]).
1182pub(crate) fn gemm3_sk_plan(m: usize, n: usize, k: usize) -> (bool, u32) {
1183    // `OSFKB_ENC_SK_DYNZ=0` pins the old static plan (16-row tile, k-based width);
1184    // `OSFKB_ENC_SK32=0` keeps dynamic width but pins the 16-row tile. Both read per call,
1185    // unlatched — one process A/Bs each arm; this runs a handful of times per encode.
1186    if std::env::var("OSFKB_ENC_SK_DYNZ").ok().as_deref() == Some("0") {
1187        return (false, gemm3_sk_chunks(k));
1188    }
1189    let cols = n.div_ceil(GEMM3_SK_TILE.1);
1190    let zpick = |base: usize| {
1191        let mut z = 8u32;
1192        while z > 2 && base * z as usize > 160 {
1193            z >>= 1;
1194        }
1195        z.max(gemm3_sk_chunks(k))
1196    };
1197    let base32 = cols * m.div_ceil(GEMM3_SK_TILE32.0);
1198    let z32 = zpick(base32);
1199    if base32 * z32 as usize >= 96 && std::env::var("OSFKB_ENC_SK32").ok().as_deref() != Some("0") {
1200        return (true, z32);
1201    }
1202    (false, zpick(cols * m.div_ceil(GEMM3_SK_TILE.0)))
1203}
1204
1205/// The split-K partials scratch length, in f32s. The pair only ever DISPATCHES below the
1206/// occupancy knees (small-n: wgs < 80; mid band: `gemm3_grid_ok` false ⟹ wgs < 64), and
1207/// wgs < 80 ⟹ `m·n ≤ 79·(16·64)` = 80 896 elements per plane — so 8 planes (the deepest
1208/// split) need at most ~647k f32, INDEPENDENT of `max_tokens` and `n`. The old sizing
1209/// (4·max_tokens·1088) allocated 142 MB at the service's 8192-token cap; this is 2.6 MB and
1210/// browser-safe. `resolve_step` asserts the invariant before every split dispatch.
1211pub(crate) const SK_PART_F32: usize = 8 * 81 * 1024;
1212
1213/// The split-K tile: the small-m occupancy case is exactly where the smallest v3 tile lives.
1214pub(crate) const GEMM3_SK_TILE: (usize, usize, usize) = (16, 64, 8);
1215
1216/// The FAT split-K partial tile ([`gemm3_sk_plan`] picks it when its grid reaches ≥ 96 WGs):
1217/// twice the rows per workgroup, measured +32–59% over the 16-row tile at every shape whose
1218/// grid can afford it.
1219pub(crate) const GEMM3_SK_TILE32: (usize, usize, usize) = (32, 64, 8);
1220
1221/// Row-wise LayerNorm with optional fused residual add and optional bias:
1222/// `out = (v - mean) / sqrt(var + eps) * w (+ b)`, `v = x (+ res)`. One 256-thread workgroup per
1223/// row; biased variance (the HF convention). Fixed-order tree reductions keep it deterministic.
1224const ENC_LAYERNORM: &str = r#"
1225struct Meta { h: u32, flags: u32, eps: f32, pad: u32 }
1226@group(0) @binding(0) var<storage, read> x: array<f32>;
1227@group(0) @binding(1) var<storage, read> res: array<f32>;
1228@group(0) @binding(2) var<storage, read> w: array<f32>;
1229@group(0) @binding(3) var<storage, read> b: array<f32>;
1230@group(0) @binding(4) var<storage, read_write> out: array<f32>;
1231@group(0) @binding(5) var<uniform> mt: Meta;
1232var<workgroup> sh: array<f32, 256>;
1233fn val(base: u32, i: u32) -> f32 {
1234    var v = x[base + i];
1235    if ((mt.flags & 1u) != 0u) { v += res[base + i]; }
1236    return v;
1237}
1238@compute @workgroup_size(256)
1239fn main(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) t: u32) {
1240    let base = wg.x * mt.h;
1241    var sum = 0.0;
1242    for (var i = t; i < mt.h; i += 256u) { sum += val(base, i); }
1243    sh[t] = sum;
1244    workgroupBarrier();
1245    for (var s = 128u; s > 0u; s >>= 1u) {
1246        if (t < s) { sh[t] += sh[t + s]; }
1247        workgroupBarrier();
1248    }
1249    // flags bit 2: RMS mode — no mean-centering (variance below becomes mean-of-squares).
1250    var mean = sh[0] / f32(mt.h);
1251    if ((mt.flags & 4u) != 0u) { mean = 0.0; }
1252    workgroupBarrier();
1253    var sq = 0.0;
1254    for (var i = t; i < mt.h; i += 256u) {
1255        let d = val(base, i) - mean;
1256        sq += d * d;
1257    }
1258    sh[t] = sq;
1259    workgroupBarrier();
1260    for (var s = 128u; s > 0u; s >>= 1u) {
1261        if (t < s) { sh[t] += sh[t + s]; }
1262        workgroupBarrier();
1263    }
1264    let inv = 1.0 / sqrt(sh[0] / f32(mt.h) + mt.eps);
1265    for (var i = t; i < mt.h; i += 256u) {
1266        var o = (val(base, i) - mean) * inv * w[i];
1267        if ((mt.flags & 2u) != 0u) { o += b[i]; }
1268        out[base + i] = o;
1269    }
1270}
1271"#;
1272
1273/// Dense full-sequence attention over a ragged batch, flash-style online softmax: one workgroup
1274/// per (query row, head), 64-key tiles, running max/denom, per-thread output-dim accumulators
1275/// (`hd ≤ 128`). Mask mode: 0 = bidirectional, 1 = causal; `window > 0` further restricts to
1276/// `|i−j| ≤ window/2` (ModernBERT local span semantics). No padding exists — the j-range comes
1277/// from `seq_starts`, so masking is exact by construction.
1278const ENC_ATTN: &str = r#"
1279struct Meta { nrows: u32, n_heads: u32, hd: u32, mode: u32, window: u32, n_kv_heads: u32, packed: u32, p2: u32 }
1280@group(0) @binding(0) var<storage, read> q: array<f32>;
1281@group(0) @binding(1) var<storage, read> k: array<f32>;
1282@group(0) @binding(2) var<storage, read> v: array<f32>;
1283@group(0) @binding(3) var<storage, read_write> out: array<f32>;
1284@group(0) @binding(4) var<storage, read> seq_starts: array<u32>;
1285@group(0) @binding(5) var<storage, read> seq_of: array<u32>;
1286@group(0) @binding(6) var<storage, read> valid: array<u32>;
1287@group(0) @binding(7) var<uniform> mt: Meta;
1288var<workgroup> qsh: array<f32, 128>;
1289var<workgroup> psh: array<f32, 64>;
1290var<workgroup> red: array<f32, 64>;
1291@compute @workgroup_size(64)
1292fn main(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) t: u32) {
1293    let i = wg.x;
1294    let head = wg.y;
1295    let h = mt.n_heads * mt.hd;
1296    // GQA: q spans n_heads, k/v span n_kv_heads; each kv head serves n_heads/n_kv_heads q heads.
1297    let kvh = mt.n_kv_heads * mt.hd;
1298    let kv_head = head / (mt.n_heads / mt.n_kv_heads);
1299    // mt.packed == 1: q/k/v are three VIEWS of one [T, q|k|v] buffer (the fused-QKV GEMM's
1300    // output, bound to all three slots) — row stride h+2·kvh, k at offset h, v at h+kvh.
1301    // mt.packed == 0: the historical three separate [T, ·] buffers.
1302    var qstride = h;
1303    var kstride = kvh;
1304    var koff = 0u;
1305    var voff = 0u;
1306    if (mt.packed == 1u) {
1307        qstride = h + 2u * kvh;
1308        kstride = h + 2u * kvh;
1309        koff = h;
1310        voff = h + kvh;
1311    }
1312    let qbase = i * qstride + head * mt.hd;
1313    let obase = i * h + head * mt.hd;
1314    for (var d = t; d < mt.hd; d += 64u) { qsh[d] = q[qbase + d]; }
1315    workgroupBarrier();
1316
1317    let s = seq_of[i];
1318    let a = seq_starts[s];
1319    let b = seq_starts[s + 1u];
1320    var jstart = a;
1321    var jend = b;
1322    if (mt.mode == 1u) { jend = min(jend, i + 1u); }
1323    if (mt.window > 0u) {
1324        let half_w = mt.window / 2u;
1325        if (i - a > half_w) { jstart = max(jstart, i - half_w); }
1326        jend = min(jend, i + half_w + 1u);
1327    }
1328
1329    let scale = 1.0 / sqrt(f32(mt.hd));
1330    var m_run = -3.0e38;
1331    var l_run = 0.0;
1332    // Per-thread output dims: d = t, t + 64 (hd ≤ 128).
1333    var acc0 = 0.0;
1334    var acc1 = 0.0;
1335    let ntiles = (jend - jstart + 63u) / 64u;
1336    for (var tile = 0u; tile < ntiles; tile++) {
1337        let j = jstart + tile * 64u + t;
1338        var score = -3.0e38;
1339        // valid[j] == 0 (ColBERT expansion pads): never an attention key.
1340        if (j < jend && valid[j] != 0u) {
1341            var dot = 0.0;
1342            let kbase = j * kstride + koff + kv_head * mt.hd;
1343            for (var d = 0u; d < mt.hd; d++) { dot += qsh[d] * k[kbase + d]; }
1344            score = dot * scale;
1345        }
1346        psh[t] = score;
1347        red[t] = score;
1348        workgroupBarrier();
1349        for (var r = 32u; r > 0u; r >>= 1u) {
1350            if (t < r) { red[t] = max(red[t], red[t + r]); }
1351            workgroupBarrier();
1352        }
1353        let tile_max = red[0];
1354        workgroupBarrier();
1355        let new_m = max(m_run, tile_max);
1356        let rescale = exp(m_run - new_m);
1357        // exp of masked lanes is exp(-inf) = 0 — they contribute nothing.
1358        var p = 0.0;
1359        if (psh[t] > -3.0e37) { p = exp(psh[t] - new_m); }
1360        psh[t] = p;
1361        red[t] = p;
1362        workgroupBarrier();
1363        for (var r = 32u; r > 0u; r >>= 1u) {
1364            if (t < r) { red[t] += red[t + r]; }
1365            workgroupBarrier();
1366        }
1367        l_run = l_run * rescale + red[0];
1368        workgroupBarrier();
1369        acc0 *= rescale;
1370        acc1 *= rescale;
1371        let tile_len = min(64u, jend - (jstart + tile * 64u));
1372        let d0 = t;
1373        let d1 = t + 64u;
1374        for (var jj = 0u; jj < tile_len; jj++) {
1375            let vbase = (jstart + tile * 64u + jj) * kstride + voff + kv_head * mt.hd;
1376            let p_j = psh[jj];
1377            if (d0 < mt.hd) { acc0 += p_j * v[vbase + d0]; }
1378            if (d1 < mt.hd) { acc1 += p_j * v[vbase + d1]; }
1379        }
1380        m_run = new_m;
1381        workgroupBarrier();
1382    }
1383    var inv = 0.0;
1384    if (l_run > 0.0) { inv = 1.0 / l_run; }
1385    if (t < mt.hd) { out[obase + t] = acc0 * inv; }
1386    if (t + 64u < mt.hd) { out[obase + t + 64u] = acc1 * inv; }
1387}
1388"#;
1389
1390/// [`ENC_ATTN`] with vec4 loads — the production head dims (32/64/128) are all %4, and the
1391/// scalar kernel issues hd MACs per key from hd scalar loads where this one issues hd/4 vec4
1392/// dots from hd/4 vec4 loads (both the QK score and the P·V accumulate). Same online-softmax
1393/// structure, same meta (including the packed-QKV strides — all %4 since hd is); FP order
1394/// inside a dot regroups, so this is tolerance-class vs the scalar kernel, gated by the same
1395/// synth parity suites. Selected at plan build when `head_dim % 4 == 0` (see `push_attn_src`);
1396/// the scalar kernel remains for exotic head dims.
1397const ENC_ATTN_V4: &str = r#"
1398struct Meta { nrows: u32, n_heads: u32, hd: u32, mode: u32, window: u32, n_kv_heads: u32, packed: u32, p2: u32 }
1399@group(0) @binding(0) var<storage, read> q: array<vec4<f32>>;
1400@group(0) @binding(1) var<storage, read> k: array<vec4<f32>>;
1401@group(0) @binding(2) var<storage, read> v: array<vec4<f32>>;
1402@group(0) @binding(3) var<storage, read_write> out: array<vec4<f32>>;
1403@group(0) @binding(4) var<storage, read> seq_starts: array<u32>;
1404@group(0) @binding(5) var<storage, read> seq_of: array<u32>;
1405@group(0) @binding(6) var<storage, read> valid: array<u32>;
1406@group(0) @binding(7) var<uniform> mt: Meta;
1407var<workgroup> qsh: array<vec4<f32>, 32>;
1408var<workgroup> psh: array<f32, 64>;
1409var<workgroup> red: array<f32, 64>;
1410@compute @workgroup_size(64)
1411fn main(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) t: u32) {
1412    let i = wg.x;
1413    let head = wg.y;
1414    let h = mt.n_heads * mt.hd;
1415    let kvh = mt.n_kv_heads * mt.hd;
1416    let kv_head = head / (mt.n_heads / mt.n_kv_heads);
1417    let hd4 = mt.hd / 4u;
1418    var qstride = h;
1419    var kstride = kvh;
1420    var koff = 0u;
1421    var voff = 0u;
1422    if (mt.packed == 1u) {
1423        qstride = h + 2u * kvh;
1424        kstride = h + 2u * kvh;
1425        koff = h;
1426        voff = h + kvh;
1427    }
1428    let qbase4 = (i * qstride + head * mt.hd) / 4u;
1429    let obase4 = (i * h + head * mt.hd) / 4u;
1430    for (var d = t; d < hd4; d += 64u) { qsh[d] = q[qbase4 + d]; }
1431    workgroupBarrier();
1432
1433    let s = seq_of[i];
1434    let a = seq_starts[s];
1435    let b = seq_starts[s + 1u];
1436    var jstart = a;
1437    var jend = b;
1438    if (mt.mode == 1u) { jend = min(jend, i + 1u); }
1439    if (mt.window > 0u) {
1440        let half_w = mt.window / 2u;
1441        if (i - a > half_w) { jstart = max(jstart, i - half_w); }
1442        jend = min(jend, i + half_w + 1u);
1443    }
1444
1445    let scale = 1.0 / sqrt(f32(mt.hd));
1446    var m_run = -3.0e38;
1447    var l_run = 0.0;
1448    // Per-thread output: dims [4t, 4t+4) — one vec4 (hd ≤ 128 ⇒ hd4 ≤ 32 active threads).
1449    var acc = vec4<f32>(0.0);
1450    let ntiles = (jend - jstart + 63u) / 64u;
1451    for (var tile = 0u; tile < ntiles; tile++) {
1452        let j = jstart + tile * 64u + t;
1453        var score = -3.0e38;
1454        if (j < jend && valid[j] != 0u) {
1455            var dot4 = 0.0;
1456            let kbase4 = (j * kstride + koff + kv_head * mt.hd) / 4u;
1457            for (var d = 0u; d < hd4; d++) { dot4 += dot(qsh[d], k[kbase4 + d]); }
1458            score = dot4 * scale;
1459        }
1460        psh[t] = score;
1461        red[t] = score;
1462        workgroupBarrier();
1463        for (var r = 32u; r > 0u; r >>= 1u) {
1464            if (t < r) { red[t] = max(red[t], red[t + r]); }
1465            workgroupBarrier();
1466        }
1467        let tile_max = red[0];
1468        workgroupBarrier();
1469        let new_m = max(m_run, tile_max);
1470        let rescale = exp(m_run - new_m);
1471        var p = 0.0;
1472        if (psh[t] > -3.0e37) { p = exp(psh[t] - new_m); }
1473        psh[t] = p;
1474        red[t] = p;
1475        workgroupBarrier();
1476        for (var r = 32u; r > 0u; r >>= 1u) {
1477            if (t < r) { red[t] += red[t + r]; }
1478            workgroupBarrier();
1479        }
1480        l_run = l_run * rescale + red[0];
1481        workgroupBarrier();
1482        acc *= rescale;
1483        let tile_len = min(64u, jend - (jstart + tile * 64u));
1484        for (var jj = 0u; jj < tile_len; jj++) {
1485            let vbase4 = ((jstart + tile * 64u + jj) * kstride + voff + kv_head * mt.hd) / 4u;
1486            let p_j = psh[jj];
1487            if (t < hd4) { acc += p_j * v[vbase4 + t]; }
1488        }
1489        m_run = new_m;
1490        workgroupBarrier();
1491    }
1492    var inv = 0.0;
1493    if (l_run > 0.0) { inv = 1.0 / l_run; }
1494    if (t < hd4) { out[obase4 + t] = acc * inv; }
1495}
1496"#;
1497
1498/// [`ENC_ATTN_V4`] with REGISTER-BLOCKED query rows — the encoder's version of the lesson the
1499/// vision tower and the decode prefill both learned: K/V traffic goes as `T²·hd / RB`, and the
1500/// scalar/v4 kernels run RB = 1. One workgroup = RB(4) query rows × one head; each thread scores
1501/// ONE key against all four rows (the `k[j]` vec4s are read once and dotted four times), and the
1502/// softmax max/sum ladders run all four rows SIMULTANEOUSLY (threads = 4 rows × 16 lanes), so the
1503/// barrier count per 64-key tile is exactly the per-row kernel's — staging K/V in shared lost on
1504/// Metal for barrier reasons (vision, measured); sharing through REGISTERS is the shape that won.
1505/// Rows may straddle sequence boundaries (ragged): bounds/masks are per row, the tile loop walks
1506/// the union. Thread (row, dim) ownership needs `hd ≤ 64` (16 lanes ≥ hd/4); hd=128 keeps v4.
1507const ENC_ATTN_RB4: &str = r#"
1508struct Meta { nrows: u32, n_heads: u32, hd: u32, mode: u32, window: u32, n_kv_heads: u32, packed: u32, p2: u32 }
1509@group(0) @binding(0) var<storage, read> q: array<vec4<f32>>;
1510@group(0) @binding(1) var<storage, read> k: array<vec4<f32>>;
1511@group(0) @binding(2) var<storage, read> v: array<vec4<f32>>;
1512@group(0) @binding(3) var<storage, read_write> out: array<vec4<f32>>;
1513@group(0) @binding(4) var<storage, read> seq_starts: array<u32>;
1514@group(0) @binding(5) var<storage, read> seq_of: array<u32>;
1515@group(0) @binding(6) var<storage, read> valid: array<u32>;
1516@group(0) @binding(7) var<uniform> mt: Meta;
1517const RB: u32 = 4u;
1518var<workgroup> qsh: array<vec4<f32>, 64>;   // [RB][hd/4 <= 16]
1519var<workgroup> ssh: array<f32, 256>;        // [RB][64] scores -> probabilities
1520var<workgroup> red: array<f32, 64>;         // [RB][16] reduction ladder
1521var<workgroup> ja_r: array<u32, 4>;
1522var<workgroup> jb_r: array<u32, 4>;
1523var<workgroup> m_run: array<f32, 4>;
1524var<workgroup> l_run: array<f32, 4>;
1525var<workgroup> m_new: array<f32, 4>;
1526var<workgroup> resc: array<f32, 4>;
1527@compute @workgroup_size(64)
1528fn main(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) t: u32) {
1529    let head = wg.y;
1530    let r0 = wg.x * RB;
1531    let h = mt.n_heads * mt.hd;
1532    let kvh = mt.n_kv_heads * mt.hd;
1533    let kv_head = head / (mt.n_heads / mt.n_kv_heads);
1534    let hd4 = mt.hd / 4u;
1535    var qstride = h;
1536    var kstride = kvh;
1537    var koff = 0u;
1538    var voff = 0u;
1539    if (mt.packed == 1u) {
1540        qstride = h + 2u * kvh;
1541        kstride = h + 2u * kvh;
1542        koff = h;
1543        voff = h + kvh;
1544    }
1545    // Per-row bounds (ragged: rows may sit in different sequences; window/causal are per row).
1546    if (t < RB) {
1547        let i = r0 + t;
1548        var ja = 0u;
1549        var jb = 0u;
1550        if (i < mt.nrows) {
1551            let s = seq_of[i];
1552            ja = seq_starts[s];
1553            jb = seq_starts[s + 1u];
1554            if (mt.mode == 1u) { jb = min(jb, i + 1u); }
1555            if (mt.window > 0u) {
1556                let half_w = mt.window / 2u;
1557                if (i - ja > half_w) { ja = max(ja, i - half_w); }
1558                jb = min(jb, i + half_w + 1u);
1559            }
1560        }
1561        ja_r[t] = ja;
1562        jb_r[t] = jb;
1563        m_run[t] = -3.0e38;
1564        l_run[t] = 0.0;
1565    }
1566    // Stage the RB query rows (cooperative: 64 threads over RB·hd4 <= 64 vec4s).
1567    if (t < RB * hd4) {
1568        let r = t / hd4;
1569        let d = t % hd4;
1570        let i = r0 + r;
1571        var qv = vec4<f32>(0.0);
1572        if (i < mt.nrows) { qv = q[(i * qstride + head * mt.hd) / 4u + d]; }
1573        qsh[r * hd4 + d] = qv;
1574    }
1575    workgroupBarrier();
1576    let jlo = min(min(ja_r[0], ja_r[1]), min(ja_r[2], ja_r[3]));
1577    let jhi = max(max(jb_r[0], jb_r[1]), max(jb_r[2], jb_r[3]));
1578
1579    let scale = 1.0 / sqrt(f32(mt.hd));
1580    // Thread (rr, dd) owns output dims [4·dd, 4·dd+4) of row r0+rr.
1581    let rr = t / 16u;
1582    let dd = t % 16u;
1583    var acc = vec4<f32>(0.0);
1584    let span = jhi - jlo;
1585    let ntiles = (span + 63u) / 64u;
1586    for (var tile = 0u; tile < ntiles; tile++) {
1587        let j = jlo + tile * 64u + t;
1588        // Score key j against ALL RB rows: k[j] is read ONCE and dotted four times.
1589        var d0 = -3.0e38;
1590        var d1 = -3.0e38;
1591        var d2 = -3.0e38;
1592        var d3 = -3.0e38;
1593        if (j < jhi && valid[j] != 0u) {
1594            var s0 = 0.0;
1595            var s1 = 0.0;
1596            var s2 = 0.0;
1597            var s3 = 0.0;
1598            let kbase4 = (j * kstride + koff + kv_head * mt.hd) / 4u;
1599            for (var d = 0u; d < hd4; d++) {
1600                let kv = k[kbase4 + d];
1601                s0 += dot(kv, qsh[d]);
1602                s1 += dot(kv, qsh[hd4 + d]);
1603                s2 += dot(kv, qsh[2u * hd4 + d]);
1604                s3 += dot(kv, qsh[3u * hd4 + d]);
1605            }
1606            if (j >= ja_r[0] && j < jb_r[0]) { d0 = s0 * scale; }
1607            if (j >= ja_r[1] && j < jb_r[1]) { d1 = s1 * scale; }
1608            if (j >= ja_r[2] && j < jb_r[2]) { d2 = s2 * scale; }
1609            if (j >= ja_r[3] && j < jb_r[3]) { d3 = s3 * scale; }
1610        }
1611        ssh[t] = d0;
1612        ssh[64u + t] = d1;
1613        ssh[128u + t] = d2;
1614        ssh[192u + t] = d3;
1615        workgroupBarrier();
1616        // Row max, all four rows at once: lane L of row R reduces keys L, L+16, L+32, L+48.
1617        let lane = dd;
1618        var mx = ssh[rr * 64u + lane];
1619        mx = max(mx, ssh[rr * 64u + lane + 16u]);
1620        mx = max(mx, ssh[rr * 64u + lane + 32u]);
1621        mx = max(mx, ssh[rr * 64u + lane + 48u]);
1622        red[t] = mx;
1623        workgroupBarrier();
1624        for (var r = 8u; r > 0u; r >>= 1u) {
1625            if (lane < r) { red[t] = max(red[t], red[t + r]); }
1626            workgroupBarrier();
1627        }
1628        if (t < RB) {
1629            let nm = max(m_run[t], red[t * 16u]);
1630            m_new[t] = nm;
1631            resc[t] = exp(m_run[t] - nm);
1632        }
1633        workgroupBarrier();
1634        // exp + row sums, same ladder.
1635        var ps = 0.0;
1636        for (var rr2 = 0u; rr2 < RB; rr2++) {
1637            let sc = ssh[rr2 * 64u + t];
1638            var p = 0.0;
1639            if (sc > -3.0e37) { p = exp(sc - m_new[rr2]); }
1640            ssh[rr2 * 64u + t] = p;
1641        }
1642        workgroupBarrier();
1643        ps = ssh[rr * 64u + lane] + ssh[rr * 64u + lane + 16u]
1644            + ssh[rr * 64u + lane + 32u] + ssh[rr * 64u + lane + 48u];
1645        red[t] = ps;
1646        workgroupBarrier();
1647        for (var r = 8u; r > 0u; r >>= 1u) {
1648            if (lane < r) { red[t] += red[t + r]; }
1649            workgroupBarrier();
1650        }
1651        if (t < RB) { l_run[t] = l_run[t] * resc[t] + red[t * 16u]; }
1652        // P·V: thread (rr, dd) accumulates its vec4 of row rr.
1653        acc *= resc[rr];
1654        let tile_len = min(64u, jhi - (jlo + tile * 64u));
1655        if (dd < hd4) {
1656            for (var jj = 0u; jj < tile_len; jj++) {
1657                let p_j = ssh[rr * 64u + jj];
1658                let vbase4 = ((jlo + tile * 64u + jj) * kstride + voff + kv_head * mt.hd) / 4u;
1659                acc += p_j * v[vbase4 + dd];
1660            }
1661        }
1662        if (t < RB) { m_run[t] = m_new[t]; }
1663        workgroupBarrier();
1664    }
1665    let i = r0 + rr;
1666    if (i < mt.nrows && dd < hd4) {
1667        var inv = 0.0;
1668        if (l_run[rr] > 0.0) { inv = 1.0 / l_run[rr]; }
1669        out[(i * h + head * mt.hd) / 4u + dd] = acc * inv;
1670    }
1671}
1672"#;
1673
1674/// DeBERTa disentangled attention (GLiNER backbone). Same flash-style online softmax as
1675/// [`ENC_ATTN`] with two extra score terms per key: content→position (`c2p`, `q_i·Kr[m]`) and
1676/// position→content (`p2c`, `k_j·Qr[m]`), where `m = clamp(bucket(i−j) + span, 0, 2·span−1)` is
1677/// computed in-shader (cheap integer math — no `[T,T]` index buffer). `Kr`/`Qr` are THIS layer's
1678/// relative projections (`pos_k`/`pos_q`, `[2·span, n_heads·hd]`), baked into the bind group at
1679/// load. The scale divides EVERY term by `sqrt(hd · scale_factor)`. Bidirectional only; no GQA
1680/// (DeBERTa has `n_kv_heads == n_heads`). See `RelAttn` for the derivation of the shared index.
1681const ENC_ATTN_DISENT: &str = r#"
1682struct Meta { nrows: u32, n_heads: u32, hd: u32, span: u32, max_rel: u32, sf: u32, c2p: u32, p2c: u32 }
1683@group(0) @binding(0) var<storage, read> q: array<f32>;
1684@group(0) @binding(1) var<storage, read> k: array<f32>;
1685@group(0) @binding(2) var<storage, read> v: array<f32>;
1686@group(0) @binding(3) var<storage, read_write> out: array<f32>;
1687@group(0) @binding(4) var<storage, read> seq_starts: array<u32>;
1688@group(0) @binding(5) var<storage, read> seq_of: array<u32>;
1689@group(0) @binding(6) var<storage, read> valid: array<u32>;
1690@group(0) @binding(7) var<storage, read> pos_k: array<f32>;
1691@group(0) @binding(8) var<storage, read> pos_q: array<f32>;
1692@group(0) @binding(9) var<uniform> mt: Meta;
1693var<workgroup> qsh: array<f32, 128>;
1694var<workgroup> psh: array<f32, 64>;
1695var<workgroup> red: array<f32, 64>;
1696// HF make_log_bucket_position: identity within ±span/2, logarithmic (ceil) beyond. Odd in rel,
1697// which is why one index m serves both the c2p and p2c gathers.
1698fn bucket(rel: i32, span: u32, max_rel: u32) -> i32 {
1699    let mid = i32(span / 2u);
1700    let a = abs(rel);
1701    if (a <= mid) { return rel; }
1702    let mid_f = f32(mid);
1703    let lp = ceil(log(f32(a) / mid_f) / log((f32(max_rel) - 1.0) / mid_f) * (mid_f - 1.0)) + mid_f;
1704    let sgn = select(1.0, -1.0, rel < 0);
1705    return i32(sgn * lp);
1706}
1707@compute @workgroup_size(64)
1708fn main(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) t: u32) {
1709    let i = wg.x;
1710    let head = wg.y;
1711    let h = mt.n_heads * mt.hd;
1712    let p2c_on = (mt.p2c & 1u) != 0u;
1713    var qkstride = h;
1714    var koff = 0u;
1715    var voff = 0u;
1716    if ((mt.p2c & 2u) != 0u) {
1717        qkstride = 3u * h;
1718        koff = h;
1719        voff = 2u * h;
1720    }
1721    let qbase = i * qkstride + head * mt.hd;
1722    let obase = i * h + head * mt.hd;
1723    for (var d = t; d < mt.hd; d += 64u) { qsh[d] = q[qbase + d]; }
1724    workgroupBarrier();
1725
1726    let s = seq_of[i];
1727    let a = seq_starts[s];
1728    let b = seq_starts[s + 1u];
1729    let qi_local = i32(i - a);
1730    let two_span = i32(2u * mt.span);
1731
1732    let scale = 1.0 / sqrt(f32(mt.hd) * f32(mt.sf));
1733    var m_run = -3.0e38;
1734    var l_run = 0.0;
1735    var acc0 = 0.0;
1736    var acc1 = 0.0;
1737    let ntiles = (b - a + 63u) / 64u;
1738    for (var tile = 0u; tile < ntiles; tile++) {
1739        let j = a + tile * 64u + t;
1740        var score = -3.0e38;
1741        if (j < b && valid[j] != 0u) {
1742            let kbase = j * qkstride + koff + head * mt.hd;
1743            // Relative-table row for (i, j): shared by the c2p and p2c terms.
1744            let m = u32(clamp(bucket(qi_local - i32(j - a), mt.span, mt.max_rel) + i32(mt.span),
1745                              0, two_span - 1));
1746            let rbase = m * h + head * mt.hd;
1747            var dot = 0.0;
1748            for (var d = 0u; d < mt.hd; d++) {
1749                let kd = k[kbase + d];
1750                dot += qsh[d] * kd;                                  // content → content
1751                if (mt.c2p != 0u) { dot += qsh[d] * pos_k[rbase + d]; }  // content q → position k
1752                if (p2c_on) { dot += kd * pos_q[rbase + d]; }      // position q -> content k
1753            }
1754            score = dot * scale;
1755        }
1756        psh[t] = score;
1757        red[t] = score;
1758        workgroupBarrier();
1759        for (var r = 32u; r > 0u; r >>= 1u) {
1760            if (t < r) { red[t] = max(red[t], red[t + r]); }
1761            workgroupBarrier();
1762        }
1763        let tile_max = red[0];
1764        workgroupBarrier();
1765        let new_m = max(m_run, tile_max);
1766        let rescale = exp(m_run - new_m);
1767        var p = 0.0;
1768        if (psh[t] > -3.0e37) { p = exp(psh[t] - new_m); }
1769        psh[t] = p;
1770        red[t] = p;
1771        workgroupBarrier();
1772        for (var r = 32u; r > 0u; r >>= 1u) {
1773            if (t < r) { red[t] += red[t + r]; }
1774            workgroupBarrier();
1775        }
1776        l_run = l_run * rescale + red[0];
1777        workgroupBarrier();
1778        acc0 *= rescale;
1779        acc1 *= rescale;
1780        let tile_len = min(64u, b - (a + tile * 64u));
1781        let d0 = t;
1782        let d1 = t + 64u;
1783        for (var jj = 0u; jj < tile_len; jj++) {
1784            let vbase = (a + tile * 64u + jj) * qkstride + voff + head * mt.hd;
1785            let p_j = psh[jj];
1786            if (d0 < mt.hd) { acc0 += p_j * v[vbase + d0]; }
1787            if (d1 < mt.hd) { acc1 += p_j * v[vbase + d1]; }
1788        }
1789        m_run = new_m;
1790        workgroupBarrier();
1791    }
1792    var inv = 0.0;
1793    if (l_run > 0.0) { inv = 1.0 / l_run; }
1794    if (t < mt.hd) { out[obase + t] = acc0 * inv; }
1795    if (t + 64u < mt.hd) { out[obase + t + 64u] = acc1 * inv; }
1796}
1797"#;
1798
1799/// [`ENC_ATTN_DISENT`] with REGISTER-BLOCKED query rows — [`ENC_ATTN_RB4`]'s pattern applied to
1800/// the GLiNER backbone's kernel (42% of its plan by the `OSFKB_ENC_TS` profile). K and V are
1801/// read once per FOUR query rows (÷4 on two of the three streams); the c2p/p2c position reads
1802/// stay per (row, key) — their LUT row `m = bucket(i−j)` differs per row, which is also why the
1803/// hoisted-LUT restructure (plan P4) remains the deeper follow-up. Same simultaneous four-row
1804/// softmax ladders; bidirectional only (DeBERTa has no window/causal here), ragged per-row
1805/// bounds; `hd ≤ 64` (DeBERTa-v3: 64 ✓). vec4 loads throughout (hd % 4 == 0).
1806const ENC_ATTN_DISENT_RB4: &str = r#"
1807struct Meta { nrows: u32, n_heads: u32, hd: u32, span: u32, max_rel: u32, sf: u32, c2p: u32, p2c: u32 }
1808@group(0) @binding(0) var<storage, read> q: array<vec4<f32>>;
1809@group(0) @binding(1) var<storage, read> k: array<vec4<f32>>;
1810@group(0) @binding(2) var<storage, read> v: array<vec4<f32>>;
1811@group(0) @binding(3) var<storage, read_write> out: array<vec4<f32>>;
1812@group(0) @binding(4) var<storage, read> seq_starts: array<u32>;
1813@group(0) @binding(5) var<storage, read> seq_of: array<u32>;
1814@group(0) @binding(6) var<storage, read> valid: array<u32>;
1815@group(0) @binding(7) var<storage, read> pos_k: array<vec4<f32>>;
1816@group(0) @binding(8) var<storage, read> pos_q: array<vec4<f32>>;
1817@group(0) @binding(9) var<uniform> mt: Meta;
1818const RB: u32 = 4u;
1819var<workgroup> qsh: array<vec4<f32>, 64>;   // [RB][hd/4 <= 16]
1820var<workgroup> ssh: array<f32, 256>;        // [RB][64]
1821var<workgroup> red: array<f32, 64>;
1822var<workgroup> ja_r: array<u32, 4>;
1823var<workgroup> jb_r: array<u32, 4>;
1824var<workgroup> qi_r: array<i32, 4>;
1825var<workgroup> m_run: array<f32, 4>;
1826var<workgroup> l_run: array<f32, 4>;
1827var<workgroup> m_new: array<f32, 4>;
1828var<workgroup> resc: array<f32, 4>;
1829fn bucket(rel: i32, span: u32, max_rel: u32) -> i32 {
1830    let mid = i32(span / 2u);
1831    let a = abs(rel);
1832    if (a <= mid) { return rel; }
1833    let mid_f = f32(mid);
1834    let lp = ceil(log(f32(a) / mid_f) / log((f32(max_rel) - 1.0) / mid_f) * (mid_f - 1.0)) + mid_f;
1835    let sgn = select(1.0, -1.0, rel < 0);
1836    return i32(sgn * lp);
1837}
1838@compute @workgroup_size(64)
1839fn main(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) t: u32) {
1840    let head = wg.y;
1841    let r0 = wg.x * RB;
1842    let h = mt.n_heads * mt.hd;
1843    let hd4 = mt.hd / 4u;
1844    // mt.p2c: bit 0 = the p2c score term; bit 1 = PACKED q|k|v (the fused-QKV GEMM's output
1845    // bound to all three slots — same trick as ENC_ATTN's packed mode; no GQA here).
1846    let p2c_on = (mt.p2c & 1u) != 0u;
1847    var qkstride = h;
1848    var koff = 0u;
1849    var voff = 0u;
1850    if ((mt.p2c & 2u) != 0u) {
1851        qkstride = 3u * h;
1852        koff = h;
1853        voff = 2u * h;
1854    }
1855    if (t < RB) {
1856        let i = r0 + t;
1857        var ja = 0u;
1858        var jb = 0u;
1859        var qi = 0;
1860        if (i < mt.nrows) {
1861            let s = seq_of[i];
1862            ja = seq_starts[s];
1863            jb = seq_starts[s + 1u];
1864            qi = i32(i - ja);
1865        }
1866        ja_r[t] = ja;
1867        jb_r[t] = jb;
1868        qi_r[t] = qi;
1869        m_run[t] = -3.0e38;
1870        l_run[t] = 0.0;
1871    }
1872    if (t < RB * hd4) {
1873        let r = t / hd4;
1874        let d = t % hd4;
1875        let i = r0 + r;
1876        var qv = vec4<f32>(0.0);
1877        if (i < mt.nrows) { qv = q[(i * qkstride + head * mt.hd) / 4u + d]; }
1878        qsh[r * hd4 + d] = qv;
1879    }
1880    workgroupBarrier();
1881    let jlo = min(min(ja_r[0], ja_r[1]), min(ja_r[2], ja_r[3]));
1882    let jhi = max(max(jb_r[0], jb_r[1]), max(jb_r[2], jb_r[3]));
1883
1884    let scale = 1.0 / sqrt(f32(mt.hd) * f32(mt.sf));
1885    let two_span = i32(2u * mt.span);
1886    let rr = t / 16u;
1887    let dd = t % 16u;
1888    var acc = vec4<f32>(0.0);
1889    let span_keys = jhi - jlo;
1890    let ntiles = (span_keys + 63u) / 64u;
1891    for (var tile = 0u; tile < ntiles; tile++) {
1892        let j = jlo + tile * 64u + t;
1893        var sc0 = -3.0e38;
1894        var sc1 = -3.0e38;
1895        var sc2 = -3.0e38;
1896        var sc3 = -3.0e38;
1897        if (j < jhi && valid[j] != 0u) {
1898            let kbase4 = (j * qkstride + koff + head * mt.hd) / 4u;
1899            // Content dots: k[j] read ONCE, dotted with all four staged q rows.
1900            var c0 = 0.0;
1901            var c1 = 0.0;
1902            var c2 = 0.0;
1903            var c3 = 0.0;
1904            for (var d = 0u; d < hd4; d++) {
1905                let kv = k[kbase4 + d];
1906                c0 += dot(kv, qsh[d]);
1907                c1 += dot(kv, qsh[hd4 + d]);
1908                c2 += dot(kv, qsh[2u * hd4 + d]);
1909                c3 += dot(kv, qsh[3u * hd4 + d]);
1910            }
1911            // Positional terms per row (the LUT row m depends on i−j — not shareable).
1912            for (var r = 0u; r < RB; r++) {
1913                if (j < ja_r[r] || j >= jb_r[r]) { continue; }
1914                let m = u32(clamp(
1915                    bucket(qi_r[r] - i32(j - ja_r[r]), mt.span, mt.max_rel) + i32(mt.span),
1916                    0, two_span - 1));
1917                let rbase4 = (m * h + head * mt.hd) / 4u;
1918                var pterm = 0.0;
1919                for (var d = 0u; d < hd4; d++) {
1920                    if (mt.c2p != 0u) { pterm += dot(qsh[r * hd4 + d], pos_k[rbase4 + d]); }
1921                    if (p2c_on) { pterm += dot(k[kbase4 + d], pos_q[rbase4 + d]); }
1922                }
1923                var content = c0;
1924                if (r == 1u) { content = c1; }
1925                if (r == 2u) { content = c2; }
1926                if (r == 3u) { content = c3; }
1927                let sc = (content + pterm) * scale;
1928                if (r == 0u) { sc0 = sc; }
1929                if (r == 1u) { sc1 = sc; }
1930                if (r == 2u) { sc2 = sc; }
1931                if (r == 3u) { sc3 = sc; }
1932            }
1933        }
1934        ssh[t] = sc0;
1935        ssh[64u + t] = sc1;
1936        ssh[128u + t] = sc2;
1937        ssh[192u + t] = sc3;
1938        workgroupBarrier();
1939        let lane = dd;
1940        var mx = ssh[rr * 64u + lane];
1941        mx = max(mx, ssh[rr * 64u + lane + 16u]);
1942        mx = max(mx, ssh[rr * 64u + lane + 32u]);
1943        mx = max(mx, ssh[rr * 64u + lane + 48u]);
1944        red[t] = mx;
1945        workgroupBarrier();
1946        for (var r = 8u; r > 0u; r >>= 1u) {
1947            if (lane < r) { red[t] = max(red[t], red[t + r]); }
1948            workgroupBarrier();
1949        }
1950        if (t < RB) {
1951            let nm = max(m_run[t], red[t * 16u]);
1952            m_new[t] = nm;
1953            resc[t] = exp(m_run[t] - nm);
1954        }
1955        workgroupBarrier();
1956        for (var rr2 = 0u; rr2 < RB; rr2++) {
1957            let sc = ssh[rr2 * 64u + t];
1958            var p = 0.0;
1959            if (sc > -3.0e37) { p = exp(sc - m_new[rr2]); }
1960            ssh[rr2 * 64u + t] = p;
1961        }
1962        workgroupBarrier();
1963        let ps = ssh[rr * 64u + lane] + ssh[rr * 64u + lane + 16u]
1964            + ssh[rr * 64u + lane + 32u] + ssh[rr * 64u + lane + 48u];
1965        red[t] = ps;
1966        workgroupBarrier();
1967        for (var r = 8u; r > 0u; r >>= 1u) {
1968            if (lane < r) { red[t] += red[t + r]; }
1969            workgroupBarrier();
1970        }
1971        if (t < RB) { l_run[t] = l_run[t] * resc[t] + red[t * 16u]; }
1972        acc *= resc[rr];
1973        let tile_len = min(64u, jhi - (jlo + tile * 64u));
1974        if (dd < hd4) {
1975            for (var jj = 0u; jj < tile_len; jj++) {
1976                let p_j = ssh[rr * 64u + jj];
1977                let vbase4 = ((jlo + tile * 64u + jj) * qkstride + voff + head * mt.hd) / 4u;
1978                acc += p_j * v[vbase4 + dd];
1979            }
1980        }
1981        if (t < RB) { m_run[t] = m_new[t]; }
1982        workgroupBarrier();
1983    }
1984    let i = r0 + rr;
1985    if (i < mt.nrows && dd < hd4) {
1986        var inv = 0.0;
1987        if (l_run[rr] > 0.0) { inv = 1.0 / l_run[rr]; }
1988        out[(i * h + head * mt.hd) / 4u + dd] = acc * inv;
1989    }
1990}
1991"#;
1992
1993/// Mean pooling over ragged sequences: one workgroup per sequence, threads stride the hidden dim.
1994const ENC_MEAN_POOL: &str = r#"
1995struct Meta { h: u32, pad0: u32, pad1: u32, pad2: u32 }
1996@group(0) @binding(0) var<storage, read> hidden: array<f32>;
1997@group(0) @binding(1) var<storage, read_write> out: array<f32>;
1998@group(0) @binding(2) var<storage, read> seq_starts: array<u32>;
1999@group(0) @binding(3) var<uniform> mt: Meta;
2000@compute @workgroup_size(256)
2001fn main(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) t: u32) {
2002    let s = wg.x;
2003    let a = seq_starts[s];
2004    let b = seq_starts[s + 1u];
2005    let inv = 1.0 / f32(max(b - a, 1u));
2006    for (var i = t; i < mt.h; i += 256u) {
2007        var sum = 0.0;
2008        for (var r = a; r < b; r++) { sum += hidden[r * mt.h + i]; }
2009        out[s * mt.h + i] = sum * inv;
2010    }
2011}
2012"#;
2013
2014/// L2 normalization in place: one workgroup per vector. Zero vectors stay unchanged (mirrors the
2015/// CPU semantics — a zero vector has no direction).
2016const ENC_L2NORM: &str = r#"
2017struct Meta { dim: u32, pad0: u32, pad1: u32, pad2: u32 }
2018@group(0) @binding(0) var<storage, read_write> buf: array<f32>;
2019@group(0) @binding(1) var<uniform> mt: Meta;
2020var<workgroup> sh: array<f32, 256>;
2021@compute @workgroup_size(256)
2022fn main(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) t: u32) {
2023    let base = wg.x * mt.dim;
2024    var sq = 0.0;
2025    for (var i = t; i < mt.dim; i += 256u) {
2026        let v = buf[base + i];
2027        sq += v * v;
2028    }
2029    sh[t] = sq;
2030    workgroupBarrier();
2031    for (var s = 128u; s > 0u; s >>= 1u) {
2032        if (t < s) { sh[t] += sh[t + s]; }
2033        workgroupBarrier();
2034    }
2035    let n = sqrt(sh[0]);
2036    if (n > 0.0) {
2037        let inv = 1.0 / n;
2038        for (var i = t; i < mt.dim; i += 256u) { buf[base + i] *= inv; }
2039    }
2040}
2041"#;
2042
2043/// Rotary embedding in place on a `[T, nh·hd]` buffer, HF `rotate_half` convention: pairs
2044/// `(j, j + hd/2)`, frequency `theta^(-2j/hd)`, angle `p·freq` with `p` LOCAL to the sequence
2045/// (`seq_starts`/`seq_of`). One workgroup per row; threads stride (head, j) pairs.
2046const ROPE_ENC: &str = r#"
2047struct Meta { nrows: u32, nh: u32, hd: u32, theta: f32 }
2048@group(0) @binding(0) var<storage, read_write> x: array<f32>;
2049@group(0) @binding(1) var<storage, read> seq_starts: array<u32>;
2050@group(0) @binding(2) var<storage, read> seq_of: array<u32>;
2051@group(0) @binding(3) var<uniform> mt: Meta;
2052@compute @workgroup_size(64)
2053fn main(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) t: u32) {
2054    let i = wg.x;
2055    let p = f32(i - seq_starts[seq_of[i]]);
2056    let half = mt.hd / 2u;
2057    let pairs = mt.nh * half;
2058    let h = mt.nh * mt.hd;
2059    for (var pj = t; pj < pairs; pj += 64u) {
2060        let head = pj / half;
2061        let j = pj % half;
2062        let freq = pow(mt.theta, -2.0 * f32(j) / f32(mt.hd));
2063        let ang = p * freq;
2064        let c = cos(ang);
2065        let s = sin(ang);
2066        let base = i * h + head * mt.hd;
2067        let a = x[base + j];
2068        let b = x[base + j + half];
2069        x[base + j] = a * c - b * s;
2070        x[base + j + half] = a * s + b * c;
2071    }
2072}
2073"#;
2074
2075/// GLU split: `mid` `[T, 2I]` per token as `input | gate`; `out = act(input) ⊙ gate` (HF
2076/// ModernBERT `chunk(2)`; nomic's `silu(fc11)·fc12` concatenates to the identical layout).
2077const GLU_SPLIT: &str = const_format_glu();
2078
2079const fn const_format_glu() -> &'static str {
2080    // ACT_FNS is compile-time constant text; keep the kernel a plain concat-free literal by
2081    // duplicating the two tiny fns (WGSL has no includes; the parity test guards divergence).
2082    r#"
2083struct Meta { i_width: u32, act: u32, p0: u32, p1: u32 }
2084@group(0) @binding(0) var<storage, read> mid: array<f32>;
2085@group(0) @binding(1) var<storage, read_write> out: array<f32>;
2086@group(0) @binding(2) var<uniform> mt: Meta;
2087fn erf_as(x: f32) -> f32 {
2088    let s = sign(x);
2089    let a = abs(x);
2090    let t = 1.0 / (1.0 + 0.3275911 * a);
2091    let y = 1.0 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t
2092        + 0.254829592) * t * exp(-a * a);
2093    return s * y;
2094}
2095fn apply_act(v: f32, code: u32) -> f32 {
2096    switch code {
2097        case 1u: { return 0.5 * v * (1.0 + erf_as(v * 0.70710678)); }
2098        // tanh-GELU. The argument is CLAMPED, exactly as the decoder's kernels already do
2099        // (forward.rs / lib.rs) — WGSL's `tanh` overflows to NaN on some backends for |arg| >~ 88,
2100        // because a naive (e^2x - 1)/(e^2x + 1) expansion divides inf by inf. `arg` reaches 105 at a
2101        // pre-activation of only 13.8, which the text encoders never hit and a ViT's outlier tokens
2102        // hit on the first block. tanh is ±1 to well inside f32 epsilon by |arg| = 20, so this is
2103        // EXACT, not an approximation.
2104        case 2u: {
2105            let a = clamp(0.7978845608 * (v + 0.044715 * v * v * v), -20.0, 20.0);
2106            return 0.5 * v * (1.0 + tanh(a));
2107        }
2108        case 3u: { return v / (1.0 + exp(-v)); }
2109        case 4u: { return tanh(v); }
2110        case 5u: { return max(v, 0.0); }
2111        default: { return v; }
2112    }
2113}
2114@compute @workgroup_size(256)
2115fn main(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) t: u32) {
2116    let row = wg.x;
2117    for (var j = t; j < mt.i_width; j += 256u) {
2118        let a = mid[row * 2u * mt.i_width + j];
2119        let g = mid[row * 2u * mt.i_width + mt.i_width + j];
2120        out[row * mt.i_width + j] = apply_act(a, mt.act) * g;
2121    }
2122}
2123"#
2124}
2125
2126/// Elementwise residual add: `dst += src` (the prenorm residual, which LAYERNORM_RES cannot
2127/// express — it always normalizes).
2128const ENC_ADD: &str = r#"
2129struct Meta { n: u32, p0: u32, p1: u32, p2: u32 }
2130@group(0) @binding(0) var<storage, read_write> dst: array<f32>;
2131@group(0) @binding(1) var<storage, read> src: array<f32>;
2132@group(0) @binding(2) var<uniform> mt: Meta;
2133@compute @workgroup_size(256)
2134fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
2135    if (gid.x < mt.n) { dst[gid.x] += src[gid.x]; }
2136}
2137"#;
2138
2139/// Per-head RMS normalization in place over `[T, n_heads·hd]` with a shared `[hd]` weight
2140/// (Qwen3 qk-norm, applied BEFORE rotary). One workgroup per row.
2141const ENC_QK_NORM: &str = r#"
2142struct Meta { nrows: u32, n_heads: u32, hd: u32, eps: f32 }
2143@group(0) @binding(0) var<storage, read_write> x: array<f32>;
2144@group(0) @binding(1) var<storage, read> w: array<f32>;
2145@group(0) @binding(2) var<uniform> mt: Meta;
2146@compute @workgroup_size(64)
2147fn main(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) t: u32) {
2148    let i = wg.x;
2149    let h = mt.n_heads * mt.hd;
2150    for (var head = t; head < mt.n_heads; head += 64u) {
2151        let base = i * h + head * mt.hd;
2152        var ms = 0.0;
2153        for (var d = 0u; d < mt.hd; d++) { let v = x[base + d]; ms += v * v; }
2154        let inv = 1.0 / sqrt(ms / f32(mt.hd) + mt.eps);
2155        for (var d = 0u; d < mt.hd; d++) { x[base + d] = x[base + d] * inv * w[d]; }
2156    }
2157}
2158"#;
2159
2160/// Last-token pooling over ragged sequences: `out[s] = hidden[seq_starts[s+1] - 1]`.
2161const ENC_LAST_POOL: &str = r#"
2162struct Meta { h: u32, p0: u32, p1: u32, p2: u32 }
2163@group(0) @binding(0) var<storage, read> hidden: array<f32>;
2164@group(0) @binding(1) var<storage, read_write> out: array<f32>;
2165@group(0) @binding(2) var<storage, read> seq_starts: array<u32>;
2166@group(0) @binding(3) var<uniform> mt: Meta;
2167@compute @workgroup_size(256)
2168fn main(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) t: u32) {
2169    let s = wg.x;
2170    let last = seq_starts[s + 1u] - 1u;
2171    for (var i = t; i < mt.h; i += 256u) {
2172        out[s * mt.h + i] = hidden[last * mt.h + i];
2173    }
2174}
2175"#;
2176
2177/// CLS (first-token) pooling over ragged sequences: `out[s] = hidden[seq_starts[s]]`. Same bindings
2178/// as [`ENC_LAST_POOL`] (so it shares `bg_pool`), reading the first row instead of the last.
2179const ENC_CLS_POOL: &str = r#"
2180struct Meta { h: u32, p0: u32, p1: u32, p2: u32 }
2181@group(0) @binding(0) var<storage, read> hidden: array<f32>;
2182@group(0) @binding(1) var<storage, read_write> out: array<f32>;
2183@group(0) @binding(2) var<storage, read> seq_starts: array<u32>;
2184@group(0) @binding(3) var<uniform> mt: Meta;
2185@compute @workgroup_size(256)
2186fn main(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) t: u32) {
2187    let s = wg.x;
2188    let first = seq_starts[s];
2189    for (var i = t; i < mt.h; i += 256u) {
2190        out[s * mt.h + i] = hidden[first * mt.h + i];
2191    }
2192}
2193"#;
2194
2195/// Elementwise copy: `dst = src` — the entry step for architectures WITHOUT an embedding-stage
2196/// norm (Qwen3), where the gathered embeddings must become the residual stream verbatim (an Add
2197/// would accumulate the previous encode's residue in the persistent scratch).
2198const ENC_COPY: &str = r#"
2199struct Meta { n: u32, p0: u32, p1: u32, p2: u32 }
2200@group(0) @binding(0) var<storage, read_write> dst: array<f32>;
2201@group(0) @binding(1) var<storage, read> src: array<f32>;
2202@group(0) @binding(2) var<uniform> mt: Meta;
2203@compute @workgroup_size(256)
2204fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
2205    if (gid.x < mt.n) { dst[gid.x] = src[gid.x]; }
2206}
2207"#;
2208
2209/// LFM2 gated short-conv over a ragged batch (stateless): `y[i,c] = C(i,c) · Σ_k w[c,k] ·
2210/// bx(i−(l−1−k), c)`, `bx(j,c) = B(j,c)·x(j,c)`, zero history before the sequence start.
2211/// `bcx` is the `[T, 3H]` in_proj output laid out `B | C | x` per token. One workgroup per row.
2212const ENC_CONV: &str = r#"
2213struct Meta { h: u32, l: u32, p0: u32, p1: u32 }
2214@group(0) @binding(0) var<storage, read> bcx: array<f32>;
2215@group(0) @binding(1) var<storage, read> conv_w: array<f32>;
2216@group(0) @binding(2) var<storage, read_write> y: array<f32>;
2217@group(0) @binding(3) var<storage, read> seq_starts: array<u32>;
2218@group(0) @binding(4) var<storage, read> seq_of: array<u32>;
2219@group(0) @binding(5) var<storage, read> valid: array<u32>;
2220@group(0) @binding(6) var<uniform> mt: Meta;
2221@compute @workgroup_size(256)
2222fn main(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) t: u32) {
2223    let i = wg.x;
2224    let a = seq_starts[seq_of[i]];
2225    let w3 = 3u * mt.h;
2226    // HF zeroes the conv-block input at padded positions: invalid row ⇒ zero output.
2227    let own_valid = valid[i] != 0u;
2228    for (var c = t; c < mt.h; c += 256u) {
2229        if (!own_valid) {
2230            y[i * mt.h + c] = 0.0;
2231            continue;
2232        }
2233        let cc = bcx[i * w3 + mt.h + c];
2234        var acc = 0.0;
2235        for (var k = 0u; k < mt.l; k++) {
2236            let back = mt.l - 1u - k;
2237            if (i >= a + back) {
2238                let j = i - back;
2239                if (valid[j] != 0u) {
2240                    let bx = bcx[j * w3 + c] * bcx[j * w3 + 2u * mt.h + c];
2241                    acc += conv_w[c * mt.l + k] * bx;
2242                }
2243            }
2244        }
2245        y[i * mt.h + c] = cc * acc;
2246    }
2247}
2248"#;
2249
2250/// The compiled encoder kernel set. Built once per device; the f16 GEMM twin exists only when
2251/// the adapter reports `SHADER_F16`.
2252pub struct EncKernels {
2253    gemm_f32: wgpu::ComputePipeline,
2254    gemm_f16: Option<wgpu::ComputePipeline>,
2255    /// GEMM v2 (register-blocked): [wide=64, skinny=32, tiny=16] × [f32, f16]. The tile is chosen by
2256    /// GRID OCCUPANCY (see `gemm2_bm`), not by `m` — a small `n` starves the grid whatever `m` is.
2257    gemm2_f32: Vec<wgpu::ComputePipeline>,
2258    gemm2_f16: Option<Vec<wgpu::ComputePipeline>>,
2259    /// GEMM v3 (vec4-shared on a `[k,n]` pre-transposed weight), one per [`GEMM3_TILES`] entry —
2260    /// the wide steps (fused QKV, MLP up) run these; see [`gemm3_eligible`].
2261    gemm3_f32: Vec<wgpu::ComputePipeline>,
2262    gemm3_f16: Option<Vec<wgpu::ComputePipeline>>,
2263    /// Split-K partial kernel ([`GEMM3_SK_TILE`], grid.z = chunks) + its reduce — the mid-band
2264    /// small-m arm; see [`gemm3_sk_band`].
2265    gemm3_sk_f32: wgpu::ComputePipeline,
2266    gemm3_sk_f16: Option<wgpu::ComputePipeline>,
2267    /// The 32-row split-K partial twins ([`GEMM3_SK_TILE32`]; same reduce).
2268    gemm3_sk32_f32: wgpu::ComputePipeline,
2269    gemm3_sk32_f16: Option<wgpu::ComputePipeline>,
2270    gemm3_sk_reduce: wgpu::ComputePipeline,
2271    /// f16-ARITHMETIC v3 (the opt-in `OSFKB_ENC_F16A` path); None without SHADER_F16.
2272    gemm3_f16a: Option<Vec<wgpu::ComputePipeline>>,
2273    /// Its split-K partial twin (f32 partials — shares `gemm3_sk_reduce`).
2274    gemm3_sk_f16a: Option<wgpu::ComputePipeline>,
2275    gemm3_sk32_f16a: Option<wgpu::ComputePipeline>,
2276    /// v4-f16 over the f16a weight layout (matrix units, f16 operands, f32 accumulate) —
2277    /// the `8x8x8 ab=F16 cr=F32` adapter config; None without coop+f16.
2278    gemm4_f16w: Option<wgpu::ComputePipeline>,
2279    layernorm: wgpu::ComputePipeline,
2280    attn: wgpu::ComputePipeline,
2281    /// vec4 twin of `attn` — selected at plan build when `head_dim % 4 == 0` (every production
2282    /// encoder); the scalar kernel stays for exotic head dims.
2283    attn4: wgpu::ComputePipeline,
2284    /// Register-blocked rows (RB=4 rows share every K/V read): the default for `hd ≤ 64`.
2285    attn_rb4: wgpu::ComputePipeline,
2286    attn_disent: wgpu::ComputePipeline,
2287    /// RB=4 twin of `attn_disent` (K/V shared across four rows; pos terms stay per-row).
2288    attn_disent_rb4: wgpu::ComputePipeline,
2289    mean_pool: wgpu::ComputePipeline,
2290    l2norm: wgpu::ComputePipeline,
2291    rope: wgpu::ComputePipeline,
2292    glu: wgpu::ComputePipeline,
2293    add: wgpu::ComputePipeline,
2294    qk_norm: wgpu::ComputePipeline,
2295    last_pool: wgpu::ComputePipeline,
2296    cls_pool: wgpu::ComputePipeline,
2297    copy: wgpu::ComputePipeline,
2298    conv: wgpu::ComputePipeline,
2299}
2300
2301/// Run the GPU's `apply_act` (tanh-GELU) over `xs` — the isolation harness for the overflow
2302/// regression test. The activation is normally FUSED into the GEMM epilogue, which is exactly why it
2303/// went unnoticed that it could return NaN: nothing ever exercised it on its own.
2304pub fn gelu_tanh_on_gpu(ctx: &GpuCtx, xs: &[f32]) -> anyhow::Result<Vec<f32>> {
2305    let src = format!(
2306        "{ACT_FNS}\n\
2307         @group(0) @binding(0) var<storage, read_write> v: array<f32>;\n\
2308         @compute @workgroup_size(64)\n\
2309         fn main(@builtin(global_invocation_id) g: vec3<u32>) {{\n\
2310         \x20   if (g.x < {}u) {{ v[g.x] = apply_act(v[g.x], 2u); }}\n\
2311         }}\n",
2312        xs.len()
2313    );
2314    let module = ctx
2315        .device
2316        .shader_module_tuned(wgpu::ShaderModuleDescriptor {
2317            label: Some("gelu_probe"),
2318            source: wgpu::ShaderSource::Wgsl(src.into()),
2319        });
2320    let pl = ctx
2321        .device
2322        .create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
2323            label: Some("gelu_probe"),
2324            layout: None,
2325            module: &module,
2326            entry_point: Some("main"),
2327            compilation_options: Default::default(),
2328            cache: None,
2329        });
2330    let buf = ctx.storage(xs);
2331    let bg = make_bg1_pub(ctx, &pl, &buf);
2332    dispatch(ctx, &pl, &bg, (xs.len() as u32).div_ceil(64), 1);
2333    ctx.read(&buf, xs.len())
2334}
2335
2336fn make_bg1_pub(ctx: &GpuCtx, pl: &wgpu::ComputePipeline, buf: &wgpu::Buffer) -> wgpu::BindGroup {
2337    ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
2338        label: None,
2339        layout: &pl.get_bind_group_layout(0),
2340        entries: &[wgpu::BindGroupEntry {
2341            binding: 0,
2342            resource: buf.as_entire_binding(),
2343        }],
2344    })
2345}
2346
2347impl EncKernels {
2348    /// Compile all encoder pipelines on `ctx`.
2349    pub fn new(ctx: &GpuCtx) -> Self {
2350        Self {
2351            gemm_f32: pipeline(ctx, "enc_gemm_f32", &enc_gemm_src(false)),
2352            gemm_f16: ctx
2353                .f16
2354                .then(|| pipeline(ctx, "enc_gemm_f16", &enc_gemm_src(true))),
2355            gemm2_f32: GEMM2_TILES
2356                .iter()
2357                .map(|&(bm, bn)| {
2358                    pipeline(
2359                        ctx,
2360                        &format!("enc_gemm2_{bm}x{bn}_f32"),
2361                        &enc_gemm2_src(false, bm, bn),
2362                    )
2363                })
2364                .collect(),
2365            gemm2_f16: ctx.f16.then(|| {
2366                GEMM2_TILES
2367                    .iter()
2368                    .map(|&(bm, bn)| {
2369                        pipeline(
2370                            ctx,
2371                            &format!("enc_gemm2_{bm}x{bn}_f16"),
2372                            &enc_gemm2_src(true, bm, bn),
2373                        )
2374                    })
2375                    .collect()
2376            }),
2377            gemm3_f32: GEMM3_TILES
2378                .iter()
2379                .map(|&(bm, bn, bk)| {
2380                    pipeline(
2381                        ctx,
2382                        &format!("enc_gemm3_{bm}x{bn}x{bk}_f32"),
2383                        &enc_gemm3_src(false, bm, bn, bk),
2384                    )
2385                })
2386                .collect(),
2387            gemm3_f16: ctx.f16.then(|| {
2388                GEMM3_TILES
2389                    .iter()
2390                    .map(|&(bm, bn, bk)| {
2391                        pipeline(
2392                            ctx,
2393                            &format!("enc_gemm3_{bm}x{bn}x{bk}_f16"),
2394                            &enc_gemm3_src(true, bm, bn, bk),
2395                        )
2396                    })
2397                    .collect()
2398            }),
2399            gemm3_sk_f32: pipeline(
2400                ctx,
2401                "enc_gemm3_sk_f32",
2402                &enc_gemm3_sk_src(false, GEMM3_SK_TILE.0, GEMM3_SK_TILE.1, GEMM3_SK_TILE.2),
2403            ),
2404            gemm3_sk_f16: ctx.f16.then(|| {
2405                pipeline(
2406                    ctx,
2407                    "enc_gemm3_sk_f16",
2408                    &enc_gemm3_sk_src(true, GEMM3_SK_TILE.0, GEMM3_SK_TILE.1, GEMM3_SK_TILE.2),
2409                )
2410            }),
2411            gemm3_sk32_f32: pipeline(
2412                ctx,
2413                "enc_gemm3_sk32_f32",
2414                &enc_gemm3_sk_src(
2415                    false,
2416                    GEMM3_SK_TILE32.0,
2417                    GEMM3_SK_TILE32.1,
2418                    GEMM3_SK_TILE32.2,
2419                ),
2420            ),
2421            gemm3_sk32_f16: ctx.f16.then(|| {
2422                pipeline(
2423                    ctx,
2424                    "enc_gemm3_sk32_f16",
2425                    &enc_gemm3_sk_src(
2426                        true,
2427                        GEMM3_SK_TILE32.0,
2428                        GEMM3_SK_TILE32.1,
2429                        GEMM3_SK_TILE32.2,
2430                    ),
2431                )
2432            }),
2433            gemm3_sk_reduce: pipeline(ctx, "enc_gemm3_sk_reduce", &enc_gemm3_sk_reduce_src()),
2434            gemm3_f16a: ctx.f16.then(|| {
2435                GEMM3_TILES
2436                    .iter()
2437                    .map(|&(bm, bn, bk)| {
2438                        pipeline(
2439                            ctx,
2440                            &format!("enc_gemm3_f16a_{bm}x{bn}x{bk}"),
2441                            &enc_gemm3_f16a_src(bm, bn, bk),
2442                        )
2443                    })
2444                    .collect()
2445            }),
2446            gemm3_sk_f16a: ctx.f16.then(|| {
2447                pipeline(
2448                    ctx,
2449                    "enc_gemm3_sk_f16a",
2450                    &enc_gemm3_sk_f16a_src(GEMM3_SK_TILE.0, GEMM3_SK_TILE.1, GEMM3_SK_TILE.2),
2451                )
2452            }),
2453            gemm3_sk32_f16a: ctx.f16.then(|| {
2454                pipeline(
2455                    ctx,
2456                    "enc_gemm3_sk32_f16a",
2457                    &enc_gemm3_sk_f16a_src(GEMM3_SK_TILE32.0, GEMM3_SK_TILE32.1, GEMM3_SK_TILE32.2),
2458                )
2459            }),
2460            gemm4_f16w: (ctx.f16 && ctx.coop_matrix)
2461                .then(|| pipeline(ctx, "enc_gemm4_f16w", &enc_gemm4_f16w_src(128, 128, 8))),
2462            layernorm: pipeline(ctx, "enc_layernorm", ENC_LAYERNORM),
2463            attn: pipeline(ctx, "enc_attn", ENC_ATTN),
2464            attn4: pipeline(ctx, "enc_attn_v4", ENC_ATTN_V4),
2465            attn_rb4: pipeline(ctx, "enc_attn_rb4", ENC_ATTN_RB4),
2466            attn_disent: pipeline(ctx, "enc_attn_disent", ENC_ATTN_DISENT),
2467            attn_disent_rb4: pipeline(ctx, "enc_attn_disent_rb4", ENC_ATTN_DISENT_RB4),
2468            mean_pool: pipeline(ctx, "enc_mean_pool", ENC_MEAN_POOL),
2469            l2norm: pipeline(ctx, "enc_l2norm", ENC_L2NORM),
2470            rope: pipeline(ctx, "enc_rope", ROPE_ENC),
2471            glu: pipeline(ctx, "enc_glu", GLU_SPLIT),
2472            add: pipeline(ctx, "enc_add", ENC_ADD),
2473            qk_norm: pipeline(ctx, "enc_qk_norm", ENC_QK_NORM),
2474            last_pool: pipeline(ctx, "enc_last_pool", ENC_LAST_POOL),
2475            cls_pool: pipeline(ctx, "enc_cls_pool", ENC_CLS_POOL),
2476            copy: pipeline(ctx, "enc_copy", ENC_COPY),
2477            conv: pipeline(ctx, "enc_conv", ENC_CONV),
2478        }
2479    }
2480
2481    /// One-shot rope for the kernel parity tests: rotate a `[T, nh·hd]` buffer in place.
2482    pub fn rope_for_tests(
2483        &self,
2484        ctx: &GpuCtx,
2485        x: &[f32],
2486        seq_starts: &[u32],
2487        n_heads: usize,
2488        hd: usize,
2489        theta: f32,
2490    ) -> Result<Vec<f32>> {
2491        let h = n_heads * hd;
2492        let nrows = x.len() / h;
2493        let xb = ctx.storage(x);
2494        let sb = ctx.storage_bytes(bytemuck::cast_slice(seq_starts));
2495        let mb = ctx.storage_bytes(bytemuck::cast_slice(&row_to_seq(seq_starts, nrows)));
2496        let meta = uni(
2497            ctx,
2498            bytemuck::cast_slice(&[nrows as u32, n_heads as u32, hd as u32, theta.to_bits()]),
2499        );
2500        let bg = make_bg(ctx, &self.rope, &[&xb, &sb, &mb], &meta);
2501        dispatch(ctx, &self.rope, &bg, nrows as u32, 1);
2502        ctx.read(&xb, x.len())
2503    }
2504
2505    /// One-shot GLU split for the kernel parity tests.
2506    pub fn glu_for_tests(
2507        &self,
2508        ctx: &GpuCtx,
2509        mid: &[f32],
2510        i_width: usize,
2511        act: Act,
2512    ) -> Result<Vec<f32>> {
2513        let rows = mid.len() / (2 * i_width);
2514        let mb = ctx.storage(mid);
2515        let ob = ctx.storage(&vec![0f32; rows * i_width]);
2516        let meta = uni(
2517            ctx,
2518            bytemuck::cast_slice(&[i_width as u32, act_code(Some(act)), 0, 0]),
2519        );
2520        let bg = make_bg(ctx, &self.glu, &[&mb, &ob], &meta);
2521        dispatch(ctx, &self.glu, &bg, rows as u32, 1);
2522        ctx.read(&ob, rows * i_width)
2523    }
2524
2525    /// The GEMM-v2 pipeline for a weight precision and row count: `wide` amortizes shared reads
2526    /// best, `skinny` trades some of that for twice the workgroups at small M (the occupancy fix
2527    /// for a 64-token call). See `enc_gemm2_src`.
2528    /// The v2 pipeline for a given ROW-TILE height (see [`gemm2_bm`]).
2529    /// Pipelines the vision tower drives directly (it hand-rolls its dispatch sequence rather than
2530    /// compiling to `EStep`s — its 2-D rope and merger have no analogue in the text encoders).
2531    pub(crate) fn ln_pl(&self) -> &wgpu::ComputePipeline {
2532        &self.layernorm
2533    }
2534    pub(crate) fn attn_pl(&self) -> &wgpu::ComputePipeline {
2535        &self.attn
2536    }
2537    pub(crate) fn add_pl(&self) -> &wgpu::ComputePipeline {
2538        &self.add
2539    }
2540
2541    pub(crate) fn gemm2_pipeline_tile(
2542        &self,
2543        f16: bool,
2544        tile: (usize, usize),
2545    ) -> Result<&wgpu::ComputePipeline> {
2546        let set = if f16 {
2547            self.gemm2_f16
2548                .as_ref()
2549                .ok_or_else(|| anyhow::anyhow!("adapter has no SHADER_F16"))?
2550        } else {
2551            &self.gemm2_f32
2552        };
2553        Ok(&set[gemm2_tier(tile)])
2554    }
2555
2556    /// The f16-arithmetic v3 pipeline for a tile (None ⇒ adapter lacks SHADER_F16).
2557    pub(crate) fn gemm3_f16a_pipeline_tile(
2558        &self,
2559        tile: (usize, usize, usize),
2560    ) -> Option<&wgpu::ComputePipeline> {
2561        self.gemm3_f16a.as_ref().map(|set| &set[gemm3_tier(tile)])
2562    }
2563
2564    /// The 32-row split-K partial pipeline for a weight precision.
2565    pub(crate) fn gemm3_sk32_pipeline(&self, f16: bool) -> Result<&wgpu::ComputePipeline> {
2566        if f16 {
2567            self.gemm3_sk32_f16
2568                .as_ref()
2569                .ok_or_else(|| anyhow::anyhow!("adapter has no SHADER_F16"))
2570        } else {
2571            Ok(&self.gemm3_sk32_f32)
2572        }
2573    }
2574
2575    /// The split-K partial pipeline for a weight precision.
2576    pub(crate) fn gemm3_sk_pipeline(&self, f16: bool) -> Result<&wgpu::ComputePipeline> {
2577        if f16 {
2578            self.gemm3_sk_f16
2579                .as_ref()
2580                .ok_or_else(|| anyhow::anyhow!("adapter has no SHADER_F16"))
2581        } else {
2582            Ok(&self.gemm3_sk_f32)
2583        }
2584    }
2585
2586    /// The v3 pipeline for a tile from [`GEMM3_TILES`].
2587    pub(crate) fn gemm3_pipeline_tile(
2588        &self,
2589        f16: bool,
2590        tile: (usize, usize, usize),
2591    ) -> Result<&wgpu::ComputePipeline> {
2592        let set = if f16 {
2593            self.gemm3_f16
2594                .as_ref()
2595                .ok_or_else(|| anyhow::anyhow!("adapter has no SHADER_F16"))?
2596        } else {
2597            &self.gemm3_f32
2598        };
2599        Ok(&set[gemm3_tier(tile)])
2600    }
2601
2602    /// The GEMM pipeline for a weight precision (f16 requested but unavailable → error).
2603    pub(crate) fn gemm_pipeline(&self, f16: bool) -> Result<&wgpu::ComputePipeline> {
2604        if f16 {
2605            self.gemm_f16
2606                .as_ref()
2607                .ok_or_else(|| anyhow::anyhow!("adapter has no SHADER_F16"))
2608        } else {
2609            Ok(&self.gemm_f32)
2610        }
2611    }
2612
2613    /// One-shot GEMM (upload → dispatch → read back) for the kernel parity tests. `w` arrives as
2614    /// f32 and is converted to f16 on upload when `w_f16` — the caller's CPU reference must
2615    /// consume the same round-tripped values.
2616    #[allow(clippy::too_many_arguments)]
2617    pub fn gemm_for_tests(
2618        &self,
2619        ctx: &GpuCtx,
2620        x: &[f32],
2621        w: &[f32],
2622        bias: Option<&[f32]>,
2623        m: usize,
2624        n: usize,
2625        k: usize,
2626        act: Option<Act>,
2627        w_f16: bool,
2628    ) -> Result<Vec<f32>> {
2629        // Honor the SAME tile selection production makes — a parity oracle that measured a
2630        // different kernel than the one that ships would be worthless.
2631        let (pl, bm, bn) = if gemm2_enabled() {
2632            let t = gemm2_tile(m, n);
2633            (self.gemm2_pipeline_tile(w_f16, t)?, t.0, t.1)
2634        } else {
2635            (self.gemm_pipeline(w_f16)?, 16, 16)
2636        };
2637        let xb = ctx.storage(x);
2638        let wb = if w_f16 {
2639            let h: Vec<u8> = w
2640                .iter()
2641                .flat_map(|&v| half::f16::from_f32(v).to_le_bytes())
2642                .collect();
2643            ctx.storage_bytes(&h)
2644        } else {
2645            ctx.storage(w)
2646        };
2647        let bb = ctx.storage(bias.unwrap_or(&[0.0]));
2648        let yb = ctx.storage(&vec![0f32; m * n]);
2649        let flags = u32::from(bias.is_some()) | (act_code(act) << 8);
2650        let meta = uni(
2651            ctx,
2652            bytemuck::cast_slice(&[m as u32, n as u32, k as u32, flags]),
2653        );
2654        let bg = make_bg(ctx, pl, &[&xb, &wb, &bb, &yb], &meta);
2655        dispatch(
2656            ctx,
2657            pl,
2658            &bg,
2659            (n as u32).div_ceil(bn as u32),
2660            (m as u32).div_ceil(bm as u32),
2661        );
2662        ctx.read(&yb, m * n)
2663    }
2664
2665    /// GEMM against weights that are ALREADY on the device.
2666    ///
2667    /// `gemm_for_tests` uploads `w` every call; at gliner2's span_rep shapes that is ~9 MB of
2668    /// weights per GEMM and it dominates — measured, the one-shot runs 0.0164 s where the kernel
2669    /// itself sustains 0.0021 s (tests/gemm_probe.rs). Hoisting the weights out is the first half of
2670    /// closing that gap; the second half (keeping the ACTIVATIONS resident too) needs the whole
2671    /// chain on device and is tracked separately.
2672    ///
2673    /// `wb`/`bb` are caller-owned and expected to live across calls — see `Linear::gpu_weights`.
2674    #[allow(clippy::too_many_arguments)]
2675    pub fn gemm_resident(
2676        &self,
2677        ctx: &GpuCtx,
2678        x: &[f32],
2679        wb: &wgpu::Buffer,
2680        bb: &wgpu::Buffer,
2681        m: usize,
2682        n: usize,
2683        k: usize,
2684        act: Option<Act>,
2685    ) -> Result<Vec<f32>> {
2686        // Same tile selection as production, for the same reason gemm_for_tests honours it.
2687        let (pl, bm, bn) = if gemm2_enabled() {
2688            let t = gemm2_tile(m, n);
2689            (self.gemm2_pipeline_tile(false, t)?, t.0, t.1)
2690        } else {
2691            (self.gemm_pipeline(false)?, 16, 16)
2692        };
2693        let xb = ctx.storage(x);
2694        let yb = ctx.storage(&vec![0f32; m * n]);
2695        let flags = 1u32 | (act_code(act) << 8); // bias always bound
2696        let meta = uni(
2697            ctx,
2698            bytemuck::cast_slice(&[m as u32, n as u32, k as u32, flags]),
2699        );
2700        let bg = make_bg(ctx, pl, &[&xb, wb, bb, &yb], &meta);
2701        dispatch(
2702            ctx,
2703            pl,
2704            &bg,
2705            (n as u32).div_ceil(bn as u32),
2706            (m as u32).div_ceil(bm as u32),
2707        );
2708        ctx.read(&yb, m * n)
2709    }
2710
2711    /// GEMM entirely in device buffers: `x` and `y` stay resident, nothing is read back.
2712    ///
2713    /// `gemm_resident` still uploads the activations and reads the result; chaining several GEMMs
2714    /// through it therefore pays a full round-trip PER STEP. For gliner2's span_rep the middle
2715    /// tensor (`hbuf`) is ~22.8 MB per call, which is exactly what capped the device port at 1.53x
2716    /// of the ~30x the kernel sustains. This variant is what lets the whole chain stay on-device.
2717    ///
2718    /// `yb` must be at least `m * n` f32s.
2719    #[allow(clippy::too_many_arguments)]
2720    pub fn gemm_resident_into(
2721        &self,
2722        ctx: &GpuCtx,
2723        xb: &wgpu::Buffer,
2724        wb: &wgpu::Buffer,
2725        bb: &wgpu::Buffer,
2726        yb: &wgpu::Buffer,
2727        m: usize,
2728        n: usize,
2729        k: usize,
2730        act: Option<Act>,
2731    ) -> Result<()> {
2732        let (pl, bm, bn) = if gemm2_enabled() {
2733            let t = gemm2_tile(m, n);
2734            (self.gemm2_pipeline_tile(false, t)?, t.0, t.1)
2735        } else {
2736            (self.gemm_pipeline(false)?, 16, 16)
2737        };
2738        let flags = 1u32 | (act_code(act) << 8); // bias always bound
2739        let meta = uni(
2740            ctx,
2741            bytemuck::cast_slice(&[m as u32, n as u32, k as u32, flags]),
2742        );
2743        let bg = make_bg(ctx, pl, &[xb, wb, bb, yb], &meta);
2744        dispatch(
2745            ctx,
2746            pl,
2747            &bg,
2748            (n as u32).div_ceil(bn as u32),
2749            (m as u32).div_ceil(bm as u32),
2750        );
2751        Ok(())
2752    }
2753
2754    /// Steady-state GFLOP/s of the GEMM at one shape — the STOP INSTRUMENT for kernel tuning.
2755    ///
2756    /// Weights and activations upload once; `reps` dispatches then run in ONE submit, so the
2757    /// number is the kernel's throughput and not the readback, the allocation, or the submit. Min
2758    /// of the timed reps (a mean on a shared box measures the contention, not the code).
2759    #[allow(clippy::too_many_arguments)]
2760    pub fn gemm_bench(
2761        &self,
2762        ctx: &GpuCtx,
2763        m: usize,
2764        n: usize,
2765        k: usize,
2766        w_f16: bool,
2767        v2: bool,
2768        reps: usize,
2769    ) -> Result<f64> {
2770        let (pl, bm, bn) = if v2 {
2771            let t = gemm2_tile(m, n);
2772            (self.gemm2_pipeline_tile(w_f16, t)?, t.0, t.1)
2773        } else {
2774            (self.gemm_pipeline(w_f16)?, 16, 16)
2775        };
2776        let xb = ctx.storage(&vec![0.5f32; m * k]);
2777        let wb = if w_f16 {
2778            ctx.storage_bytes(&vec![0u8; n * k * 2])
2779        } else {
2780            ctx.storage(&vec![0.25f32; n * k])
2781        };
2782        let bb = ctx.storage(&[0.0f32]);
2783        let yb = ctx.storage(&vec![0f32; m * n]);
2784        let meta = uni(
2785            ctx,
2786            bytemuck::cast_slice(&[m as u32, n as u32, k as u32, 0u32]),
2787        );
2788        let bg = make_bg(ctx, pl, &[&xb, &wb, &bb, &yb], &meta);
2789        let (gx, gy) = (
2790            (n as u32).div_ceil(bn as u32),
2791            (m as u32).div_ceil(bm as u32),
2792        );
2793
2794        let run = |reps: usize| -> f64 {
2795            let mut enc = ctx
2796                .device
2797                .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
2798            {
2799                let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor::default());
2800                pass.set_pipeline(pl);
2801                pass.set_bind_group(0, &bg, &[]);
2802                for _ in 0..reps {
2803                    pass.dispatch_workgroups(gx, gy, 1);
2804                }
2805            }
2806            let t0 = std::time::Instant::now();
2807            ctx.queue.submit([enc.finish()]);
2808            let _ = ctx.device.poll(wgpu::PollType::wait_indefinitely());
2809            t0.elapsed().as_secs_f64()
2810        };
2811        run(2); // warm: shader specialization + first-touch
2812        let mut best = f64::MAX;
2813        for _ in 0..5 {
2814            best = best.min(run(reps));
2815        }
2816        let flop = 2.0 * m as f64 * n as f64 * k as f64 * reps as f64;
2817        Ok(flop / best / 1e9)
2818    }
2819
2820    /// [`gemm_bench`]'s v3 twin — the STOP instrument for deciding whether the vec4/transposed
2821    /// kernel earns a place in the TEXT-encoder plan (its wins were proven at vision shapes,
2822    /// m≈912; the text shapes are m∈{64,192} where occupancy, not instruction count, may rule).
2823    /// The pipeline is created ad hoc (a lab probe, not a plan resident); weight contents are
2824    /// irrelevant to throughput so zeros stand in for the `[k, n]` transposed upload.
2825    #[allow(clippy::too_many_arguments)]
2826    pub fn gemm3_bench(
2827        &self,
2828        ctx: &GpuCtx,
2829        m: usize,
2830        n: usize,
2831        k: usize,
2832        bm: usize,
2833        bn: usize,
2834        bk: usize,
2835        reps: usize,
2836    ) -> Result<f64> {
2837        let pl = pipeline(
2838            ctx,
2839            &format!("enc_gemm3_{bm}x{bn}x{bk}_lab"),
2840            &enc_gemm3_src(false, bm, bn, bk),
2841        );
2842        let xb = ctx.storage(&vec![0.5f32; m * k]);
2843        let wb = ctx.storage(&vec![0.25f32; k * n]); // [k, n] transposed layout
2844        let bb = ctx.storage(&[0.0f32]);
2845        let yb = ctx.storage(&vec![0f32; m * n]);
2846        let meta = uni(
2847            ctx,
2848            bytemuck::cast_slice(&[m as u32, n as u32, k as u32, 0u32]),
2849        );
2850        let bg = make_bg(ctx, &pl, &[&xb, &wb, &bb, &yb], &meta);
2851        let (gx, gy) = (
2852            (n as u32).div_ceil(bn as u32),
2853            (m as u32).div_ceil(bm as u32),
2854        );
2855        let run = |reps: usize| -> f64 {
2856            let mut enc = ctx
2857                .device
2858                .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
2859            {
2860                let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor::default());
2861                pass.set_pipeline(&pl);
2862                pass.set_bind_group(0, &bg, &[]);
2863                for _ in 0..reps {
2864                    pass.dispatch_workgroups(gx, gy, 1);
2865                }
2866            }
2867            let t0 = std::time::Instant::now();
2868            ctx.queue.submit([enc.finish()]);
2869            let _ = ctx.device.poll(wgpu::PollType::wait_indefinitely());
2870            t0.elapsed().as_secs_f64()
2871        };
2872        run(2);
2873        let mut best = f64::MAX;
2874        for _ in 0..5 {
2875            best = best.min(run(reps));
2876        }
2877        let flop = 2.0 * m as f64 * n as f64 * k as f64 * reps as f64;
2878        Ok(flop / best / 1e9)
2879    }
2880
2881    /// [`gemm3_bench`]'s split-K twin: the partial+reduce PAIR, timed as the plan would run it
2882    /// (both dispatches in one pass, in-pass ordering supplying the dependency). The lab probe
2883    /// for whether the k-dimension can rescue thin-grid shapes OUTSIDE the wired mid-band —
2884    /// e.g. /score's n=384 bucket, whose best plain tile runs only 72 workgroups.
2885    #[allow(clippy::too_many_arguments)]
2886    pub fn gemm3_sk_bench(
2887        &self,
2888        ctx: &GpuCtx,
2889        m: usize,
2890        n: usize,
2891        k: usize,
2892        bm: usize,
2893        bn: usize,
2894        bk: usize,
2895        chunks: u32,
2896        reps: usize,
2897    ) -> Result<f64> {
2898        let pl = pipeline(
2899            ctx,
2900            &format!("enc_gemm3_sk_{bm}x{bn}x{bk}_lab"),
2901            &enc_gemm3_sk_src(false, bm, bn, bk),
2902        );
2903        let rpl = pipeline(ctx, "enc_gemm3_sk_reduce_lab", &enc_gemm3_sk_reduce_src());
2904        let xb = ctx.storage(&vec![0.5f32; m * k]);
2905        let wb = ctx.storage(&vec![0.25f32; k * n]); // [k, n] transposed layout
2906        let bb = ctx.storage(&[0.0f32]);
2907        let part = ctx.storage(&vec![0f32; chunks as usize * m * n]);
2908        let yb = ctx.storage(&vec![0f32; m * n]);
2909        let meta = uni(
2910            ctx,
2911            bytemuck::cast_slice(&[m as u32, n as u32, k as u32, 0u32]),
2912        );
2913        let meta_r = uni(
2914            ctx,
2915            bytemuck::cast_slice(&[m as u32, n as u32, 0u32, chunks]),
2916        );
2917        let bg = make_bg(ctx, &pl, &[&xb, &wb, &bb, &part], &meta);
2918        let rbg = make_bg(ctx, &rpl, &[&part, &bb, &yb], &meta_r);
2919        let (gx, gy) = (
2920            (n as u32).div_ceil(bn as u32),
2921            (m as u32).div_ceil(bm as u32),
2922        );
2923        let rgx = ((m * n) as u32).div_ceil(256);
2924        let run = |reps: usize| -> f64 {
2925            let mut enc = ctx
2926                .device
2927                .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
2928            {
2929                let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor::default());
2930                for _ in 0..reps {
2931                    pass.set_pipeline(&pl);
2932                    pass.set_bind_group(0, &bg, &[]);
2933                    pass.dispatch_workgroups(gx, gy, chunks);
2934                    pass.set_pipeline(&rpl);
2935                    pass.set_bind_group(0, &rbg, &[]);
2936                    pass.dispatch_workgroups(rgx, 1, 1);
2937                }
2938            }
2939            let t0 = std::time::Instant::now();
2940            ctx.queue.submit([enc.finish()]);
2941            let _ = ctx.device.poll(wgpu::PollType::wait_indefinitely());
2942            t0.elapsed().as_secs_f64()
2943        };
2944        run(2);
2945        let mut best = f64::MAX;
2946        for _ in 0..5 {
2947            best = best.min(run(reps));
2948        }
2949        let flop = 2.0 * m as f64 * n as f64 * k as f64 * reps as f64;
2950        Ok(flop / best / 1e9)
2951    }
2952
2953    /// One-shot LayerNorm(+residual)(+bias) for the kernel parity tests.
2954    #[allow(clippy::too_many_arguments)]
2955    pub fn layernorm_for_tests(
2956        &self,
2957        ctx: &GpuCtx,
2958        x: &[f32],
2959        res: Option<&[f32]>,
2960        w: &[f32],
2961        b: Option<&[f32]>,
2962        t: usize,
2963        h: usize,
2964        eps: f32,
2965    ) -> Result<Vec<f32>> {
2966        let xb = ctx.storage(x);
2967        let rb = ctx.storage(res.unwrap_or(&[0.0]));
2968        let wb = ctx.storage(w);
2969        let bb = ctx.storage(b.unwrap_or(&[0.0]));
2970        let ob = ctx.storage(&vec![0f32; t * h]);
2971        let flags = u32::from(res.is_some()) | (u32::from(b.is_some()) << 1);
2972        let meta = uni(
2973            ctx,
2974            bytemuck::cast_slice(&[h as u32, flags, eps.to_bits(), 0]),
2975        );
2976        let bg = make_bg(ctx, &self.layernorm, &[&xb, &rb, &wb, &bb, &ob], &meta);
2977        dispatch(ctx, &self.layernorm, &bg, t as u32, 1);
2978        ctx.read(&ob, t * h)
2979    }
2980
2981    /// One-shot ragged attention for the kernel parity tests.
2982    #[allow(clippy::too_many_arguments)]
2983    #[allow(clippy::too_many_arguments)]
2984    pub fn attention_for_tests(
2985        &self,
2986        ctx: &GpuCtx,
2987        q: &[f32],
2988        k: &[f32],
2989        v: &[f32],
2990        seq_starts: &[u32],
2991        n_heads: usize,
2992        n_kv_heads: usize,
2993        hd: usize,
2994        mask: MaskKind,
2995        window: u32,
2996    ) -> Result<Vec<f32>> {
2997        anyhow::ensure!(hd <= 128, "encoder attention supports head_dim ≤ 128");
2998        let h = n_heads * hd;
2999        let nrows = q.len() / h;
3000        let seq_of = row_to_seq(seq_starts, nrows);
3001        let qb = ctx.storage(q);
3002        let kb = ctx.storage(k);
3003        let vb = ctx.storage(v);
3004        let ob = ctx.storage(&vec![0f32; nrows * h]);
3005        let sb = ctx.storage_bytes(bytemuck::cast_slice(seq_starts));
3006        let mb = ctx.storage_bytes(bytemuck::cast_slice(&seq_of));
3007        let mode = match mask {
3008            MaskKind::Bidirectional => 0u32,
3009            MaskKind::Causal => 1u32,
3010        };
3011        let meta = uni(
3012            ctx,
3013            bytemuck::cast_slice(&[
3014                nrows as u32,
3015                n_heads as u32,
3016                hd as u32,
3017                mode,
3018                window,
3019                n_kv_heads as u32,
3020                0,
3021                0,
3022            ]),
3023        );
3024        let valid = ctx.storage_bytes(bytemuck::cast_slice(&vec![1u32; nrows]));
3025        let bg = make_bg(
3026            ctx,
3027            &self.attn,
3028            &[&qb, &kb, &vb, &ob, &sb, &mb, &valid],
3029            &meta,
3030        );
3031        dispatch(ctx, &self.attn, &bg, nrows as u32, n_heads as u32);
3032        ctx.read(&ob, nrows * h)
3033    }
3034
3035    /// One-shot mean-pool + L2-normalize for the kernel parity tests.
3036    pub fn mean_pool_l2_for_tests(
3037        &self,
3038        ctx: &GpuCtx,
3039        hidden: &[f32],
3040        h: usize,
3041        seq_starts: &[u32],
3042    ) -> Result<Vec<Vec<f32>>> {
3043        let n_seqs = seq_starts.len() - 1;
3044        let hb = ctx.storage(hidden);
3045        let ob = ctx.storage(&vec![0f32; n_seqs * h]);
3046        let sb = ctx.storage_bytes(bytemuck::cast_slice(seq_starts));
3047        let meta = uni(ctx, bytemuck::cast_slice(&[h as u32, 0, 0, 0]));
3048        let bg = make_bg(ctx, &self.mean_pool, &[&hb, &ob, &sb], &meta);
3049        dispatch(ctx, &self.mean_pool, &bg, n_seqs as u32, 1);
3050        let meta2 = uni(ctx, bytemuck::cast_slice(&[h as u32, 0, 0, 0]));
3051        let bg2 = make_bg(ctx, &self.l2norm, &[&ob], &meta2);
3052        dispatch(ctx, &self.l2norm, &bg2, n_seqs as u32, 1);
3053        let flat = ctx.read(&ob, n_seqs * h)?;
3054        Ok(flat.chunks_exact(h).map(<[f32]>::to_vec).collect())
3055    }
3056}
3057
3058/// Record one compute pass and submit it (the one-shot test path; the model executor records
3059/// many dispatches per submit instead).
3060/// In-place biased LayerNorm over `[rows, h]` (biased variance) — the load-time helper for
3061/// DeBERTa's relative table. GPU LayerNorm is a per-token kernel; this is the CPU twin, kept
3062/// tiny and local because it runs once at load, never per batch.
3063fn cpu_layer_norm_rows(x: &mut [f32], h: usize, w: &[f32], b: &[f32], eps: f32) {
3064    for row in x.chunks_exact_mut(h) {
3065        let mean = row.iter().sum::<f32>() / h as f32;
3066        let var = row.iter().map(|v| (v - mean) * (v - mean)).sum::<f32>() / h as f32;
3067        let inv = 1.0 / (var + eps).sqrt();
3068        for (j, v) in row.iter_mut().enumerate() {
3069            *v = (*v - mean) * inv * w[j] + b[j];
3070        }
3071    }
3072}
3073
3074/// `y[r, n] = x[r, :] · W[n, :] + bias[n]` (HF Linear layout, `W` is `[n, k]` row-major). Used to
3075/// precompute DeBERTa's relative projections at load; f32, since it feeds parity-sensitive scores.
3076fn cpu_linear(x: &[f32], w: &[f32], bias: Option<&[f32]>, n: usize, k: usize) -> Vec<f32> {
3077    let rows = x.len() / k;
3078    let mut out = vec![0f32; rows * n];
3079    // Serial: this runs once per layer at LOAD (rows = 2·span = 512), never per batch.
3080    for (o, xr) in out.chunks_exact_mut(n).zip(x.chunks_exact(k)) {
3081        for (nn, o_n) in o.iter_mut().enumerate() {
3082            let wr = &w[nn * k..(nn + 1) * k];
3083            let mut acc = bias.map_or(0.0, |b| b[nn]);
3084            for (xk, wk) in xr.iter().zip(wr) {
3085                acc += xk * wk;
3086            }
3087            *o_n = acc;
3088        }
3089    }
3090    out
3091}
3092
3093pub(crate) fn dispatch(
3094    ctx: &GpuCtx,
3095    pl: &wgpu::ComputePipeline,
3096    bg: &wgpu::BindGroup,
3097    gx: u32,
3098    gy: u32,
3099) {
3100    let mut enc = ctx
3101        .device
3102        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
3103    {
3104        let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor::default());
3105        pass.set_pipeline(pl);
3106        pass.set_bind_group(0, bg, &[]);
3107        pass.dispatch_workgroups(gx, gy, 1);
3108    }
3109    ctx.queue.submit([enc.finish()]);
3110}
3111
3112/// Row index → sequence index (ragged batches; rows of one sequence are contiguous).
3113pub(crate) fn row_to_seq(seq_starts: &[u32], t: usize) -> Vec<u32> {
3114    let mut map = vec![0u32; t];
3115    for (s, w) in seq_starts.windows(2).enumerate() {
3116        for r in w[0]..w[1] {
3117            map[r as usize] = s as u32;
3118        }
3119    }
3120    map
3121}
3122
3123// ============================================================================
3124// EncoderGpu — the whole-model executor
3125// ============================================================================
3126
3127use crate::encoder_weights::{EncArch, EncBatch, EncoderConfig, MlpKind, NormKind, PosKind};
3128use crate::pooling::{EmbedOut, Pooling};
3129use crate::weights::{LazySt, f32_to_f16_bytes};
3130use anyhow::Context;
3131use std::path::Path;
3132
3133/// A weight matrix on the GPU in its serving precision (encoders are never Q4).
3134enum EncMatBuf {
3135    F16(wgpu::Buffer),
3136    F32(wgpu::Buffer),
3137}
3138
3139/// One linear projection: weight (HF `[n, k]` row-major), optional bias, dims.
3140struct EncGpuLinear {
3141    w: EncMatBuf,
3142    b: Option<wgpu::Buffer>,
3143    n: u32,
3144    k: u32,
3145    /// Uploaded `[k, n]`-transposed for the v3 kernel (see [`gemm3_eligible`]); the layout and
3146    /// the replay kernel family travel together, so this flag decides both.
3147    v3: bool,
3148    /// Mid-band width ([`gemm3_sk_band`]): transposed layout too, replayed as plain-v3 OR the
3149    /// split-K pair by runtime `m`.
3150    sk: bool,
3151    /// f16-ARITHMETIC upload (`OSFKB_ENC_F16A=1` + SHADER_F16): transposed AND stored f16;
3152    /// supersedes `v3`/`sk` for this weight.
3153    f16a: bool,
3154    /// Host copy of the bias, retained ONLY for f16a uploads — the matrix-unit arm seeds its
3155    /// accumulators from a `[8, n]` broadcast built at plan time.
3156    b_host: Option<Vec<f32>>,
3157}
3158
3159/// The `BertForSequenceClassification` scoring head on the GPU: CLS → pooler dense + tanh →
3160/// classifier → `n_labels` raw logits.
3161///
3162/// `/score` (the ontology-verifier's hot path) had NO GPU path at all: `EmbedEngine::auto` routed
3163/// every CrossEncoder checkpoint straight to the CPU encoder, because the GPU pool kernels
3164/// implement Mean/LastToken/PerToken and nothing else. It measured 2.07× torch-CPU there. Every
3165/// piece needed already existed — `cls_pool`, and a GEMM epilogue that already implements bias and
3166/// **tanh** — so the head is two GEMMs after the pool.
3167///
3168/// These run OUTSIDE the plan's step list, because the plan's GEMMs are shaped `m = t` (tokens) and
3169/// these are shaped `m = b_seqs` (pairs). Their metas are rewritten per encode like the others.
3170struct CeHeadGpu {
3171    /// pooler: `[b_seqs, h] → tanh([b_seqs, h])`
3172    bg_pooler: GemmBg,
3173    /// classifier: `[b_seqs, h] → [b_seqs, n_labels]`
3174    bg_classifier: GemmBg,
3175    /// Metas rewritten with `b_seqs` per encode (the plan's own metas carry `t`).
3176    metas: Vec<wgpu::Buffer>,
3177    /// `[max_seqs, n_labels]` — the raw logits `encode` reads back.
3178    out: wgpu::Buffer,
3179    n_labels: usize,
3180    /// The FUSED head ([`ENC_CE_FUSED`]): cls-gather + pooler(tanh) + classifier in ONE
3181    /// dispatch per batch. None on f16-storage loads / h > 2048 / `OSFKB_ENC_CE_FUSED=0`.
3182    fused: Option<(wgpu::ComputePipeline, wgpu::BindGroup)>,
3183    /// Kept alive alongside the prebaked bind groups.
3184    _mid: wgpu::Buffer,
3185    _weights: Vec<EncGpuLinear>,
3186}
3187
3188/// The CrossEncoder head, FUSED: one workgroup = one sequence — CLS gather, pooler mat-vec +
3189/// tanh, classifier dots — replacing three dispatches and two tiny-grid GEMMs (m = b_seqs was
3190/// the worst tile-occupancy case in the whole plan). Shared: `cls[h]` + `pooled[h]` (h ≤ 2048
3191/// keeps it inside WebGPU's 16 KB default). f32 weights only; the 3-dispatch path remains for
3192/// f16-storage loads and larger h. `OSFKB_ENC_CE_FUSED=0` pins the old path.
3193const ENC_CE_FUSED: &str = r#"
3194struct Meta { h: u32, n_labels: u32, p0: u32, p1: u32 }
3195@group(0) @binding(0) var<storage, read> hidden: array<f32>;      // [T, h]
3196@group(0) @binding(1) var<storage, read> seq_starts: array<u32>;
3197@group(0) @binding(2) var<storage, read> pw: array<f32>;          // pooler [h, h] (HF [n,k])
3198@group(0) @binding(3) var<storage, read> pb: array<f32>;          // pooler bias [h]
3199@group(0) @binding(4) var<storage, read> cw: array<f32>;          // classifier [n_labels, h]
3200@group(0) @binding(5) var<storage, read> cb: array<f32>;          // classifier bias [n_labels]
3201@group(0) @binding(6) var<storage, read_write> out: array<f32>;   // [b, n_labels]
3202@group(0) @binding(7) var<uniform> mt: Meta;
3203var<workgroup> cls: array<f32, 2048>;
3204var<workgroup> pooled: array<f32, 2048>;
3205var<workgroup> red: array<f32, 256>;
3206@compute @workgroup_size(256)
3207fn main(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) t: u32) {
3208    let s = wg.x;
3209    let h = mt.h;
3210    let base = seq_starts[s] * h; // the sequence's FIRST token = CLS
3211    for (var j = t; j < h; j += 256u) { cls[j] = hidden[base + j]; }
3212    workgroupBarrier();
3213    // pooled[j] = tanh(pw[j]·cls + pb[j]) — each thread owns rows j, j+256, …
3214    for (var j = t; j < h; j += 256u) {
3215        var acc = 0.0;
3216        let rb = j * h;
3217        for (var d = 0u; d < h; d++) { acc += pw[rb + d] * cls[d]; }
3218        pooled[j] = tanh(clamp(acc + pb[j], -20.0, 20.0));
3219    }
3220    workgroupBarrier();
3221    // classifier: one ladder-reduced dot per label (n_labels is 1-2 in practice).
3222    for (var l = 0u; l < mt.n_labels; l++) {
3223        var part = 0.0;
3224        let rb = l * h;
3225        for (var d = t; d < h; d += 256u) { part += cw[rb + d] * pooled[d]; }
3226        red[t] = part;
3227        workgroupBarrier();
3228        for (var r = 128u; r > 0u; r >>= 1u) {
3229            if (t < r) { red[t] += red[t + r]; }
3230            workgroupBarrier();
3231        }
3232        if (t == 0u) { out[s * mt.n_labels + l] = red[0] + cb[l]; }
3233        workgroupBarrier();
3234    }
3235}
3236"#;
3237
3238/// Bake one bind group per v2 tile height (they are pipeline-exclusive).
3239pub(crate) fn v2_bgs(
3240    ctx: &GpuCtx,
3241    kernels: &EncKernels,
3242    f16: bool,
3243    bufs: &[&wgpu::Buffer],
3244    meta: &wgpu::Buffer,
3245) -> Result<Vec<wgpu::BindGroup>> {
3246    GEMM2_TILES
3247        .into_iter()
3248        .map(|t| {
3249            Ok(make_bg(
3250                ctx,
3251                kernels.gemm2_pipeline_tile(f16, t)?,
3252                bufs,
3253                meta,
3254            ))
3255        })
3256        .collect()
3257}
3258
3259/// [`v2_bgs`]'s v3 twin: one bind group per [`GEMM3_TILES`] entry (auto-layout bind groups are
3260/// exclusive to their pipeline, so the replay-time tile pick needs them all prebaked).
3261pub(crate) fn v3_bgs(
3262    ctx: &GpuCtx,
3263    kernels: &EncKernels,
3264    f16: bool,
3265    bufs: &[&wgpu::Buffer],
3266    meta: &wgpu::Buffer,
3267) -> Result<Vec<wgpu::BindGroup>> {
3268    GEMM3_TILES
3269        .into_iter()
3270        .map(|t| {
3271            Ok(make_bg(
3272                ctx,
3273                kernels.gemm3_pipeline_tile(f16, t)?,
3274                bufs,
3275                meta,
3276            ))
3277        })
3278        .collect()
3279}
3280
3281/// Upload one head matrix and prebake its GEMM bind group + meta.
3282///
3283/// The plan's `push_gemm` does this for layer weights, but its meta lands in `metas_tokens` (whose
3284/// `m` is rewritten to the TOKEN count each encode). The head's GEMMs are shaped `m = b_seqs`, so
3285/// they need their own meta — hence a separate helper rather than a reused one.
3286#[allow(clippy::too_many_arguments)]
3287fn head_gemm(
3288    ctx: &GpuCtx,
3289    kernels: &EncKernels,
3290    use_f16: bool,
3291    w: &[f32],
3292    bias: &[f32],
3293    n: u32,
3294    k: u32,
3295    x: &wgpu::Buffer,
3296    y: &wgpu::Buffer,
3297    act: Option<Act>,
3298) -> Result<(GemmBg, wgpu::Buffer, EncGpuLinear)> {
3299    anyhow::ensure!(
3300        w.len() == (n as usize) * (k as usize),
3301        "head weight shape {} != {n}×{k}",
3302        w.len()
3303    );
3304    let mat = if use_f16 {
3305        EncMatBuf::F16(ctx.storage_bytes(&f32_to_f16_bytes(w)))
3306    } else {
3307        EncMatBuf::F32(ctx.storage(w))
3308    };
3309    let bbuf = ctx.storage(bias);
3310    // flags: bit 0 = bias present, bits 8.. = activation code — same encoding `push_gemm` uses.
3311    let flags = 1u32 | (act_code(act) << 8);
3312    let meta = uni(ctx, bytemuck::cast_slice(&[0u32, n, k, flags]));
3313    let wbuf = match &mat {
3314        EncMatBuf::F16(b) | EncMatBuf::F32(b) => b,
3315    };
3316    let bufs = [x, wbuf, &bbuf, y];
3317    let bg = if gemm2_enabled() {
3318        // Which v2 tile runs depends on the GRID, known only at replay — bake all three.
3319        GemmBg::V2(v2_bgs(ctx, kernels, use_f16, &bufs, &meta)?)
3320    } else {
3321        GemmBg::V1(make_bg(ctx, kernels.gemm_pipeline(use_f16)?, &bufs, &meta))
3322    };
3323    let keep = EncGpuLinear {
3324        w: mat,
3325        b: Some(bbuf),
3326        n,
3327        k,
3328        v3: false, // head GEMMs are m=b_seqs tiny — v2/v1 territory, [n,k] layout
3329        sk: false,
3330        f16a: false,
3331        b_host: None,
3332    };
3333    Ok((bg, meta, keep))
3334}
3335
3336/// One norm: weight + optional bias.
3337struct EncGpuNorm {
3338    w: wgpu::Buffer,
3339    b: Option<wgpu::Buffer>,
3340}
3341
3342/// One prebaked dispatch of the encode plan. The plan is declarative-config-driven: both the
3343/// post-LN (BERT) and prenorm (ModernBERT) dataflows compile to this same step list — the
3344/// executor replays steps and never branches on architecture.
3345/// Bind groups for a GEMM step. v1 needs one; v2 needs one per tile variant, because which
3346/// variant runs depends on the token count, which is only known at replay.
3347enum GemmBg {
3348    V1(wgpu::BindGroup),
3349    /// One per tile height, indexed by [`gemm2_tier`]. wgpu's auto-layout bind groups are EXCLUSIVE
3350    /// to the pipeline that created them, so each tile needs its own — which is why all three are
3351    /// baked at load and picked at replay, when `m` (and hence the grid) is finally known.
3352    V2(Vec<wgpu::BindGroup>),
3353    /// GEMM v3 (vec4-shared, `[k,n]`-transposed weight): one bind group per [`GEMM3_TILES`]
3354    /// entry, indexed by [`gemm3_tier`]. Chosen at LOAD per step (`gemm3_eligible`) because the
3355    /// weight's upload layout differs.
3356    V3(Vec<wgpu::BindGroup>),
3357    /// v3 with the m-dependent arm (mid band [`gemm3_sk_band`] + small-n [`gemm3_smalln_sk`]):
3358    /// `plain` = the v3 tile set for big m; `part`+`reduce` = the split-K pair for small m —
3359    /// split width chosen at REPLAY ([`gemm3_sk_z`]; the reduce meta's `sk` word is rewritten
3360    /// per encode, which is why `k` rides along here).
3361    V3Sk {
3362        plain: Vec<wgpu::BindGroup>,
3363        part: wgpu::BindGroup,
3364        /// The 32-row partial twin ([`gemm3_sk_plan`] chooses per dispatch).
3365        part32: wgpu::BindGroup,
3366        reduce: wgpu::BindGroup,
3367        k: u32,
3368        /// Pipelines are the f16-arithmetic twins (`OSFKB_ENC_F16A` at load).
3369        f16a: bool,
3370    },
3371    /// f16-arithmetic v3 (`OSFKB_ENC_F16A=1` at load, SHADER_F16 present): one bind group per
3372    /// [`GEMM3_TILES`] entry against the f16 `[k,n]` weight, plus the MATRIX-UNIT arm
3373    /// (v4-f16, f32 accumulate) for activation-free steps whose 128×128 grid fills the GPU
3374    /// (measured: wins ≥ 48 WGs — deberta qkv3 1.55×, gliner-up-class 1.2×; loses below).
3375    F16A {
3376        tiles: Vec<wgpu::BindGroup>,
3377        coop: Option<wgpu::BindGroup>,
3378    },
3379}
3380
3381/// One resolved dispatch of the plan replay: (pipeline, bind group, gx, gy, gz, label).
3382type StepDisp<'s> = (
3383    &'s wgpu::ComputePipeline,
3384    &'s wgpu::BindGroup,
3385    u32,
3386    u32,
3387    u32,
3388    &'static str,
3389);
3390
3391enum EStep {
3392    /// Tiled GEMM: dispatch `(⌈n/16⌉, ⌈t/16⌉)`.
3393    Gemm { bg: GemmBg, n: u32 },
3394    /// LayerNorm(+res)(+bias): one workgroup per token row.
3395    Ln { bg: wgpu::BindGroup },
3396    /// Rotary on q or k in place: one workgroup per token row.
3397    Rope { bg: wgpu::BindGroup },
3398    /// Per-head qk RMS normalization in place: one workgroup per token row.
3399    QkNorm { bg: wgpu::BindGroup },
3400    /// GLU split: one workgroup per token row.
3401    Glu { bg: wgpu::BindGroup },
3402    /// Ragged attention: dispatch `(t, n_heads)`.
3403    Attn { bg: wgpu::BindGroup },
3404    /// Ragged attention, vec4 kernel (`head_dim % 4 == 0`): dispatch `(t, n_heads)`.
3405    Attn4 { bg: wgpu::BindGroup },
3406    /// Ragged attention, register-blocked rows (`hd ≤ 64`): dispatch `(⌈t/4⌉, n_heads)`.
3407    AttnRb { bg: wgpu::BindGroup },
3408    /// DeBERTa disentangled ragged attention (this layer's relative projections are baked into
3409    /// the bind group): dispatch `(t, n_heads)`.
3410    AttnDisent { bg: wgpu::BindGroup },
3411    /// Disentangled attention, register-blocked rows: dispatch `(⌈t/4⌉, n_heads)`.
3412    AttnDisentRb { bg: wgpu::BindGroup },
3413    /// Residual add over the `[t, hidden]` stream: dispatch `⌈t·h/256⌉`.
3414    Add { bg: wgpu::BindGroup },
3415    /// Stream copy (`dst = src`) over `[t, hidden]`: dispatch `⌈t·h/256⌉`.
3416    Copy { bg: wgpu::BindGroup },
3417    /// LFM2 gated short-conv: one workgroup per token row.
3418    Conv { bg: wgpu::BindGroup },
3419    /// Per-token L2 normalization (late interaction): one workgroup per token row.
3420    L2Rows { bg: wgpu::BindGroup },
3421}
3422
3423/// The GPU encoder: a prebuilt dispatch plan (bind groups + uniforms baked at load) over
3424/// persistent scratch sized `max_tokens`, replayed per batch with only the token-count uniform
3425/// fields and the ragged-batch buffers rewritten. One submit + one readback per `encode`.
3426///
3427/// The token/position/type embedding GATHER runs CPU-side and uploads the `[T, hidden]`
3428/// activation matrix — this both removes a gather kernel and keeps the huge word-embedding
3429/// table (250k rows for XLM-R vocabularies) out of GPU storage-binding limits.
3430pub struct EncoderGpu {
3431    cfg: EncoderConfig,
3432    kernels: EncKernels,
3433    use_f16: bool,
3434    max_tokens: usize,
3435    // CPU-side embedding tables.
3436    word: Vec<f32>,
3437    pos_table: Option<Vec<f32>>,
3438    ttype: Option<Vec<f32>>,
3439    // Upload target of the gathered embeddings (the plan's first LN reads it).
3440    emb_in: wgpu::Buffer,
3441    // The residual stream: `[max_tokens, hidden]`, holding the FINAL per-token states once the
3442    // plan has run. `encode` pools it; `forward_hidden` reads it directly.
3443    hidden_buf: wgpu::Buffer,
3444    // Bound inside `bg_pool` (the pooling kernel's sequence buffer); this field only keeps it
3445    // alive alongside the prebaked bind group — nothing reads it directly.
3446    _pool_src_seq_buf: wgpu::Buffer,
3447    pooled: wgpu::Buffer,
3448    seq_starts_buf: wgpu::Buffer,
3449    seq_of_buf: wgpu::Buffer,
3450    valid_buf: wgpu::Buffer,
3451    // The replayable plan.
3452    steps: Vec<EStep>,
3453    // Pooled modes only (None for PerToken, whose projection/L2 live inside `steps`).
3454    bg_pool: Option<wgpu::BindGroup>,
3455    bg_l2: Option<wgpu::BindGroup>,
3456    // Late-interaction per-token output buffer (`[max_tokens, dim]`; None for pooled modes).
3457    ptb: Option<wgpu::Buffer>,
3458    // Uniforms whose first u32 is rewritten per encode: token count `t`.
3459    metas_tokens: Vec<wgpu::Buffer>,
3460    /// Split-K reduce metas: `(buffer, n, k)` — word 3 (the split width) is rewritten per
3461    /// encode with [`gemm3_sk_z`], next to the `m` rewrite `metas_tokens` gets.
3462    metas_sk: Vec<(wgpu::Buffer, u32, u32)>,
3463    // Uniforms whose first u32 is `t · hidden` (residual adds).
3464    metas_elems: Vec<wgpu::Buffer>,
3465    /// `Some` only for `Pooling::CrossEncoder` checkpoints.
3466    ce_head: Option<CeHeadGpu>,
3467    /// Persistent MAP_READ staging for `encode`'s readback: the copy rides the SAME submit as
3468    /// the compute pass and this buffer is reused every call — no per-call staging allocation,
3469    /// no second submit+fence round trip. OPT-IN (`OSFKB_ENC_STAGED_READ=1`): the theory says
3470    /// ~150 µs (one submit+wait turnaround), but the only box available at landing time ran at
3471    /// load 37 and the interleaved A/B scattered ±0.7 ms — per the measurement rules it does
3472    /// not become the default until a quiet box proves it. Default = [`GpuCtx::read`].
3473    staging: wgpu::Buffer,
3474    staged_read: bool,
3475    // Buffers referenced by the prebaked bind groups (kept alive alongside them).
3476    _weights: Vec<EncGpuLinear>,
3477    _norms: Vec<EncGpuNorm>,
3478    _scratch: Vec<wgpu::Buffer>,
3479}
3480
3481/// Max images per batched SigLIP vision replay (`run_layers_gpu_batched`). The plan scratch is
3482/// sized `MAX_VISION_BATCH · n_patches`; callers chunk larger batches. 32 × 196 patches × 768
3483/// hidden ≈ 19 MB per scratch buffer — negligible VRAM, large dispatch-overhead amortization.
3484pub const MAX_VISION_BATCH: usize = 32;
3485
3486impl EncoderGpu {
3487    /// The compiled kernel set, so a task head on the SAME device (gliner2's span_rep) can dispatch
3488    /// its own GEMMs instead of dropping to CPU. Exposed rather than duplicated: a second pipeline
3489    /// set would mean recompiling identical shaders per consumer.
3490    pub fn kernels(&self) -> &EncKernels {
3491        &self.kernels
3492    }
3493
3494    /// Load with automatic weight precision: f16 when the adapter supports shader f16
3495    /// (encoders are precision-sensitive — Q4 is never used), else f32.
3496    /// Load on the GPU, choosing the weight precision by MEASURED throughput rather than by what
3497    /// the adapter merely supports.
3498    ///
3499    /// f16 weights halve the bytes read, so "use f16 when SHADER_F16 is available" looks obviously
3500    /// right — and on Metal it is 3–4× SLOWER. The encoder's shapes are ALU/occupancy-bound, not
3501    /// weight-bandwidth-bound (arithmetic intensity ≥ M FLOP/byte against a ridge near 35), so the
3502    /// f16→f32 widening in the inner loop costs more than the bandwidth it saves. Measured on an
3503    /// M4 Max with GEMM v2 (`gemm_bench_production_shapes`):
3504    ///
3505    /// ```text
3506    ///   BioLORD  M=64  N=768  K=768   f32 743 GF/s   f16 196 GF/s
3507    ///   DeBERTa  M=512 N=3072 K=768   f32 1282       f16 590
3508    /// ```
3509    ///
3510    /// So f32 is the default. `OSFKB_ENC_F16=1` opts back in for a device where f16 wins (or where
3511    /// the weights would not otherwise fit — the honest reason to want it).
3512    pub fn load(ctx: &GpuCtx, dir: &Path, max_tokens: usize) -> Result<Self> {
3513        let want_f16 = matches!(std::env::var("OSFKB_ENC_F16").ok().as_deref(), Some("1"));
3514        Self::load_with(ctx, dir, max_tokens, want_f16 && ctx.f16)
3515    }
3516
3517    /// Force f32 weights — the whole-model parity tests use this so the CPU oracle consumes
3518    /// value-identical weights (only FP order differs).
3519    pub fn load_f32(ctx: &GpuCtx, dir: &Path, max_tokens: usize) -> Result<Self> {
3520        Self::load_with(ctx, dir, max_tokens, false)
3521    }
3522
3523    /// SigLIP VISION tower on the GPU (auto precision — f32 unless `OSFKB_ENC_F16=1` on an f16
3524    /// adapter). The joint SigLIP config is not resolved by `encoder_config_from_dir`, so the
3525    /// vision spec is parsed here and its `EncoderConfig` handed to `load_with_cfg`; `max_tokens`
3526    /// is the patch count `(image/patch)²`. Pair with `CpuEncoder::load_siglip_vision` for the
3527    /// shared patch-embed + MAP head (see `crate::siglip_vision::VisionEmbed`).
3528    pub fn load_siglip_vision(ctx: &GpuCtx, dir: &Path) -> Result<Self> {
3529        let want_f16 = matches!(std::env::var("OSFKB_ENC_F16").ok().as_deref(), Some("1"));
3530        let (_, spec) = crate::encoder_weights::siglip_configs_from_dir(dir)?;
3531        let n = (spec.image_size / spec.patch_size).pow(2);
3532        // Size the plan scratch for up to MAX_VISION_BATCH images per replay (batched embedding
3533        // amortizes the per-image dispatch overhead — see run_layers_gpu_batched).
3534        Self::load_with_cfg(ctx, dir, spec.config, MAX_VISION_BATCH * n, want_f16 && ctx.f16)
3535    }
3536
3537    /// Force-f32 SigLIP vision tower — the parity test uses this so the CPU oracle consumes
3538    /// value-identical weights (only FP reduction order differs).
3539    pub fn load_siglip_vision_f32(ctx: &GpuCtx, dir: &Path) -> Result<Self> {
3540        let (_, spec) = crate::encoder_weights::siglip_configs_from_dir(dir)?;
3541        let n = (spec.image_size / spec.patch_size).pow(2);
3542        Self::load_with_cfg(ctx, dir, spec.config, MAX_VISION_BATCH * n, false)
3543    }
3544
3545    fn load_with(ctx: &GpuCtx, dir: &Path, max_tokens: usize, use_f16: bool) -> Result<Self> {
3546        let cfg = crate::encoder_weights::encoder_config_from_dir(dir)?;
3547        Self::load_with_cfg(ctx, dir, cfg, max_tokens, use_f16)
3548    }
3549
3550    /// `load_with` with a caller-supplied config: the SigLIP towers parse a JOINT `config.json`
3551    /// that `encoder_config_from_dir` does not resolve, so they build the `EncoderConfig`
3552    /// themselves and hand it in. Everything downstream (kernels, plan, struct assembly) is
3553    /// architecture-driven and identical.
3554    fn load_with_cfg(
3555        ctx: &GpuCtx,
3556        dir: &Path,
3557        cfg: EncoderConfig,
3558        max_tokens: usize,
3559        use_f16: bool,
3560    ) -> Result<Self> {
3561        anyhow::ensure!(
3562            cfg.head_dim <= 128,
3563            "encoder attention supports head_dim ≤ 128"
3564        );
3565        let st = LazySt::open(dir)?;
3566        let kernels = EncKernels::new(ctx);
3567        if use_f16 {
3568            kernels.gemm_pipeline(true)?; // fail at load, not first encode
3569        }
3570        let mut b = PlanBuilder::new(ctx, &kernels, &cfg, max_tokens, use_f16);
3571        let (word, pos_table, ttype) = match cfg.arch {
3572            EncArch::Bert | EncArch::XlmRoberta => b.build_bert_family(&st)?,
3573            EncArch::DebertaV2 => b.build_deberta(&st)?,
3574            EncArch::ModernBert => b.build_modernbert(&st)?,
3575            EncArch::Qwen3Embed => b.build_qwen3_embed(&st)?,
3576            EncArch::Lfm2Colbert => b.build_lfm2_colbert(&st, dir)?,
3577            EncArch::NomicBert => b.build_nomic(&st)?,
3578            EncArch::SiglipVision => b.build_siglip_vision(&st)?,
3579            other => anyhow::bail!(
3580                "GPU encoder tensor table for {other:?} pending verification against a real \
3581                 checkpoint"
3582            ),
3583        };
3584        let PlanBuilder {
3585            emb_in,
3586            cur,
3587            pool_src_seq_buf,
3588            pooled,
3589            ptb,
3590            valid_buf,
3591            seq_starts_buf,
3592            seq_of_buf,
3593            steps,
3594            bg_pool,
3595            bg_l2,
3596            metas_tokens,
3597            metas_sk,
3598            metas_elems,
3599            weights,
3600            norms,
3601            scratch,
3602            ..
3603        } = b;
3604        // PerToken pools inside the plan; MapHead (SigLIP vision) pools CPU-side after readback —
3605        // neither builds the mean/last/cls pooling bind groups.
3606        if !matches!(cfg.pooling, Pooling::PerToken { .. } | Pooling::MapHead) {
3607            anyhow::ensure!(
3608                bg_pool.is_some() && bg_l2.is_some(),
3609                "plan built no pooling bind groups"
3610            );
3611        }
3612
3613        // ── the CrossEncoder scoring head ───────────────────────────────────────────────────
3614        // Two GEMMs over the POOLED CLS states, so `m = b_seqs`, not `t` — which is why they are
3615        // not plan steps. Bind groups are prebaked here; the metas' `m` is written per encode.
3616        let ce_head = if let Pooling::CrossEncoder { n_labels } = cfg.pooling {
3617            let h = cfg.hidden;
3618            // A task-headed export nests the encoder under `bert.`/`roberta.` — so the POOLER is
3619            // `bert.pooler.dense.*` while the CLASSIFIER stays top-level. Same discovery the CPU
3620            // loader does; hardcoding either one loses on half the checkpoints.
3621            let prefix = ["", "bert.", "roberta."]
3622                .into_iter()
3623                .find(|p| st.has(&format!("{p}embeddings.word_embeddings.weight")))
3624                .context("cross-encoder: word embeddings not under '', 'bert.' or 'roberta.'")?;
3625            // `pooled` is sized `max_tokens × h` and every sequence has at least one token, so
3626            // max_tokens is a safe upper bound on the sequence count too.
3627            let cap = max_tokens;
3628            let mid = ctx.storage(&vec![0f32; cap * h]);
3629            let out = ctx.storage(&vec![0f32; cap * n_labels]);
3630            // `pooled` holds the CLS rows (cls_pool writes them). The L2 step is SKIPPED for this
3631            // pooling: a relevance logit is a score, not an embedding.
3632            // Head tensor names: BERT layout (`pooler.dense` → tanh → `classifier`) OR RoBERTa/XLM-R
3633            // layout (`classifier.dense` → tanh → `classifier.out_proj`, no pooler — bge-reranker-v2-m3,
3634            // jina-reranker-v2). Same two-GEMM math; only the names differ. Mirrored in the CPU loader.
3635            let (pool_w, pool_b, cls_w, cls_b) = if st.has(&format!("{prefix}pooler.dense.weight"))
3636            {
3637                (
3638                    format!("{prefix}pooler.dense.weight"),
3639                    format!("{prefix}pooler.dense.bias"),
3640                    "classifier.weight".to_string(),
3641                    "classifier.bias".to_string(),
3642                )
3643            } else {
3644                (
3645                    "classifier.dense.weight".to_string(),
3646                    "classifier.dense.bias".to_string(),
3647                    "classifier.out_proj.weight".to_string(),
3648                    "classifier.out_proj.bias".to_string(),
3649                )
3650            };
3651            let (bg_pooler, m1, w1) = head_gemm(
3652                ctx,
3653                &kernels,
3654                use_f16,
3655                &st.tensor_f32(&pool_w)?,
3656                &st.tensor_f32(&pool_b)?,
3657                h as u32,
3658                h as u32,
3659                &pooled,
3660                &mid,
3661                Some(Act::Tanh),
3662            )?;
3663            let (bg_classifier, m2, w2) = head_gemm(
3664                ctx,
3665                &kernels,
3666                use_f16,
3667                &st.tensor_f32(&cls_w)?,
3668                &st.tensor_f32(&cls_b)?,
3669                n_labels as u32,
3670                h as u32,
3671                &mid,
3672                &out,
3673                None,
3674            )?;
3675            // The fused single-dispatch head: needs raw f32 weight buffers and the shared-memory
3676            // bound; where either fails the 3-dispatch path serves (identical math).
3677            let fused = if !use_f16
3678                && h <= 2048
3679                && std::env::var("OSFKB_ENC_CE_FUSED").ok().as_deref() != Some("0")
3680            {
3681                let (EncMatBuf::F32(pwb) | EncMatBuf::F16(pwb)) = &w1.w;
3682                let (EncMatBuf::F32(cwb) | EncMatBuf::F16(cwb)) = &w2.w;
3683                let pl = pipeline(ctx, "enc_ce_fused", ENC_CE_FUSED);
3684                let meta = uni(
3685                    ctx,
3686                    bytemuck::cast_slice(&[h as u32, n_labels as u32, 0, 0]),
3687                );
3688                let bg = make_bg(
3689                    ctx,
3690                    &pl,
3691                    &[
3692                        &cur,
3693                        &seq_starts_buf,
3694                        pwb,
3695                        w1.b.as_ref().expect("pooler bias"),
3696                        cwb,
3697                        w2.b.as_ref().expect("classifier bias"),
3698                        &out,
3699                    ],
3700                    &meta,
3701                );
3702                // meta is static (h, n_labels) — b_seqs rides the dispatch grid.
3703                Some((pl, bg))
3704            } else {
3705                None
3706            };
3707            Some(CeHeadGpu {
3708                bg_pooler,
3709                bg_classifier,
3710                metas: vec![m1, m2],
3711                out,
3712                n_labels,
3713                fused,
3714                _mid: mid,
3715                _weights: vec![w1, w2],
3716            })
3717        } else {
3718            None
3719        };
3720
3721        // Staged readback: sized for every `encode` target — pooled `[b,h]`, per-token `[t,dim]`,
3722        // CE logits `[b,n_labels]`; b, t ≤ max_tokens and n_labels ≤ hidden always.
3723        let stage_floats = max_tokens
3724            * cfg.hidden.max(match cfg.pooling {
3725                Pooling::PerToken { dim } => dim,
3726                _ => 0,
3727            });
3728        let staging = ctx.device.create_buffer(&wgpu::BufferDescriptor {
3729            label: Some("enc_readback_staging"),
3730            size: (stage_floats * 4) as u64,
3731            usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
3732            mapped_at_creation: false,
3733        });
3734        let staged_read = std::env::var("OSFKB_ENC_STAGED_READ").ok().as_deref() == Some("1");
3735
3736        Ok(Self {
3737            cfg,
3738            kernels,
3739            use_f16,
3740            max_tokens,
3741            word,
3742            pos_table,
3743            ttype,
3744            emb_in,
3745            hidden_buf: cur,
3746            _pool_src_seq_buf: pool_src_seq_buf,
3747            pooled,
3748            seq_starts_buf,
3749            seq_of_buf,
3750            valid_buf,
3751            steps,
3752            bg_pool,
3753            bg_l2,
3754            ptb,
3755            metas_tokens,
3756            metas_sk,
3757            metas_elems,
3758            ce_head,
3759            staging,
3760            staged_read,
3761            _weights: weights,
3762            _norms: norms,
3763            _scratch: scratch,
3764        })
3765    }
3766
3767    /// The parsed configuration.
3768    pub fn config(&self) -> &EncoderConfig {
3769        &self.cfg
3770    }
3771
3772    /// Serving precision tag for diagnostics (`"f16"` / `"f32"`).
3773    pub fn precision(&self) -> &'static str {
3774        if self.use_f16 { "f16" } else { "f32" }
3775    }
3776
3777    /// Embed a ragged batch: gather embeddings CPU-side, replay the prebaked plan in one submit,
3778    /// read back the pooled, L2-normalized vectors.
3779    /// Per-token hidden states `[T, hidden]` — the residual stream BEFORE pooling.
3780    ///
3781    /// The GPU twin of [`CpuEncoder::forward_hidden`](crate::encoder_cpu::CpuEncoder::forward_hidden).
3782    /// Downstream heads that consume token states rather than a pooled vector (GLiNER's span head)
3783    /// need this; the plan already computes it, so this only changes which buffer is read back.
3784    pub fn forward_hidden(&mut self, ctx: &GpuCtx, batch: &EncBatch) -> Result<Vec<f32>> {
3785        // Same first-encode profile hook as `encode` — GLiNER-family heads enter here.
3786        if std::env::var("OSFKB_ENC_TS").ok().as_deref() == Some("1") {
3787            static ONCE: std::sync::Once = std::sync::Once::new();
3788            let mut rows = None;
3789            ONCE.call_once(|| rows = Some(self.profile_encode(ctx, batch)));
3790            if let Some(Ok(rows)) = rows {
3791                let total: f64 = rows.iter().map(|r| r.1).sum();
3792                eprintln!("  [enc ts] plan kernels {total:8.1} µs (head/readback excluded)");
3793                for (name, us, cnt) in rows {
3794                    eprintln!(
3795                        "  [enc ts] {name:16} {us:8.1} µs ({:4.1}%) ×{cnt}",
3796                        us / total * 100.0
3797                    );
3798                }
3799            }
3800        }
3801        let t = self.dispatch(ctx, batch)?;
3802        ctx.read(&self.hidden_buf, t * self.cfg.hidden)
3803    }
3804
3805    /// Run ONLY the transformer layers over a pre-built `[n, hidden]` residual stream (the
3806    /// SigLIP vision patch embedding) and read back the post-LN `[n, hidden]` states. Patch
3807    /// embedding and the MAP pooling head stay CPU-side (shared with the oracle via
3808    /// `CpuEncoder::patch_embed_rows` / `map_head`), so this is the ONLY device-divergent stage.
3809    /// No token gather, no pooling phase: uploads `rows` as the residual seed, replays the plan
3810    /// for one full-attention sequence of `n` patches, reads `hidden_buf`.
3811    pub fn run_layers_gpu(&mut self, ctx: &GpuCtx, rows: &[f32], n: usize) -> Result<Vec<f32>> {
3812        self.run_layers_gpu_batched(ctx, rows, n, 1)
3813    }
3814
3815    /// Run a BATCH of `batch` images (each `seq_len` patches) through the transformer layer plan
3816    /// in ONE replay. The layer stack is a ragged-batch machine — per-sequence attention via
3817    /// `seq_starts`, all-rows GEMMs/LNs — so B images become B ragged sequences and the ~80
3818    /// per-image GPU dispatches are amortized across the batch (the dispatch-overhead-bound cost
3819    /// of a tiny model). `rows` = concatenated `[batch·seq_len, hidden]`; returns the post-LN
3820    /// hidden `[batch·seq_len, hidden]`. Attention stays WITHIN each image (full bidirectional),
3821    /// so batching does not cross-contaminate. `batch·seq_len` must be ≤ `max_tokens`.
3822    pub fn run_layers_gpu_batched(
3823        &mut self,
3824        ctx: &GpuCtx,
3825        rows: &[f32],
3826        seq_len: usize,
3827        batch: usize,
3828    ) -> Result<Vec<f32>> {
3829        let h = self.cfg.hidden;
3830        let t = batch * seq_len;
3831        anyhow::ensure!(
3832            rows.len() == t * h,
3833            "rows {} != batch {batch} × seq_len {seq_len} × hidden {h}",
3834            rows.len()
3835        );
3836        anyhow::ensure!(
3837            t <= self.max_tokens,
3838            "batch·seq_len {t} exceeds max_tokens {}",
3839            self.max_tokens
3840        );
3841        ctx.queue
3842            .write_buffer(&self.emb_in, 0, bytemuck::cast_slice(rows));
3843        // `batch` ragged sequences of `seq_len` patches each.
3844        let seq_starts: Vec<u32> = (0..=batch).map(|i| (i * seq_len) as u32).collect();
3845        let seq_of = row_to_seq(&seq_starts, t);
3846        let valid = vec![1u32; t];
3847        ctx.queue
3848            .write_buffer(&self.seq_starts_buf, 0, bytemuck::cast_slice(&seq_starts));
3849        ctx.queue
3850            .write_buffer(&self.seq_of_buf, 0, bytemuck::cast_slice(&seq_of));
3851        ctx.queue
3852            .write_buffer(&self.valid_buf, 0, bytemuck::cast_slice(&valid));
3853        // Same per-encode meta rewrites `dispatch` does, but over the full batch token count `t`:
3854        // row count `t`, split-K depth, elem counts for the residual add/copy steps.
3855        for m in &self.metas_tokens {
3856            ctx.queue
3857                .write_buffer(m, 0, bytemuck::cast_slice(&[t as u32]));
3858        }
3859        for (m, nn, k) in &self.metas_sk {
3860            let z = gemm3_sk_plan(t, *nn as usize, *k as usize).1;
3861            ctx.queue.write_buffer(m, 12, bytemuck::cast_slice(&[z]));
3862        }
3863        for m in &self.metas_elems {
3864            ctx.queue
3865                .write_buffer(m, 0, bytemuck::cast_slice(&[(t * h) as u32]));
3866        }
3867        let mut cmd = ctx
3868            .device
3869            .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
3870        {
3871            let mut pass = cmd.begin_compute_pass(&wgpu::ComputePassDescriptor::default());
3872            for step in &self.steps {
3873                let (d, d2) = self.resolve_step(step, t)?;
3874                for (pl, bg, gx, gy, gz, _) in std::iter::once(d).chain(d2) {
3875                    pass.set_pipeline(pl);
3876                    pass.set_bind_group(0, bg, &[]);
3877                    pass.dispatch_workgroups(gx, gy, gz);
3878                }
3879            }
3880        }
3881        ctx.queue.submit([cmd.finish()]);
3882        ctx.read(&self.hidden_buf, t * h)
3883    }
3884
3885    pub fn encode(&mut self, ctx: &GpuCtx, batch: &EncBatch) -> Result<EmbedOut> {
3886        // `OSFKB_ENC_GPU_TRACE=1` splits an embed into gather+upload / GPU / readback. `/embed` on a
3887        // 64-token text measured 2.16x torch-MPS, and the P6 plan ASSUMED that was fixed overhead
3888        // (fresh staging buffer per read, CPU gather every call). At T=64 the gather is 196 KB and
3889        // the pooled readback is 3 KB, so that assumption deserves a measurement before anyone
3890        // writes a kernel against it.
3891        let _trace = std::env::var("OSFKB_ENC_GPU_TRACE").is_ok();
3892        // `OSFKB_ENC_TS=1`: per-kernel GPU timestamps for the FIRST encode of this process —
3893        // the split the wall-clock trace below cannot attribute.
3894        if std::env::var("OSFKB_ENC_TS").ok().as_deref() == Some("1") {
3895            static ONCE: std::sync::Once = std::sync::Once::new();
3896            let mut rows = None;
3897            ONCE.call_once(|| rows = Some(self.profile_encode(ctx, batch)));
3898            if let Some(rows) = rows {
3899                match rows {
3900                    Ok(rows) => {
3901                        let total: f64 = rows.iter().map(|r| r.1).sum();
3902                        eprintln!(
3903                            "  [enc ts] plan kernels {total:8.1} µs (pool/head/readback excluded)"
3904                        );
3905                        for (name, us, cnt) in rows {
3906                            eprintln!(
3907                                "  [enc ts] {name:16} {us:8.1} µs ({:4.1}%) ×{cnt}",
3908                                us / total * 100.0
3909                            );
3910                        }
3911                    }
3912                    Err(e) => eprintln!("  [enc ts] profile failed: {e}"),
3913                }
3914            }
3915        }
3916        let _t0 = std::time::Instant::now();
3917        let t = self.dispatch(ctx, batch)?;
3918        if _trace {
3919            eprintln!(
3920                "  [enc gpu] dispatch (gather+upload+GPU) {:?}",
3921                _t0.elapsed()
3922            );
3923        }
3924        let _t1 = std::time::Instant::now();
3925        let (cfg, h, b_seqs) = (&self.cfg, self.cfg.hidden, batch.n_seqs());
3926
3927        if let Pooling::PerToken { dim } = cfg.pooling {
3928            let flat = if self.staged_read {
3929                self.read_staged(ctx, t * dim)?
3930            } else {
3931                let ptb = self
3932                    .ptb
3933                    .as_ref()
3934                    .context("PerToken plan built no output buffer")?;
3935                ctx.read(ptb, t * dim)?
3936            };
3937            let mut out: Vec<Vec<Vec<f32>>> = Vec::with_capacity(b_seqs);
3938            for w in batch.seq_starts.windows(2) {
3939                out.push(
3940                    (w[0] as usize..w[1] as usize)
3941                        .map(|i| flat[i * dim..(i + 1) * dim].to_vec())
3942                        .collect(),
3943                );
3944            }
3945            return Ok(EmbedOut::PerToken(out));
3946        }
3947        if let Some(ce) = &self.ce_head {
3948            // `[b_seqs, n_labels]` raw logits — one relevance score per pair.
3949            let flat = if self.staged_read {
3950                self.read_staged(ctx, b_seqs * ce.n_labels)?
3951            } else {
3952                ctx.read(&ce.out, b_seqs * ce.n_labels)?
3953            };
3954            if _trace {
3955                eprintln!(
3956                    "  [enc gpu] readback (cross-encoder logits) {:?}",
3957                    _t1.elapsed()
3958                );
3959            }
3960            return Ok(EmbedOut::Pooled(
3961                flat.chunks_exact(ce.n_labels)
3962                    .map(<[f32]>::to_vec)
3963                    .collect(),
3964            ));
3965        }
3966        let flat = if self.staged_read {
3967            self.read_staged(ctx, b_seqs * h)?
3968        } else {
3969            ctx.read(&self.pooled, b_seqs * h)?
3970        };
3971        if _trace {
3972            eprintln!(
3973                "  [enc gpu] readback ({} floats) {:?}",
3974                b_seqs * h,
3975                _t1.elapsed()
3976            );
3977        }
3978        Ok(EmbedOut::Pooled(
3979            flat.chunks_exact(h).map(<[f32]>::to_vec).collect(),
3980        ))
3981    }
3982
3983    /// Validate, gather+upload the embeddings, replay the plan, submit. Returns the token count.
3984    /// Both [`Self::encode`] and [`Self::forward_hidden`] run this; they differ only in which
3985    /// buffer they read back afterwards.
3986    fn dispatch(&mut self, ctx: &GpuCtx, batch: &EncBatch) -> Result<usize> {
3987        let cfg = &self.cfg;
3988        let (t, b_seqs) = (batch.tokens.len(), batch.n_seqs());
3989        anyhow::ensure!(b_seqs > 0, "empty batch");
3990        anyhow::ensure!(
3991            t <= self.max_tokens,
3992            "batch of {t} tokens exceeds max_tokens {} — chunk at sequence boundaries",
3993            self.max_tokens
3994        );
3995        let offset = match cfg.pos_kind {
3996            PosKind::Learned { offset } => offset,
3997            PosKind::Rope { .. } => 0,
3998        };
3999        for w in batch.seq_starts.windows(2) {
4000            let len = (w[1] - w[0]) as usize;
4001            anyhow::ensure!(len > 0, "empty sequence in batch");
4002            anyhow::ensure!(
4003                len + offset <= cfg.max_pos,
4004                "sequence of {len} tokens exceeds max positions {} (offset {offset})",
4005                cfg.max_pos
4006            );
4007        }
4008        for &tok in &batch.tokens {
4009            anyhow::ensure!((tok as usize) < cfg.vocab, "token id {tok} out of vocab");
4010        }
4011
4012        // CPU gather: word (+ learned position, + the token's SEGMENT row) → [T, hidden] → upload.
4013        let h = cfg.hidden;
4014        let mut emb = vec![0f32; t * h];
4015        let seq_of = row_to_seq(&batch.seq_starts, t);
4016        for (i, row) in emb.chunks_exact_mut(h).enumerate() {
4017            let tok = batch.tokens[i] as usize;
4018            row.copy_from_slice(&self.word[tok * h..(tok + 1) * h]);
4019            if let Some(pos_table) = &self.pos_table {
4020                let local = i - batch.seq_starts[seq_of[i] as usize] as usize;
4021                for (r, p) in row
4022                    .iter_mut()
4023                    .zip(&pos_table[(offset + local) * h..(offset + local + 1) * h])
4024                {
4025                    *r += p;
4026                }
4027            }
4028            if let Some(tt) = &self.ttype {
4029                // The token's OWN segment id, not row 0. A pair input (`query [SEP] candidate` —
4030                // every CrossEncoder request) puts the candidate in segment 1, and hardcoding row 0
4031                // silently embedded it as if it were part of the query. It does not error; it just
4032                // returns a different, plausible-looking score. `None` ⇒ all zeros, which is the
4033                // single-sequence embedder case and is why nothing caught this.
4034                let ty = batch.type_ids.as_ref().map_or(0, |t| t[i] as usize);
4035                anyhow::ensure!(
4036                    (ty + 1) * h <= tt.len(),
4037                    "token_type id {ty} exceeds the checkpoint's segment table"
4038                );
4039                for (r, v) in row.iter_mut().zip(&tt[ty * h..(ty + 1) * h]) {
4040                    *r += v;
4041                }
4042            }
4043        }
4044        ctx.queue
4045            .write_buffer(&self.emb_in, 0, bytemuck::cast_slice(&emb));
4046        ctx.queue.write_buffer(
4047            &self.seq_starts_buf,
4048            0,
4049            bytemuck::cast_slice(&batch.seq_starts),
4050        );
4051        ctx.queue
4052            .write_buffer(&self.seq_of_buf, 0, bytemuck::cast_slice(&seq_of));
4053        // Validity: explicit mask or all-1s (must overwrite — the buffer persists across
4054        // encodes and a previous batch may have written zeros).
4055        let ones;
4056        let valid_slice: &[u32] = match &batch.valid {
4057            Some(v) => {
4058                anyhow::ensure!(
4059                    v.len() == t,
4060                    "validity mask length {} != tokens {t}",
4061                    v.len()
4062                );
4063                v
4064            }
4065            None => {
4066                ones = vec![1u32; t];
4067                &ones
4068            }
4069        };
4070        ctx.queue
4071            .write_buffer(&self.valid_buf, 0, bytemuck::cast_slice(valid_slice));
4072        for m in &self.metas_tokens {
4073            ctx.queue
4074                .write_buffer(m, 0, bytemuck::cast_slice(&[t as u32]));
4075        }
4076        for (m, n, k) in &self.metas_sk {
4077            let z = gemm3_sk_plan(t, *n as usize, *k as usize).1;
4078            ctx.queue.write_buffer(m, 12, bytemuck::cast_slice(&[z]));
4079        }
4080        for m in &self.metas_elems {
4081            ctx.queue
4082                .write_buffer(m, 0, bytemuck::cast_slice(&[(t * h) as u32]));
4083        }
4084        // The CrossEncoder head's GEMMs are shaped `m = b_seqs` (one CLS row per pair), NOT `m = t`.
4085        if let Some(ce) = &self.ce_head {
4086            for m in &ce.metas {
4087                ctx.queue
4088                    .write_buffer(m, 0, bytemuck::cast_slice(&[b_seqs as u32]));
4089            }
4090        }
4091
4092        // Replay the plan: one pass, one submit.
4093        let mut enc = ctx
4094            .device
4095            .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
4096        {
4097            let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor::default());
4098            for step in &self.steps {
4099                let (d, d2) = self.resolve_step(step, t)?;
4100                for (pl, bg, gx, gy, gz, _) in std::iter::once(d).chain(d2) {
4101                    pass.set_pipeline(pl);
4102                    pass.set_bind_group(0, bg, &[]);
4103                    pass.dispatch_workgroups(gx, gy, gz);
4104                }
4105            }
4106            let gemm_pl = self.kernels.gemm_pipeline(self.use_f16)?;
4107            match cfg.pooling {
4108                Pooling::Mean | Pooling::LastToken | Pooling::Cls => {
4109                    let pool_pl = match cfg.pooling {
4110                        Pooling::LastToken => &self.kernels.last_pool,
4111                        Pooling::Cls => &self.kernels.cls_pool,
4112                        _ => &self.kernels.mean_pool,
4113                    };
4114                    let (bg_pool, bg_l2) = (
4115                        self.bg_pool.as_ref().expect("validated at load"),
4116                        self.bg_l2.as_ref().expect("validated at load"),
4117                    );
4118                    pass.set_pipeline(pool_pl);
4119                    pass.set_bind_group(0, bg_pool, &[]);
4120                    pass.dispatch_workgroups(b_seqs as u32, 1, 1);
4121                    pass.set_pipeline(&self.kernels.l2norm);
4122                    pass.set_bind_group(0, bg_l2, &[]);
4123                    pass.dispatch_workgroups(b_seqs as u32, 1, 1);
4124                }
4125                Pooling::CrossEncoder { .. } => {
4126                    // CLS pool, then pooler(tanh) → classifier. NO L2: a relevance logit is a
4127                    // score, not an embedding — normalizing it would silently destroy the ranking
4128                    // the ontology-verifier consumes.
4129                    let ce = self
4130                        .ce_head
4131                        .as_ref()
4132                        .expect("CrossEncoder head built at load");
4133                    if let Some((pl, bg)) = &ce.fused {
4134                        // ONE dispatch replaces cls_pool + two m=b_seqs tiny-grid GEMMs.
4135                        pass.set_pipeline(pl);
4136                        pass.set_bind_group(0, bg, &[]);
4137                        pass.dispatch_workgroups(b_seqs as u32, 1, 1);
4138                    } else {
4139                        let bg_pool = self.bg_pool.as_ref().expect("validated at load");
4140                        pass.set_pipeline(&self.kernels.cls_pool);
4141                        pass.set_bind_group(0, bg_pool, &[]);
4142                        pass.dispatch_workgroups(b_seqs as u32, 1, 1);
4143
4144                        let m = b_seqs;
4145                        for (bg, n) in [
4146                            (&ce.bg_pooler, cfg.hidden),
4147                            (&ce.bg_classifier, ce.n_labels),
4148                        ] {
4149                            let (pl, bg, gx, gy) = match bg {
4150                                GemmBg::V1(bg) => (
4151                                    gemm_pl,
4152                                    bg,
4153                                    (n as u32).div_ceil(16),
4154                                    (m as u32).div_ceil(16),
4155                                ),
4156                                GemmBg::V2(bgs) => {
4157                                    // `m` here is b_seqs (a handful of pairs) and `n` the hidden
4158                                    // size or n_labels — a tiny grid either way, so the tile
4159                                    // choice matters most of all here.
4160                                    let tile = gemm2_tile(m, n);
4161                                    (
4162                                        self.kernels.gemm2_pipeline_tile(self.use_f16, tile)?,
4163                                        &bgs[gemm2_tier(tile)],
4164                                        (n as u32).div_ceil(tile.1 as u32),
4165                                        (m as u32).div_ceil(tile.0 as u32),
4166                                    )
4167                                }
4168                                GemmBg::V3(_) | GemmBg::V3Sk { .. } | GemmBg::F16A { .. } => {
4169                                    unreachable!(
4170                                        "head_gemm never uploads v3/sk/f16a (see its `keep`)"
4171                                    )
4172                                }
4173                            };
4174                            pass.set_pipeline(pl);
4175                            pass.set_bind_group(0, bg, &[]);
4176                            pass.dispatch_workgroups(gx, gy, 1);
4177                        }
4178                    }
4179                }
4180                // Per-token projection + L2 are regular plan steps; nothing extra here.
4181                Pooling::PerToken { .. } => {}
4182                other => anyhow::bail!("pooling {other:?} lands with its phase"),
4183            }
4184        }
4185        // Staged read: `encode`'s readback copy rides THIS submit (same pooling match encode
4186        // uses), so the read half only maps the persistent buffer — no second submit, no fresh
4187        // staging allocation. `forward_hidden` keeps the plain `ctx.read` path.
4188        if self.staged_read {
4189            let (src, floats) = if let Pooling::PerToken { dim } = cfg.pooling {
4190                (
4191                    self.ptb
4192                        .as_ref()
4193                        .context("PerToken plan built no output buffer")?,
4194                    t * dim,
4195                )
4196            } else if let Some(ce) = &self.ce_head {
4197                (&ce.out, b_seqs * ce.n_labels)
4198            } else {
4199                (&self.pooled, b_seqs * cfg.hidden)
4200            };
4201            enc.copy_buffer_to_buffer(src, 0, &self.staging, 0, (floats * 4) as u64);
4202        }
4203        ctx.queue.submit([enc.finish()]);
4204        Ok(t)
4205    }
4206
4207    /// Resolve one plan step into its dispatch(es) — pipeline, bind group, grid, label. The ONE
4208    /// place this decision lives: the hot replay and the `OSFKB_ENC_TS` profiler both consume
4209    /// it, so the profiler can never time a different kernel than production dispatches (the
4210    /// lcpp geometry-drift lesson, applied to the encoder). Split-K steps at small `m` return a
4211    /// second dispatch (the reduce).
4212    #[allow(clippy::type_complexity)]
4213    fn resolve_step<'s>(
4214        &'s self,
4215        step: &'s EStep,
4216        t: usize,
4217    ) -> Result<(StepDisp<'s>, Option<StepDisp<'s>>)> {
4218        let cfg = &self.cfg;
4219        let t32 = t as u32;
4220        let h = cfg.hidden;
4221        Ok(match step {
4222            EStep::Gemm {
4223                bg:
4224                    GemmBg::V3Sk {
4225                        plain,
4226                        part,
4227                        part32,
4228                        reduce,
4229                        k,
4230                        f16a,
4231                    },
4232                n,
4233            } => {
4234                let tile = gemm3_tile(t, *n as usize);
4235                let wgs = (*n as usize).div_ceil(tile.1) * t.div_ceil(tile.0);
4236                // Plain-vs-pair, per band: the mid band keeps its validated predicate; the
4237                // small-n band's knee is measured at ~80 WGs (~2/core — 72 WGs loses to the
4238                // pair by 1.5×, 90 wins; `gemm3_smalln_bench`).
4239                let plain_ok = if *n <= 512 {
4240                    wgs >= 80
4241                } else {
4242                    gemm3_grid_ok(t, tile, wgs)
4243                };
4244                if plain_ok {
4245                    let pl = if *f16a {
4246                        self.kernels
4247                            .gemm3_f16a_pipeline_tile(tile)
4248                            .ok_or_else(|| anyhow::anyhow!("f16a plan on a non-f16 adapter"))?
4249                    } else {
4250                        self.kernels.gemm3_pipeline_tile(self.use_f16, tile)?
4251                    };
4252                    (
4253                        (
4254                            pl,
4255                            &plain[gemm3_tier(tile)],
4256                            n.div_ceil(tile.1 as u32),
4257                            t32.div_ceil(tile.0 as u32),
4258                            1,
4259                            if *f16a { "gemm3f16a" } else { "gemm3" },
4260                        ),
4261                        None,
4262                    )
4263                } else {
4264                    // Replay-dynamic split plan — the SAME function the encode-time meta
4265                    // rewrite used, so grid.z always matches the reduce's baked `sk` word.
4266                    let (use32, z) = gemm3_sk_plan(t, *n as usize, *k as usize);
4267                    let pl = match (*f16a, use32) {
4268                        (true, false) => self
4269                            .kernels
4270                            .gemm3_sk_f16a
4271                            .as_ref()
4272                            .ok_or_else(|| anyhow::anyhow!("f16a plan on a non-f16 adapter"))?,
4273                        (true, true) => self
4274                            .kernels
4275                            .gemm3_sk32_f16a
4276                            .as_ref()
4277                            .ok_or_else(|| anyhow::anyhow!("f16a plan on a non-f16 adapter"))?,
4278                        (false, false) => self.kernels.gemm3_sk_pipeline(self.use_f16)?,
4279                        (false, true) => self.kernels.gemm3_sk32_pipeline(self.use_f16)?,
4280                    };
4281                    let (pbg, rows) = if use32 {
4282                        (part32, GEMM3_SK_TILE32.0 as u32)
4283                    } else {
4284                        (part, GEMM3_SK_TILE.0 as u32)
4285                    };
4286                    anyhow::ensure!(
4287                        z as usize * t * (*n as usize) <= SK_PART_F32,
4288                        "split-K partials overflow: z={z} t={t} n={n} — the occupancy knee \
4289                         should make this impossible"
4290                    );
4291                    (
4292                        (
4293                            pl,
4294                            pbg,
4295                            n.div_ceil(GEMM3_SK_TILE.1 as u32),
4296                            t32.div_ceil(rows),
4297                            z,
4298                            "gemm3sk",
4299                        ),
4300                        Some((
4301                            &self.kernels.gemm3_sk_reduce,
4302                            reduce,
4303                            (t32 * n).div_ceil(256),
4304                            1,
4305                            1,
4306                            "skreduce",
4307                        )),
4308                    )
4309                }
4310            }
4311            EStep::Gemm { bg, n } => (
4312                match bg {
4313                    GemmBg::F16A { tiles, coop } => {
4314                        // Matrix-unit arm when its 128×128 grid fills the GPU (measured knee:
4315                        // 48 WGs) and the padded row count stays inside the scratch (rows past
4316                        // `t` compute garbage into rows never read — safe by construction).
4317                        let wgs128 = (*n as usize).div_ceil(128) * t.div_ceil(128);
4318                        let m_pad = t.div_ceil(128) * 128;
4319                        if let (Some(bg), true, true) =
4320                            (coop.as_ref(), wgs128 >= 48, m_pad <= self.max_tokens)
4321                        {
4322                            (
4323                                self.kernels
4324                                    .gemm4_f16w
4325                                    .as_ref()
4326                                    .ok_or_else(|| anyhow::anyhow!("coop arm without kernel"))?,
4327                                bg,
4328                                n.div_ceil(128),
4329                                (m_pad as u32) / 128,
4330                                1,
4331                                "gemm4f16",
4332                            )
4333                        } else {
4334                            let tile = gemm3_tile(t, *n as usize);
4335                            (
4336                                self.kernels.gemm3_f16a_pipeline_tile(tile).ok_or_else(|| {
4337                                    anyhow::anyhow!("f16a plan on a non-f16 adapter")
4338                                })?,
4339                                &tiles[gemm3_tier(tile)],
4340                                n.div_ceil(tile.1 as u32),
4341                                t32.div_ceil(tile.0 as u32),
4342                                1,
4343                                "gemm3f16a",
4344                            )
4345                        }
4346                    }
4347                    GemmBg::V1(bg) => (
4348                        self.kernels.gemm_pipeline(self.use_f16)?,
4349                        bg,
4350                        n.div_ceil(16),
4351                        t32.div_ceil(16),
4352                        1,
4353                        "gemm1",
4354                    ),
4355                    GemmBg::V2(bgs) => {
4356                        // The tile depends on the GRID — the token count AND this step's output
4357                        // width. Keying on `t` alone left `/score`'s 384-wide qkv on an
4358                        // 18-workgroup grid whatever `t` was.
4359                        let tile = gemm2_tile(t, *n as usize);
4360                        (
4361                            self.kernels.gemm2_pipeline_tile(self.use_f16, tile)?,
4362                            &bgs[gemm2_tier(tile)],
4363                            n.div_ceil(tile.1 as u32),
4364                            t32.div_ceil(tile.0 as u32),
4365                            1,
4366                            "gemm2",
4367                        )
4368                    }
4369                    GemmBg::V3(bgs) => {
4370                        let tile = gemm3_tile(t, *n as usize);
4371                        (
4372                            self.kernels.gemm3_pipeline_tile(self.use_f16, tile)?,
4373                            &bgs[gemm3_tier(tile)],
4374                            n.div_ceil(tile.1 as u32),
4375                            t32.div_ceil(tile.0 as u32),
4376                            1,
4377                            "gemm3",
4378                        )
4379                    }
4380                    GemmBg::V3Sk { .. } => unreachable!("handled by the arm above"),
4381                },
4382                None,
4383            ),
4384            EStep::Ln { bg } => ((&self.kernels.layernorm, bg, t32, 1, 1, "ln"), None),
4385            EStep::Rope { bg } => ((&self.kernels.rope, bg, t32, 1, 1, "rope"), None),
4386            EStep::QkNorm { bg } => ((&self.kernels.qk_norm, bg, t32, 1, 1, "qknorm"), None),
4387            EStep::Glu { bg } => ((&self.kernels.glu, bg, t32, 1, 1, "glu"), None),
4388            EStep::Attn { bg } => (
4389                (&self.kernels.attn, bg, t32, cfg.n_heads as u32, 1, "attn"),
4390                None,
4391            ),
4392            EStep::Attn4 { bg } => (
4393                (&self.kernels.attn4, bg, t32, cfg.n_heads as u32, 1, "attn4"),
4394                None,
4395            ),
4396            EStep::AttnRb { bg } => (
4397                (
4398                    &self.kernels.attn_rb4,
4399                    bg,
4400                    t32.div_ceil(4),
4401                    cfg.n_heads as u32,
4402                    1,
4403                    "attnRB",
4404                ),
4405                None,
4406            ),
4407            EStep::AttnDisent { bg } => (
4408                (
4409                    &self.kernels.attn_disent,
4410                    bg,
4411                    t32,
4412                    cfg.n_heads as u32,
4413                    1,
4414                    "disent",
4415                ),
4416                None,
4417            ),
4418            EStep::AttnDisentRb { bg } => (
4419                (
4420                    &self.kernels.attn_disent_rb4,
4421                    bg,
4422                    t32.div_ceil(4),
4423                    cfg.n_heads as u32,
4424                    1,
4425                    "disentRB",
4426                ),
4427                None,
4428            ),
4429            EStep::Add { bg } => (
4430                (
4431                    &self.kernels.add,
4432                    bg,
4433                    ((t * h) as u32).div_ceil(256),
4434                    1,
4435                    1,
4436                    "add",
4437                ),
4438                None,
4439            ),
4440            EStep::Copy { bg } => (
4441                (
4442                    &self.kernels.copy,
4443                    bg,
4444                    ((t * h) as u32).div_ceil(256),
4445                    1,
4446                    1,
4447                    "copy",
4448                ),
4449                None,
4450            ),
4451            EStep::Conv { bg } => ((&self.kernels.conv, bg, t32, 1, 1, "conv"), None),
4452            EStep::L2Rows { bg } => ((&self.kernels.l2norm, bg, t32, 1, 1, "l2"), None),
4453        })
4454    }
4455
4456    /// Per-kernel GPU time for one encode of `batch`, aggregated by step kind — the instrument
4457    /// the encoder never had (`OSFKB_ENC_GPU_TRACE` is wall-clock around the whole submit; this
4458    /// is timestamps around every dispatch). Runs the plan once normally (uploads + warm), then
4459    /// replays each dispatch in its own timestamped pass. Serializing into per-dispatch passes
4460    /// perturbs absolute times a little; the SHARES are what to read. Pooling/head/readback are
4461    /// outside the plan and outside this table.
4462    pub fn profile_encode(
4463        &mut self,
4464        ctx: &GpuCtx,
4465        batch: &EncBatch,
4466    ) -> Result<Vec<(String, f64, u32)>> {
4467        anyhow::ensure!(ctx.timestamps, "adapter lacks TIMESTAMP_QUERY");
4468        let t = self.dispatch(ctx, batch)?; // uploads + normal replay (warm)
4469        let ndisp: usize = self
4470            .steps
4471            .iter()
4472            .map(|s| match self.resolve_step(s, t) {
4473                Ok((_, Some(_))) => 2,
4474                _ => 1,
4475            })
4476            .sum();
4477        let nq = (ndisp * 2) as u32;
4478        let qs = ctx.device.create_query_set(&wgpu::QuerySetDescriptor {
4479            label: Some("enc_prof"),
4480            ty: wgpu::QueryType::Timestamp,
4481            count: nq,
4482        });
4483        let qbuf = ctx.device.create_buffer(&wgpu::BufferDescriptor {
4484            label: Some("enc_prof_resolve"),
4485            size: u64::from(nq) * 8,
4486            usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC,
4487            mapped_at_creation: false,
4488        });
4489        let qstage = ctx.device.create_buffer(&wgpu::BufferDescriptor {
4490            label: Some("enc_prof_stage"),
4491            size: u64::from(nq) * 8,
4492            usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
4493            mapped_at_creation: false,
4494        });
4495        let mut enc = ctx
4496            .device
4497            .create_command_encoder(&wgpu::CommandEncoderDescriptor::default());
4498        let mut labels: Vec<(&'static str, u32)> = Vec::with_capacity(ndisp);
4499        {
4500            let mut qi = 0u32;
4501            for step in &self.steps {
4502                let n_of = |s: &EStep| match s {
4503                    EStep::Gemm { n, .. } => *n,
4504                    _ => 0,
4505                };
4506                let (d, d2) = self.resolve_step(step, t)?;
4507                for (pl, bg, gx, gy, gz, label) in std::iter::once(d).chain(d2) {
4508                    let mut p = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
4509                        label: None,
4510                        timestamp_writes: Some(wgpu::ComputePassTimestampWrites {
4511                            query_set: &qs,
4512                            beginning_of_pass_write_index: Some(qi * 2),
4513                            end_of_pass_write_index: Some(qi * 2 + 1),
4514                        }),
4515                    });
4516                    p.set_pipeline(pl);
4517                    p.set_bind_group(0, bg, &[]);
4518                    p.dispatch_workgroups(gx, gy, gz);
4519                    labels.push((label, n_of(step)));
4520                    qi += 1;
4521                }
4522            }
4523        }
4524        enc.resolve_query_set(&qs, 0..nq, &qbuf, 0);
4525        enc.copy_buffer_to_buffer(&qbuf, 0, &qstage, 0, u64::from(nq) * 8);
4526        ctx.queue.submit([enc.finish()]);
4527        let slice = qstage.slice(..);
4528        let (tx, rx) = std::sync::mpsc::channel();
4529        slice.map_async(wgpu::MapMode::Read, move |r| {
4530            let _ = tx.send(r);
4531        });
4532        let _ = ctx.device.poll(wgpu::PollType::wait_indefinitely());
4533        rx.recv()
4534            .map_err(|_| anyhow::anyhow!("prof staging dropped"))?
4535            .map_err(|e| anyhow::anyhow!("map_async: {e:?}"))?;
4536        let raw: Vec<u64> =
4537            bytemuck::cast_slice(&slice.get_mapped_range().expect("mapped range")).to_vec();
4538        qstage.unmap();
4539        let mut agg: std::collections::BTreeMap<(&'static str, u32), (f64, u32)> =
4540            std::collections::BTreeMap::new();
4541        for (qi, (label, n)) in labels.iter().enumerate() {
4542            let dt =
4543                raw[qi * 2 + 1].saturating_sub(raw[qi * 2]) as f64 * f64::from(ctx.ts_period) / 1e3;
4544            let e = agg.entry((label, *n)).or_insert((0.0, 0));
4545            e.0 += dt;
4546            e.1 += 1;
4547        }
4548        let mut rows: Vec<(String, f64, u32)> = agg
4549            .into_iter()
4550            .map(|((label, n), (us, cnt))| {
4551                let name = if n > 0 {
4552                    format!("{label} n={n}")
4553                } else {
4554                    label.to_string()
4555                };
4556                (name, us, cnt)
4557            })
4558            .collect();
4559        rows.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
4560        Ok(rows)
4561    }
4562
4563    /// The read half of the staged path: map the persistent staging buffer (the copy already
4564    /// rode `dispatch`'s submit) and hand the floats back. Mirrors [`GpuCtx::read`]'s wait
4565    /// discipline, spin-poll included.
4566    fn read_staged(&self, ctx: &GpuCtx, len: usize) -> Result<Vec<f32>> {
4567        let slice = self.staging.slice(..(len * 4) as u64);
4568        let (tx, rx) = std::sync::mpsc::channel();
4569        slice.map_async(wgpu::MapMode::Read, move |r| {
4570            let _ = tx.send(r);
4571        });
4572        if ctx.spin_poll {
4573            loop {
4574                let r = ctx.device.poll(wgpu::PollType::Poll);
4575                if rx.try_recv().is_ok() {
4576                    break;
4577                }
4578                if let Ok(status) = &r
4579                    && status.is_queue_empty()
4580                {
4581                    rx.recv()
4582                        .map_err(|_| anyhow::anyhow!("map_async callback dropped"))?
4583                        .map_err(|e| anyhow::anyhow!("map_async: {e:?}"))?;
4584                    break;
4585                }
4586                std::hint::spin_loop();
4587            }
4588        } else {
4589            let _ = ctx.device.poll(wgpu::PollType::wait_indefinitely());
4590            rx.recv()
4591                .map_err(|_| anyhow::anyhow!("map_async callback dropped"))?
4592                .map_err(|e| anyhow::anyhow!("map_async: {e:?}"))?;
4593        }
4594        let data = slice.get_mapped_range().expect("mapped range");
4595        let out: Vec<f32> = bytemuck::cast_slice(&data).to_vec();
4596        drop(data);
4597        self.staging.unmap();
4598        Ok(out)
4599    }
4600}
4601
4602/// Plan construction state: allocates the shared scratch, uploads weights, and pushes [`EStep`]s
4603/// for the architecture's dataflow. Buffer roles during building:
4604/// `emb_in` (gather upload) → `cur` (residual stream) with `alt`/`qb`/`kb`/`vb`/`cb`/`mid`/`glu`
4605/// as stage scratch.
4606struct PlanBuilder<'a> {
4607    ctx: &'a GpuCtx,
4608    kernels: &'a EncKernels,
4609    cfg: &'a EncoderConfig,
4610    use_f16: bool,
4611    eps: f32,
4612    emb_in: wgpu::Buffer,
4613    cur: wgpu::Buffer,
4614    alt: wgpu::Buffer,
4615    qb: wgpu::Buffer,
4616    kb: wgpu::Buffer,
4617    vb: wgpu::Buffer,
4618    cb: wgpu::Buffer,
4619    mid: wgpu::Buffer,
4620    glu: wgpu::Buffer,
4621    pooled: wgpu::Buffer,
4622    /// Late-interaction per-token output (allocated on demand by the ColBERT plan).
4623    ptb: Option<wgpu::Buffer>,
4624    pool_src_seq_buf: wgpu::Buffer,
4625    seq_starts_buf: wgpu::Buffer,
4626    seq_of_buf: wgpu::Buffer,
4627    valid_buf: wgpu::Buffer,
4628    steps: Vec<EStep>,
4629    bg_pool: Option<wgpu::BindGroup>,
4630    bg_l2: Option<wgpu::BindGroup>,
4631    metas_tokens: Vec<wgpu::Buffer>,
4632    /// Split-K reduce metas: `(buffer, n, k)` — word 3 (the split width) is rewritten per
4633    /// encode with [`gemm3_sk_z`], next to the `m` rewrite `metas_tokens` gets.
4634    metas_sk: Vec<(wgpu::Buffer, u32, u32)>,
4635    metas_elems: Vec<wgpu::Buffer>,
4636    weights: Vec<EncGpuLinear>,
4637    norms: Vec<EncGpuNorm>,
4638    scratch: Vec<wgpu::Buffer>,
4639    /// Split-K partials scratch (lazy — only mid-band models pay the 4·T·1088 f32).
4640    sk_part: Option<wgpu::Buffer>,
4641    max_tokens: usize,
4642}
4643
4644impl<'a> PlanBuilder<'a> {
4645    fn new(
4646        ctx: &'a GpuCtx,
4647        kernels: &'a EncKernels,
4648        cfg: &'a EncoderConfig,
4649        max_tokens: usize,
4650        use_f16: bool,
4651    ) -> Self {
4652        let h = cfg.hidden;
4653        let mut up_width = match cfg.mlp_kind {
4654            MlpKind::Dense { .. } => cfg.intermediate,
4655            MlpKind::Glu { .. } => 2 * cfg.intermediate,
4656        };
4657        if cfg.layer_is_attn.iter().any(|a| !a) {
4658            // Conv layers stage their [T, 3H] in_proj output in `mid`.
4659            up_width = up_width.max(3 * h);
4660        }
4661        // Attention scratch must fit the Q width, which EXCEEDS hidden on some decoders
4662        // (Qwen3-Embedding-0.6B: 16 heads × 128 = 2048 > hidden 1024).
4663        let qw = (cfg.n_heads * cfg.head_dim).max(h);
4664        let zeros = |n: usize| vec![0f32; n];
4665        Self {
4666            eps: cfg.eps,
4667            emb_in: ctx.storage(&zeros(max_tokens * h)),
4668            cur: ctx.storage(&zeros(max_tokens * h)),
4669            alt: ctx.storage(&zeros(max_tokens * h)),
4670            qb: ctx.storage(&zeros(max_tokens * qw)),
4671            kb: ctx.storage(&zeros(max_tokens * qw)),
4672            vb: ctx.storage(&zeros(max_tokens * qw)),
4673            cb: ctx.storage(&zeros(max_tokens * qw)),
4674            mid: ctx.storage(&zeros(max_tokens * up_width)),
4675            glu: ctx.storage(&zeros(max_tokens * cfg.intermediate)),
4676            pooled: ctx.storage(&zeros(max_tokens * h)),
4677            ptb: None,
4678            pool_src_seq_buf: ctx.storage_bytes(&[0u8; 8]),
4679            seq_starts_buf: ctx.storage_bytes(&vec![0u8; (max_tokens + 1) * 4]),
4680            seq_of_buf: ctx.storage_bytes(&vec![0u8; max_tokens * 4]),
4681            valid_buf: ctx.storage_bytes(bytemuck::cast_slice::<u32, u8>(&vec![1u32; max_tokens])),
4682            steps: Vec::new(),
4683            bg_pool: None,
4684            bg_l2: None,
4685            metas_tokens: Vec::new(),
4686            metas_sk: Vec::new(),
4687            metas_elems: Vec::new(),
4688            weights: Vec::new(),
4689            norms: Vec::new(),
4690            scratch: Vec::new(),
4691            sk_part: None,
4692            max_tokens,
4693            ctx,
4694            kernels,
4695            cfg,
4696            use_f16,
4697        }
4698    }
4699
4700    fn upload_linear(&mut self, w: &[f32], b: Option<Vec<f32>>, n: u32, k: u32) -> Result<usize> {
4701        anyhow::ensure!(
4702            w.len() == (n as usize) * (k as usize),
4703            "weight shape {} != {n}×{k}",
4704            w.len()
4705        );
4706        // v3-family steps (wide n ≥ 1152, small n ≤ 512, and the mid band's split-K arm) need
4707        // the weight `[k, n]`-transposed for vec4 loads. The transpose happens ONCE here at
4708        // upload; v2 keeps the HF `[n, k]` layout. One format per weight — the kill switches
4709        // decide at load.
4710        let v3 = gemm3_eligible(n as usize);
4711        // The split-K PAIR rides two bands: the mid band (v3-ineligible widths), and the
4712        // small-n band where plain v3 keeps big grids but the pair rescues thin ones.
4713        let sk = (!v3 && gemm3_sk_band(n as usize)) || (v3 && gemm3_smalln_sk(n as usize));
4714        // f16-ARITHMETIC opt-in: every transposed-eligible step switches to the f16a kernel
4715        // (and its f16 storage). Falls back silently to the f32 v3/SK arms without SHADER_F16.
4716        let f16a = (v3 || sk) && f16a_enabled() && self.ctx.f16;
4717        let wt_store;
4718        let w = if v3 || sk {
4719            let (n, k) = (n as usize, k as usize);
4720            let mut wt = vec![0f32; n * k];
4721            for nn in 0..n {
4722                for kk in 0..k {
4723                    wt[kk * n + nn] = w[nn * k + kk];
4724                }
4725            }
4726            wt_store = wt;
4727            &wt_store[..]
4728        } else {
4729            w
4730        };
4731        let mat = if f16a || self.use_f16 {
4732            EncMatBuf::F16(self.ctx.storage_bytes(&f32_to_f16_bytes(w)))
4733        } else {
4734            EncMatBuf::F32(self.ctx.storage(w))
4735        };
4736        let b_host = if f16a { b.clone() } else { None };
4737        self.weights.push(EncGpuLinear {
4738            w: mat,
4739            b: b.map(|bv| self.ctx.storage(&bv)),
4740            n,
4741            k,
4742            v3,
4743            sk,
4744            f16a,
4745            b_host,
4746        });
4747        Ok(self.weights.len() - 1)
4748    }
4749
4750    /// The split-K partials scratch, allocated on FIRST use and shared by every split step
4751    /// (the plan is serial, each reduce drains it before the next partial write). Sized by the
4752    /// dispatch invariant ([`SK_PART_F32`]), NOT by `max_tokens` — the pair only runs below
4753    /// the occupancy knees, so 2.6 MB covers the deepest split at the largest admissible tile.
4754    fn sk_part_buf(&mut self) -> wgpu::Buffer {
4755        if self.sk_part.is_none() {
4756            self.sk_part = Some(self.ctx.storage(&vec![0f32; SK_PART_F32]));
4757        }
4758        self.sk_part.clone().expect("just filled")
4759    }
4760
4761    /// GEMM step: `y[t, n] = act(x · Wᵀ (+bias))`.
4762    fn push_gemm(&mut self, wi: usize, x: &wgpu::Buffer, y: &wgpu::Buffer, act: Option<Act>) {
4763        let lw = &self.weights[wi];
4764        let (n, k, v3, sk, has_b) = (lw.n, lw.k, lw.v3, lw.sk, lw.b.is_some());
4765        let lw_f16a = lw.f16a;
4766        let wbuf = match &lw.w {
4767            EncMatBuf::F16(b) | EncMatBuf::F32(b) => b.clone(),
4768        };
4769        // Bias-free layers bind a 1-element placeholder (never read: flags bit 0 is clear).
4770        let bbuf = lw.b.clone().unwrap_or_else(|| self.ctx.storage(&[0.0]));
4771        let flags = u32::from(has_b) | (act_code(act) << 8);
4772        let meta = uni(self.ctx, bytemuck::cast_slice(&[0u32, n, k, flags]));
4773        let bufs = [x, &wbuf, &bbuf, y];
4774        let bg = if lw_f16a && sk {
4775            // Mid-band under f16 arithmetic: the SAME m-dependent arm, f16a pipelines. The
4776            // first f16a A/B proved this matters both ways — plain f16a at m=230 won GLiNER
4777            // −19%, while losing the split arm regressed /embed at m=64.
4778            let part = self.sk_part_buf();
4779            let plain = GEMM3_TILES
4780                .iter()
4781                .map(|&tl| {
4782                    make_bg(
4783                        self.ctx,
4784                        self.kernels
4785                            .gemm3_f16a_pipeline_tile(tl)
4786                            .expect("f16a implies SHADER_F16 (checked at upload)"),
4787                        &bufs,
4788                        &meta,
4789                    )
4790                })
4791                .collect();
4792            let part_bg = make_bg(
4793                self.ctx,
4794                self.kernels
4795                    .gemm3_sk_f16a
4796                    .as_ref()
4797                    .expect("f16a implies SHADER_F16 (checked at upload)"),
4798                &[x, &wbuf, &bbuf, &part],
4799                &meta,
4800            );
4801            let part32_bg = make_bg(
4802                self.ctx,
4803                self.kernels
4804                    .gemm3_sk32_f16a
4805                    .as_ref()
4806                    .expect("f16a implies SHADER_F16 (checked at upload)"),
4807                &[x, &wbuf, &bbuf, &part],
4808                &meta,
4809            );
4810            let skn = gemm3_sk_chunks(k as usize); // placeholder; word 3 is rewritten per encode
4811            let rmeta = uni(self.ctx, bytemuck::cast_slice(&[0u32, n, flags, skn]));
4812            let reduce = make_bg(
4813                self.ctx,
4814                &self.kernels.gemm3_sk_reduce,
4815                &[&part, &bbuf, y],
4816                &rmeta,
4817            );
4818            self.metas_sk.push((rmeta.clone(), n, k));
4819            self.metas_tokens.push(rmeta);
4820            GemmBg::V3Sk {
4821                plain,
4822                part: part_bg,
4823                part32: part32_bg,
4824                reduce,
4825                k,
4826                f16a: true,
4827            }
4828        } else if lw_f16a {
4829            let set = GEMM3_TILES
4830                .iter()
4831                .map(|&tl| {
4832                    make_bg(
4833                        self.ctx,
4834                        self.kernels
4835                            .gemm3_f16a_pipeline_tile(tl)
4836                            .expect("f16a implies SHADER_F16 (checked at upload)"),
4837                        &bufs,
4838                        &meta,
4839                    )
4840                })
4841                .collect();
4842            // Matrix-unit arm: v4 seeds its accumulator from a [8, n] bias broadcast and does
4843            // NOT fuse activations — so only activation-free steps get the coop bind group.
4844            let coop = match (&self.kernels.gemm4_f16w, act, &self.weights[wi].b_host) {
4845                (Some(pl), None, bh) => {
4846                    let bias_vec = bh.clone().unwrap_or_else(|| vec![0f32; n as usize]);
4847                    let mut b8 = Vec::with_capacity(8 * n as usize);
4848                    for _ in 0..8 {
4849                        b8.extend_from_slice(&bias_vec);
4850                    }
4851                    let b8buf = self.ctx.storage(&b8);
4852                    let bg = make_bg(self.ctx, pl, &[x, &wbuf, &b8buf, y], &meta);
4853                    self.scratch.push(b8buf);
4854                    Some(bg)
4855                }
4856                _ => None,
4857            };
4858            GemmBg::F16A { tiles: set, coop }
4859        } else if sk {
4860            // Mid-band: plain-v3 tiles for big m + the split-K pair for small m; the replay
4861            // picks by the grid the token count produces.
4862            let part = self.sk_part_buf();
4863            let plain = v3_bgs(self.ctx, self.kernels, self.use_f16, &bufs, &meta)
4864                .expect("checked at load");
4865            let part_bg = make_bg(
4866                self.ctx,
4867                self.kernels
4868                    .gemm3_sk_pipeline(self.use_f16)
4869                    .expect("checked at load"),
4870                &[x, &wbuf, &bbuf, &part],
4871                &meta,
4872            );
4873            let part32_bg = make_bg(
4874                self.ctx,
4875                self.kernels
4876                    .gemm3_sk32_pipeline(self.use_f16)
4877                    .expect("checked at load"),
4878                &[x, &wbuf, &bbuf, &part],
4879                &meta,
4880            );
4881            let skn = gemm3_sk_chunks(k as usize); // placeholder; word 3 is rewritten per encode
4882            let rmeta = uni(self.ctx, bytemuck::cast_slice(&[0u32, n, flags, skn]));
4883            let reduce = make_bg(
4884                self.ctx,
4885                &self.kernels.gemm3_sk_reduce,
4886                &[&part, &bbuf, y],
4887                &rmeta,
4888            );
4889            self.metas_sk.push((rmeta.clone(), n, k));
4890            self.metas_tokens.push(rmeta); // its `m` is rewritten per encode, like `meta`'s
4891            GemmBg::V3Sk {
4892                plain,
4893                part: part_bg,
4894                part32: part32_bg,
4895                reduce,
4896                k,
4897                f16a: false,
4898            }
4899        } else if v3 {
4900            // The weight was uploaded `[k, n]`-transposed at load — v3 is this step's only
4901            // kernel family (the tile within the family is still picked at replay).
4902            GemmBg::V3(
4903                v3_bgs(self.ctx, self.kernels, self.use_f16, &bufs, &meta)
4904                    .expect("checked at load"),
4905            )
4906        } else if gemm2_enabled() {
4907            // Which v2 tile runs is a function of the GRID (token count × output width), known only
4908            // at replay — so bake all three. (Bind groups are cheap; dispatching one against the
4909            // wrong pipeline's auto-layout is a validation error.)
4910            GemmBg::V2(
4911                v2_bgs(self.ctx, self.kernels, self.use_f16, &bufs, &meta)
4912                    .expect("checked at load"),
4913            )
4914        } else {
4915            GemmBg::V1(make_bg(
4916                self.ctx,
4917                self.kernels
4918                    .gemm_pipeline(self.use_f16)
4919                    .expect("checked at load"),
4920                &bufs,
4921                &meta,
4922            ))
4923        };
4924        if !has_b {
4925            self.scratch.push(bbuf);
4926        }
4927        self.steps.push(EStep::Gemm { bg, n });
4928        self.metas_tokens.push(meta);
4929    }
4930
4931    /// LayerNorm step: `out = LN(x (+res)) · w (+b)`.
4932    fn push_ln(
4933        &mut self,
4934        ni: usize,
4935        x: &wgpu::Buffer,
4936        res: Option<&wgpu::Buffer>,
4937        out: &wgpu::Buffer,
4938    ) {
4939        let n = &self.norms[ni];
4940        let rms = matches!(self.cfg.norm_kind, NormKind::RmsNorm);
4941        let flags =
4942            u32::from(res.is_some()) | (u32::from(n.b.is_some()) << 1) | (u32::from(rms) << 2);
4943        let meta = uni(
4944            self.ctx,
4945            bytemuck::cast_slice(&[self.cfg.hidden as u32, flags, self.eps.to_bits(), 0]),
4946        );
4947        let res_buf = res.unwrap_or(x); // unread when flag clear; must still bind
4948        let b_placeholder;
4949        let bbuf = match &n.b {
4950            Some(b) => b,
4951            None => {
4952                b_placeholder = self.ctx.storage(&[0.0]);
4953                self.scratch.push(b_placeholder.clone());
4954                self.scratch.last().expect("just pushed")
4955            }
4956        };
4957        let bg = make_bg(
4958            self.ctx,
4959            &self.kernels.layernorm,
4960            &[x, res_buf, &n.w, bbuf, out],
4961            &meta,
4962        );
4963        self.steps.push(EStep::Ln { bg });
4964        self.scratch.push(meta); // eps/h are static; no per-encode rewrite
4965    }
4966
4967    fn push_rope(&mut self, x: &wgpu::Buffer, theta: f32) {
4968        self.push_rope_heads(x, theta, self.cfg.n_heads);
4969    }
4970
4971    /// Rope over an explicit head count (GQA: q spans `n_heads`, k spans `n_kv_heads`).
4972    fn push_rope_heads(&mut self, x: &wgpu::Buffer, theta: f32, heads: usize) {
4973        let meta = uni(
4974            self.ctx,
4975            bytemuck::cast_slice(&[
4976                0u32,
4977                heads as u32,
4978                self.cfg.head_dim as u32,
4979                theta.to_bits(),
4980            ]),
4981        );
4982        let bg = make_bg(
4983            self.ctx,
4984            &self.kernels.rope,
4985            &[x, &self.seq_starts_buf, &self.seq_of_buf],
4986            &meta,
4987        );
4988        self.steps.push(EStep::Rope { bg });
4989        self.scratch.push(meta);
4990    }
4991
4992    /// Per-head qk RMS normalization step on a `[T, heads·hd]` buffer (Qwen3, before rotary).
4993    fn push_qk_norm(&mut self, x: &wgpu::Buffer, heads: usize, w: Vec<f32>) {
4994        let wb = self.ctx.storage(&w);
4995        let meta = uni(
4996            self.ctx,
4997            bytemuck::cast_slice(&[
4998                0u32,
4999                heads as u32,
5000                self.cfg.head_dim as u32,
5001                self.eps.to_bits(),
5002            ]),
5003        );
5004        let bg = make_bg(self.ctx, &self.kernels.qk_norm, &[x, &wb], &meta);
5005        self.steps.push(EStep::QkNorm { bg });
5006        self.scratch.push(wb);
5007        self.scratch.push(meta);
5008    }
5009
5010    fn push_attn(&mut self, window: u32) {
5011        self.push_attn_src(window, None);
5012    }
5013
5014    /// Attention step. `packed` = the fused-QKV GEMM's `[T, q|k|v]` output: it is bound to all
5015    /// three q/k/v slots (read-only aliasing is legal) and the kernel switches to packed row
5016    /// strides via the meta word — one GEMM dispatch upstream instead of three.
5017    fn push_attn_src(&mut self, window: u32, packed: Option<&wgpu::Buffer>) {
5018        let mode = match self.cfg.attn_mask {
5019            MaskKind::Bidirectional => 0u32,
5020            MaskKind::Causal => 1u32,
5021        };
5022        let meta = uni(
5023            self.ctx,
5024            bytemuck::cast_slice(&[
5025                0u32,
5026                self.cfg.n_heads as u32,
5027                self.cfg.head_dim as u32,
5028                mode,
5029                window,
5030                self.cfg.n_kv_heads as u32,
5031                u32::from(packed.is_some()),
5032                0,
5033            ]),
5034        );
5035        let (q, k, v) = match packed {
5036            Some(b) => (b, b, b),
5037            None => (&self.qb, &self.kb, &self.vb),
5038        };
5039        // Kernel ladder: register-blocked rows (RB=4 — K/V read once per FOUR query rows) where
5040        // the (row, dim) thread layout fits (`hd ≤ 64`, every production encoder except
5041        // Qwen3-embed's 128); plain vec4 above that; scalar for exotic head dims.
5042        // `OSFKB_ENC_ATTN_RB=0` / `OSFKB_ENC_ATTN_V4=0` pin the previous rungs for A/B.
5043        let v4 = self.cfg.head_dim.is_multiple_of(4)
5044            && std::env::var("OSFKB_ENC_ATTN_V4").ok().as_deref() != Some("0");
5045        let rb = v4
5046            && self.cfg.head_dim <= 64
5047            && std::env::var("OSFKB_ENC_ATTN_RB").ok().as_deref() != Some("0");
5048        let pl = if rb {
5049            &self.kernels.attn_rb4
5050        } else if v4 {
5051            &self.kernels.attn4
5052        } else {
5053            &self.kernels.attn
5054        };
5055        let bg = make_bg(
5056            self.ctx,
5057            pl,
5058            &[
5059                q,
5060                k,
5061                v,
5062                &self.cb,
5063                &self.seq_starts_buf,
5064                &self.seq_of_buf,
5065                &self.valid_buf,
5066            ],
5067            &meta,
5068        );
5069        self.steps.push(if rb {
5070            EStep::AttnRb { bg }
5071        } else if v4 {
5072            EStep::Attn4 { bg }
5073        } else {
5074            EStep::Attn { bg }
5075        });
5076        self.metas_tokens.push(meta);
5077    }
5078
5079    /// DeBERTa disentangled attention for one layer. `pos_k`/`pos_q` are THIS layer's relative
5080    /// projections (`[2·span, n_heads·hd]`), baked into the bind group here since they differ per
5081    /// layer — unlike q/k/v/out which are shared scratch replayed every layer.
5082    fn push_attn_disent(&mut self, pos_k: &wgpu::Buffer, pos_q: &wgpu::Buffer) {
5083        self.push_attn_disent_src(pos_k, pos_q, None);
5084    }
5085
5086    /// Disentangled attention step; `packed` = the fused-QKV GEMM's `[T, q|k|v]` output, bound
5087    /// to all three q/k/v slots (bit 1 of the p2c meta word switches the kernel's strides —
5088    /// no extra binding, so the browser storage-buffer cap is untouched).
5089    fn push_attn_disent_src(
5090        &mut self,
5091        pos_k: &wgpu::Buffer,
5092        pos_q: &wgpu::Buffer,
5093        packed: Option<&wgpu::Buffer>,
5094    ) {
5095        let rel = self
5096            .cfg
5097            .rel_attn
5098            .expect("push_attn_disent on a non-DeBERTa config");
5099        let meta = uni(
5100            self.ctx,
5101            bytemuck::cast_slice(&[
5102                0u32,
5103                self.cfg.n_heads as u32,
5104                self.cfg.head_dim as u32,
5105                rel.span as u32,
5106                rel.max_rel as u32,
5107                rel.scale_factor() as u32,
5108                u32::from(rel.c2p),
5109                u32::from(rel.p2c) | (u32::from(packed.is_some()) << 1),
5110            ]),
5111        );
5112        // RB=4 wherever the (row, dim) layout fits — DeBERTa-v3's hd=64 always does;
5113        // `OSFKB_ENC_ATTN_RB=0` pins the per-row kernel (same switch as the plain path).
5114        let rb = self.cfg.head_dim.is_multiple_of(4)
5115            && self.cfg.head_dim <= 64
5116            && std::env::var("OSFKB_ENC_ATTN_RB").ok().as_deref() != Some("0");
5117        let pl = if rb {
5118            &self.kernels.attn_disent_rb4
5119        } else {
5120            &self.kernels.attn_disent
5121        };
5122        let (q, k, v) = match packed {
5123            Some(b) => (b, b, b),
5124            None => (&self.qb, &self.kb, &self.vb),
5125        };
5126        let bg = make_bg(
5127            self.ctx,
5128            pl,
5129            &[
5130                q,
5131                k,
5132                v,
5133                &self.cb,
5134                &self.seq_starts_buf,
5135                &self.seq_of_buf,
5136                &self.valid_buf,
5137                pos_k,
5138                pos_q,
5139            ],
5140            &meta,
5141        );
5142        self.steps.push(if rb {
5143            EStep::AttnDisentRb { bg }
5144        } else {
5145            EStep::AttnDisent { bg }
5146        });
5147        self.metas_tokens.push(meta);
5148    }
5149
5150    fn push_glu(&mut self, act: Act) {
5151        let meta = uni(
5152            self.ctx,
5153            bytemuck::cast_slice(&[self.cfg.intermediate as u32, act_code(Some(act)), 0, 0]),
5154        );
5155        let bg = make_bg(self.ctx, &self.kernels.glu, &[&self.mid, &self.glu], &meta);
5156        self.steps.push(EStep::Glu { bg });
5157        self.scratch.push(meta);
5158    }
5159
5160    /// Stream copy step (`dst = src`) — see [`ENC_COPY`] for why Add cannot serve here.
5161    fn push_copy(&mut self, dst: &wgpu::Buffer, src: &wgpu::Buffer) {
5162        let meta = uni(self.ctx, bytemuck::cast_slice(&[0u32, 0, 0, 0]));
5163        let bg = make_bg(self.ctx, &self.kernels.copy, &[dst, src], &meta);
5164        self.steps.push(EStep::Copy { bg });
5165        self.metas_elems.push(meta);
5166    }
5167
5168    /// LFM2 short-conv step: `bcx` `[T, 3H]` → gated depthwise conv → `y` `[T, H]`.
5169    fn push_conv(&mut self, bcx: &wgpu::Buffer, conv_w: Vec<f32>, y: &wgpu::Buffer) {
5170        let wb = self.ctx.storage(&conv_w);
5171        let meta = uni(
5172            self.ctx,
5173            bytemuck::cast_slice(&[self.cfg.hidden as u32, self.cfg.conv_l as u32, 0, 0]),
5174        );
5175        let bg = make_bg(
5176            self.ctx,
5177            &self.kernels.conv,
5178            &[
5179                bcx,
5180                &wb,
5181                y,
5182                &self.seq_starts_buf,
5183                &self.seq_of_buf,
5184                &self.valid_buf,
5185            ],
5186            &meta,
5187        );
5188        self.steps.push(EStep::Conv { bg });
5189        self.scratch.push(wb);
5190        self.scratch.push(meta);
5191    }
5192
5193    /// Per-token L2 step over a `[T, dim]` buffer (late-interaction output).
5194    fn push_l2_rows(&mut self, buf: &wgpu::Buffer, dim: usize) {
5195        let meta = uni(self.ctx, bytemuck::cast_slice(&[dim as u32, 0, 0, 0]));
5196        let bg = make_bg(self.ctx, &self.kernels.l2norm, &[buf], &meta);
5197        self.steps.push(EStep::L2Rows { bg });
5198        self.scratch.push(meta);
5199    }
5200
5201    fn push_add(&mut self, dst: &wgpu::Buffer, src: &wgpu::Buffer) {
5202        let meta = uni(self.ctx, bytemuck::cast_slice(&[0u32, 0, 0, 0]));
5203        let bg = make_bg(self.ctx, &self.kernels.add, &[dst, src], &meta);
5204        self.steps.push(EStep::Add { bg });
5205        self.metas_elems.push(meta);
5206    }
5207
5208    fn push_pool(&mut self, src: &wgpu::Buffer) {
5209        let h = self.cfg.hidden as u32;
5210        let meta = uni(self.ctx, bytemuck::cast_slice(&[h, 0, 0, 0]));
5211        // Auto bind-group layouts are EXCLUSIVE to their pipeline: bake against the pipeline the
5212        // pooling mode will actually dispatch (mean vs last-token share a binding shape but not
5213        // a layout object).
5214        let pool_pl = match self.cfg.pooling {
5215            crate::pooling::Pooling::LastToken => &self.kernels.last_pool,
5216            // A CrossEncoder scores the CLS row, exactly like `Cls` — it just feeds a head
5217            // afterwards instead of L2-normalizing. The catch-all below would bake this bind group
5218            // against `mean_pool`, and wgpu's auto-layout bind groups are EXCLUSIVE to the pipeline
5219            // that made them, so dispatching it with `cls_pool` is a validation error rather than a
5220            // wrong answer. (Which is lucky: the failure mode of the silent version is mean-pooled
5221            // scores that still rank plausibly.)
5222            crate::pooling::Pooling::Cls | crate::pooling::Pooling::CrossEncoder { .. } => {
5223                &self.kernels.cls_pool
5224            }
5225            _ => &self.kernels.mean_pool,
5226        };
5227        self.bg_pool = Some(make_bg(
5228            self.ctx,
5229            pool_pl,
5230            &[src, &self.pooled, &self.seq_starts_buf],
5231            &meta,
5232        ));
5233        self.scratch.push(meta);
5234        let meta2 = uni(self.ctx, bytemuck::cast_slice(&[h, 0, 0, 0]));
5235        self.bg_l2 = Some(make_bg(
5236            self.ctx,
5237            &self.kernels.l2norm,
5238            &[&self.pooled],
5239            &meta2,
5240        ));
5241        self.scratch.push(meta2);
5242    }
5243
5244    fn norm_from(&mut self, w: Vec<f32>, b: Option<Vec<f32>>) -> usize {
5245        self.norms.push(EncGpuNorm {
5246            w: self.ctx.storage(&w),
5247            b: b.map(|bv| self.ctx.storage(&bv)),
5248        });
5249        self.norms.len() - 1
5250    }
5251
5252    /// BERT/XLM-R: post-LN plan. Returns the CPU-side embedding tables.
5253    #[allow(clippy::type_complexity)]
5254    fn build_bert_family(
5255        &mut self,
5256        st: &LazySt,
5257    ) -> Result<(Vec<f32>, Option<Vec<f32>>, Option<Vec<f32>>)> {
5258        let cfg = self.cfg;
5259        let prefix = ["", "bert.", "roberta."]
5260            .into_iter()
5261            .find(|p| st.has(&format!("{p}embeddings.word_embeddings.weight")))
5262            .context("word embeddings not found under known prefixes ('', 'bert.', 'roberta.')")?;
5263        let get = |name: &str| -> Result<Vec<f32>> { st.tensor_f32(&format!("{prefix}{name}")) };
5264        let word = get("embeddings.word_embeddings.weight")?;
5265        anyhow::ensure!(
5266            word.len() == cfg.vocab * cfg.hidden,
5267            "word embedding shape {} != vocab {} × hidden {}",
5268            word.len(),
5269            cfg.vocab,
5270            cfg.hidden
5271        );
5272        anyhow::ensure!(
5273            matches!(cfg.norm_kind, NormKind::LayerNorm { bias: true }),
5274            "BERT-family norms are biased LayerNorm"
5275        );
5276        let (h, im) = (cfg.hidden as u32, cfg.intermediate as u32);
5277        let mlp_act = match cfg.mlp_kind {
5278            MlpKind::Dense { act, .. } => act,
5279            MlpKind::Glu { .. } => anyhow::bail!("BERT family MLPs are dense"),
5280        };
5281        // jina-reranker-v2 ("jina-flash" XLM-RoBERTa): same math, but a FUSED QKV
5282        // (`encoder.layers.N.mixer.Wqkv`) and renamed tensors (`mixer.out_proj`, `mlp.fc1/fc2`,
5283        // `norm1/norm2`, `emb_ln`). Detected here; the CPU twin lives in `load_bert_family`.
5284        let jina = st.has(&format!("{prefix}encoder.layers.0.mixer.Wqkv.weight"));
5285        // Embedding LN: emb_in → cur (jina renames it `emb_ln`).
5286        let emb_ln = if jina {
5287            self.norm_from(get("emb_ln.weight")?, Some(get("emb_ln.bias")?))
5288        } else {
5289            self.norm_from(
5290                get("embeddings.LayerNorm.weight")?,
5291                Some(get("embeddings.LayerNorm.bias")?),
5292            )
5293        };
5294        let emb_in = self.emb_in.clone();
5295        let cur = self.cur.clone();
5296        self.push_ln(emb_ln, &emb_in, None, &cur);
5297        let (alt, qb, kb, vb, cb, mid) = (
5298            self.alt.clone(),
5299            self.qb.clone(),
5300            self.kb.clone(),
5301            self.vb.clone(),
5302            self.cb.clone(),
5303            self.mid.clone(),
5304        );
5305        // Fused QKV: one [3H, H] GEMM instead of three [H, H] — 2 fewer dispatches per layer
5306        // AND 3× the column tiles for the occupancy-starved small-n shapes (a 64-token XLM-R
5307        // qkv goes 12 → 36 column tiles). The packed [T, q|k|v] output stages in `mid` (already
5308        // ≥ [T, 3H] whenever im ≥ 3h — every production BERT-family checkpoint; the MLP's use
5309        // of `mid` starts only after attention consumed it). `OSFKB_ENC_QKV_FUSED=0` pins the
5310        // historical three-dispatch path.
5311        let qkv_fused = cfg.intermediate >= 3 * cfg.hidden
5312            && std::env::var("OSFKB_ENC_QKV_FUSED").ok().as_deref() != Some("0");
5313        let mid_qkv = self.mid.clone();
5314        for i in 0..cfg.n_layers {
5315            let p = if jina {
5316                format!("encoder.layers.{i}")
5317            } else {
5318                format!("encoder.layer.{i}")
5319            };
5320            let lin = |b: &mut Self, wn: String, bn: String, n: u32, k: u32| -> Result<usize> {
5321                b.upload_linear(
5322                    &st.tensor_f32(&format!("{prefix}{wn}"))?,
5323                    Some(st.tensor_f32(&format!("{prefix}{bn}"))?),
5324                    n,
5325                    k,
5326                )
5327            };
5328            // QKV: jina ships ONE fused `mixer.Wqkv` `[3H,H]` (upload as-is); standard XLM-R has
5329            // separate query/key/value, optionally concatenated here into the same fused GEMM.
5330            let qkv = if jina {
5331                let w = st.tensor_f32(&format!("{prefix}{p}.mixer.Wqkv.weight"))?;
5332                let bias = st.tensor_f32(&format!("{prefix}{p}.mixer.Wqkv.bias"))?;
5333                anyhow::ensure!(
5334                    w.len() == 3 * cfg.hidden * cfg.hidden,
5335                    "{p}.mixer.Wqkv shape {} != 3·{}·{}",
5336                    w.len(),
5337                    cfg.hidden,
5338                    cfg.hidden
5339                );
5340                Some(self.upload_linear(&w, Some(bias), 3 * h, h)?)
5341            } else if qkv_fused {
5342                let mut w = Vec::with_capacity(3 * cfg.hidden * cfg.hidden);
5343                let mut bias = Vec::with_capacity(3 * cfg.hidden);
5344                for part in ["query", "key", "value"] {
5345                    w.extend(st.tensor_f32(&format!("{prefix}{p}.attention.self.{part}.weight"))?);
5346                    bias.extend(st.tensor_f32(&format!("{prefix}{p}.attention.self.{part}.bias"))?);
5347                }
5348                Some(self.upload_linear(&w, Some(bias), 3 * h, h)?)
5349            } else {
5350                None
5351            };
5352            let (q, k, v) = if qkv.is_some() {
5353                (0, 0, 0) // unused: the fused index above carries all three
5354            } else {
5355                (
5356                    lin(
5357                        self,
5358                        format!("{p}.attention.self.query.weight"),
5359                        format!("{p}.attention.self.query.bias"),
5360                        h,
5361                        h,
5362                    )?,
5363                    lin(
5364                        self,
5365                        format!("{p}.attention.self.key.weight"),
5366                        format!("{p}.attention.self.key.bias"),
5367                        h,
5368                        h,
5369                    )?,
5370                    lin(
5371                        self,
5372                        format!("{p}.attention.self.value.weight"),
5373                        format!("{p}.attention.self.value.bias"),
5374                        h,
5375                        h,
5376                    )?,
5377                )
5378            };
5379            // The remaining projections and norms differ only by NAME between the two layouts.
5380            let (o_n, up_n, down_n, ln1_n, ln2_n) = if jina {
5381                ("mixer.out_proj", "mlp.fc1", "mlp.fc2", "norm1", "norm2")
5382            } else {
5383                (
5384                    "attention.output.dense",
5385                    "intermediate.dense",
5386                    "output.dense",
5387                    "attention.output.LayerNorm",
5388                    "output.LayerNorm",
5389                )
5390            };
5391            let o = lin(
5392                self,
5393                format!("{p}.{o_n}.weight"),
5394                format!("{p}.{o_n}.bias"),
5395                h,
5396                h,
5397            )?;
5398            let up = lin(
5399                self,
5400                format!("{p}.{up_n}.weight"),
5401                format!("{p}.{up_n}.bias"),
5402                im,
5403                h,
5404            )?;
5405            let down = lin(
5406                self,
5407                format!("{p}.{down_n}.weight"),
5408                format!("{p}.{down_n}.bias"),
5409                h,
5410                im,
5411            )?;
5412            let ln1 = self.norm_from(
5413                get(&format!("{p}.{ln1_n}.weight"))?,
5414                Some(get(&format!("{p}.{ln1_n}.bias"))?),
5415            );
5416            let ln2 = self.norm_from(
5417                get(&format!("{p}.{ln2_n}.weight"))?,
5418                Some(get(&format!("{p}.{ln2_n}.bias"))?),
5419            );
5420            // x=cur: q/k/v → attn → o → LN(o_out + cur) → alt; MLP(alt) → LN(+alt) → cur.
5421            if let Some(qkv) = qkv {
5422                self.push_gemm(qkv, &cur, &mid_qkv, None);
5423                self.push_attn_src(cfg.layer_window[i], Some(&mid_qkv));
5424            } else {
5425                self.push_gemm(q, &cur, &qb, None);
5426                self.push_gemm(k, &cur, &kb, None);
5427                self.push_gemm(v, &cur, &vb, None);
5428                self.push_attn(cfg.layer_window[i]);
5429            }
5430            self.push_gemm(o, &cb, &qb, None);
5431            self.push_ln(ln1, &qb, Some(&cur), &alt);
5432            self.push_gemm(up, &alt, &mid, Some(mlp_act));
5433            self.push_gemm(down, &mid, &kb, None);
5434            self.push_ln(ln2, &kb, Some(&alt), &cur);
5435        }
5436        self.push_pool(&cur);
5437        Ok((
5438            word,
5439            Some(get("embeddings.position_embeddings.weight")?),
5440            if cfg.type_vocab > 0 {
5441                Some(get("embeddings.token_type_embeddings.weight")?)
5442            } else {
5443                None
5444            },
5445        ))
5446    }
5447
5448    /// DeBERTa-v2/v3 (GLiNER backbone). A BERT plan with three differences: `query_proj`/
5449    /// `key_proj`/`value_proj` names, NO absolute position table (`pos_table: None` — position
5450    /// enters only through attention), and a disentangled-attention step per layer whose relative
5451    /// projections (`pos_k`/`pos_q`) are precomputed here on CPU. They are the LayerNorm'd relative
5452    /// table pushed through each layer's OWN key/query weights (`share_att_key: true`) — they depend
5453    /// only on weights, so computing them once at load and uploading is pure economy.
5454    #[allow(clippy::type_complexity)]
5455    fn build_deberta(
5456        &mut self,
5457        st: &LazySt,
5458    ) -> Result<(Vec<f32>, Option<Vec<f32>>, Option<Vec<f32>>)> {
5459        let cfg = self.cfg;
5460        let rel = cfg.rel_attn.context("DeBERTa config without rel_attn")?;
5461        // The last prefix is GLiNER's: its checkpoint nests the whole backbone under the token
5462        // representation layer, so one `model.safetensors` carries backbone AND head. (Keep this in
5463        // step with `encoder_cpu::load_deberta_v2` — when they disagreed, the GPU load failed and
5464        // `EmbedEngine::auto` silently fell back to CPU, which reads as "the GPU is just slow".)
5465        let prefix = ["", "deberta.", "token_rep_layer.bert_layer.model."]
5466            .into_iter()
5467            .find(|p| st.has(&format!("{p}embeddings.word_embeddings.weight")))
5468            .context(
5469                "word embeddings not found under known prefixes ('', 'deberta.', \
5470                 'token_rep_layer.bert_layer.model.')",
5471            )?;
5472        let get = |name: &str| -> Result<Vec<f32>> { st.tensor_f32(&format!("{prefix}{name}")) };
5473        let word = get("embeddings.word_embeddings.weight")?;
5474        anyhow::ensure!(
5475            word.len() == cfg.vocab * cfg.hidden,
5476            "word embedding shape {} != vocab {} × hidden {}",
5477            word.len(),
5478            cfg.vocab,
5479            cfg.hidden
5480        );
5481        let (h, im) = (cfg.hidden as u32, cfg.intermediate as u32);
5482        let mlp_act = match cfg.mlp_kind {
5483            MlpKind::Dense { act, .. } => act,
5484            MlpKind::Glu { .. } => anyhow::bail!("DeBERTa MLPs are dense"),
5485        };
5486
5487        // The relative table, LayerNorm'd once (HF get_rel_embedding) — shared by every layer.
5488        let rows = 2 * rel.span;
5489        let hs = cfg.hidden;
5490        let mut rel_emb = get("encoder.rel_embeddings.weight")?;
5491        anyhow::ensure!(
5492            rel_emb.len() >= rows * hs,
5493            "rel_embeddings has {} rows, need 2·span = {rows}",
5494            rel_emb.len() / hs.max(1)
5495        );
5496        rel_emb.truncate(rows * hs);
5497        cpu_layer_norm_rows(
5498            &mut rel_emb,
5499            hs,
5500            &get("encoder.LayerNorm.weight")?,
5501            &get("encoder.LayerNorm.bias")?,
5502            cfg.eps,
5503        );
5504
5505        // Emb LN → cur.
5506        let emb_ln = self.norm_from(
5507            get("embeddings.LayerNorm.weight")?,
5508            Some(get("embeddings.LayerNorm.bias")?),
5509        );
5510        let emb_in = self.emb_in.clone();
5511        let cur = self.cur.clone();
5512        self.push_ln(emb_ln, &emb_in, None, &cur);
5513        let (alt, qb, kb, vb, cb, mid) = (
5514            self.alt.clone(),
5515            self.qb.clone(),
5516            self.kb.clone(),
5517            self.vb.clone(),
5518            self.cb.clone(),
5519            self.mid.clone(),
5520        );
5521        for i in 0..cfg.n_layers {
5522            let p = format!("encoder.layer.{i}");
5523            let getw = |n: String| -> Result<Vec<f32>> { st.tensor_f32(&format!("{prefix}{n}")) };
5524            let qw = getw(format!("{p}.attention.self.query_proj.weight"))?;
5525            let qb_w = getw(format!("{p}.attention.self.query_proj.bias"))?;
5526            let kw = getw(format!("{p}.attention.self.key_proj.weight"))?;
5527            let kb_w = getw(format!("{p}.attention.self.key_proj.bias"))?;
5528            // share_att_key: pos projections ARE the content projections (bias included, as HF
5529            // runs the full nn.Linear over the relative table). Precompute on CPU, upload f32.
5530            let pos_k = self
5531                .ctx
5532                .storage(&cpu_linear(&rel_emb, &kw, Some(&kb_w), hs, hs));
5533            let pos_q = self
5534                .ctx
5535                .storage(&cpu_linear(&rel_emb, &qw, Some(&qb_w), hs, hs));
5536
5537            // Fused QKV, same trick as the BERT family (one [3H,H] GEMM, packed [T,q|k|v]
5538            // staged in `mid`, disent reads packed strides): DeBERTa's three n=768 projections
5539            // were 3/5 of GLiNER's dominant gemm bucket, and the fused n=2304 shape is v3's
5540            // proven wide band (2.4× at m=512). `OSFKB_ENC_QKV_FUSED=0` pins the 3-GEMM path.
5541            let vw = getw(format!("{p}.attention.self.value_proj.weight"))?;
5542            let vb_w = getw(format!("{p}.attention.self.value_proj.bias"))?;
5543            let fused = cfg.intermediate >= 3 * cfg.hidden
5544                && std::env::var("OSFKB_ENC_QKV_FUSED").ok().as_deref() != Some("0");
5545            let qkv = if fused {
5546                let mut w = Vec::with_capacity(3 * cfg.hidden * cfg.hidden);
5547                let mut bias = Vec::with_capacity(3 * cfg.hidden);
5548                for (ww, bb) in [(&qw, &qb_w), (&kw, &kb_w), (&vw, &vb_w)] {
5549                    w.extend_from_slice(ww);
5550                    bias.extend_from_slice(bb);
5551                }
5552                Some(self.upload_linear(&w, Some(bias), 3 * h, h)?)
5553            } else {
5554                None
5555            };
5556            let (q, k, v) = if qkv.is_some() {
5557                (0, 0, 0) // unused: the fused index carries all three
5558            } else {
5559                (
5560                    self.upload_linear(&qw, Some(qb_w.clone()), h, h)?,
5561                    self.upload_linear(&kw, Some(kb_w.clone()), h, h)?,
5562                    self.upload_linear(&vw, Some(vb_w.clone()), h, h)?,
5563                )
5564            };
5565            let o = self.upload_linear(
5566                &getw(format!("{p}.attention.output.dense.weight"))?,
5567                Some(getw(format!("{p}.attention.output.dense.bias"))?),
5568                h,
5569                h,
5570            )?;
5571            let up = self.upload_linear(
5572                &getw(format!("{p}.intermediate.dense.weight"))?,
5573                Some(getw(format!("{p}.intermediate.dense.bias"))?),
5574                im,
5575                h,
5576            )?;
5577            let down = self.upload_linear(
5578                &getw(format!("{p}.output.dense.weight"))?,
5579                Some(getw(format!("{p}.output.dense.bias"))?),
5580                h,
5581                im,
5582            )?;
5583            let ln1 = self.norm_from(
5584                get(&format!("{p}.attention.output.LayerNorm.weight"))?,
5585                Some(get(&format!("{p}.attention.output.LayerNorm.bias"))?),
5586            );
5587            let ln2 = self.norm_from(
5588                get(&format!("{p}.output.LayerNorm.weight"))?,
5589                Some(get(&format!("{p}.output.LayerNorm.bias"))?),
5590            );
5591            if let Some(qkv) = qkv {
5592                self.push_gemm(qkv, &cur, &mid, None);
5593                let mid_qkv = self.mid.clone();
5594                self.push_attn_disent_src(&pos_k, &pos_q, Some(&mid_qkv));
5595            } else {
5596                self.push_gemm(q, &cur, &qb, None);
5597                self.push_gemm(k, &cur, &kb, None);
5598                self.push_gemm(v, &cur, &vb, None);
5599                self.push_attn_disent(&pos_k, &pos_q);
5600            }
5601            self.push_gemm(o, &cb, &qb, None);
5602            self.push_ln(ln1, &qb, Some(&cur), &alt);
5603            self.push_gemm(up, &alt, &mid, Some(mlp_act));
5604            self.push_gemm(down, &mid, &kb, None);
5605            self.push_ln(ln2, &kb, Some(&alt), &cur);
5606        }
5607        self.push_pool(&cur);
5608        Ok((
5609            word,
5610            None, // position_biased_input: false — no absolute position table
5611            if cfg.type_vocab > 0 {
5612                Some(get("embeddings.token_type_embeddings.weight")?)
5613            } else {
5614                None
5615            },
5616        ))
5617    }
5618
5619    /// LFM2-ColBERT: prenorm plan over the conv/attention hybrid backbone (verified LiquidAI
5620    /// layout) + pylate Dense per-token projection with per-token L2 — late interaction.
5621    #[allow(clippy::type_complexity)]
5622    fn build_lfm2_colbert(
5623        &mut self,
5624        st: &LazySt,
5625        dir: &Path,
5626    ) -> Result<(Vec<f32>, Option<Vec<f32>>, Option<Vec<f32>>)> {
5627        let cfg = self.cfg;
5628        let get = |name: &str| -> Result<Vec<f32>> { st.tensor_f32(name) };
5629        let word = get("embed_tokens.weight")?;
5630        anyhow::ensure!(
5631            word.len() == cfg.vocab * cfg.hidden,
5632            "embed_tokens shape {} != vocab {} × hidden {}",
5633            word.len(),
5634            cfg.vocab,
5635            cfg.hidden
5636        );
5637        let (hd, im) = (cfg.head_dim as u32, cfg.intermediate as u32);
5638        let hu = cfg.hidden as u32;
5639        let (qh, kvh) = (cfg.n_heads as u32 * hd, cfg.n_kv_heads as u32 * hd);
5640        let theta = match cfg.pos_kind {
5641            PosKind::Rope { theta, .. } => theta,
5642            PosKind::Learned { .. } => anyhow::bail!("LFM2 uses rotary positions"),
5643        };
5644        let glu_act = match cfg.mlp_kind {
5645            MlpKind::Glu { act } => act,
5646            MlpKind::Dense { .. } => anyhow::bail!("LFM2 MLPs are GLU"),
5647        };
5648        let dim = match cfg.pooling {
5649            Pooling::PerToken { dim } => dim,
5650            other => anyhow::bail!("ColBERT plan requires PerToken pooling, got {other:?}"),
5651        };
5652        let emb_in = self.emb_in.clone();
5653        let cur = self.cur.clone();
5654        self.push_copy(&cur, &emb_in);
5655        let (alt, qb, kb, vb, cb, mid, glu) = (
5656            self.alt.clone(),
5657            self.qb.clone(),
5658            self.kb.clone(),
5659            self.vb.clone(),
5660            self.cb.clone(),
5661            self.mid.clone(),
5662            self.glu.clone(),
5663        );
5664        for i in 0..cfg.n_layers {
5665            let p = format!("layers.{i}");
5666            let an = self.norm_from(get(&format!("{p}.operator_norm.weight"))?, None);
5667            self.push_ln(an, &cur, None, &alt);
5668            if cfg.layer_is_attn[i] {
5669                let q = self.upload_linear(
5670                    &get(&format!("{p}.self_attn.q_proj.weight"))?,
5671                    None,
5672                    qh,
5673                    hu,
5674                )?;
5675                let k = self.upload_linear(
5676                    &get(&format!("{p}.self_attn.k_proj.weight"))?,
5677                    None,
5678                    kvh,
5679                    hu,
5680                )?;
5681                let v = self.upload_linear(
5682                    &get(&format!("{p}.self_attn.v_proj.weight"))?,
5683                    None,
5684                    kvh,
5685                    hu,
5686                )?;
5687                let o = self.upload_linear(
5688                    &get(&format!("{p}.self_attn.out_proj.weight"))?,
5689                    None,
5690                    hu,
5691                    qh,
5692                )?;
5693                let q_norm_w = get(&format!("{p}.self_attn.q_layernorm.weight"))?;
5694                let k_norm_w = get(&format!("{p}.self_attn.k_layernorm.weight"))?;
5695                self.push_gemm(q, &alt, &qb, None);
5696                self.push_gemm(k, &alt, &kb, None);
5697                self.push_gemm(v, &alt, &vb, None);
5698                self.push_qk_norm(&qb, cfg.n_heads, q_norm_w);
5699                self.push_qk_norm(&kb, cfg.n_kv_heads, k_norm_w);
5700                self.push_rope_heads(&qb, theta, cfg.n_heads);
5701                self.push_rope_heads(&kb, theta, cfg.n_kv_heads);
5702                self.push_attn(0);
5703                self.push_gemm(o, &cb, &alt, None);
5704            } else {
5705                let in_proj = self.upload_linear(
5706                    &get(&format!("{p}.conv.in_proj.weight"))?,
5707                    None,
5708                    3 * hu,
5709                    hu,
5710                )?;
5711                let conv_w = get(&format!("{p}.conv.conv.weight"))?;
5712                anyhow::ensure!(
5713                    conv_w.len() == cfg.hidden * cfg.conv_l,
5714                    "{p} conv taps {} != hidden × conv_l",
5715                    conv_w.len()
5716                );
5717                let out_proj =
5718                    self.upload_linear(&get(&format!("{p}.conv.out_proj.weight"))?, None, hu, hu)?;
5719                self.push_gemm(in_proj, &alt, &mid, None);
5720                self.push_conv(&mid, conv_w, &cb);
5721                self.push_gemm(out_proj, &cb, &alt, None);
5722            }
5723            self.push_add(&cur, &alt);
5724            let mn = self.norm_from(get(&format!("{p}.ffn_norm.weight"))?, None);
5725            self.push_ln(mn, &cur, None, &alt);
5726            let gate = get(&format!("{p}.feed_forward.w1.weight"))?;
5727            let up = get(&format!("{p}.feed_forward.w3.weight"))?;
5728            let mut wi = gate;
5729            wi.extend_from_slice(&up);
5730            let wi = self.upload_linear(&wi, None, 2 * im, hu)?;
5731            let wo =
5732                self.upload_linear(&get(&format!("{p}.feed_forward.w2.weight"))?, None, hu, im)?;
5733            self.push_gemm(wi, &alt, &mid, None);
5734            self.push_glu(glu_act);
5735            self.push_gemm(wo, &glu, &alt, None);
5736            self.push_add(&cur, &alt);
5737        }
5738        // Trailing `embedding_norm` (LFM2's final norm), then the pylate Dense projection and
5739        // per-token L2 into the late-interaction output buffer.
5740        let fin = self.norm_from(get("embedding_norm.weight")?, None);
5741        self.push_ln(fin, &cur, None, &alt);
5742        let dense_st = LazySt::open(&dir.join("1_Dense"))?;
5743        let proj =
5744            self.upload_linear(&dense_st.tensor_f32("linear.weight")?, None, dim as u32, hu)?;
5745        let ptb = self.ctx.storage(&vec![
5746            0f32;
5747            self.pooled.size() as usize / (4 * self.cfg.hidden)
5748                * dim
5749        ]);
5750        self.push_gemm(proj, &alt, &ptb, None);
5751        self.push_l2_rows(&ptb, dim);
5752        self.ptb = Some(ptb);
5753        Ok((word, None, None))
5754    }
5755
5756    /// nomic-bert: POST-norm plan with rotary positions and SwiGLU (verified layout —
5757    /// fused bias-free `attn.Wqkv` sliced q|k|v, `norm1` after the attention residual,
5758    /// `norm2` after the MLP residual, `fc11`|`fc12` fused GLU, no trailing norm).
5759    #[allow(clippy::type_complexity)]
5760    fn build_nomic(
5761        &mut self,
5762        st: &LazySt,
5763    ) -> Result<(Vec<f32>, Option<Vec<f32>>, Option<Vec<f32>>)> {
5764        let cfg = self.cfg;
5765        let get = |name: &str| -> Result<Vec<f32>> { st.tensor_f32(name) };
5766        let word = get("embeddings.word_embeddings.weight")?;
5767        anyhow::ensure!(
5768            word.len() == cfg.vocab * cfg.hidden,
5769            "word embedding shape {} != vocab {} × hidden {}",
5770            word.len(),
5771            cfg.vocab,
5772            cfg.hidden
5773        );
5774        let hu = cfg.hidden as u32;
5775        let (im, h) = (cfg.intermediate as u32, cfg.hidden);
5776        let theta = match cfg.pos_kind {
5777            PosKind::Rope { theta, .. } => theta,
5778            PosKind::Learned { .. } => anyhow::bail!("nomic uses rotary positions"),
5779        };
5780        let glu_act = match cfg.mlp_kind {
5781            MlpKind::Glu { act } => act,
5782            MlpKind::Dense { .. } => anyhow::bail!("nomic MLPs are GLU"),
5783        };
5784        // Embedding norm: emb_in → cur (biased LN).
5785        let emb_ln = self.norm_from(get("emb_ln.weight")?, Some(get("emb_ln.bias")?));
5786        let emb_in = self.emb_in.clone();
5787        let cur = self.cur.clone();
5788        self.push_ln(emb_ln, &emb_in, None, &cur);
5789        let (alt, qb, kb, vb, cb, mid, glu) = (
5790            self.alt.clone(),
5791            self.qb.clone(),
5792            self.kb.clone(),
5793            self.vb.clone(),
5794            self.cb.clone(),
5795            self.mid.clone(),
5796            self.glu.clone(),
5797        );
5798        for i in 0..cfg.n_layers {
5799            let p = format!("encoder.layers.{i}");
5800            let wqkv = get(&format!("{p}.attn.Wqkv.weight"))?;
5801            anyhow::ensure!(wqkv.len() == 3 * h * h, "{p}.attn.Wqkv shape");
5802            let q = self.upload_linear(&wqkv[..h * h], None, hu, hu)?;
5803            let k = self.upload_linear(&wqkv[h * h..2 * h * h], None, hu, hu)?;
5804            let v = self.upload_linear(&wqkv[2 * h * h..], None, hu, hu)?;
5805            let o =
5806                self.upload_linear(&get(&format!("{p}.attn.out_proj.weight"))?, None, hu, hu)?;
5807            // Verified nomic GLU order: `y = fc11(x) · act(fc12(x))` — fc12 (the gate)
5808            // concatenates FIRST in the act(first) ⊙ second fused layout.
5809            let gate = get(&format!("{p}.mlp.fc12.weight"))?;
5810            let lin_half = get(&format!("{p}.mlp.fc11.weight"))?;
5811            let mut wi = gate;
5812            wi.extend_from_slice(&lin_half);
5813            let wi = self.upload_linear(&wi, None, 2 * im, hu)?;
5814            let wo = self.upload_linear(&get(&format!("{p}.mlp.fc2.weight"))?, None, hu, im)?;
5815            let ln1 = self.norm_from(
5816                get(&format!("{p}.norm1.weight"))?,
5817                Some(get(&format!("{p}.norm1.bias"))?),
5818            );
5819            let ln2 = self.norm_from(
5820                get(&format!("{p}.norm2.weight"))?,
5821                Some(get(&format!("{p}.norm2.bias"))?),
5822            );
5823            // Post-norm dataflow: q/k/v(cur) → rope → attn → o → LN(o_out + cur) → alt;
5824            // GLU MLP(alt) → LN(down_out + alt) → cur.
5825            self.push_gemm(q, &cur, &qb, None);
5826            self.push_gemm(k, &cur, &kb, None);
5827            self.push_gemm(v, &cur, &vb, None);
5828            self.push_rope(&qb, theta);
5829            self.push_rope(&kb, theta);
5830            self.push_attn(cfg.layer_window[i]);
5831            self.push_gemm(o, &cb, &qb, None);
5832            self.push_ln(ln1, &qb, Some(&cur), &alt);
5833            self.push_gemm(wi, &alt, &mid, None);
5834            self.push_glu(glu_act);
5835            self.push_gemm(wo, &glu, &kb, None);
5836            self.push_ln(ln2, &kb, Some(&alt), &cur);
5837        }
5838        self.push_pool(&cur);
5839        Ok((
5840            word,
5841            None,
5842            Some(get("embeddings.token_type_embeddings.weight")?),
5843        ))
5844    }
5845
5846    /// Qwen3-Embedding: prenorm plan over the DECODER checkpoint run as an embedder — RMSNorm,
5847    /// per-head qk-norm before rotary, causal ragged attention with GQA, SiLU-GLU MLP
5848    /// (`gate_proj`/`up_proj` concatenated into the fused `[2I, H]` layout), trailing
5849    /// `model.norm`, last-token pooling. No embedding-stage norm (rotary decoders have none).
5850    #[allow(clippy::type_complexity)]
5851    fn build_qwen3_embed(
5852        &mut self,
5853        st: &LazySt,
5854    ) -> Result<(Vec<f32>, Option<Vec<f32>>, Option<Vec<f32>>)> {
5855        let cfg = self.cfg;
5856        let get = |name: &str| -> Result<Vec<f32>> { st.tensor_f32(&format!("model.{name}")) };
5857        let word = get("embed_tokens.weight")?;
5858        anyhow::ensure!(
5859            word.len() == cfg.vocab * cfg.hidden,
5860            "embed_tokens shape {} != vocab {} × hidden {}",
5861            word.len(),
5862            cfg.vocab,
5863            cfg.hidden
5864        );
5865        let (hd, im) = (cfg.head_dim as u32, cfg.intermediate as u32);
5866        let hu = cfg.hidden as u32;
5867        let (qh, kvh) = (cfg.n_heads as u32 * hd, cfg.n_kv_heads as u32 * hd);
5868        let glu_act = match cfg.mlp_kind {
5869            MlpKind::Glu { act } => act,
5870            MlpKind::Dense { .. } => anyhow::bail!("Qwen3 embedder MLPs are GLU"),
5871        };
5872        // The gathered embeddings ARE the initial residual stream (no embedding-stage norm):
5873        // a COPY into `cur` (persistent scratch — an Add would accumulate the previous encode's
5874        // residue) keeps `cur` the uniform stream with zero special cases downstream.
5875        let emb_in = self.emb_in.clone();
5876        let cur = self.cur.clone();
5877        self.push_copy(&cur, &emb_in);
5878        let (alt, qb, kb, vb, cb, mid, glu) = (
5879            self.alt.clone(),
5880            self.qb.clone(),
5881            self.kb.clone(),
5882            self.vb.clone(),
5883            self.cb.clone(),
5884            self.mid.clone(),
5885            self.glu.clone(),
5886        );
5887        for i in 0..cfg.n_layers {
5888            let p = format!("layers.{i}");
5889            let q =
5890                self.upload_linear(&get(&format!("{p}.self_attn.q_proj.weight"))?, None, qh, hu)?;
5891            let k = self.upload_linear(
5892                &get(&format!("{p}.self_attn.k_proj.weight"))?,
5893                None,
5894                kvh,
5895                hu,
5896            )?;
5897            let v = self.upload_linear(
5898                &get(&format!("{p}.self_attn.v_proj.weight"))?,
5899                None,
5900                kvh,
5901                hu,
5902            )?;
5903            let o =
5904                self.upload_linear(&get(&format!("{p}.self_attn.o_proj.weight"))?, None, hu, qh)?;
5905            let gate = get(&format!("{p}.mlp.gate_proj.weight"))?;
5906            let up = get(&format!("{p}.mlp.up_proj.weight"))?;
5907            let mut wi = gate;
5908            wi.extend_from_slice(&up);
5909            let wi = self.upload_linear(&wi, None, 2 * im, hu)?;
5910            let wo =
5911                self.upload_linear(&get(&format!("{p}.mlp.down_proj.weight"))?, None, hu, im)?;
5912            let an = self.norm_from(get(&format!("{p}.input_layernorm.weight"))?, None);
5913            let mn = self.norm_from(get(&format!("{p}.post_attention_layernorm.weight"))?, None);
5914            let q_norm_w = get(&format!("{p}.self_attn.q_norm.weight"))?;
5915            let k_norm_w = get(&format!("{p}.self_attn.k_norm.weight"))?;
5916
5917            self.push_ln(an, &cur, None, &alt);
5918            self.push_gemm(q, &alt, &qb, None);
5919            self.push_gemm(k, &alt, &kb, None);
5920            self.push_gemm(v, &alt, &vb, None);
5921            self.push_qk_norm(&qb, cfg.n_heads, q_norm_w);
5922            self.push_qk_norm(&kb, cfg.n_kv_heads, k_norm_w);
5923            let theta = match cfg.pos_kind {
5924                PosKind::Rope { theta, .. } => theta,
5925                PosKind::Learned { .. } => anyhow::bail!("Qwen3 embedder uses rotary positions"),
5926            };
5927            self.push_rope_heads(&qb, theta, cfg.n_heads);
5928            self.push_rope_heads(&kb, theta, cfg.n_kv_heads);
5929            self.push_attn(0);
5930            self.push_gemm(o, &cb, &alt, None);
5931            self.push_add(&cur, &alt);
5932            self.push_ln(mn, &cur, None, &alt);
5933            self.push_gemm(wi, &alt, &mid, None);
5934            self.push_glu(glu_act);
5935            self.push_gemm(wo, &glu, &alt, None);
5936            self.push_add(&cur, &alt);
5937        }
5938        let fin = self.norm_from(get("norm.weight")?, None);
5939        self.push_ln(fin, &cur, None, &alt);
5940        self.push_pool(&alt);
5941        Ok((word, None, None))
5942    }
5943
5944    /// SigLIP VISION tower: prenorm plan over the patch-embedded residual stream. The stream is
5945    /// seeded by the CPU-shared `patch_embed_rows` upload (`run_layers_gpu` writes it into
5946    /// `emb_in`), NOT a word gather — so this builds no embedding tables and returns them empty.
5947    /// Biased pre-LN layers (`layer_norm1`/`layer_norm2`), full bidirectional attention, dense
5948    /// tanh-GELU MLP (`fc1`/`fc2`), trailing `post_layernorm`. NO pooling step: the MAP
5949    /// attention-pooling head runs CPU-side on the read-back `[n_patches, hidden]` states
5950    /// (`CpuEncoder::map_head`), exactly as the oracle — so only the 12 transformer layers differ
5951    /// by device. Tensors live under `vision_model.` (same table as the CPU vision loader).
5952    #[allow(clippy::type_complexity)]
5953    fn build_siglip_vision(
5954        &mut self,
5955        st: &LazySt,
5956    ) -> Result<(Vec<f32>, Option<Vec<f32>>, Option<Vec<f32>>)> {
5957        let cfg = self.cfg;
5958        let get =
5959            |name: &str| -> Result<Vec<f32>> { st.tensor_f32(&format!("vision_model.{name}")) };
5960        let (h, im) = (cfg.hidden as u32, cfg.intermediate as u32);
5961        let mlp_act = match cfg.mlp_kind {
5962            MlpKind::Dense { act, .. } => act,
5963            MlpKind::Glu { .. } => anyhow::bail!("SigLIP vision MLPs are dense"),
5964        };
5965        // The patch-embedded rows ARE the initial residual stream (no embedding-stage norm): a
5966        // COPY into `cur` (persistent scratch — an Add would accumulate the previous encode's
5967        // residue) keeps `cur` the uniform stream, matching the Qwen3 copy-first seed.
5968        let emb_in = self.emb_in.clone();
5969        let cur = self.cur.clone();
5970        self.push_copy(&cur, &emb_in);
5971        let (alt, qb, kb, vb, cb, mid) = (
5972            self.alt.clone(),
5973            self.qb.clone(),
5974            self.kb.clone(),
5975            self.vb.clone(),
5976            self.cb.clone(),
5977            self.mid.clone(),
5978        );
5979        for i in 0..cfg.n_layers {
5980            let p = format!("encoder.layers.{i}");
5981            // Every projection is biased (`qkv_bias`, dense MLP with bias) — attach at upload.
5982            let biased = |b: &mut Self, name: &str, n: u32, k: u32| -> Result<usize> {
5983                b.upload_linear(
5984                    &st.tensor_f32(&format!("vision_model.{p}.{name}.weight"))?,
5985                    Some(st.tensor_f32(&format!("vision_model.{p}.{name}.bias"))?),
5986                    n,
5987                    k,
5988                )
5989            };
5990            let q = biased(self, "self_attn.q_proj", h, h)?;
5991            let k = biased(self, "self_attn.k_proj", h, h)?;
5992            let v = biased(self, "self_attn.v_proj", h, h)?;
5993            let o = biased(self, "self_attn.out_proj", h, h)?;
5994            let up = biased(self, "mlp.fc1", im, h)?;
5995            let down = biased(self, "mlp.fc2", h, im)?;
5996            let ln1 = self.norm_from(
5997                get(&format!("{p}.layer_norm1.weight"))?,
5998                Some(get(&format!("{p}.layer_norm1.bias"))?),
5999            );
6000            let ln2 = self.norm_from(
6001                get(&format!("{p}.layer_norm2.weight"))?,
6002                Some(get(&format!("{p}.layer_norm2.bias"))?),
6003            );
6004            // pre-LN dataflow: attn_in = LN1(cur); cur += out_proj(attn(q,k,v));
6005            //                  mlp_in = LN2(cur);  cur += fc2(gelu_tanh(fc1(mlp_in))).
6006            self.push_ln(ln1, &cur, None, &alt);
6007            self.push_gemm(q, &alt, &qb, None);
6008            self.push_gemm(k, &alt, &kb, None);
6009            self.push_gemm(v, &alt, &vb, None);
6010            self.push_attn(0); // Bidirectional (cfg.attn_mask), full window — no rope, no qk-norm.
6011            self.push_gemm(o, &cb, &alt, None);
6012            self.push_add(&cur, &alt);
6013            self.push_ln(ln2, &cur, None, &alt);
6014            self.push_gemm(up, &alt, &mid, Some(mlp_act));
6015            self.push_gemm(down, &mid, &alt, None);
6016            self.push_add(&cur, &alt);
6017        }
6018        let fin = self.norm_from(
6019            get("post_layernorm.weight")?,
6020            Some(get("post_layernorm.bias")?),
6021        );
6022        self.push_ln(fin, &cur, None, &alt);
6023        // Land the post-LN output in `cur` (== hidden_buf) — no push_pool; the MAP head reads it
6024        // back CPU-side.
6025        self.push_copy(&cur, &alt);
6026        Ok((Vec::new(), None, None))
6027    }
6028
6029    /// ModernBERT: prenorm plan (RoPE dual-theta, per-layer windows, GeGLU, bias-free,
6030    /// layer-0 attn-norm skip, trailing final_norm).
6031    #[allow(clippy::type_complexity)]
6032    fn build_modernbert(
6033        &mut self,
6034        st: &LazySt,
6035    ) -> Result<(Vec<f32>, Option<Vec<f32>>, Option<Vec<f32>>)> {
6036        let cfg = self.cfg;
6037        let prefix = ["", "model."]
6038            .into_iter()
6039            .find(|p| st.has(&format!("{p}embeddings.tok_embeddings.weight")))
6040            .context("ModernBERT tok_embeddings not found under known prefixes ('', 'model.')")?;
6041        let get = |name: &str| -> Result<Vec<f32>> { st.tensor_f32(&format!("{prefix}{name}")) };
6042        let has = |name: &str| st.has(&format!("{prefix}{name}"));
6043        let word = get("embeddings.tok_embeddings.weight")?;
6044        anyhow::ensure!(
6045            word.len() == cfg.vocab * cfg.hidden,
6046            "tok embedding shape {} != vocab {} × hidden {}",
6047            word.len(),
6048            cfg.vocab,
6049            cfg.hidden
6050        );
6051        anyhow::ensure!(!cfg.qkv_bias, "ModernBERT is bias-free");
6052        let (theta, local_theta) = match cfg.pos_kind {
6053            PosKind::Rope { theta, local_theta } => (theta, local_theta),
6054            PosKind::Learned { .. } => anyhow::bail!("ModernBERT uses rotary positions"),
6055        };
6056        let glu_act = match cfg.mlp_kind {
6057            MlpKind::Glu { act } => act,
6058            MlpKind::Dense { .. } => anyhow::bail!("ModernBERT MLPs are GLU"),
6059        };
6060        let norm_of = |b: &mut Self, name: &str| -> Result<usize> {
6061            let bias_name = format!("{}.bias", name.trim_end_matches(".weight"));
6062            let bias = if has(&bias_name) {
6063                Some(get(&bias_name)?)
6064            } else {
6065                None
6066            };
6067            Ok(b.norm_from(get(name)?, bias))
6068        };
6069        let hu = cfg.hidden as u32;
6070        let (im, h) = (cfg.intermediate as u32, cfg.hidden);
6071        // Embedding norm: emb_in → cur.
6072        let emb_ln = norm_of(self, "embeddings.norm.weight")?;
6073        let emb_in = self.emb_in.clone();
6074        let cur = self.cur.clone();
6075        self.push_ln(emb_ln, &emb_in, None, &cur);
6076        let (alt, qb, kb, vb, cb, mid, glu) = (
6077            self.alt.clone(),
6078            self.qb.clone(),
6079            self.kb.clone(),
6080            self.vb.clone(),
6081            self.cb.clone(),
6082            self.mid.clone(),
6083            self.glu.clone(),
6084        );
6085        for i in 0..cfg.n_layers {
6086            let p = format!("layers.{i}");
6087            let wqkv = get(&format!("{p}.attn.Wqkv.weight"))?;
6088            anyhow::ensure!(
6089                wqkv.len() == 3 * h * h,
6090                "{p}.attn.Wqkv shape {} != 3·{h}·{h}",
6091                wqkv.len()
6092            );
6093            let q = self.upload_linear(&wqkv[..h * h], None, hu, hu)?;
6094            let k = self.upload_linear(&wqkv[h * h..2 * h * h], None, hu, hu)?;
6095            let v = self.upload_linear(&wqkv[2 * h * h..], None, hu, hu)?;
6096            let o = self.upload_linear(&get(&format!("{p}.attn.Wo.weight"))?, None, hu, hu)?;
6097            let wi = self.upload_linear(&get(&format!("{p}.mlp.Wi.weight"))?, None, 2 * im, hu)?;
6098            let wo = self.upload_linear(&get(&format!("{p}.mlp.Wo.weight"))?, None, hu, im)?;
6099            let window = cfg.layer_window[i];
6100            let layer_theta = if window > 0 { local_theta } else { theta };
6101            // Attention input: LN(cur) → alt, except layer 0 (skip ⇒ read cur directly).
6102            let attn_src = if i == 0 && cfg.skip_first_attn_norm {
6103                anyhow::ensure!(
6104                    !has(&format!("{p}.attn_norm.weight")),
6105                    "layer 0 attn_norm present but config says skip — checkpoint mismatch"
6106                );
6107                &cur
6108            } else {
6109                let an = norm_of(self, &format!("{p}.attn_norm.weight"))?;
6110                self.push_ln(an, &cur, None, &alt);
6111                &alt
6112            };
6113            self.push_gemm(q, attn_src, &qb, None);
6114            self.push_gemm(k, attn_src, &kb, None);
6115            self.push_gemm(v, attn_src, &vb, None);
6116            self.push_rope(&qb, layer_theta);
6117            self.push_rope(&kb, layer_theta);
6118            self.push_attn(window);
6119            self.push_gemm(o, &cb, &alt, None);
6120            self.push_add(&cur, &alt);
6121            let mn = norm_of(self, &format!("{p}.mlp_norm.weight"))?;
6122            self.push_ln(mn, &cur, None, &alt);
6123            self.push_gemm(wi, &alt, &mid, None);
6124            self.push_glu(glu_act);
6125            self.push_gemm(wo, &glu, &alt, None);
6126            self.push_add(&cur, &alt);
6127        }
6128        let fin = norm_of(self, "final_norm.weight")?;
6129        self.push_ln(fin, &cur, None, &alt);
6130        self.push_pool(&alt);
6131        Ok((word, None, None))
6132    }
6133}