File size: 10,134 Bytes
99eecc5
 
 
 
 
 
 
 
 
249a0e9
99eecc5
 
 
249a0e9
99eecc5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249a0e9
99eecc5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249a0e9
99eecc5
 
 
249a0e9
99eecc5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import { numpy as np, nn, jit, valueAndGrad, tree, blockUntilReady } from '@jax-js/jax';
import {sampleCharacter} from './sampling.js';

export function rng(seed = 42) {
  let a = seed >>> 0;
  return () => { a += 0x6D2B79F5; let t = a; t = Math.imul(t ^ t >>> 15, t | 1); t ^= t + Math.imul(t ^ t >>> 7, t | 61); return ((t ^ t >>> 14) >>> 0) / 4294967296; };
}
export function tokenize(text) {
  const chars = [...new Set([...text])].sort();
  if (chars.length < 2 || chars.length > 128) throw new Error('The dataset must contain 2 to 128 distinct characters.');
  const index = new Map(chars.map((c, i) => [c, i]));
  const letters=[...text],paragraphStarts=[0];
  for(let i=1;i<letters.length-1;i++)if(letters[i-1]==='\n'&&letters[i]==='\n'&&letters[i+1]!=='\n')paragraphStarts.push(i+1);
  return { chars, paragraphStarts, ids: [...text].map(c => index.get(c)), encode: s => [...s].map(c => { if (!index.has(c)) throw new Error(`Character missing from the dataset: ${JSON.stringify(c)}`); return index.get(c); }) };
}
export function initParams(c, seed = 42) {
  const random = rng(seed);
  const weight = (shape, scale) => np.array(Float32Array.from({ length: shape.reduce((a,b) => a*b,1) }, () => (random()*2-1)*scale)).reshape(shape);
  if(c.architecture==='nanogpt') {
    // NanoGPT bias=False, dropout=0: LayerNorm, exact GELU, tied embedding/head.
    // Same distribution and residual scaling; JS/Python RNG streams differ.
    const normal=(shape,std=.02)=>np.array(Float32Array.from({length:shape.reduce((a,b)=>a*b,1)},()=>Math.sqrt(-2*Math.log(Math.max(1e-9,random())))*Math.cos(2*Math.PI*random())*std)).reshape(shape);
    const p={embed:normal([c.vocab,c.dim]),pos:normal([c.context,c.dim]),norm:np.ones([c.dim])};
    for(let l=0;l<c.layers;l++) {
      for(const k of ['q','k','v'])p[`${l}.${k}`]=normal([c.dim,c.dim]);
      p[`${l}.o`]=normal([c.dim,c.dim],.02/Math.sqrt(2*c.layers));
      p[`${l}.up`]=normal([c.dim,4*c.dim]);
      p[`${l}.down`]=normal([4*c.dim,c.dim],.02/Math.sqrt(2*c.layers));
      p[`${l}.n1`]=np.ones([c.dim]);p[`${l}.n2`]=np.ones([c.dim]);
    }
    return p;
  }
  if(c.architecture==='modern') {
    // Modded-inspired FP32 variant: Gaussian fan-in init, zero residual projections,
    // RMSNorm, QK norm, RoPE, squared ReLU, soft-capped untied head. AdamW below.
    const normal=(shape,std)=>np.array(Float32Array.from({length:shape.reduce((a,b)=>a*b,1)},()=>Math.sqrt(-2*Math.log(Math.max(1e-9,random())))*Math.cos(2*Math.PI*random())*std)).reshape(shape);
    const zeroOrNormal=shape=>c.nonzeroInit?normal(shape,0.1):np.zeros(shape);
    const p={embed:normal([c.vocab,c.dim],1),norm:np.ones([c.dim]),head:zeroOrNormal([c.dim,c.vocab])};
    for(let l=0;l<c.layers;l++) {
      for(const k of ['q','k','v'])p[`${l}.${k}`]=normal([c.dim,c.dim],Math.sqrt(.33/c.dim));
      p[`${l}.o`]=zeroOrNormal([c.dim,c.dim]);
      p[`${l}.up`]=normal([c.dim,4*c.dim],Math.sqrt(.33/c.dim));
      p[`${l}.down`]=zeroOrNormal([4*c.dim,c.dim]);
      p[`${l}.n1`]=np.ones([c.dim]);p[`${l}.n2`]=np.ones([c.dim]);
    }
    return p;
  }
  const p = { embed: weight([c.vocab,c.dim],0.2), pos: weight([c.context,c.dim],0.02) };
  for(let l=0;l<c.layers;l++) {
    for(const k of ['q','k','v','o']) p[`${l}.${k}`] = weight([c.dim,c.dim],Math.sqrt(3/c.dim));
    p[`${l}.up`] = weight([c.dim,4*c.dim],Math.sqrt(3/c.dim));
    p[`${l}.down`] = weight([4*c.dim,c.dim],Math.sqrt(3/(4*c.dim)));
    p[`${l}.n1`] = np.ones([c.dim]); p[`${l}.n2`] = np.ones([c.dim]);
  }
  p.norm = np.ones([c.dim]); p.head = weight([c.dim,c.vocab],0.02);
  // Keep the same RNG draws for other weights, for the positional ablation.
  if(c.architecture==='rotary'){p.pos.dispose();delete p.pos;}
  return p;
}
const norm = (x,w) => x.ref.div(np.sqrt(np.mean(np.square(x),-1,{keepdims:true}).add(1e-5))).mul(w);
const layerNorm=(x,w)=>{const centered=x.ref.sub(np.mean(x,-1,{keepdims:true}));return norm(centered,w);};
const rms = x => x.ref.div(np.sqrt(np.mean(np.square(x),-1,{keepdims:true}).add(1e-5)));
function rotary(x,t,hd,full=false) {
  const angles=Array.from({length:t*hd/2},(_,i)=>{
    const j=i%(hd/2),pos=Math.floor(i/(hd/2));
    return full?pos*(1/10000)**(2*j/hd):j<hd/4?pos*(1/1024)**(j/Math.max(1,hd/4-1)):0;
  });
  const cs=np.array(angles.map(Math.cos)).reshape([1,t,1,hd/2]);
  const sn=np.array(angles.map(Math.sin)).reshape([1,t,1,hd/2]);
  const [a,b]=np.split(x,2,-1);
  return np.concatenate([a.ref.mul(cs.ref).add(b.ref.mul(sn.ref)),b.mul(cs).sub(a.mul(sn))],-1);
}
// Each argument is consumed exactly once, including every parameter leaf.
export function forward(p, ids, c) {
  const [b,t] = ids.shape, d = c.dim, h = c.heads;
  const modern=c.architecture==='modern',rope=c.architecture==='rotary',nano=c.architecture==='nanogpt',normalize=nano?layerNorm:norm;
  let x = np.matmul(nn.oneHot(ids,c.vocab),nano?p.embed.ref:p.embed);
  if(!modern&&!rope)x=x.add(t===c.context?p.pos:p.pos.slice([0,t],[]));
  for(let l=0;l<c.layers;l++) {
    const n = normalize(x.ref,p[`${l}.n1`]);
    let q = np.matmul(n.ref,p[`${l}.q`]).reshape([b,t,h,d/h]);
    let k = np.matmul(n.ref,p[`${l}.k`]).reshape([b,t,h,d/h]);
    const v = np.matmul(n,p[`${l}.v`]).reshape([b,t,h,d/h]);
    if(modern){q=rotary(rms(q),t,d/h);k=rotary(rms(k),t,d/h);}
    if(rope){q=rotary(q,t,d/h,true);k=rotary(k,t,d/h,true);}
    const a = nn.dotProductAttention(q,k,v,{isCausal:true,...(modern?{scale:.12}:{})}).reshape([b,t,d]);
    x = x.add(np.matmul(a,p[`${l}.o`]));
    const z = normalize(x.ref,p[`${l}.n2`]);
    const u=np.matmul(z,p[`${l}.up`]);
    x = x.add(np.matmul(modern?np.square(nn.relu(u)):nn.gelu(u,{approximate:!nano}),p[`${l}.down`]));
  }
  const logits=np.matmul(normalize(x,p.norm),nano?p.embed.transpose():p.head);
  return modern?logits.ref.mul(15).div(np.sqrt(np.square(logits).add(225))):logits;
}
export function loss(p,x,y,c) {
  return nn.logSoftmax(forward(p,x,c),-1).mul(nn.oneHot(y,c.vocab)).sum(-1).mean().neg();
}
export function batch(ids,c,random) {
  const x = [], y = [];
  for(let b=0;b<c.batch;b++) {
    const start = Math.floor(random()*(ids.length-c.context));
    x.push(...ids.slice(start,start+c.context)); y.push(...ids.slice(start+1,start+c.context+1));
  }
  return [np.array(x,{dtype:np.int32}).reshape([c.batch,c.context]),np.array(y,{dtype:np.int32}).reshape([c.batch,c.context])];
}
// Paragraph starts use Unicode code-point offsets, like token ids.
// Circular continuation includes the last paragraph without padding fake targets.
export function paragraphBatch(ids,c,random,starts) {
  if(!starts?.length)throw new Error('No paragraph starts available.');
  const x=[],y=[];
  for(let b=0;b<c.batch;b++){
    const start=starts[Math.floor(random()*starts.length)];
    for(let i=0;i<c.context;i++){x.push(ids[(start+i)%ids.length]);y.push(ids[(start+i+1)%ids.length]);}
  }
  return [np.array(x,{dtype:np.int32}).reshape([c.batch,c.context]),np.array(y,{dtype:np.int32}).reshape([c.batch,c.context])];
}
export class Trainer {
  constructor(c,seed=42) {
    this.c=c; this.p=initParams(c,seed); this.step=0;
    this.m=tree.map(p=>np.zeros(p.shape),this.p); this.v=tree.map(p=>np.zeros(p.shape),this.p);
    this.lossGrad=jit(valueAndGrad((p,x,y)=>loss(p,x,y,c)));
    this.infer=jit((p,x)=>forward(p,x,c));
    this.inferLast=jit((p,x,i)=>np.take(forward(p,x,c),i,1));
    this.evaluate=jit((p,x,y)=>nn.logSoftmax(forward(p,x,c),-1).mul(nn.oneHot(y,c.vocab)).sum(-1).slice([], [Math.max(0,c.context-16),c.context]).mean().neg());
    this.beta2=c.beta2??.999;
    this.update=jit((p,m,v,g,lr,b1,b2)=>{
      // Global norm clipping before Adam.
      const total=Object.values(g).map(x=>np.square(x.ref).sum()).reduce((a,b)=>a.add(b));
      const scale=np.minimum(1,np.reciprocal(np.sqrt(total).add(1e-8)));
      const nextP={},nextM={},nextV={};
      for(const k of Object.keys(p)) {
        const grad=g[k].mul(scale.ref);
        const mm=m[k].mul(0.9).add(grad.ref.mul(0.1));
        const vv=v[k].mul(this.beta2).add(np.square(grad).mul(1-this.beta2));
        const decayed=p[k].mul(np.array(1).sub(lr.ref.mul(p[k].shape.length===2?(c.weightDecay??0):0)));
        nextP[k]=decayed.sub(mm.ref.div(b1.ref).div(np.sqrt(vv.ref.div(b2.ref)).add(1e-8)).mul(lr.ref));
        nextM[k]=mm; nextV[k]=vv;
      }
      scale.dispose(); lr.dispose(); b1.dispose(); b2.dispose();
      return [nextP,nextM,nextV];
    });
  }
  async train(x,y,rate=0.003) {
    const [l,g]=this.lossGrad(tree.ref(this.p),x,y);
    this.step++;
    [this.p,this.m,this.v]=this.update(this.p,this.m,this.v,g,np.array(rate),np.array(1-0.9**this.step),np.array(1-this.beta2**this.step));
    await blockUntilReady([this.p,this.m,this.v]);
    const value=(await l.data())[0];
    if(!Number.isFinite(value)) throw new Error('Non-finite loss. Reset and lower the learning rate.');
    return value;
  }
  async generate(prefix,tokenizer,{length=80,temperature=0.8,minP=0,seed=123,bucketed=this.c.fastSampling??false,cancel=()=>false,onToken=()=>{}}={}) {
    const ids=tokenizer.encode(prefix); if(!ids.length) throw new Error('The prompt must not be empty.');
    const random=rng(seed); let text='';
    for(let i=0;i<length&&!cancel();i++) {
      const ctx=ids.slice(-this.c.context), pos=ctx.length-1;
      const size=bucketed?Math.min(this.c.context,Math.max(16,2**Math.ceil(Math.log2(ctx.length)))):this.c.context;
      const padded=[...ctx,...Array(size-ctx.length).fill(0)];
      const x=np.array(padded,{dtype:np.int32}).reshape([1,size]);
      const logits=bucketed?this.inferLast(tree.ref(this.p),x,np.array(pos,{dtype:np.int32})):this.infer(tree.ref(this.p),x);
      const raw=await logits.data(),values=bucketed?raw:raw.slice(pos*this.c.vocab,(pos+1)*this.c.vocab); 
      const choice=sampleCharacter(values,temperature,random,minP);
      ids.push(choice); text+=tokenizer.chars[choice]; onToken(text);
      if(i%8===0) await new Promise(r=>setTimeout(r,0));
    }
    return text;
  }
  dispose(){tree.dispose([this.p,this.m,this.v]); this.lossGrad.dispose(); this.infer.dispose(); this.inferLast.dispose(); this.evaluate.dispose(); this.update.dispose();}
}