nanofable / js /model.js
Adit Rahman
NanoFable static demo: all 8 packs served same-origin
7b33d33
Raw
History Blame Contribute Delete
8.32 kB
// Client-side forward pass for the nanofable decoder-only transformer, mirroring
// src/nanofable/model.py exactly: pre-norm RMSNorm (eps 1e-5), NeoX RoPE (base 10000),
// causal attention (1/sqrt(head_dim)), SwiGLU, tied embedding/LM head.
// Single-token steps with a KV cache; prefill is the same step run per prompt token.
const EPS = 1e-5;
// y = W x, W row-major [nOut, nIn]; ternary weights dispatch on .trits.
function linear(out, x, W, nOut, nIn) {
const w = W.trits ?? W.f32;
const scale = W.trits ? W.scale : 1;
for (let o = 0; o < nOut; o++) {
let acc = 0;
const row = o * nIn;
for (let i = 0; i < nIn; i++) acc += x[i] * w[row + i];
out[o] = acc * scale;
}
}
function rmsnorm(out, x, gain, n) {
let ss = 0;
for (let i = 0; i < n; i++) ss += x[i] * x[i];
const inv = 1 / Math.sqrt(ss / n + EPS);
for (let i = 0; i < n; i++) out[i] = x[i] * inv * gain[i];
}
function silu(v) {
return v / (1 + Math.exp(-v));
}
export class Model {
constructor({ header, tensors }) {
const cfg = header.config;
this.cfg = cfg;
this.headDim = cfg.n_embd / cfg.n_head;
const get = (name) => {
const t = tensors.get(name);
if (!t) throw new Error(`pack missing tensor ${name}`);
return t;
};
this.tokEmb = get("tok_emb.weight").f32; // [vocab, n_embd], also the tied head
this.finalNorm = get("final_norm.weight").f32;
this.layers = [];
for (let i = 0; i < cfg.n_layer; i++) {
const p = `blocks.${i}.`;
this.layers.push({
attnNorm: get(p + "attn_norm.weight").f32,
q: get(p + "attn.q.weight"), k: get(p + "attn.k.weight"),
v: get(p + "attn.v.weight"), o: get(p + "attn.o.weight"),
mlpNorm: get(p + "mlp_norm.weight").f32,
gate: get(p + "mlp.gate.weight"), up: get(p + "mlp.up.weight"),
down: get(p + "mlp.down.weight"),
});
}
this.mlpHidden = get("blocks.0.mlp.gate.weight").trits
? tensors.get("blocks.0.mlp.gate.weight").trits.length / cfg.n_embd
: tensors.get("blocks.0.mlp.gate.weight").f32.length / cfg.n_embd;
// RoPE cache: cos/sin of pos * invFreq[j], j < headDim/2 (NeoX pairs j, j+hd/2).
const half = this.headDim / 2;
this.ropeCos = new Float32Array(cfg.ctx * half);
this.ropeSin = new Float32Array(cfg.ctx * half);
for (let p = 0; p < cfg.ctx; p++) {
for (let j = 0; j < half; j++) {
const angle = p / 10000 ** ((2 * j) / this.headDim);
this.ropeCos[p * half + j] = Math.cos(angle);
this.ropeSin[p * half + j] = Math.sin(angle);
}
}
// KV cache + scratch buffers.
const E = cfg.n_embd;
this.kCache = this.layers.map(() => new Float32Array(cfg.ctx * E));
this.vCache = this.layers.map(() => new Float32Array(cfg.ctx * E));
this.pos = 0;
this.x = new Float32Array(E);
this.xn = new Float32Array(E);
this.qB = new Float32Array(E);
this.kB = new Float32Array(E);
this.vB = new Float32Array(E);
this.attnB = new Float32Array(E);
this.projB = new Float32Array(E);
this.gB = new Float32Array(this.mlpHidden);
this.uB = new Float32Array(this.mlpHidden);
this.scores = new Float32Array(cfg.ctx);
this.logits = new Float32Array(cfg.vocab);
}
reset() {
this.pos = 0;
}
applyRope(vec, pos) {
const { n_head } = this.cfg;
const hd = this.headDim, half = hd / 2;
for (let h = 0; h < n_head; h++) {
const base = h * hd;
for (let j = 0; j < half; j++) {
const c = this.ropeCos[pos * half + j];
const s = this.ropeSin[pos * half + j];
const a = vec[base + j], b = vec[base + j + half];
vec[base + j] = a * c - b * s;
vec[base + j + half] = b * c + a * s;
}
}
}
// Feed one token at the current position; returns logits (valid until next step).
step(tokenId) {
const cfg = this.cfg, E = cfg.n_embd, hd = this.headDim;
const pos = this.pos;
if (pos >= cfg.ctx) throw new Error("context window full");
this.x.set(this.tokEmb.subarray(tokenId * E, (tokenId + 1) * E));
for (let l = 0; l < this.layers.length; l++) {
const L = this.layers[l];
// attention
rmsnorm(this.xn, this.x, L.attnNorm, E);
linear(this.qB, this.xn, L.q, E, E);
linear(this.kB, this.xn, L.k, E, E);
linear(this.vB, this.xn, L.v, E, E);
this.applyRope(this.qB, pos);
this.applyRope(this.kB, pos);
this.kCache[l].set(this.kB, pos * E);
this.vCache[l].set(this.vB, pos * E);
const K = this.kCache[l], V = this.vCache[l];
const invSqrt = 1 / Math.sqrt(hd);
for (let h = 0; h < cfg.n_head; h++) {
const ho = h * hd;
let max = -Infinity;
for (let p = 0; p <= pos; p++) {
let dot = 0;
const ko = p * E + ho;
for (let j = 0; j < hd; j++) dot += this.qB[ho + j] * K[ko + j];
const sc = dot * invSqrt;
this.scores[p] = sc;
if (sc > max) max = sc;
}
let sum = 0;
for (let p = 0; p <= pos; p++) {
const e = Math.exp(this.scores[p] - max);
this.scores[p] = e;
sum += e;
}
for (let j = 0; j < hd; j++) {
let acc = 0;
for (let p = 0; p <= pos; p++) acc += this.scores[p] * V[p * E + ho + j];
this.attnB[ho + j] = acc / sum;
}
}
linear(this.projB, this.attnB, L.o, E, E);
for (let i = 0; i < E; i++) this.x[i] += this.projB[i];
// mlp
rmsnorm(this.xn, this.x, L.mlpNorm, E);
linear(this.gB, this.xn, L.gate, this.mlpHidden, E);
linear(this.uB, this.xn, L.up, this.mlpHidden, E);
for (let i = 0; i < this.mlpHidden; i++) this.gB[i] = silu(this.gB[i]) * this.uB[i];
linear(this.projB, this.gB, L.down, E, this.mlpHidden);
for (let i = 0; i < E; i++) this.x[i] += this.projB[i];
}
rmsnorm(this.xn, this.x, this.finalNorm, E);
linear(this.logits, this.xn, { f32: this.tokEmb }, cfg.vocab, E);
this.pos++;
return this.logits;
}
// Run all prompt tokens; returns logits after the last one.
prefill(ids) {
let logits = null;
for (const id of ids) logits = this.step(id);
return logits;
}
}
// Deterministic PRNG for reproducible sampling (JS-vs-JS; torch RNG differs).
// The seed is scrambled splitmix-style first: raw small seeds (0, 1, 2 …) give
// mulberry32 a near-empty state whose first few outputs are biased tiny, which
// visibly skews the first sampled tokens.
export function mulberry32(seed) {
let a = (seed >>> 0) ^ 0x9e3779b9;
a = Math.imul(a ^ (a >>> 16), 0x21f0aaad);
a = Math.imul(a ^ (a >>> 15), 0x735a2d97);
a = (a ^ (a >>> 15)) >>> 0;
return function () {
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;
};
}
// Mirrors src/nanofable/generate.py next_token: temperature floor 1e-6, top-k keeps
// ties at the k-th value, top_k=0 disables the mask, then multinomial via CDF.
export function sampleToken(logits, { temperature, topK }, rand) {
const n = logits.length;
const t = Math.max(temperature, 1e-6);
const scaled = new Float32Array(n);
for (let i = 0; i < n; i++) scaled[i] = logits[i] / t;
if (topK) {
const k = Math.min(topK, n);
const sorted = Float32Array.from(scaled).sort().reverse();
const threshold = sorted[k - 1];
for (let i = 0; i < n; i++) if (scaled[i] < threshold) scaled[i] = -Infinity;
}
let max = -Infinity;
for (let i = 0; i < n; i++) if (scaled[i] > max) max = scaled[i];
let sum = 0;
for (let i = 0; i < n; i++) {
scaled[i] = Math.exp(scaled[i] - max);
sum += scaled[i];
}
const r = rand() * sum;
let cum = 0;
for (let i = 0; i < n; i++) {
cum += scaled[i];
if (r < cum) return i;
}
return n - 1;
}
// Dev/parity helper: greedy (argmax) continuation, comparable to a Python argmax loop.
export function greedy(model, ids, n) {
model.reset();
let logits = model.prefill(ids);
const out = [];
for (let i = 0; i < n && model.pos < model.cfg.ctx; i++) {
let best = 0;
for (let j = 1; j < logits.length; j++) if (logits[j] > logits[best]) best = j;
out.push(best);
logits = model.step(best);
}
return out;
}