File size: 12,565 Bytes
c971a45 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 | // Encoder pass: embedding → ENC_LAYERS post-LN transformer layers → fused
// cross-K/V projections for every decoder layer. Everything is recorded into ONE
// command encoder / ONE compute pass (WebGPU orders dispatches that touch the
// same storage buffers within a pass) and submitted once.
//
// Per layer (post-LN Marian):
// qkvOut = x @ qkv.w + qkv.b [B·S, 3·HD] fused q|k|v
// attnOut = attention(qkvOut) reads the fused buffer
// directly via strides
// y = attnOut @ out.w + out.b
// x' = LN1(y + x)
// ffnTmp = SiLU(x' @ fc1.w + fc1.b)
// y = ffnTmp @ fc2.w + fc2.b
// x'' = LN2(y + x')
// LN output must not alias its residual input (read + read_write on one
// buffer is a WebGPU usage conflict), so the hidden state ping-pongs
// a → b → a within each layer and ends every layer back in `a`.
import { createSplitArena } from './arena.js';
import { dispatchGemm, dispatchAttention, dispatchAddLn, dispatchEmbed, dispatchScatterRows } from './pipelines.js';
import {
D_MODEL, HEADS, HEAD_DIM, FFN, ENC_LAYERS, DEC_LAYERS, assertModelActive,
} from './constants.js';
// ctx = {device, dtype?}; activation dtype follows weights.dtype (the kernels'
// {{T}} must match the storage type of the weight tensors).
// Returns {encOut, crossKV: [GPUBuffer×2], lensBuf, B, S, arena}. The returned
// arena owns the retained buffers; encoder-only scratch is destroyed right
// after a normal submit. `retainEncOut=false` skips the inspectable final
// hidden output used only by encoder debug/readback gates.
//
// recordInto: profiling hook — an already-begun compute pass (or a proxy
// implementing setPipeline/setBindGroup/dispatchWorkgroups, see
// profile.js makeProfilingPass). When given, dispatches are recorded into it
// and NOTHING is submitted: the caller owns pass.end()/submit and must
// destroy the returned `scratch` buffers after its submit.
//
// gemmOverrides: tuning hook — merged over the default GEMM flags (e.g.
// {bkk: 32} to change the tile K-slice, {tiled: false} for the naive-kernel
// control in sweep tests). attnOverrides: same for the self-attention site
// (e.g. {block: false} for the unblocked control, {qb, jb} tile shapes).
// Production callers leave both unset.
//
// packed: encoder row-packing — drop the pad rows (file23k batch fill: 80.8%
// at b128) so every GEMM runs at T = Σ lens rows instead of B·S and
// attention early-exits whole query blocks past len. crossKV and encOut are
// scattered back to the padded [B·S, ·] layout at the end of the pass, so
// the returned contract (and the decoder, compaction, every test) is
// unchanged — bit-exact on valid rows, zeros on pad rows (enc_pack_equiv;
// unpacked pad rows hold computed garbage nothing reads). 'auto' (default)
// packs except under attnOverrides — the attention sweeps A/B the padded
// kernel variants (incl. block:false, which has no packed path).
// splitSubmits: instead of ONE submit, cut the pass at the embed and every
// layer boundary (8 submits total). Dispatch order and results are identical
// (queue order sequences chunks exactly like one pass); only the submit
// boundaries move, so no single submit keeps a slow GPU busy for seconds —
// the Android-watchdog regime where the batch with the largest B·S dies with
// VK_ERROR_DEVICE_LOST at its encoder submit. Ignored under recordInto.
export async function runEncoder(ctx, weights, { ids, lens, B, S }, { recordInto = null, gemmOverrides = null, attnOverrides = null, packed = 'auto', sg = false, attnQbAlign8 = false, retainEncOut = true, splitSubmits = false } = {}) {
assertModelActive(weights.model, 'runEncoder weights');
const HD = HEADS * HEAD_DIM; // == D_MODEL (enforced by applyModelConfig)
const QKV_N = 3 * HD; // fused q|k|v
const CROSS_KV_N = 2 * HD; // fused k|v
const { device } = ctx;
const t = weights.dtype;
const eb = t === 'f16' ? 2 : 4;
const padRows = B * S;
let total = 0;
for (let i = 0; i < B; i++) total += lens[i];
const usePacked = (packed === 'auto' ? !attnOverrides : !!packed)
&& total < padRows && S <= 0xffff;
const rows = usePacked ? total : padRows;
const flags = { t };
// Subgroup add_ln (translateBatch passes tuned sg through): perf knob on
// Metal, CORRECTNESS requirement on Adreno-class devices where the tree
// reduction miscompiles (m1_encoder_parity failure, 2026-07 Android round).
const lnFlags = sg ? { ...flags, sg: true } : flags;
// Encoder GEMMs are large-M (rows = B·S ≥ 32) — route them to the tiled
// kernel (enc_profile baseline: GEMMs = ~88% of encoder time on the naive
// kernel). tm8 (8×4 register subtile, gemm_tiled2.wgsl): tiled_sweep
// 2026-07-06 b64 medians v1 84.5 / v2 63.0 / v2+tm8 58.9ms. Non-GEMM sites
// (attention/add_ln/embed) keep plain `flags`.
const gemmFlags = { ...flags, tiled: true, tm8: true, ...(gemmOverrides ?? {}) };
// Self-attention: blocked kernel (QB queries share staged K/V tiles —
// attn_sweep 2026-07-06 picks the default QB/JB in pipelines.js).
const attnFlags = { ...flags, block: true, packed: usePacked, ...(attnQbAlign8 ? { qbAlign8: true } : {}), ...(attnOverrides ?? {}) };
const arena = createSplitArena(device);
const scratch = [];
try {
const retained = arena.retained;
const scratchArena = arena.scratch;
const upload = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST;
const act = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC;
const idsBuf = scratchArena.buf(rows * 4, upload, 'enc ids');
const lensBuf = retained.buf(B * 4, upload, 'enc lens');
let startsBuf = null;
if (usePacked) {
// Packed rows: sequence bi occupies rows starts[bi] .. starts[bi]+len;
// each ids word carries its own position (embed.wgsl PACKED).
const words = new Uint32Array(rows);
const starts = new Uint32Array(B);
let r = 0;
for (let bi = 0; bi < B; bi++) {
starts[bi] = r;
for (let m = 0; m < lens[bi]; m++, r++) words[r] = (m << 16) | ids[bi * S + m];
}
startsBuf = scratchArena.buf(B * 4, upload, 'enc starts');
device.queue.writeBuffer(idsBuf, 0, words);
device.queue.writeBuffer(startsBuf, 0, starts);
} else {
device.queue.writeBuffer(idsBuf, 0, ids);
}
device.queue.writeBuffer(lensBuf, 0, lens);
// In the unpacked inspectable path `a` is also encOut, so retain it. The
// production path and every packed path keep `a` scratch-only.
const aArena = retainEncOut && !usePacked ? retained : scratchArena;
const a = aArena.buf(rows * D_MODEL * eb, act, 'enc hidden a');
const b = scratchArena.buf(rows * D_MODEL * eb, act, 'enc hidden b');
const y = scratchArena.buf(rows * D_MODEL * eb, act, 'enc sublayer y');
const qkvOut = scratchArena.buf(rows * QKV_N * eb, act, 'enc qkv out');
const attnOut = scratchArena.buf(rows * HD * eb, act, 'enc attn out');
const ffnTmp = scratchArena.buf(rows * FFN * eb, act, 'enc ffn tmp');
// crossKV keeps the padded [B·S, 2·HD] contract; packed runs project into
// packed temporaries and scatter into these at the end of the pass.
const crossKV = [
retained.buf(padRows * CROSS_KV_N * eb, act, 'crossKV dec.0'),
retained.buf(padRows * CROSS_KV_N * eb, act, 'crossKV dec.1'),
];
const crossKVP = usePacked ? [
scratchArena.buf(rows * CROSS_KV_N * eb, act, 'crossKV packed dec.0'),
scratchArena.buf(rows * CROSS_KV_N * eb, act, 'crossKV packed dec.1'),
] : crossKV;
const W = (name) => weights.bindingFor(name);
let encoder = recordInto ? null : device.createCommandEncoder({ label: 'encoder pass' });
let pass = recordInto ?? encoder.beginComputePass({ label: 'encoder pass' });
const rec = ({ scratch: s }) => scratch.push(...s);
// splitSubmits: close the current chunk and open the next. Buffers written
// by a submitted chunk are queue-retained; later chunks read them in queue
// order, so results are bit-identical to the single-submit pass.
const split = !recordInto && splitSubmits;
let chunkIdx = 0;
const cut = () => {
if (!split) return;
pass.end();
device.queue.submit([encoder.finish()]);
chunkIdx++;
encoder = device.createCommandEncoder({ label: `encoder chunk ${chunkIdx}` });
pass = encoder.beginComputePass({ label: `encoder chunk ${chunkIdx}` });
};
// 1. Embedding: x = shared[id]·EMBED_SCALE + pos_embed[pos] → a.
rec(dispatchEmbed(device, pass, {
ids: idsBuf, table: W('shared.weight'), posEmbed: W('pos_embed'), y: a,
mode: 'src', nRows: rows, batch: B, s: S, packed: usePacked, flags,
}));
cut();
// 2. Transformer layers. Hidden state: in `a` at layer start and layer end.
for (let l = 0; l < ENC_LAYERS; l++) {
const p = (name) => `enc.${l}.${name}`;
// Self-attention block: a → b
rec(dispatchGemm(device, pass, {
x: a, w: W(p('qkv.weight')), b: W(p('qkv.bias')), y: qkvOut,
M: rows, K: D_MODEL, N: QKV_N, flags: gemmFlags,
}));
rec(dispatchAttention(device, pass, {
q: qkvOut, k: qkvOut, v: qkvOut, lens: lensBuf, y: attnOut, starts: startsBuf,
B, M: S, L: S, lenMode: 1,
qStride: QKV_N, qOff: 0, kvStride: QKV_N, kOff: HD, vOff: 2 * HD,
flags: attnFlags,
}));
rec(dispatchGemm(device, pass, {
x: attnOut, w: W(p('out.weight')), b: W(p('out.bias')), y,
M: rows, K: HD, N: D_MODEL, flags: gemmFlags,
}));
rec(dispatchAddLn(device, pass, {
x: y, r: a, gamma: W(p('ln1.weight')), beta: W(p('ln1.bias')), y: b,
rows, flags: lnFlags,
}));
// FFN block: b → a
rec(dispatchGemm(device, pass, {
x: b, w: W(p('fc1.weight')), b: W(p('fc1.bias')), y: ffnTmp,
M: rows, K: D_MODEL, N: FFN, flags: { ...gemmFlags, silu: true },
}));
rec(dispatchGemm(device, pass, {
x: ffnTmp, w: W(p('fc2.weight')), b: W(p('fc2.bias')), y,
M: rows, K: FFN, N: D_MODEL, flags: gemmFlags,
}));
rec(dispatchAddLn(device, pass, {
x: y, r: b, gamma: W(p('ln2.weight')), beta: W(p('ln2.bias')), y: a,
rows, flags: lnFlags,
}));
cut();
}
// 3. Fused cross-attention K/V for both decoder layers (attention reads
// these directly with kvStride 896, kOff 0, vOff 448).
for (let l = 0; l < DEC_LAYERS; l++) {
rec(dispatchGemm(device, pass, {
x: a, w: W(`dec.${l}.cross_kv.weight`), b: W(`dec.${l}.cross_kv.bias`), y: crossKVP[l],
M: rows, K: D_MODEL, N: CROSS_KV_N, flags: gemmFlags,
}));
}
// 4. Packed runs: scatter crossKV and the final hidden state back to the
// padded layout (valid rows copied, pad rows stay zero — see
// scatter_rows.wgsl) so downstream consumers see the unpacked contract.
let encOut = retainEncOut && !usePacked ? a : null;
if (usePacked) {
for (let l = 0; l < DEC_LAYERS; l++) {
rec(dispatchScatterRows(device, pass, {
x: crossKVP[l], y: crossKV[l], starts: startsBuf, lens: lensBuf,
B, S, N: CROSS_KV_N, flags,
}));
}
if (retainEncOut) {
encOut = retained.buf(padRows * D_MODEL * eb, act, 'enc out padded');
rec(dispatchScatterRows(device, pass, {
x: a, y: encOut, starts: startsBuf, lens: lensBuf,
B, S, N: D_MODEL, flags,
}));
}
}
if (recordInto) {
return { encOut, crossKV, lensBuf, B, S, arena, scratch, packed: usePacked };
}
pass.end();
device.queue.submit([encoder.finish()]);
// Capture the encoder-only queue boundary before any caller can submit
// decode work. The promise is used both for honest submitted-byte accounting
// and for the existing non-blocking encoder timing in translateBatch.
const submittedDone = device.queue.onSubmittedWorkDone();
for (const buf of scratch) buf.destroy(); // safe post-submit
// Submitted commands retain their resources until execution completes.
// Retiring JS ownership here lets WebKit release the large encoder-only
// backing allocations as soon as that submit drains, instead of after the
// entire decode batch.
arena.retireScratch(submittedDone);
return { encOut, crossKV, lensBuf, B, S, arena, packed: usePacked, submittedDone };
} catch (err) {
// A failure before ownership is returned must retire both dispatch-local
// uniforms and every partially allocated arena buffer. Cleanup is best
// effort so the original encoder error remains the one callers see.
for (const buf of scratch) {
try { buf.destroy(); } catch {}
}
try { arena.destroy(); } catch {}
throw err;
}
}
|