export class Tensor { // When false, operations skip building the graph: static gradEnabled = true; constructor(rows, columns) { this.rows = rows; this.columns = columns; this.data = new Float32Array(rows * columns); // zero-filled this.grad = null; // allocated on demand by backward() this._inputs = []; // tensors this one was built from this._backward = () => {}; // leaves do nothing } get(row, column) { // returns a plain number now, not a Value return this.data[row * this.columns + column]; } set(row, column, value) { this.data[row * this.columns + column] = value; } // --- Initialization: these create the model's leaf parameters. --- initToZeroes() { for (let i = 0; i < this.data.length; ++i) { this.data[i] = 0.0; } } initToOnes() { for (let i = 0; i < this.data.length; ++i) { this.data[i] = 1.0; } } initToSmallRandom() { const scale = 1 / Math.sqrt(this.rows); for (let i = 0; i < this.data.length; ++i) { this.data[i] = (Math.random() - 0.5) * 2 * scale; // uniform in [-scale, +scale) } } zeroGrad() { if (this.grad !== null) this.grad.fill(0); } parameters() { return [this]; // a tensor is one parameter block } // --- Element-wise operations (both tensors share the same shape). --- add(other) { const out = new Tensor(this.rows, this.columns); for (let i = 0; i < this.data.length; ++i) { out.data[i] = this.data[i] + other.data[i]; } if (Tensor.gradEnabled) { out._inputs = [this, other]; out._backward = () => { for (let i = 0; i < this.data.length; ++i) { this.grad[i] += out.grad[i]; other.grad[i] += out.grad[i]; } }; } return out; } sub(other) { const out = new Tensor(this.rows, this.columns); for (let i = 0; i < this.data.length; ++i) { out.data[i] = this.data[i] - other.data[i]; } if (Tensor.gradEnabled) { out._inputs = [this, other]; out._backward = () => { for (let i = 0; i < this.data.length; ++i) { this.grad[i] += out.grad[i]; other.grad[i] -= out.grad[i]; } }; } return out; } mul(other) { // element-wise multiply (matching numpy/pytorch); matrix multiply is matmul const out = new Tensor(this.rows, this.columns); for (let i = 0; i < this.data.length; ++i) { out.data[i] = this.data[i] * other.data[i]; } if (Tensor.gradEnabled) { out._inputs = [this, other]; out._backward = () => { for (let i = 0; i < this.data.length; ++i) { this.grad[i] += other.data[i] * out.grad[i]; other.grad[i] += this.data[i] * out.grad[i]; } }; } return out; } scale(scalar) { // multiply every value by one plain number const out = new Tensor(this.rows, this.columns); for (let i = 0; i < this.data.length; ++i) { out.data[i] = this.data[i] * scalar; } if (Tensor.gradEnabled) { out._inputs = [this]; out._backward = () => { for (let i = 0; i < this.data.length; ++i) { this.grad[i] += scalar * out.grad[i]; } }; } return out; } relu() { const out = new Tensor(this.rows, this.columns); for (let i = 0; i < this.data.length; ++i) { out.data[i] = this.data[i] > 0 ? this.data[i] : 0; } if (Tensor.gradEnabled) { out._inputs = [this]; out._backward = () => { for (let i = 0; i < this.data.length; ++i) { this.grad[i] += (this.data[i] > 0 ? 1 : 0) * out.grad[i]; } }; } return out; } gelu() { // Exact GELU: x * Phi(x), where Phi is the standard normal CDF. // Phi(x) = 0.5 * (1 + erf(x / sqrt(2))), and JS has no erf, so it's inlined. const SQRT_2 = Math.sqrt(2); const INV_SQRT_2PI = 1 / Math.sqrt(2 * Math.PI); // Abramowitz & Stegun 7.1.26 — erf to ~1e-7. const erf = (x) => { const sign = x < 0 ? -1 : 1; const ax = Math.abs(x); const t = 1 / (1 + 0.3275911 * ax); const y = 1 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-ax * ax); return sign * y; }; const out = new Tensor(this.rows, this.columns); for (let i = 0; i < this.data.length; ++i) { const x = this.data[i]; out.data[i] = x * 0.5 * (1 + erf(x / SQRT_2)); } if (Tensor.gradEnabled) { out._inputs = [this]; out._backward = () => { for (let i = 0; i < this.data.length; ++i) { const x = this.data[i]; const cdf = 0.5 * (1 + erf(x / SQRT_2)); const pdf = INV_SQRT_2PI * Math.exp(-0.5 * x * x); // d/dx [x * Phi(x)] = Phi(x) + x * phi(x) this.grad[i] += (cdf + x * pdf) * out.grad[i]; } }; } return out; } // --- Matrix multiplication: the one node that replaces the most scalars. --- matmul(other) { // (this.rows x this.columns) * (other.rows x other.columns) const m = this.rows; const k = this.columns; // shared inner dimension const n = other.columns; const out = new Tensor(m, n); for (let row = 0; row < m; ++row) { for (let col = 0; col < n; ++col) { let sum = 0.0; // accumulate in double, store as float for (let element = 0; element < k; ++element) { sum += this.data[row * k + element] * other.data[element * n + col]; } out.data[row * n + col] = sum; } } if (Tensor.gradEnabled) { out._inputs = [this, other]; out._backward = () => { // dThis = dOut * other^T for (let row = 0; row < m; ++row) { for (let p = 0; p < k; ++p) { let sum = 0.0; for (let col = 0; col < n; ++col) { sum += out.grad[row * n + col] * other.data[p * n + col]; } this.grad[row * k + p] += sum; } } // dOther = this^T * dOut for (let p = 0; p < k; ++p) { for (let col = 0; col < n; ++col) { let sum = 0.0; for (let row = 0; row < m; ++row) { sum += this.data[row * k + p] * out.grad[row * n + col]; } other.grad[p * n + col] += sum; } } }; } return out; } transposed() { const out = new Tensor(this.columns, this.rows); for (let row = 0; row < this.rows; ++row) { for (let col = 0; col < this.columns; ++col) { out.data[col * this.rows + row] = this.data[row * this.columns + col]; } } if (Tensor.gradEnabled) { out._inputs = [this]; out._backward = () => { for (let row = 0; row < this.rows; ++row) { for (let col = 0; col < this.columns; ++col) { this.grad[row * this.columns + col] += out.grad[col * this.rows + row]; } } }; } return out; } // --- Fused operations. Each is one node with a single analytic backward // rule instead of being assembled from many smaller nodes, which is // both faster and far lighter on memory. These are general array // operations, so they live here. RMS normalization and the // cross-entropy loss are also fused, but each belongs to one component // (the norm layer, the trainer), so they are defined there using the // same _inputs/_backward protocol. --- softMaxedRows() { const rows = this.rows; const cols = this.columns; const out = new Tensor(rows, cols); for (let row = 0; row < rows; ++row) { const base = row * cols; let rowMax = this.data[base]; for (let col = 1; col < cols; ++col) { const v = this.data[base + col]; if (v > rowMax) rowMax = v; } let rowSum = 0.0; for (let col = 0; col < cols; ++col) { const e = Math.exp(this.data[base + col] - rowMax); out.data[base + col] = e; rowSum += e; } for (let col = 0; col < cols; ++col) { out.data[base + col] /= rowSum; } } if (Tensor.gradEnabled) { out._inputs = [this]; out._backward = () => { // For each row: dx_j = p_j * (g_j - sum_k g_k p_k) for (let row = 0; row < rows; ++row) { const base = row * cols; let dot = 0.0; for (let col = 0; col < cols; ++col) { dot += out.grad[base + col] * out.data[base + col]; } for (let col = 0; col < cols; ++col) { const p = out.data[base + col]; this.grad[base + col] += p * (out.grad[base + col] - dot); } } }; } return out; } causalMasked() { const rows = this.rows; const cols = this.columns; const out = new Tensor(rows, cols); for (let row = 0; row < rows; ++row) { for (let col = 0; col < cols; ++col) { const index = row * cols + col; out.data[index] = col > row ? -Infinity : this.data[index]; } } if (Tensor.gradEnabled) { out._inputs = [this]; out._backward = () => { // Masked cells are constants, so gradient only flows where col <= row. for (let row = 0; row < rows; ++row) { for (let col = 0; col <= row; ++col) { const index = row * cols + col; this.grad[index] += out.grad[index]; } } }; } return out; } // Embedding lookup: copy the rows named by `indices` into a new tensor. // The gradient scatters back, accumulating when an index repeats. gatherRows(indices) { const out = new Tensor(indices.length, this.columns); for (let r = 0; r < indices.length; ++r) { const src = indices[r] * this.columns; const dst = r * this.columns; for (let col = 0; col < this.columns; ++col) { out.data[dst + col] = this.data[src + col]; } } if (Tensor.gradEnabled) { out._inputs = [this]; out._backward = () => { for (let r = 0; r < indices.length; ++r) { const src = indices[r] * this.columns; const dst = r * this.columns; for (let col = 0; col < this.columns; ++col) { this.grad[src + col] += out.grad[dst + col]; } } }; } return out; } // Stack another tensor's rows below this one's, returning a new, taller // tensor. Both must share the same column count. The KV cache uses this to // grow one row per generated token. The gradient of the top rows flows back // to this, the bottom rows to other. Generation runs with the graph off, so // that rule never fires in practice, but it keeps appendRow a first-class op. appendRow(other) { // A new tensor: this tensor's rows on top, other's rows stacked below. // Both must share the same column count. const out = new Tensor(this.rows + other.rows, this.columns); out.data.set(this.data, 0); // top block out.data.set(other.data, this.data.length); // bottom block if (Tensor.gradEnabled) { out._inputs = [this, other]; out._backward = () => { // Gradient of the top rows flows to this, the bottom rows to other. for (let i = 0; i < this.data.length; ++i) { this.grad[i] += out.grad[i]; } for (let i = 0; i < other.data.length; ++i) { other.grad[i] += out.grad[this.data.length + i]; } }; } return out; } // RoPE: rotate each row's component pairs by an angle set by the row's // position. Query and key rows sit in sequence order, so the row index is // the position — plus positionOffset, which the KV cache uses to rotate a // new token by its true position rather than its row (offset zero, the // default, reproduces the original behavior). The angles are fixed functions // of position and frequency, not learned, so this op adds nothing to // parameters(); the gradient only routes back to the input. The cosine and // sine of each angle are cached, since the backward pass — a rotation by the // negated angle — reuses them. ropeRotated(thetaBase = 10000, positionOffset = 0) { // positionOffset is new const rows = this.rows; const cols = this.columns; // head_dim, must be even const halfCols = cols / 2; const out = new Tensor(rows, cols); // Frequency depends on the pair, not the row // Compute it once and cache const freqs = new Float32Array(halfCols); for (let pair = 0; pair < halfCols; ++pair) { freqs[pair] = 1.0 / Math.pow(thetaBase, (2 * pair) / cols); } // Cached for the backward pass. const cosTable = new Float32Array(rows * halfCols); const sinTable = new Float32Array(rows * halfCols); for (let row = 0; row < rows; ++row) { const base = row * cols; const trig = row * halfCols; for (let pair = 0; pair < halfCols; ++pair) { const angle = (positionOffset + row) * freqs[pair]; // true position = offset + row const cos = Math.cos(angle); const sin = Math.sin(angle); cosTable[trig + pair] = cos; sinTable[trig + pair] = sin; const a = this.data[base + 2 * pair]; const b = this.data[base + 2 * pair + 1]; out.data[base + 2 * pair] = a * cos - b * sin; out.data[base + 2 * pair + 1] = a * sin + b * cos; } } if (Tensor.gradEnabled) { out._inputs = [this]; out._backward = () => { // Rotation is linear, the gradient is the inverse rotation. for (let row = 0; row < rows; ++row) { const base = row * cols; const trig = row * halfCols; for (let pair = 0; pair < halfCols; ++pair) { const cos = cosTable[trig + pair]; const sin = sinTable[trig + pair]; const ga = out.grad[base + 2 * pair]; const gb = out.grad[base + 2 * pair + 1]; this.grad[base + 2 * pair] += ga * cos + gb * sin; this.grad[base + 2 * pair + 1] += -ga * sin + gb * cos; } } }; } return out; } backward() { // Topological sort: inputs come before the nodes built from them. const topo = []; const visited = new Set(); const visit = (node) => { if (visited.has(node)) return; visited.add(node); for (let i = 0; i < node._inputs.length; ++i) { visit(node._inputs[i]); } topo.push(node); }; visit(this); // Make sure every node in the graph has a gradient buffer. Freshly // built tensors start at null and get a zeroed buffer here. Parameter // tensors keep the buffer the trainer already zeroed. for (let i = 0; i < topo.length; ++i) { if (topo[i].grad === null) { topo[i].grad = new Float32Array(topo[i].data.length); } } // Seed: derivative of the output with respect to itself is 1. this.grad.fill(1); // Walk in reverse. Each node pushes gradient onto its inputs. for (let i = topo.length - 1; i >= 0; --i) { topo[i]._backward(); } } } export class Tokenizer { constructor() { // Merge rules array. // Index = token id. // Value = the two token ids that merge into index. this.merges = new Array(); // Reserved tokens, an array of strings this.reserved = new Array(); // Seed first 256 tokens as placeholders. // These merge rules are not actually valid. for (let i = 0; i < 256; ++i) { this.merges.push([i, i]); } } vocabSize() { return this.merges.length; } encode(text) { // string -> Array let bytes = new TextEncoder().encode(text); // Apply every merge rule, in order for (let rule = 256; rule < this.merges.length; ++rule) { bytes = this._merge(bytes, this.merges[rule][0], this.merges[rule][1], rule); } return bytes; } // Given a list of tokens, replace every instance of firstToken and secondToken with repalcementToken _merge(listOfTokens, firstToken, secondToken, replacementToken) { const result = new Array(); for (let i = 0; i < listOfTokens.length; ++i) { if (listOfTokens[i] == firstToken) { if (i + 1 < listOfTokens.length) { if (listOfTokens[i + 1] == secondToken) { result.push(replacementToken); i += 1; continue; } } } result.push(listOfTokens[i]); } return result; } decode(ids) { // Array -> string const bytes = []; for (let i = 0; i < ids.length; ++i) { const stack = [ids[i]]; while (stack.length > 0) { const id = stack.pop(); if (id < 256) { bytes.push(id); } else { const pair = this.merges[id]; stack.push(pair[1]); // second half pushed FIRST, stack.push(pair[0]); // so first half pops first } } } return new TextDecoder().decode(new Uint8Array(bytes)); } reserve(text) { // string -> id; idempotent, call before train // 1. Apply every existing merge, in order. This is the dedupe: anything already learnable collapses now. let bytes = this.encode(text); // 2. Teach the leftovers, left to right. Each new rule's id is just its index. let id = bytes[0]; for (let i = 1; i < bytes.length; ++i) { this.merges.push([id, bytes[i]]); id = this.merges.length - 1; } this.reserved.push(text); return id; } // inputText -> "Long text" // returns -> ["Long", " text"] _split(inputText) { // Splits long text into an array of text chunks // Sort this.reserved by reverse length, so _matchKeyword can match longest first this.reserved.sort((a, b) => b.length - a.length); const _matchKeyword = (text, position, keywords) => { for (const keyword of keywords) { if (text.startsWith(keyword, position)) { return keyword; } } return null; } const _isLetter = (char) => { return char !== undefined && char.toLowerCase() !== char.toUpperCase(); } const _isDigit = (char) => { return char >= '0' && char <= '9'; } const chunks = []; let i = 0; while (i < inputText.length) { // Rule 1: keywords always win, they are atomic const keyword = _matchKeyword(inputText, i, this.reserved); if (keyword !== null) { chunks.push(keyword); i += keyword.length; continue; } const char = inputText[i]; // Rule 2: a word, optionally carrying ONE leading space if (_isLetter(char) || (char === ' ' && _isLetter(inputText[i + 1]))) { let chunk = char; i++; while (i < inputText.length && _isLetter(inputText[i])) { chunk += inputText[i]; i++; } chunks.push(chunk); continue; } // Rule 3: digits, grouped to at most 3 if (_isDigit(char)) { let chunk = ''; while (chunk.length < 3 && _isDigit(inputText[i])) { chunk += inputText[i]; i++; } chunks.push(chunk); continue; } // Rule 4: anything else (punctuation, leftover spaces) is its own chunk chunks.push(char); i++; } return chunks; } train(text, targetVocabSize) { // learns merges const splitText = this._split(text); const chunks = new Array(splitText.length); for (let i = 0; i < splitText.length; ++i) { chunks[i] = this.encode(splitText[i]); } // while vocabSize < targetVocabSize while (this.merges.length < targetVocabSize) { const pairs = new Map(); // Count pairs in all chunks for (let i = 0; i < chunks.length; ++i) { const chunk = chunks[i]; for (let j = 0; j < chunk.length - 1; ++j) { const key = chunk[j] + ',' + chunk[j + 1]; const entry = pairs.get(key); if (entry) { entry.count += 1; } else { pairs.set(key, { count: 1, firstToken: chunk[j], secondToken: chunk[j + 1] }); } } } // Pick Best Pair let best = null; for (const entry of pairs.values()) { if (best === null || entry.count > best.count) { best = entry; } } // Early out if (best == null) { break; // No best pair, all merged } else if (best.count < 2) { // If the best token was only seen once break; } // Record new rule this.merges.push([best.firstToken, best.secondToken]); const newToken = this.merges.length - 1; // Apply new rule to each chunk for (let i = 0; i < chunks.length; ++i) { chunks[i] = this._merge(chunks[i], best.firstToken, best.secondToken, newToken); } } } serializeToJSON() { // -> string return JSON.stringify({ reserved: this.reserved, merges: this.merges.slice(256), // ids 0-255 are seeded, never saved }); } deserializeFromJSON(json) { // string -> void; replaces all state const data = JSON.parse(json); // Restore the freshly-constructed state: 256 byte tokens, nothing else. this.merges.length = 256; this.reserved = data.reserved; // Pushing the pairs back in order reproduces every id exactly for (let i = 0; i < data.merges.length; ++i) { this.merges.push(data.merges[i]); } } } // A key-value cache holds one key tensor and one value tensor for every block, // for every head. The model reads and writes it but does not own it: the caller // creates one and passes it in, so a single MiniGPT can serve many independent // generations at once, each with its own cache. export class KVCache { constructor(numBlocks, numHeads) { // One key tensor and one value tensor per block, per head. Each starts // null and becomes a growing tensor as tokens flow through. this.keys = []; this.values = []; for (let block = 0; block < numBlocks; ++block) { const blockKeys = []; const blockValues = []; for (let head = 0; head < numHeads; ++head) { blockKeys.push(null); blockValues.push(null); } this.keys.push(blockKeys); this.values.push(blockValues); } } // Tokens cached so far. Every block and head advances together, so the // first head's key tensor speaks for all of them. get length() { const first = this.keys[0][0]; return first === null ? 0 : first.rows; } } export class RMSNorm { constructor(feature_dim) { // Learned per-feature scale, applied after normalization. Starts at one, // so the layer begins as a plain normalize until training moves it. this.gamma = new Tensor(1, feature_dim); this.gamma.initToOnes(); } forward(x) { const rows = x.rows; const n = x.columns; const eps = 1e-5; const gamma = this.gamma; const out = new Tensor(rows, n); const invRmsByRow = new Float32Array(rows); // cached for the backward for (let row = 0; row < rows; ++row) { const base = row * n; let sumSq = 0.0; for (let col = 0; col < n; ++col) { const v = x.data[base + col]; sumSq += v * v; } const invRms = 1 / Math.sqrt(sumSq / n + eps); invRmsByRow[row] = invRms; for (let col = 0; col < n; ++col) { // normalize, then scale by the per-feature gamma out.data[base + col] = x.data[base + col] * invRms * gamma.data[col]; } } if (Tensor.gradEnabled) { out._inputs = [x, gamma]; out._backward = () => { for (let row = 0; row < rows; ++row) { const base = row * n; const invRms = invRmsByRow[row]; const invRms3 = invRms * invRms * invRms; // Upstream gradient passes through gamma before the RMS rule. let dotGX = 0.0; for (let col = 0; col < n; ++col) { const gNorm = out.grad[base + col] * gamma.data[col]; dotGX += gNorm * x.data[base + col]; } for (let col = 0; col < n; ++col) { const g = out.grad[base + col]; const xv = x.data[base + col]; const gNorm = g * gamma.data[col]; x.grad[base + col] += gNorm * invRms - (xv * dotGX * invRms3) / n; // gamma is shared across rows: sum the normalized input times upstream grad gamma.grad[col] += xv * invRms * g; } } }; } return out; } parameters() { return [this.gamma]; } } export class MultiHeadAttention { constructor(feature_dim, num_heads, rope_base) { this.num_heads = num_heads; this.head_dim = feature_dim / num_heads; // must divide evenly, and be even for RoPE this.rope_base = rope_base; // Each head owns Q, K, V down to head_dim, and an output projection // back up to feature_dim. The summed outputs equal a concat plus one // big output projection, so no concat operation is needed. this.heads = []; for (let i = 0; i < num_heads; ++i) { const learnedQ = new Tensor(feature_dim, this.head_dim); learnedQ.initToSmallRandom(); const learnedK = new Tensor(feature_dim, this.head_dim); learnedK.initToSmallRandom(); const learnedV = new Tensor(feature_dim, this.head_dim); learnedV.initToSmallRandom(); const learnedO = new Tensor(this.head_dim, feature_dim); learnedO.initToSmallRandom(); this.heads.push({ learnedQ, learnedK, learnedV, learnedO }); } } forward(rms_norm_matrix, cache = null, layerIndex = 0) { // Shape: new_tokens, feature_dim const scale = 1.0 / Math.sqrt(this.head_dim); let output = null; for (let i = 0; i < this.num_heads; ++i) { const head = this.heads[i]; let Q = rms_norm_matrix.matmul(head.learnedQ); // Shape: new_tokens, head_dim let K = rms_norm_matrix.matmul(head.learnedK); // Shape: new_tokens, head_dim let V = rms_norm_matrix.matmul(head.learnedV); // Shape: new_tokens, head_dim // The new tokens sit after whatever this head has already cached. const pastK = cache === null ? null : cache.keys[layerIndex][i]; const pastV = cache === null ? null : cache.values[layerIndex][i]; const positionOffset = pastK === null ? 0 : pastK.rows; // Rotate by true position. Cached keys keep the rotation they were // stored with; the new keys are rotated from the offset onward. Q = Q.ropeRotated(this.rope_base, positionOffset); K = K.ropeRotated(this.rope_base, positionOffset); // Grow the cache with the new keys and values, then attend over the // full history. Without a cache, K and V are just the new tokens. if (cache !== null) { K = pastK === null ? K : pastK.appendRow(K); V = pastV === null ? V : pastV.appendRow(V); cache.keys[layerIndex][i] = K; cache.values[layerIndex][i] = V; } let scores = Q.matmul(K.transposed()); // Shape: new_tokens, total_tokens scores = scores.scale(scale); // The prompt's first pass scores a square block and needs the mask. // Every later token sees only its past, so no mask is needed. if (positionOffset === 0) { scores = scores.causalMasked(); } const probabilities = scores.softMaxedRows(); const mixed = probabilities.matmul(V); // Shape: new_tokens, head_dim // Project this head up to feature_dim and accumulate. const projected = mixed.matmul(head.learnedO); // Shape: new_tokens, feature_dim output = output === null ? projected : output.add(projected); } return output; // Shape: new_tokens, feature_dim } parameters() { const params = []; for (let i = 0; i < this.heads.length; ++i) { const head = this.heads[i]; const matrices = [head.learnedQ, head.learnedK, head.learnedV, head.learnedO]; for (let j = 0; j < matrices.length; ++j) { const matParams = matrices[j].parameters(); for (let k = 0; k < matParams.length; ++k) { params.push(matParams[k]); } } } return params; } } export class MLP { constructor(feature_dim_size, hidden_dim_size) { this.learnedUp = new Tensor(feature_dim_size, hidden_dim_size); this.learnedUp.initToSmallRandom(); this.learnedDown = new Tensor(hidden_dim_size, feature_dim_size); this.learnedDown.initToSmallRandom(); // Per-neuron bias on the hidden layer, added to each pre-activation // before GELU. Starts at zero, so the layer begins as it did without it. this.bias = new Tensor(1, hidden_dim_size); this.bias.initToZeroes(); } forward(x) { // Shape: sequence_length, feature_dim const up = x.matmul(this.learnedUp); // Shape: sequence_length, hidden_dim // Shift each pre-activation by its neuron's bias, broadcast across // every row. Fused into one node so the shared bias gradient sums // down each column in a single pass. const bias = this.bias; const rows = up.rows; const cols = up.columns; const preActivation = new Tensor(rows, cols); for (let row = 0; row < rows; ++row) { const base = row * cols; for (let col = 0; col < cols; ++col) { preActivation.data[base + col] = up.data[base + col] + bias.data[col]; } } if (Tensor.gradEnabled) { preActivation._inputs = [up, bias]; preActivation._backward = () => { for (let row = 0; row < rows; ++row) { const base = row * cols; for (let col = 0; col < cols; ++col) { const g = preActivation.grad[base + col]; up.grad[base + col] += g; // bias is shared by every row, so its gradient sums down the column. bias.grad[col] += g; } } }; } // Bend with GELU, then project back down. return preActivation.gelu().matmul(this.learnedDown); // Shape: sequence_length, feature_dim } parameters() { const params = []; const tensors = [this.learnedUp, this.learnedDown, this.bias]; for (let i = 0; i < tensors.length; ++i) { const sub = tensors[i].parameters(); for (let j = 0; j < sub.length; ++j) { params.push(sub[j]); } } return params; } } export class TransformerBlock { constructor(feature_dim, num_heads, rope_base) { // MLP usually expands to 4x the feature dimension. const hidden_dim = feature_dim * 4; this.attentionNorm = new RMSNorm(feature_dim); this.attention = new MultiHeadAttention(feature_dim, num_heads, rope_base); this.mlpNorm = new RMSNorm(feature_dim); this.mlp = new MLP(feature_dim, hidden_dim); } forward(x, cache = null, layerIndex = 0) { // cache and layerIndex are new // Attention sub-layer, with residual. The cache and layer index pass // straight through to attention; the MLP and norms never mix tokens, // so they have nothing to cache. const attentionInput = this.attentionNorm.forward(x); const attentionDelta = this.attention.forward(attentionInput, cache, layerIndex); const afterAttention = x.add(attentionDelta); // MLP sub-layer, with residual const mlpInput = this.mlpNorm.forward(afterAttention); const mlpDelta = this.mlp.forward(mlpInput); const afterMLP = afterAttention.add(mlpDelta); return afterMLP; } parameters() { const params = []; const components = [this.attentionNorm, this.attention, this.mlpNorm, this.mlp]; for (let i = 0; i < components.length; ++i) { const sub = components[i].parameters(); for (let j = 0; j < sub.length; ++j) { params.push(sub[j]); } } return params; } } export class MiniGPT { constructor(vocabSize, featureDim, numHeads, ropeBase, numBlocks) { this.vocabSize = vocabSize; this.featureDim = featureDim; this.numHeads = numHeads; this.ropeBase = ropeBase; // replaces maxContextLength this.numBlocks = numBlocks; // Token embedding table (position now enters through rotation in attention). this.tokenEmbeddings = new Tensor(vocabSize, featureDim); this.tokenEmbeddings.initToSmallRandom(); // Stack of transformer blocks. this.blocks = []; for (let i = 0; i < numBlocks; ++i) { this.blocks.push(new TransformerBlock(featureDim, numHeads, ropeBase)); // heads and base handed down } // Final norm before the unembedding. this.finalNorm = new RMSNorm(featureDim); } embed(ids) { // Token embeddings only; position now enters through rotation in attention return this.tokenEmbeddings.gatherRows(ids); } forward(tokenIdArray, cache = null) { // cache is new // No maxContextLength check: there is no position table to index past. // A sequence beyond the trained length just runs slower and predicts worse. // With a cache, tokenIdArray holds only the new tokens; the block index // threads through so each layer reads and writes its own cached slot. let x = this.embed(tokenIdArray); // Shape: new_tokens, feature_dim for (let i = 0; i < this.blocks.length; ++i) { x = this.blocks[i].forward(x, cache, i); // Shape preserved. } x = this.finalNorm.forward(x); // Shape: new_tokens, feature_dim // Tied unembedding: (sequence_length, feature_dim) * (feature_dim, vocabSize) const logits = x.matmul(this.tokenEmbeddings.transposed()); // Shape: sequence_length, vocabSize return logits; } // Predict the next token. Temperature reshapes the distribution, top-k and // top-p rule out the unlikely tail, and an optional KV cache skips rerunning // tokens that were processed on an earlier call. predictNextToken(tokenIdArray, temperature = 1.0, topK = 50, topP = 0.9, cache = null) { // With a cache, the stored tokens do not run again. Only the tokens // past the cached length are new. const newTokens = cache === null ? tokenIdArray : tokenIdArray.slice(cache.length); const logits = this.forward(newTokens, cache); // was forward(tokenIdArray) // The last row holds the scores for the token after the sequence, // whether that is the last row of a full prompt or the only row of a // single cached step. const lastRow = logits.rows - 1; // Temperature zero means greedy decoding: the single highest logit. // Softmax is monotonic, so the argmax of the logits is the argmax of // the probabilities, and the softmax can be skipped here. if (temperature === 0) { let bestId = 0; let bestScore = logits.get(lastRow, 0); for (let col = 1; col < this.vocabSize; ++col) { const score = logits.get(lastRow, col); if (score > bestScore) { bestScore = score; bestId = col; } } return bestId; } // Reshape by temperature and turn the last row into probabilities. const scaled = logits.scale(1 / temperature); const probabilities = scaled.softMaxedRows(); // Rank the tokens from most to least likely so the worst can be cut. const ranked = []; for (let col = 0; col < this.vocabSize; ++col) { ranked.push({ id: col, probability: probabilities.get(lastRow, col) }); } ranked.sort((a, b) => b.probability - a.probability); // top-k: keep only the k most likely tokens. const capped = ranked.slice(0, topK); // top-p: from the top, keep tokens until their combined probability // crosses topP, then stop. Always keeps at least the single top token. const kept = []; let keptTotal = 0.0; for (let i = 0; i < capped.length; ++i) { kept.push(capped[i]); keptTotal += capped[i].probability; if (keptTotal >= topP) { break; } } // The survivors cover only part of the number line now, up to keptTotal. // Draw a random number up to that total, then walk them as before. const draw = Math.random() * keptTotal; let cumulative = 0.0; for (let i = 0; i < kept.length; ++i) { cumulative += kept[i].probability; if (draw < cumulative) { return kept[i].id; } } return kept[kept.length - 1].id; // guard against floating-point drift } parameters() { const params = []; // Token embedding table (also used tied as the unembedding). const tokenParams = this.tokenEmbeddings.parameters(); for (let i = 0; i < tokenParams.length; ++i) { params.push(tokenParams[i]); } // Per-block parameters. for (let i = 0; i < this.blocks.length; ++i) { const blockParams = this.blocks[i].parameters(); for (let j = 0; j < blockParams.length; ++j) { params.push(blockParams[j]); } } // Final norm now carries a learned scale (gamma), gathered like the rest. const normParams = this.finalNorm.parameters(); for (let i = 0; i < normParams.length; ++i) { params.push(normParams[i]); } return params; } serializeToArrayBuffer() { const params = this.parameters(); // Each parameter is a tensor with many values, so size the buffer to the // total number of values across all tensors. let total = 0; for (const t of params) { total += t.data.length; } const view = new Float32Array(total); let offset = 0; for (const t of params) { view.set(t.data, offset); offset += t.data.length; } return view.buffer; } deserializeFromArrayBuffer(buffer) { const params = this.parameters(); const view = new Float32Array(buffer); let offset = 0; for (const t of params) { t.data.set(view.subarray(offset, offset + t.data.length)); offset += t.data.length; } } } export class AdamWTrainer { constructor(model, { maxLearningRate = 3e-4, minLearningRate = 3e-5, warmupSteps = 100, totalSteps = 10000, beta1 = 0.9, beta2 = 0.999, epsilon = 1e-8, weightDecay = 0.01, gradientClip = 1.0, } = {}) { this.model = model; this.maxLearningRate = maxLearningRate; this.minLearningRate = minLearningRate; this.warmupSteps = warmupSteps; this.totalSteps = totalSteps; this.beta1 = beta1; this.beta2 = beta2; this.epsilon = epsilon; this.weightDecay = weightDecay; this.gradientClip = gradientClip; // Grab the parameter list once. The tensors inside are stable references, // so the moment buffers below stay matched to them for the whole run. this.params = model.parameters(); // Adam keeps two running averages per parameter, each the same shape as // the parameter itself. A fresh Float32Array is already all zeros. this.firstMoments = []; this.secondMoments = []; for (let i = 0; i < this.params.length; ++i) { const length = this.params[i].data.length; this.firstMoments.push(new Float32Array(length)); // m this.secondMoments.push(new Float32Array(length)); // v } // Updates applied so far. Drives both bias correction and the schedule. this.step = 0; // The learning rate actually used on the last step, exposed for logging. this.lastLearningRate = 0; } // Fused mean cross-entropy. The forward pass uses the log-sum-exp form for // each row of logits, which avoids exponentiating large values directly: // for a row with correct token t, the loss is log(sum_j e^{x_j}) - x_t. // The softmax probabilities are computed along the way and cached, because // the backward rule reuses them. crossEntropyLoss(logits, targetIds, targetMask = null) { // mask: new argument const rows = logits.rows; const cols = logits.columns; const probs = new Float32Array(rows * cols); // cached for the backward let total = 0.0; let count = 0; // mask: unmasked rows — the divisor for the mean for (let row = 0; row < rows; ++row) { // mask: a context-only row contributes no loss and no gradient. // Skip it entirely; its probs are never read. if (targetMask !== null && !targetMask[row]) { continue; } const base = row * cols; let rowMax = logits.data[base]; for (let col = 1; col < cols; ++col) { const v = logits.data[base + col]; if (v > rowMax) rowMax = v; } let sum = 0.0; for (let col = 0; col < cols; ++col) { const e = Math.exp(logits.data[base + col] - rowMax); probs[base + col] = e; sum += e; } for (let col = 0; col < cols; ++col) { probs[base + col] /= sum; } const logSumExp = rowMax + Math.log(sum); total += logSumExp - logits.data[base + targetIds[row]]; count += 1; // mask: this row counted toward the mean } if (count === 0) { throw new Error("No unmasked targets were provided"); } const loss = new Tensor(1, 1); loss.data[0] = total / count; // mask: divide by unmasked count, not rows if (Tensor.gradEnabled) { loss._inputs = [logits]; loss._backward = () => { const seed = loss.grad[0] / count; // mask: count, not rows for (let row = 0; row < rows; ++row) { // mask: skipped rows got no probs, so they get no gradient. if (targetMask !== null && !targetMask[row]) { continue; } const base = row * cols; for (let col = 0; col < cols; ++col) { logits.grad[base + col] += seed * probs[base + col]; } logits.grad[base + targetIds[row]] -= seed; } }; } return loss; } learningRate() { const step = this.step; // Warmup: ramp linearly from zero to the peak over the first steps. if (step < this.warmupSteps) { return this.maxLearningRate * (step / this.warmupSteps); } // Past the planned end: hold at the floor. if (step >= this.totalSteps) { return this.minLearningRate; } // Cosine decay: ease from the peak down to the floor. const progress = (step - this.warmupSteps) / (this.totalSteps - this.warmupSteps); const cosine = 0.5 * (1 + Math.cos(Math.PI * progress)); return this.minLearningRate + (this.maxLearningRate - this.minLearningRate) * cosine; } clipGradients() { // Global L2 norm across every gradient in the model. let sumSquares = 0.0; for (let i = 0; i < this.params.length; ++i) { const grad = this.params[i].grad; for (let j = 0; j < grad.length; ++j) { sumSquares += grad[j] * grad[j]; } } const norm = Math.sqrt(sumSquares); // Only scale down when the norm is over the threshold. A smaller // update is left alone. if (norm > this.gradientClip) { const scale = this.gradientClip / norm; for (let i = 0; i < this.params.length; ++i) { const grad = this.params[i].grad; for (let j = 0; j < grad.length; ++j) { grad[j] *= scale; } } } return norm; // useful for logging } train(tokenIds, tokenMask = null) { const inputIds = tokenIds.slice(0, tokenIds.length - 1); const targetIds = tokenIds.slice(1); // The mask lines up with tokenIds, so shift it like the targets. The // first token is never a target, so its mask is dropped. const targetMask = tokenMask === null ? null : tokenMask.slice(1); // Clear last step's gradients. for (let i = 0; i < this.params.length; ++i) { this.params[i].zeroGrad(); } // Forward, loss, backward — same as the SGD trainer. const logits = this.model.forward(inputIds); const loss = this.crossEntropyLoss(logits, targetIds, targetMask); loss.backward(); // Rein in the occasional oversized gradient before it moves anything. this.clipGradients(); // Advance the step, then read this step's scheduled learning rate. this.step += 1; const learningRate = this.learningRate(); this.lastLearningRate = learningRate; // Cache hyperparameters as locals for the inner loop. const beta1 = this.beta1; const beta2 = this.beta2; const epsilon = this.epsilon; const weightDecay = this.weightDecay; // Bias-correction denominators for this step. const correction1 = 1 - Math.pow(beta1, this.step); const correction2 = 1 - Math.pow(beta2, this.step); // AdamW update, parameter by parameter. for (let i = 0; i < this.params.length; ++i) { const param = this.params[i]; const m = this.firstMoments[i]; const v = this.secondMoments[i]; for (let j = 0; j < param.data.length; ++j) { const g = param.grad[j]; const weight = param.data[j]; // Update the running averages of the gradient and its square. m[j] = beta1 * m[j] + (1 - beta1) * g; v[j] = beta2 * v[j] + (1 - beta2) * g * g; // Undo the startup bias toward zero. const mHat = m[j] / correction1; const vHat = v[j] / correction2; // The Adam step: smoothed direction, per-parameter scale. const adamStep = mHat / (Math.sqrt(vHat) + epsilon); // Decoupled weight decay: pull the weight toward zero on its // own, not routed through the adaptive scaling above. const decayStep = weightDecay * weight; param.data[j] = weight - learningRate * (adamStep + decayStep); } } return loss.data[0]; } }