DaisyChain-Train / web /public /transformer.js
Quazim0t0's picture
Upload web/public/transformer.js with huggingface_hub
617ba47 verified
Raw
History Blame Contribute Delete
30.3 kB
// A miniature transformer language model that trains THROUGH the verified INT8
// units: every matrix product in the forward pass — QKV projections, attention
// scores, attention·values, output projection, the MLP, and the unembedding —
// runs through the verified multiply LUT (an emulated INT8 tensor core).
// Backward is a straight-through estimator in float (the integer path has no
// gradient), exactly like the Python VerifiedLinear.
//
// Task: next-character prediction on a deterministic, self-generated corpus
// (every peer builds the same text from the same seed — nothing to download).
(function (root) {
"use strict";
let TC, V; // TrainCore / Verified — resolved per environment at the end
function mulberry32(a) { return function () { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }
function randn(n, rng) { const o = new Float32Array(n); for (let i = 0; i < n; i += 2) { let u = 0, v = 0; while (u === 0) u = rng(); while (v === 0) v = rng(); const m = Math.sqrt(-2 * Math.log(u)); o[i] = m * Math.cos(2 * Math.PI * v); if (i + 1 < n) o[i + 1] = m * Math.sin(2 * Math.PI * v); } return o; }
// ---- corpus: deterministic cottagecore prose, identical on every peer -----
const W_ADJ = ["mossy", "golden", "amber", "quiet", "little", "misty", "sunny", "wild", "cozy", "dusty", "merry", "brave"];
const W_NOUN = ["fox", "hare", "owl", "badger", "toad", "sparrow", "otter", "deer", "mushroom", "acorn", "willow", "robin", "river", "meadow", "garden", "lantern"];
const W_VERB = ["naps", "sings", "wanders", "hides", "dreams", "waits", "dances", "listens", "rests", "grows"];
const W_PREP = ["by", "under", "near", "beside", "beyond", "inside"];
function buildCorpus() {
const rng = mulberry32(20260712);
const pick = (a) => a[Math.floor(rng() * a.length)];
let s = "";
while (s.length < 60000)
s += `the ${pick(W_ADJ)} ${pick(W_NOUN)} ${pick(W_VERB)} ${pick(W_PREP)} the ${pick(W_ADJ)} ${pick(W_NOUN)}. `;
return s;
}
// ---- tokenizer -------------------------------------------------------------
// Spikewhale tokenizer (tokenizer.json): byte-level greedy longest-match
// ("length-max"), ~16.5k tokens. Until it loads (or if the file is missing)
// a 96-char byte-level vocab keeps the app working — but ALL devices in a
// group must use the same tokenizer (the config broadcast enforces it).
const FALLBACK_CHARS = [...Array(95)].map((_, i) => String.fromCharCode(32 + i)).concat(["\n"]);
let tok = {
name: "char-96 (fallback)",
vocab: Object.fromEntries(FALLBACK_CHARS.map((c, i) => [c, i])),
ids: FALLBACK_CHARS, maxLen: 1, size: FALLBACK_CHARS.length,
unk: 0, specials: new Set(),
};
tok.unk = tok.vocab[" "];
function vocabSize() { return tok.size; }
function tokenizerName() { return tok.name; }
function loadTokenizerData(d) { // plain {vocab, vocab_size, max_token_len}
const ids = new Array(d.vocab_size);
for (const [t, i] of Object.entries(d.vocab)) ids[i] = t;
tok = { name: `Spikewhale length-max (${d.vocab_size} tokens)`,
vocab: d.vocab, ids, maxLen: d.max_token_len || 24, size: d.vocab_size,
unk: d.vocab["<unk>"] ?? 1,
specials: new Set(["<pad>", "<unk>", "<bos>", "<eos>", ...(d.special_tokens || [])]) };
IDS = encode(CORPUS); // re-tokenize whatever corpus is loaded
return tok.name;
}
async function loadTokenizer(url) {
const r = await fetch(url || "tokenizer.json");
if (!r.ok) throw new Error(`tokenizer.json HTTP ${r.status}`);
return loadTokenizerData(await r.json());
}
function toLatin1(s) { const b = new TextEncoder().encode(s); let o = ""; for (const x of b) o += String.fromCharCode(x); return o; }
function encode(text) { // greedy longest match over bytes
const s = toLatin1(text), out = [];
let i = 0;
while (i < s.length) {
let m = null;
for (let L = Math.min(tok.maxLen, s.length - i); L > 0; L--) {
const sub = s.substr(i, L);
if (sub in tok.vocab) { m = sub; break; }
}
if (m === null) { out.push(tok.unk); i++; continue; }
out.push(tok.vocab[m]); i += m.length;
}
return Int32Array.from(out);
}
function decode(idArr) {
let s = "";
for (const id of idArr) {
const t = tok.ids[id];
if (t === undefined || tok.specials.has(t)) continue;
s += t;
}
const bytes = Uint8Array.from([...s].map(c => c.charCodeAt(0)));
return new TextDecoder().decode(bytes);
}
let CORPUS = buildCorpus();
let IDS = encode(CORPUS);
let DATASET = "built-in corpus";
// Training text: FineWeb-Edu (10BT sample), HARDCODED as the only dataset.
// The serving Space reads random slices of the parquet shards straight off
// the HF CDN with range requests (see server.js /data) — no dependency on
// the datasets-server rows API, which 503s routinely. Each device pulls its
// own random slice (that's data parallelism — batches were always
// per-device anyway). Offline or on failure the built-in corpus stays.
const DEFAULT_DS = "HuggingFaceFW/fineweb-edu";
async function streamDataset() { // dataset choice removed on purpose
const r = await fetch("data");
if (!r.ok) throw new Error(`/data HTTP ${r.status}`);
const text = (await r.text()).replace(/[^\x20-\x7e\n]/g, " ");
if (text.length < 10000) throw new Error("too little text returned");
CORPUS = text.slice(0, 500000);
IDS = encode(CORPUS);
DATASET = `${DEFAULT_DS} · 10BT sample (parquet via this Space)`;
return { name: DATASET, chars: CORPUS.length };
}
const streamFineWebEdu = () => streamDataset();
function datasetName() { return DATASET; }
// ---- verified matmul: block-scaled INT8 through the units ------------------
// CUTLASS ex. 67/81 blockwise scaling: per-row activation scales × per-column
// weight scales, one exact LUT/DP4A GEMM, dequant (+ optional fused ReLU) in
// the kernel epilogue (ex. 12). Replaces the per-tensor 3-pass: same outlier
// robustness at one third of the unit ops.
async function vmm(Xf, Wf, m, k, n, ctx, relu) {
// ctx.audit re-checks random cells of this LIVE GEMM against the units
return V.vgemmBlock(Xf, Wf, { m, k, n, batch: 1, relu: !!relu }, ctx.L, ctx.bgemm, ctx.audit);
}
// CUTLASS ex. 45 (dual GEMM): sibling GEMMs that share the same LEFT operand
// run as ONE batched dispatch, and the shared operand is quantized ONCE
// instead of once per sibling. Used for the q/k/v projections — same X
// (ln1.y), three weights, identical shapes. Bit-identical to three separate
// vmm calls: quantizeRows is deterministic (same input -> same int8+scales),
// the tiled copies index exactly like separate batch elements, and block
// scales are per-row/per-column PER BATCH ELEMENT, so concatenation changes
// no scale and no product. The batched kernel is the same exact-gated bgemm
// that training already runs, and the live-shape audit still samples it.
async function vmmShared3(Xf, Wa, Wb, Wc, m, k, n, ctx) {
const x = V.quantizeRows(Xf, m, k);
const xq = new Int8Array(3 * m * k), xs = new Float32Array(3 * m);
for (let i = 0; i < 3; i++) { xq.set(x.q, i * m * k); xs.set(x.s, i * m); }
const wq = new Int8Array(3 * k * n), ws = new Float32Array(3 * n);
[Wa, Wb, Wc].forEach((W, i) => { const w = V.quantizeCols(W, k, n); wq.set(w.q, i * k * n); ws.set(w.s, i * n); });
const d = { m, k, n, batch: 3 };
let out;
if (ctx.bgemm) {
out = await ctx.bgemm(xq, wq, xs, ws, d);
if (ctx.audit && ctx.audit.due()) {
const bad = V.auditTile(xq, wq, xs, ws, d, out, ctx.L, ctx.audit.cells);
if (bad) ctx.audit.fail(bad);
}
} else {
out = V.bgemmJS(xq, wq, xs, ws, d, ctx.L);
}
const MN = m * n;
return [out.subarray(0, MN), out.subarray(MN, 2 * MN), out.subarray(2 * MN, 3 * MN)];
}
// ---- layernorm (no affine) -------------------------------------------------
function lnFwd(x, rows, C) {
const y = new Float32Array(rows * C), sig = new Float32Array(rows);
for (let r = 0; r < rows; r++) {
let mu = 0; for (let j = 0; j < C; j++) mu += x[r * C + j]; mu /= C;
let v = 0; for (let j = 0; j < C; j++) { const d = x[r * C + j] - mu; v += d * d; }
const s = Math.sqrt(v / C + 1e-5); sig[r] = s;
for (let j = 0; j < C; j++) y[r * C + j] = (x[r * C + j] - mu) / s;
}
return { y, sig };
}
function lnBwd(dy, y, sig, rows, C) {
const dx = new Float32Array(rows * C);
for (let r = 0; r < rows; r++) {
let mdy = 0, mdyy = 0;
for (let j = 0; j < C; j++) { mdy += dy[r * C + j]; mdyy += dy[r * C + j] * y[r * C + j]; }
mdy /= C; mdyy /= C;
for (let j = 0; j < C; j++) dx[r * C + j] = (dy[r * C + j] - mdy - y[r * C + j] * mdyy) / sig[r];
}
return dx;
}
// ---- model -----------------------------------------------------------------
// cfg: { c: width, t: seq len, b: batch/device, layers, heads, steps, lr }
// engine: the Compute backend object ({bgemm} for the fused WebGPU path) or a
// legacy matmulInt8 function (Node tests, inference kit) -> CPU LUT mirror
function init(cfg, L, engine, audit) {
const c = cfg.c, layers = cfg.layers || 2, heads = cfg.heads || 2, hidden = 2 * c;
let seed = 100;
const mk = (nEl, scale) => { const w = randn(nEl, mulberry32(seed++)); for (let i = 0; i < nEl; i++) w[i] *= scale; return w; };
const params = [], names = [];
const add = (name, w) => { params.push(w); names.push(name); return w; };
const m = {
cfg: { ...cfg, layers, heads, hidden, vocab: vocabSize() },
ctx: { L, bgemm: (engine && engine.bgemm) || null,
att: (engine && engine.att) || null, fgemm: (engine && engine.fgemm) || null,
fgemm2: (engine && engine.fgemm2) || null,
mlp: (engine && engine.mlp) || null,
audit: audit || null, unitBackward: !!cfg.unitBackward },
emb: add("emb", mk(vocabSize() * c, 0.08)),
pos: add("pos", mk(cfg.t * c, 0.02)),
blocks: [], params, names,
};
for (let l = 0; l < layers; l++)
m.blocks.push({
Wq: add(`b${l}.Wq`, mk(c * c, 0.08)), Wk: add(`b${l}.Wk`, mk(c * c, 0.08)),
Wv: add(`b${l}.Wv`, mk(c * c, 0.08)), Wo: add(`b${l}.Wo`, mk(c * c, 0.08)),
W1: add(`b${l}.W1`, mk(c * hidden, 0.08)), W2: add(`b${l}.W2`, mk(hidden * c, 0.08)),
});
// weight-tied unembedding: logits use embᵀ (no separate Wu). Halves the
// vocab-sized parameters — and with a 16k vocab that's ~half of ALL
// parameters, so gradients over the wire shrink ~2× too.
m.nParams = params.reduce((a, p) => a + p.length, 0);
return m;
}
function sampleBatch(cfg) {
const { b, t } = cfg;
const X = new Int32Array(b * t), Y = new Int32Array(b * t);
for (let i = 0; i < b; i++) {
const off = Math.floor(Math.random() * (IDS.length - t - 1));
for (let j = 0; j < t; j++) { X[i * t + j] = IDS[off + j]; Y[i * t + j] = IDS[off + j + 1]; }
}
return { X, Y };
}
// ---- head layout helpers ---------------------------------------------------
// q/k/v live as BT×C with head h owning columns [h*hd, (h+1)*hd). The backward
// wants every head as its own GEMM problem, so gather once into BH×T×hd and
// scatter back at the end — one pass each, instead of slicing per head inside
// the loop and paying a GPU dispatch per tiny matmul.
function gatherHeads(x, B, T, C, heads, hd) { // BT×C -> BH×T×hd
const out = new Float32Array(B * heads * T * hd);
for (let bi = 0; bi < B; bi++)
for (let h = 0; h < heads; h++) {
const bz = bi * heads + h;
for (let ti = 0; ti < T; ti++)
for (let j = 0; j < hd; j++) out[(bz * T + ti) * hd + j] = x[(bi * T + ti) * C + h * hd + j];
}
return out;
}
function scatterHeadsAcc(dst, src, B, T, C, heads, hd) { // BH×T×hd -> += BT×C
for (let bi = 0; bi < B; bi++)
for (let h = 0; h < heads; h++) {
const bz = bi * heads + h;
for (let ti = 0; ti < T; ti++)
for (let j = 0; j < hd; j++) dst[(bi * T + ti) * C + h * hd + j] += src[(bz * T + ti) * hd + j];
}
}
function batchedTranspose(x, batch, rows, cols) { // per-batch rows×cols -> cols×rows
const out = new Float32Array(batch * rows * cols);
for (let b = 0; b < batch; b++) {
const o = b * rows * cols;
for (let r = 0; r < rows; r++)
for (let c = 0; c < cols; c++) out[o + c * rows + r] = x[o + r * cols + c];
}
return out;
}
// ---- forward THROUGH the verified units (caches kept for STE backward) -----
async function forward(m, X, Y) {
const { c: C, t: T, b: B, layers, heads, hidden, vocab } = m.cfg;
const BT = B * T, hd = C / heads, ctx = m.ctx;
const cache = { X, Y, blocks: [] };
let x = new Float32Array(BT * C);
for (let i = 0; i < BT; i++) {
const id = X[i], tpos = i % T;
for (let j = 0; j < C; j++) x[i * C + j] = m.emb[id * C + j] + m.pos[tpos * C + j];
}
for (let l = 0; l < layers; l++) {
const bl = m.blocks[l], cb = { xin: x };
const l1 = lnFwd(x, BT, C); cb.ln1 = l1;
// q/k/v share the same left operand — one batched dispatch, one quantize
// of ln1.y instead of three (CUTLASS ex. 45; see vmmShared3)
const [q, k, v] = await vmmShared3(l1.y, bl.Wq, bl.Wk, bl.Wv, BT, C, C, ctx);
cb.q = q; cb.k = k; cb.v = v;
const scale = 1 / Math.sqrt(hd);
// gather-FUSED attention (CUTLASS ex. 36/52): the kernels read q/k/v in
// their natural BT×C layout with head-strided indexing and scatter ctx
// straight back — no JS gather copies, no kᵀ transpose. All B×H heads in
// one dispatch per stage, every product through the verified units.
const BH = B * heads, dAtt = { B, T, heads, hd };
// per-(token,head) row quantization: the (BT·heads)×hd view IS the buffer
const qq = V.quantizeRows(q, BT * heads, hd), kq = V.quantizeRows(k, BT * heads, hd);
const sAll = ctx.att ? await ctx.att.scores(qq.q, kq.q, qq.s, kq.s, dAtt)
: V.attScoresJS(qq.q, kq.q, qq.s, kq.s, dAtt, ctx.L);
// live-shape audit: the init gate only ever saw four test shapes
if (ctx.att && ctx.audit && ctx.audit.due()) {
const bad = V.auditAttScores(qq.q, kq.q, qq.s, kq.s, dAtt, sAll, ctx.L, ctx.audit.cells);
if (bad) ctx.audit.fail(bad);
}
const aAll = new Float32Array(BH * T * T); // causal softmax
for (let bz = 0; bz < BH; bz++) {
const so = bz * T * T;
for (let ti = 0; ti < T; ti++) {
let mx = -1e30;
for (let tj = 0; tj <= ti; tj++) mx = Math.max(mx, sAll[so + ti * T + tj] * scale);
let z = 0;
for (let tj = 0; tj <= ti; tj++) { const e = Math.exp(sAll[so + ti * T + tj] * scale - mx); aAll[so + ti * T + tj] = e; z += e; }
for (let tj = 0; tj <= ti; tj++) aAll[so + ti * T + tj] /= z;
}
}
const aq = V.quantizeRows(aAll, BH * T, T);
const vq = V.quantizeHeadCols(v, B, T, heads, hd);
const ctxOut = ctx.att ? await ctx.att.ctx(aq.q, vq.q, aq.s, vq.s, dAtt)
: V.attCtxJS(aq.q, vq.q, aq.s, vq.s, dAtt, ctx.L);
if (ctx.att && ctx.audit && ctx.audit.due()) {
const bad = V.auditAttCtx(aq.q, vq.q, aq.s, vq.s, dAtt, ctxOut, ctx.L, ctx.audit.cells);
if (bad) ctx.audit.fail(bad);
}
cb.aAll = aAll; // backward slices heads from q/k/v/aAll
cb.ctxOut = ctxOut;
const attnOut = await vmm(ctxOut, bl.Wo, BT, C, C, ctx);
const x2 = new Float32Array(BT * C);
for (let i = 0; i < x2.length; i++) x2[i] = x[i] + attnOut[i];
cb.x2 = x2;
const l2 = lnFwd(x2, BT, C); cb.ln2 = l2;
// CUTLASS ex. 13 + 23: both MLP GEMMs run back-to-back on the GPU. The
// intermediate h1 is quantized ON-DEVICE (exact-gated respec — see
// vmlpBlock in verified_core.js) and only its per-row absmax (~1KB)
// visits JS between the GEMMs; h1 itself comes back solely because the
// STE backward needs it. CPU devices run the bit-identical mirror chain.
const { h1, out: mlpOut } = await V.vmlpBlock(l2.y, bl.W1, bl.W2,
{ m: BT, k: C, h: hidden, n: C }, ctx.L, ctx.mlp, ctx.audit);
const mask = new Uint8Array(h1.length);
for (let i = 0; i < h1.length; i++) if (h1[i] > 0) mask[i] = 1;
cb.h1 = h1; cb.mask = mask;
x = new Float32Array(BT * C);
for (let i = 0; i < x.length; i++) x[i] = x2[i] + mlpOut[i];
cache.blocks.push(cb);
}
const lf = lnFwd(x, BT, C); cache.lnf = lf; cache.xf = x;
const logits = await vmm(lf.y, TC.transpose(m.emb, vocab, C), BT, C, vocab, ctx); // tied: embᵀ
// cross-entropy + dlogits
let loss = 0;
const dlogits = new Float32Array(BT * vocab);
for (let i = 0; i < BT; i++) {
let mx = -1e30;
for (let j = 0; j < vocab; j++) mx = Math.max(mx, logits[i * vocab + j]);
let z = 0;
for (let j = 0; j < vocab; j++) z += Math.exp(logits[i * vocab + j] - mx);
const lz = Math.log(z) + mx;
loss += lz - logits[i * vocab + Y[i]];
for (let j = 0; j < vocab; j++)
dlogits[i * vocab + j] = (Math.exp(logits[i * vocab + j] - lz) - (j === Y[i] ? 1 : 0)) / BT;
}
loss /= BT;
cache.dlogits = dlogits;
return { loss, cache, logits };
}
// ---- STE backward (float), mirrors forward exactly --------------------------
// The two vocab-sized matmuls run on the split-K f32 GPU kernel when
// available (CUTLASS ex. 06) — same float math, off the JS thread.
async function backward(m, cache) {
const { c: C, t: T, b: B, layers, heads, hidden, vocab } = m.cfg;
const BT = B * T, hd = C / heads, tr = TC.transpose;
const g = m.params.map(p => new Float32Array(p.length));
const gi = Object.fromEntries(m.names.map((n, i) => [n, i]));
// Every matmul here goes through `bmm`. With ctx.unitBackward the STE
// gradient is computed BY the verified units (block-scaled int8, exact int32
// accumulate) instead of in float. STE is a claim about the math — pretend
// the quantizer was the identity — not about the datatype that evaluates it,
// so the two are orthogonal and this stays a correct STE.
const units = !!m.ctx.unitBackward;
const bmm = units
? (A, Bm, mm_, k, n) => vmm(A, Bm, mm_, k, n, m.ctx)
: async (A, Bm, mm_, k, n) => TC.matmul(A, Bm, mm_, k, n);
// batched: all `batch` problems in ONE dispatch (CUTLASS ex. 05/24). The
// per-head backward is 4 GEMMs x B x heads of tiny matrices; issued one at a
// time the GPU spends all its time on dispatch overhead rather than math.
const bmmB = units
? (A, Bm, rows, k, n, batch) =>
V.vgemmBlock(A, Bm, { m: rows, k, n, batch }, m.ctx.L, m.ctx.bgemm, m.ctx.audit)
: async (A, Bm, rows, k, n, batch) => {
const out = new Float32Array(batch * rows * n);
for (let bz = 0; bz < batch; bz++)
out.set(TC.matmul(A.subarray(bz * rows * k, (bz + 1) * rows * k),
Bm.subarray(bz * k * n, (bz + 1) * k * n), rows, k, n), bz * rows * n);
return out;
};
// tied unembed: logits = lnf @ embᵀ, so the unembedding gradient flows
// straight into emb — dlogitsᵀ @ lnf is V×C, emb's own shape
let dlnfIn;
if (m.ctx.fgemm2 && !units) {
// Both GEMMs consume dlogits (BT x vocab, ~17 MB at the 16512 vocab).
// fgemm2 uploads it ONCE and runs both on one submit — profiling had
// this pair at 55% of the step, over half of it re-uploading the same
// operand. Bit-identical to the two separate calls (gated at init).
[g[gi.emb], dlnfIn] = await m.ctx.fgemm2(
cache.dlogits,
cache.lnf.y, { m: vocab, k: BT, n: C, transA: true },
m.emb, { m: BT, k: vocab, n: C }); // split-K shape
} else if (m.ctx.fgemm && !units) {
[g[gi.emb], dlnfIn] = await Promise.all([
m.ctx.fgemm(cache.dlogits, cache.lnf.y, { m: vocab, k: BT, n: C, transA: true }),
m.ctx.fgemm(cache.dlogits, m.emb, { m: BT, k: vocab, n: C }), // split-K shape
]);
} else if (units && m.ctx.bgemm) {
// units + GPU: the g.emb operand is dlogitsᵀ (vocab×BT, ~4M elements), and
// tr() + quantizeRows() is three full passes over it in JS. Quantizing the
// COLUMNS of dlogits directly into transposed int8 is one pass and
// bit-identical: same |max| scan, same rounds, in the same order — only
// the write pattern changes. The GEMM itself still goes through ctx.bgemm
// (exact-gated), and the live-shape audit still samples it.
const quantizeColsAsRows = (X, rows, cols) => { // == quantizeRows(tr(X), cols, rows)
const q = new Int8Array(cols * rows), s = new Float32Array(cols);
for (let c = 0; c < cols; c++) {
let mx = 0;
for (let r = 0; r < rows; r++) { const a = Math.abs(X[r * cols + c]); if (a > mx) mx = a; }
const sc = Math.max(mx / 127, 1e-8); s[c] = sc;
for (let r = 0; r < rows; r++) {
const v = Math.round(X[r * cols + c] / sc);
q[c * rows + r] = v < -128 ? -128 : v > 127 ? 127 : v;
}
}
return { q, s };
};
const dlq = quantizeColsAsRows(cache.dlogits, BT, vocab); // dlogitsᵀ quantized, one pass
const wq2 = V.quantizeCols(cache.lnf.y, BT, C);
const dEmb = { m: vocab, k: BT, n: C, batch: 1 };
const [gEmb, dIn] = await Promise.all([
m.ctx.bgemm(dlq.q, wq2.q, dlq.s, wq2.s, dEmb),
bmm(cache.dlogits, m.emb, BT, vocab, C),
]);
if (m.ctx.audit && m.ctx.audit.due()) {
const bad = V.auditTile(dlq.q, wq2.q, dlq.s, wq2.s, dEmb, gEmb, m.ctx.L, m.ctx.audit.cells);
if (bad) m.ctx.audit.fail(bad);
}
g[gi.emb] = gEmb; dlnfIn = dIn;
} else {
// independent GEMMs — overlap them (these are the two vocab-sized calls,
// the largest in the whole backward; each is its own round trip)
[g[gi.emb], dlnfIn] = await Promise.all([
bmm(tr(cache.dlogits, BT, vocab), cache.lnf.y, vocab, BT, C),
bmm(cache.dlogits, m.emb, BT, vocab, C),
]);
}
let dx = lnBwd(dlnfIn, cache.lnf.y, cache.lnf.sig, BT, C);
const scale = 1 / Math.sqrt(hd);
// concat helper for fusing sibling GEMMs into one batched dispatch
const cat = (...arrs) => {
const out = new Float32Array(arrs.reduce((a, x) => a + x.length, 0));
let o = 0; for (const x of arrs) { out.set(x, o); o += x.length; }
return out;
};
for (let l = layers - 1; l >= 0; l--) {
const bl = m.blocks[l], cb = cache.blocks[l];
// mlp: x3 = x2 + relu(ln2 @ W1) @ W2
// gW2 and dh1 are independent — overlap their dispatches. On GPU each bmm
// is a full upload/submit/readback round trip, so sequential awaits leave
// the GPU idle between every pair; this is pure latency, not arithmetic,
// and each GEMM's int32 accumulation is exact so overlap changes no bit.
const dmlpOut = dx; // residual passthrough handled below
const [gW2, dh1] = await Promise.all([
bmm(tr(cb.h1, BT, hidden), dmlpOut, hidden, BT, C),
bmm(dmlpOut, tr(bl.W2, hidden, C), BT, C, hidden),
]);
g[gi[`b${l}.W2`]] = gW2;
for (let i = 0; i < dh1.length; i++) if (!cb.mask[i]) dh1[i] = 0;
const [gW1, dln2raw] = await Promise.all([
bmm(tr(cb.ln2.y, BT, C), dh1, C, BT, hidden),
bmm(dh1, tr(bl.W1, C, hidden), BT, hidden, C),
]);
g[gi[`b${l}.W1`]] = gW1;
const dln2in = lnBwd(dln2raw, cb.ln2.y, cb.ln2.sig, BT, C);
const dx2 = new Float32Array(BT * C);
for (let i = 0; i < dx2.length; i++) dx2[i] = dx[i] + dln2in[i];
// attention: x2 = xin + (ctxOut @ Wo)
const [gWo, dctx] = await Promise.all([
bmm(tr(cb.ctxOut, BT, C), dx2, C, BT, C),
bmm(dx2, tr(bl.Wo, C, C), BT, C, C),
]);
g[gi[`b${l}.Wo`]] = gWo;
// gather every head once, then run each stage as ONE batched GEMM over all
// B*heads problems: 4 dispatches per layer instead of 4 per head.
const BH = B * heads;
const qb = gatherHeads(cb.q, B, T, C, heads, hd);
const kb = gatherHeads(cb.k, B, T, C, heads, hd);
const vb = gatherHeads(cb.v, B, T, C, heads, hd);
const dchb = gatherHeads(dctx, B, T, C, heads, hd);
const aT = batchedTranspose(cb.aAll, BH, T, T); // BH×T×T
const vT = batchedTranspose(vb, BH, T, hd); // BH×hd×T
const [dvAll, daAll] = await Promise.all([
bmmB(aT, dchb, T, T, hd, BH), // aᵀ @ dctx
bmmB(dchb, vT, T, hd, T, BH), // dctx @ vᵀ
]);
// softmax backward is elementwise + a causal row reduction: stays in float
// (no matrix math here, so nothing for the units to do)
const dsAll = new Float32Array(BH * T * T);
for (let bz = 0; bz < BH; bz++) {
const o = bz * T * T;
for (let ti = 0; ti < T; ti++) {
let dot = 0;
for (let tj = 0; tj <= ti; tj++) dot += daAll[o + ti * T + tj] * cb.aAll[o + ti * T + tj];
for (let tj = 0; tj <= ti; tj++)
dsAll[o + ti * T + tj] = cb.aAll[o + ti * T + tj] * (daAll[o + ti * T + tj] - dot) * scale;
}
}
const dsT = batchedTranspose(dsAll, BH, T, T);
const [dqAll, dkAll] = await Promise.all([
bmmB(dsAll, kb, T, T, hd, BH), // ds @ k
bmmB(dsT, qb, T, T, hd, BH), // dsᵀ @ q
]);
const dq = new Float32Array(BT * C), dk = new Float32Array(BT * C), dv = new Float32Array(BT * C);
scatterHeadsAcc(dq, dqAll, B, T, C, heads, hd);
scatterHeadsAcc(dk, dkAll, B, T, C, heads, hd);
scatterHeadsAcc(dv, dvAll, B, T, C, heads, hd);
// The QKV weight grads share the same left operand (ln1ᵀ), and the three
// dln1in terms share one shape — each trio fuses into ONE batched GEMM
// (batch=3) instead of three dispatches. Bit-identical to separate calls:
// block scales are per-row of X and per-column of W PER BATCH ELEMENT, so
// concatenation changes no scale and no product.
const ln1T = tr(cb.ln1.y, BT, C);
const [gQKV, dIn3] = await Promise.all([
bmmB(cat(ln1T, ln1T, ln1T), cat(dq, dk, dv), C, BT, C, 3),
bmmB(cat(dq, dk, dv), cat(tr(bl.Wq, C, C), tr(bl.Wk, C, C), tr(bl.Wv, C, C)), BT, C, C, 3),
]);
const CC = C * C, BTC = BT * C;
g[gi[`b${l}.Wq`]] = gQKV.slice(0, CC);
g[gi[`b${l}.Wk`]] = gQKV.slice(CC, 2 * CC);
g[gi[`b${l}.Wv`]] = gQKV.slice(2 * CC, 3 * CC);
// sum the three dln1in terms in q,k,v order with an f32 round after EACH
// add — the old code accumulated into a Float32Array element three times,
// which rounds per step; a bare q+k+v here would run in f64 and round
// once, a last-ulp difference that forks replicas. (Exactly the epilogue
// mirror lesson: match the rounding schedule, not just the values.)
const dln1in = new Float32Array(BTC);
for (let i = 0; i < BTC; i++)
dln1in[i] = Math.fround(Math.fround(dIn3[i] + dIn3[BTC + i]) + dIn3[2 * BTC + i]);
const dxin = lnBwd(dln1in, cb.ln1.y, cb.ln1.sig, BT, C);
dx = new Float32Array(BT * C);
for (let i = 0; i < dx.length; i++) dx[i] = dx2[i] + dxin[i];
}
// embedding + positional
const ge = g[gi.emb], gp = g[gi.pos];
for (let i = 0; i < BT; i++) {
const id = cache.X[i], tpos = i % T;
for (let j = 0; j < C; j++) { ge[id * C + j] += dx[i * C + j]; gp[tpos * C + j] += dx[i * C + j]; }
}
// flatten
const flat = new Float32Array(m.nParams);
let off = 0;
for (const t of g) { flat.set(t, off); off += t.length; }
return flat;
}
async function trainStep(m) {
const { X, Y } = sampleBatch(m.cfg);
const { loss, cache } = await forward(m, X, Y);
const grad = await backward(m, cache);
return { loss, grad };
}
function applyUpdate(m, upd) { // W -= upd (lr folded in by the optimizer)
let off = 0;
for (const p of m.params) { for (let i = 0; i < p.length; i++) p[i] -= upd[off + i]; off += p.length; }
}
function getFlatParams(m) {
const flat = new Float32Array(m.nParams);
let off = 0;
for (const p of m.params) { flat.set(p, off); off += p.length; }
return flat;
}
function setFlatParams(m, flat) {
let off = 0;
for (const p of m.params) { p.set(flat.subarray(off, off + p.length)); off += p.length; }
}
// greedy sampling — watch the model actually speak
async function generate(m, prompt, nChars) {
const { t: T } = m.cfg;
let ids = [...encode(prompt)];
for (let step = 0; step < nChars; step++) {
const win = ids.slice(-T);
const X = new Int32Array(T), Y = new Int32Array(T);
for (let i = 0; i < win.length; i++) X[T - win.length + i] = win[i];
const save = m.cfg.b; m.cfg.b = 1;
const { logits } = await forward(m, X, Y);
m.cfg.b = save;
const row = (T - 1) * m.cfg.vocab;
let best = 0, bv = -1e30;
for (let j = 0; j < m.cfg.vocab; j++) if (logits[row + j] > bv) { bv = logits[row + j]; best = j; }
ids.push(best);
}
return decode(ids);
}
const api = { init, trainStep, applyUpdate, getFlatParams, setFlatParams, generate,
streamFineWebEdu, streamDataset, datasetName, loadTokenizer, loadTokenizerData,
vocabSize, tokenizerName, encode, decode };
if (typeof module !== "undefined" && module.exports) { TC = require("./traincore.js"); V = require("./verified_core.js"); module.exports = api; }
else { TC = root.TrainCore; V = root.Verified; root.Transformer = api; }
})(typeof self !== "undefined" ? self : this);