@seanhogg/builderforce-memory-engine 2026.7.12 → 2026.7.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,6 +24,7 @@
24
24
  import { SharedExpertMoE } from "../moe/moe_model.js";
25
25
  import { crossEntropyLoss, crossEntropyGrad } from "../training/autograd.js";
26
26
  import { AdamW, type AdamWOptions } from "../optim/adamw.js";
27
+ import { DynamicLossScaler, roundFp16, type LossScalerOptions } from "../training/mixed_precision.js";
27
28
  import { SeededRng } from "../utils/rng.js";
28
29
  import { quantizeFp16, dequantizeFp16 } from "../utils/quantization.js";
29
30
  import { appendCrcTrailer, verifyCrcTrailer } from "../utils/crc32.js";
@@ -181,68 +182,71 @@ export class EvermindLM {
181
182
 
182
183
  // ── Forward ────────────────────────────────────────────────────────────────
183
184
 
184
- /** Run the model over a token sequence; returns per-position logits + a cache. */
185
- forward(tokens: number[]): { logits: Float32Array[]; cache: ForwardCache } {
186
- const { dModel, convKernel, numLayers, vocabSize } = this.config;
187
- const T = tokens.length;
188
-
189
- // Embed.
190
- let x: Float32Array[] = tokens.map((tok) => {
185
+ /** Embed a token sequence into per-position channel vectors. */
186
+ private _embed(tokens: number[]): Float32Array[] {
187
+ const { dModel } = this.config;
188
+ return tokens.map((tok) => {
191
189
  const row = new Float32Array(dModel);
192
190
  const off = tok * dModel;
193
191
  for (let c = 0; c < dModel; c++) row[c] = this.emb[off + c]!;
194
192
  return row;
195
193
  });
194
+ }
196
195
 
197
- const layers: LayerCache[] = [];
198
- for (let l = 0; l < numLayers; l++) {
199
- const layerIn = x;
200
- const ker = this.conv[l]!;
201
- const nConv = this.nConv[l]!;
202
- const nMoe = this.nMoe[l]!;
203
-
204
- // Pre-norm → depthwise causal conv → residual.
205
- const normedConv: Float32Array[] = [];
206
- const rmsConv: number[] = [];
207
- for (let t = 0; t < T; t++) {
208
- const { y, r } = rmsNorm(layerIn[t]!, nConv);
209
- normedConv.push(y);
210
- rmsConv.push(r);
211
- }
212
- const afterConv: Float32Array[] = [];
213
- for (let t = 0; t < T; t++) {
214
- const out = Float32Array.from(layerIn[t]!); // residual base
215
- for (let c = 0; c < dModel; c++) {
216
- let acc = 0;
217
- for (let j = 0; j < convKernel; j++) {
218
- const ti = t - j;
219
- if (ti >= 0) acc += ker[c * convKernel + j]! * normedConv[ti]![c]!;
220
- }
221
- out[c] = out[c]! + acc;
196
+ /**
197
+ * One (conv + MoE) block: pre-norm → depthwise causal conv → residual, then
198
+ * pre-norm → MoE channel mixer → residual. Returns the block output and the
199
+ * activation cache its backward needs. Isolating this is what lets
200
+ * {@link lossAndBackwardCheckpointed} recompute a layer's activations on demand
201
+ * instead of retaining every layer's cache at once.
202
+ */
203
+ private _forwardLayer(l: number, layerIn: Float32Array[]): { afterMoe: Float32Array[]; cache: LayerCache } {
204
+ const { dModel, convKernel } = this.config;
205
+ const T = layerIn.length;
206
+ const ker = this.conv[l]!;
207
+ const nConv = this.nConv[l]!;
208
+ const nMoe = this.nMoe[l]!;
209
+
210
+ const normedConv: Float32Array[] = [];
211
+ const rmsConv: number[] = [];
212
+ for (let t = 0; t < T; t++) {
213
+ const { y, r } = rmsNorm(layerIn[t]!, nConv);
214
+ normedConv.push(y);
215
+ rmsConv.push(r);
216
+ }
217
+ const afterConv: Float32Array[] = [];
218
+ for (let t = 0; t < T; t++) {
219
+ const out = Float32Array.from(layerIn[t]!); // residual base
220
+ for (let c = 0; c < dModel; c++) {
221
+ let acc = 0;
222
+ for (let j = 0; j < convKernel; j++) {
223
+ const ti = t - j;
224
+ if (ti >= 0) acc += ker[c * convKernel + j]! * normedConv[ti]![c]!;
222
225
  }
223
- afterConv.push(out);
224
- }
225
-
226
- // Pre-norm → MoE channel mixer → residual.
227
- const rmsMoe: number[] = [];
228
- const moeCache: MoECacheLike[] = [];
229
- const afterMoe: Float32Array[] = [];
230
- for (let t = 0; t < T; t++) {
231
- const { y, r } = rmsNorm(afterConv[t]!, nMoe);
232
- rmsMoe.push(r);
233
- const out = Float32Array.from(afterConv[t]!); // residual base
234
- const mr = this.moe[l]!.forward(y);
235
- for (let c = 0; c < dModel; c++) out[c] = out[c]! + mr.output[c]!;
236
- afterMoe.push(out);
237
- moeCache.push(mr.cache as unknown as MoECacheLike);
226
+ out[c] = out[c]! + acc;
238
227
  }
228
+ afterConv.push(out);
229
+ }
239
230
 
240
- layers.push({ layerIn, normedConv, rmsConv, afterConv, rmsMoe, moeCache });
241
- x = afterMoe;
231
+ const rmsMoe: number[] = [];
232
+ const moeCache: MoECacheLike[] = [];
233
+ const afterMoe: Float32Array[] = [];
234
+ for (let t = 0; t < T; t++) {
235
+ const { y, r } = rmsNorm(afterConv[t]!, nMoe);
236
+ rmsMoe.push(r);
237
+ const out = Float32Array.from(afterConv[t]!); // residual base
238
+ const mr = this.moe[l]!.forward(y);
239
+ for (let c = 0; c < dModel; c++) out[c] = out[c]! + mr.output[c]!;
240
+ afterMoe.push(out);
241
+ moeCache.push(mr.cache as unknown as MoECacheLike);
242
242
  }
243
+ return { afterMoe, cache: { layerIn, normedConv, rmsConv, afterConv, rmsMoe, moeCache } };
244
+ }
243
245
 
244
- // Tied head: logits_t[v] = x_t · emb[v].
245
- const logits: Float32Array[] = x.map((xt) => {
246
+ /** Tied output head: logits_t[v] = x_t · emb[v]. */
247
+ private _head(x: Float32Array[]): Float32Array[] {
248
+ const { dModel, vocabSize } = this.config;
249
+ return x.map((xt) => {
246
250
  const lg = new Float32Array(vocabSize);
247
251
  for (let v = 0; v < vocabSize; v++) {
248
252
  let acc = 0;
@@ -252,34 +256,39 @@ export class EvermindLM {
252
256
  }
253
257
  return lg;
254
258
  });
259
+ }
255
260
 
256
- return { logits, cache: { tokens, layers, finalX: x } };
261
+ /** Run the model over a token sequence; returns per-position logits + a cache. */
262
+ forward(tokens: number[]): { logits: Float32Array[]; cache: ForwardCache } {
263
+ let x = this._embed(tokens);
264
+ const layers: LayerCache[] = [];
265
+ for (let l = 0; l < this.config.numLayers; l++) {
266
+ const { afterMoe, cache } = this._forwardLayer(l, x);
267
+ layers.push(cache);
268
+ x = afterMoe;
269
+ }
270
+ return { logits: this._head(x), cache: { tokens, layers, finalX: x } };
257
271
  }
258
272
 
259
273
  // ── Loss + backward ──────────────────────────────────────────────────────────
260
274
 
261
275
  /**
262
- * Next-token cross-entropy over the sequence (predict tokens[t+1] from
263
- * position t), accumulating exact gradients. Returns the mean loss. Call
264
- * {@link zeroGrad} before and an optimiser step after.
276
+ * Head + tied-embedding gradient. Accumulates dL/d(head→emb) into gEmb and
277
+ * returns the mean next-token loss plus dL/d(finalX). Shared by the full and
278
+ * checkpointed backward paths so the head maths lives in one place.
265
279
  */
266
- lossAndBackward(tokens: number[]): number {
267
- const { dModel, convKernel, numLayers, vocabSize } = this.config;
280
+ private _headBackward(tokens: number[], logits: Float32Array[], finalX: Float32Array[]): { loss: number; dX: Float32Array[] } {
281
+ const { dModel, vocabSize } = this.config;
268
282
  const T = tokens.length;
269
- if (T < 2) return 0;
270
- const { logits, cache } = this.forward(tokens);
271
-
272
283
  const predPositions = T - 1; // positions 0..T-2 predict the next token
273
284
  const inv = 1 / predPositions;
274
-
275
- // dL/d(finalX_t) and head gradient into the tied embedding.
276
285
  const dX: Float32Array[] = Array.from({ length: T }, () => new Float32Array(dModel));
277
286
  let loss = 0;
278
287
  for (let t = 0; t < predPositions; t++) {
279
288
  const target = tokens[t + 1]!;
280
289
  loss += crossEntropyLoss(logits[t]!, target) * inv;
281
290
  const dLogit = crossEntropyGrad(logits[t]!, target); // probs - onehot
282
- const xt = cache.finalX[t]!;
291
+ const xt = finalX[t]!;
283
292
  for (let v = 0; v < vocabSize; v++) {
284
293
  const g = dLogit[v]! * inv;
285
294
  if (g === 0) continue;
@@ -290,59 +299,109 @@ export class EvermindLM {
290
299
  }
291
300
  }
292
301
  }
302
+ return { loss, dX };
303
+ }
293
304
 
294
- // Backprop through layers in reverse.
295
- for (let l = numLayers - 1; l >= 0; l--) {
296
- const lc = cache.layers[l]!;
297
- const ker = this.conv[l]!;
298
- const gker = this.gConv[l]!;
299
- const nConv = this.nConv[l]!;
300
- const gNConv = this.gNConv[l]!;
301
- const nMoe = this.nMoe[l]!;
302
- const gNMoe = this.gNMoe[l]!;
303
-
304
- // MoE sub-block: afterMoe = afterConv + MoE(RMSNorm(afterConv, nMoe)).
305
- const dAfterConv: Float32Array[] = [];
306
- for (let t = 0; t < T; t++) {
307
- const dMoeNormed = this.moe[l]!.backward(dX[t]!, lc.moeCache[t] as never);
308
- const { dx, dgain } = rmsNormBackward(dMoeNormed, lc.afterConv[t]!, lc.rmsMoe[t]!, nMoe);
309
- for (let c = 0; c < dModel; c++) gNMoe[c] = gNMoe[c]! + dgain[c]!;
310
- const d = Float32Array.from(dX[t]!); // residual passthrough
311
- for (let c = 0; c < dModel; c++) d[c] = d[c]! + dx[c]!;
312
- dAfterConv.push(d);
313
- }
305
+ /**
306
+ * Backward through one (conv + MoE) block given dL/d(block output) and the
307
+ * block's activation cache. Accumulates conv/norm/MoE gradients and returns
308
+ * dL/d(block input). The inverse of {@link _forwardLayer}.
309
+ */
310
+ private _backwardLayer(l: number, dOut: Float32Array[], lc: LayerCache): Float32Array[] {
311
+ const { dModel, convKernel } = this.config;
312
+ const T = dOut.length;
313
+ const ker = this.conv[l]!;
314
+ const gker = this.gConv[l]!;
315
+ const nConv = this.nConv[l]!;
316
+ const gNConv = this.gNConv[l]!;
317
+ const nMoe = this.nMoe[l]!;
318
+ const gNMoe = this.gNMoe[l]!;
319
+
320
+ // MoE sub-block: afterMoe = afterConv + MoE(RMSNorm(afterConv, nMoe)).
321
+ const dAfterConv: Float32Array[] = [];
322
+ for (let t = 0; t < T; t++) {
323
+ const dMoeNormed = this.moe[l]!.backward(dOut[t]!, lc.moeCache[t] as never);
324
+ const { dx, dgain } = rmsNormBackward(dMoeNormed, lc.afterConv[t]!, lc.rmsMoe[t]!, nMoe);
325
+ for (let c = 0; c < dModel; c++) gNMoe[c] = gNMoe[c]! + dgain[c]!;
326
+ const d = Float32Array.from(dOut[t]!); // residual passthrough
327
+ for (let c = 0; c < dModel; c++) d[c] = d[c]! + dx[c]!;
328
+ dAfterConv.push(d);
329
+ }
314
330
 
315
- // Conv sub-block: afterConv = layerIn + conv(RMSNorm(layerIn, nConv)).
316
- const dNormedConv: Float32Array[] = Array.from({ length: T }, () => new Float32Array(dModel));
317
- const dLayerIn: Float32Array[] = dAfterConv.map((v) => Float32Array.from(v)); // residual passthrough
318
- for (let t = 0; t < T; t++) {
319
- for (let c = 0; c < dModel; c++) {
320
- const dmix = dAfterConv[t]![c]!;
321
- if (dmix === 0) continue;
322
- for (let j = 0; j < convKernel; j++) {
323
- const ti = t - j;
324
- if (ti < 0) continue;
325
- gker[c * convKernel + j] = gker[c * convKernel + j]! + dmix * lc.normedConv[ti]![c]!;
326
- dNormedConv[ti]![c] = dNormedConv[ti]![c]! + dmix * ker[c * convKernel + j]!;
327
- }
331
+ // Conv sub-block: afterConv = layerIn + conv(RMSNorm(layerIn, nConv)).
332
+ const dNormedConv: Float32Array[] = Array.from({ length: T }, () => new Float32Array(dModel));
333
+ const dLayerIn: Float32Array[] = dAfterConv.map((v) => Float32Array.from(v)); // residual passthrough
334
+ for (let t = 0; t < T; t++) {
335
+ for (let c = 0; c < dModel; c++) {
336
+ const dmix = dAfterConv[t]![c]!;
337
+ if (dmix === 0) continue;
338
+ for (let j = 0; j < convKernel; j++) {
339
+ const ti = t - j;
340
+ if (ti < 0) continue;
341
+ gker[c * convKernel + j] = gker[c * convKernel + j]! + dmix * lc.normedConv[ti]![c]!;
342
+ dNormedConv[ti]![c] = dNormedConv[ti]![c]! + dmix * ker[c * convKernel + j]!;
328
343
  }
329
344
  }
330
- for (let t = 0; t < T; t++) {
331
- const { dx, dgain } = rmsNormBackward(dNormedConv[t]!, lc.layerIn[t]!, lc.rmsConv[t]!, nConv);
332
- for (let c = 0; c < dModel; c++) {
333
- gNConv[c] = gNConv[c]! + dgain[c]!;
334
- dLayerIn[t]![c] = dLayerIn[t]![c]! + dx[c]!;
335
- }
345
+ }
346
+ for (let t = 0; t < T; t++) {
347
+ const { dx, dgain } = rmsNormBackward(dNormedConv[t]!, lc.layerIn[t]!, lc.rmsConv[t]!, nConv);
348
+ for (let c = 0; c < dModel; c++) {
349
+ gNConv[c] = gNConv[c]! + dgain[c]!;
350
+ dLayerIn[t]![c] = dLayerIn[t]![c]! + dx[c]!;
336
351
  }
337
- for (let t = 0; t < T; t++) dX[t] = dLayerIn[t]!;
338
352
  }
353
+ return dLayerIn;
354
+ }
339
355
 
340
- // Embedding lookup: dX at layer-0 input flows into the row for token_t.
341
- for (let t = 0; t < T; t++) {
356
+ /** Embedding lookup: dL/d(layer-0 input) flows into the row for each token. */
357
+ private _embedBackward(tokens: number[], dIn: Float32Array[]): void {
358
+ const { dModel } = this.config;
359
+ for (let t = 0; t < tokens.length; t++) {
342
360
  const off = tokens[t]! * dModel;
343
- for (let c = 0; c < dModel; c++) this.gEmb[off + c] = this.gEmb[off + c]! + dX[t]![c]!;
361
+ for (let c = 0; c < dModel; c++) this.gEmb[off + c] = this.gEmb[off + c]! + dIn[t]![c]!;
344
362
  }
363
+ }
364
+
365
+ /**
366
+ * Next-token cross-entropy over the sequence (predict tokens[t+1] from
367
+ * position t), accumulating exact gradients. Returns the mean loss. Call
368
+ * {@link zeroGrad} before and an optimiser step after.
369
+ */
370
+ lossAndBackward(tokens: number[]): number {
371
+ if (tokens.length < 2) return 0;
372
+ const { logits, cache } = this.forward(tokens);
373
+ const { loss, dX } = this._headBackward(tokens, logits, cache.finalX);
374
+ let d = dX;
375
+ for (let l = this.config.numLayers - 1; l >= 0; l--) d = this._backwardLayer(l, d, cache.layers[l]!);
376
+ this._embedBackward(tokens, d);
377
+ return loss;
378
+ }
345
379
 
380
+ /**
381
+ * Activation-checkpointed backward — numerically identical gradients to
382
+ * {@link lossAndBackward}, but retains only the per-LAYER inputs during the
383
+ * forward instead of every layer's full activation cache. Each layer's
384
+ * activations are RECOMPUTED (a cheap extra forward) when its backward runs,
385
+ * so peak activation memory is one layer's cache, not all of them. This is the
386
+ * memory-for-compute trade the cookbook pairs with FSDP to fit longer
387
+ * sequences / bigger models on a constrained device.
388
+ */
389
+ lossAndBackwardCheckpointed(tokens: number[]): number {
390
+ if (tokens.length < 2) return 0;
391
+ let x = this._embed(tokens);
392
+ const layerInputs: Float32Array[][] = [];
393
+ for (let l = 0; l < this.config.numLayers; l++) {
394
+ layerInputs.push(x); // keep only the input; drop the cache
395
+ x = this._forwardLayer(l, x).afterMoe;
396
+ }
397
+ const logits = this._head(x);
398
+ const { loss, dX } = this._headBackward(tokens, logits, x);
399
+ let d = dX;
400
+ for (let l = this.config.numLayers - 1; l >= 0; l--) {
401
+ const { cache } = this._forwardLayer(l, layerInputs[l]!); // recompute this layer's activations
402
+ d = this._backwardLayer(l, d, cache);
403
+ }
404
+ this._embedBackward(tokens, d);
346
405
  return loss;
347
406
  }
348
407
 
@@ -513,29 +572,114 @@ export class EvermindLM {
513
572
  }
514
573
  }
515
574
 
575
+ export interface EvermindLMTrainOptions extends AdamWOptions {
576
+ epochs?: number;
577
+ /**
578
+ * Gradient accumulation: average gradients over this many sequences (micro-
579
+ * batches) before each optimiser step, for a larger effective batch on a
580
+ * memory-constrained device. Default 1.
581
+ */
582
+ accumSteps?: number;
583
+ /**
584
+ * Use activation checkpointing (recompute layer activations in backward) to
585
+ * cap peak activation memory. Identical gradients, a little extra compute.
586
+ * Default false.
587
+ */
588
+ checkpoint?: boolean;
589
+ /**
590
+ * Mixed-precision training: fp16-rounded gradients with dynamic loss scaling
591
+ * over fp32 master weights (the model params). Overflowing steps are skipped
592
+ * and the scale backs off. Default false.
593
+ */
594
+ mixedPrecision?: boolean | LossScalerOptions;
595
+ }
596
+
516
597
  /** Minimal sequence trainer: AdamW over next-token cross-entropy. */
517
598
  export class EvermindLMTrainer {
518
599
  private readonly adam: AdamW;
600
+ private readonly scaler: DynamicLossScaler | null;
601
+ private readonly grads: { data: Float32Array }[];
519
602
  constructor(
520
603
  private readonly model: EvermindLM,
521
- private readonly opts: AdamWOptions & { epochs?: number } = {},
604
+ private readonly opts: EvermindLMTrainOptions = {},
522
605
  ) {
523
606
  this.adam = new AdamW(model, opts);
607
+ this.grads = model.gradients();
608
+ this.scaler = opts.mixedPrecision
609
+ ? new DynamicLossScaler(typeof opts.mixedPrecision === "object" ? opts.mixedPrecision : {})
610
+ : null;
611
+ }
612
+
613
+ /** The dynamic loss scaler (mixed-precision mode only) — exposes scale/overflow stats. */
614
+ get lossScaler(): DynamicLossScaler | null {
615
+ return this.scaler;
616
+ }
617
+
618
+ private _backward(seq: number[]): number {
619
+ return this.opts.checkpoint ? this.model.lossAndBackwardCheckpointed(seq) : this.model.lossAndBackward(seq);
524
620
  }
621
+
622
+ /** Divide accumulated gradients by `n` (accumulation averaging), in place. */
623
+ private _scaleGrads(n: number): void {
624
+ if (n === 1) return;
625
+ const inv = 1 / n;
626
+ for (const g of this.grads) for (let i = 0; i < g.data.length; i++) g.data[i] = g.data[i]! * inv;
627
+ }
628
+
629
+ /** Round accumulated gradients to fp16 precision (mixed-precision simulation), in place. */
630
+ private _fp16Grads(): void {
631
+ for (const g of this.grads) for (let i = 0; i < g.data.length; i++) g.data[i] = roundFp16(g.data[i]!);
632
+ }
633
+
634
+ /** Multiply accumulated gradients by `s`, in place. */
635
+ private _mulGrads(s: number): void {
636
+ if (s === 1) return;
637
+ for (const g of this.grads) for (let i = 0; i < g.data.length; i++) g.data[i] = g.data[i]! * s;
638
+ }
639
+
525
640
  /** Train on a set of token sequences; returns per-epoch mean loss. */
526
641
  fit(sequences: number[][]): number[] {
527
642
  const epochs = this.opts.epochs ?? 1;
643
+ const accum = Math.max(1, this.opts.accumSteps ?? 1);
528
644
  const history: number[] = [];
529
645
  for (let e = 0; e < epochs; e++) {
530
646
  let total = 0;
531
647
  let n = 0;
648
+ let pending = 0;
649
+ // The scale is fixed for the duration of one accumulation window, so scale-up
650
+ // (per sequence) and unscale (at flush) always use the SAME factor even as the
651
+ // controller grows/backs it off between windows.
652
+ let windowScale = this.scaler ? this.scaler.scale : 1;
653
+ this.model.zeroGrad();
654
+ const flush = () => {
655
+ if (pending === 0) return;
656
+ this._scaleGrads(pending); // accumulation averaging
657
+ if (this.scaler) {
658
+ this._fp16Grads(); // half-precision gradients
659
+ const overflow = this.scaler.check(this.grads);
660
+ if (this.scaler.update(overflow)) {
661
+ this._mulGrads(1 / windowScale); // unscale by the SAME factor we scaled by
662
+ this.adam.step();
663
+ } // else: skip the step, scale has backed off
664
+ windowScale = this.scaler.scale; // next window uses the updated scale
665
+ } else {
666
+ this.adam.step();
667
+ }
668
+ this.model.zeroGrad();
669
+ pending = 0;
670
+ };
532
671
  for (const seq of sequences) {
533
672
  if (seq.length < 2) continue;
534
- this.model.zeroGrad();
535
- total += this.model.lossAndBackward(seq);
536
- this.adam.step();
673
+ if (pending === 0 && this.scaler) windowScale = this.scaler.scale;
674
+ const loss = this._backward(seq);
675
+ // Emulate scaling the loss before backward: the gradient is linear in the
676
+ // loss, so scaling the just-produced gradients by the loss scale is equivalent.
677
+ this._mulGrads(windowScale);
678
+ total += loss;
537
679
  n++;
680
+ if (++pending >= accum) flush();
538
681
  }
682
+ flush();
539
683
  history.push(n > 0 ? total / n : 0);
540
684
  }
541
685
  return history;
package/src/lm/index.ts CHANGED
@@ -3,4 +3,10 @@
3
3
  */
4
4
 
5
5
  export { EvermindLM, EvermindLMTrainer, DEFAULT_LM_CONFIG, DEFAULT_LM_SEED } from "./evermind_lm.js";
6
- export type { EvermindLMConfig, LMGenerateOptions, TextCodec } from "./evermind_lm.js";
6
+ export type { EvermindLMConfig, LMGenerateOptions, TextCodec, EvermindLMTrainOptions } from "./evermind_lm.js";
7
+
8
+ // PEFT / efficient-training toolkit (LoRA, QLoRA, mixed precision).
9
+ export { LoRAAdapter, EvermindLMLoRA, quantizeBase } from "../training/lora.js";
10
+ export type { LoRAConfig, LoRAFitOptions, BaseQuant } from "../training/lora.js";
11
+ export { DynamicLossScaler, roundFp16, fp16View } from "../training/mixed_precision.js";
12
+ export type { LossScalerOptions } from "../training/mixed_precision.js";
@@ -15,19 +15,42 @@ export interface OptimTarget {
15
15
  gradients(): OptimParam[];
16
16
  }
17
17
 
18
+ /**
19
+ * Optimizer-state sharding (ZeRO-1 analog). When set, this optimiser instance
20
+ * OWNS only parameter tensors where `paramIndex % count === index`, and only
21
+ * allocates the AdamW moments (m, v — 2× the parameter bytes, the dominant cost
22
+ * of full fine-tuning per the cookbook's memory accounting) for its shard.
23
+ *
24
+ * Run `count` instances over the SAME model, one per `index`, and the union of
25
+ * their steps equals one unsharded step — but each holds only 1/count of the
26
+ * optimizer state. That is exactly how FSDP/ZeRO fits a larger model on the same
27
+ * hardware; here it is the seam a multi-device trainer partitions on, and even
28
+ * single-process it caps resident moment memory.
29
+ */
30
+ export interface ShardSpec {
31
+ /** This shard's rank, 0-based. */
32
+ index: number;
33
+ /** Total number of shards. */
34
+ count: number;
35
+ }
36
+
18
37
  export interface AdamWOptions {
19
38
  lr?: number;
20
39
  beta1?: number;
21
40
  beta2?: number;
22
41
  eps?: number;
23
42
  weightDecay?: number;
43
+ /** Optimizer-state sharding. Omit for the full (single-owner) optimiser. */
44
+ shard?: ShardSpec;
24
45
  }
25
46
 
26
47
  export class AdamW {
27
- private readonly m: Float32Array[] = [];
28
- private readonly v: Float32Array[] = [];
48
+ // Per-parameter moments, or null for tensors this shard does not own (no memory).
49
+ private readonly m: (Float32Array | null)[] = [];
50
+ private readonly v: (Float32Array | null)[] = [];
29
51
  private t = 0;
30
- private readonly opt: Required<AdamWOptions>;
52
+ private readonly opt: Required<Omit<AdamWOptions, "shard">>;
53
+ private readonly shard: ShardSpec | null;
31
54
 
32
55
  constructor(
33
56
  private readonly target: OptimTarget,
@@ -40,13 +63,29 @@ export class AdamW {
40
63
  eps: options.eps ?? 1e-8,
41
64
  weightDecay: options.weightDecay ?? 0,
42
65
  };
43
- for (const p of target.parameters()) {
44
- this.m.push(new Float32Array(p.data.length));
45
- this.v.push(new Float32Array(p.data.length));
66
+ const shard = options.shard ?? null;
67
+ if (shard && (shard.count <= 0 || shard.index < 0 || shard.index >= shard.count)) {
68
+ throw new Error(`AdamW: invalid shard ${shard.index}/${shard.count}`);
69
+ }
70
+ this.shard = shard;
71
+ const params = target.parameters();
72
+ for (let p = 0; p < params.length; p++) {
73
+ if (this._owns(p)) {
74
+ this.m.push(new Float32Array(params[p]!.data.length));
75
+ this.v.push(new Float32Array(params[p]!.data.length));
76
+ } else {
77
+ this.m.push(null);
78
+ this.v.push(null);
79
+ }
46
80
  }
47
81
  }
48
82
 
49
- /** One optimiser step from the currently-accumulated gradients. */
83
+ /** Whether this shard owns (and updates) parameter tensor `p`. */
84
+ private _owns(p: number): boolean {
85
+ return this.shard === null || p % this.shard.count === this.shard.index;
86
+ }
87
+
88
+ /** One optimiser step from the currently-accumulated gradients (owned tensors only). */
50
89
  step(): void {
51
90
  this.t++;
52
91
  const { lr, beta1, beta2, eps, weightDecay } = this.opt;
@@ -55,6 +94,7 @@ export class AdamW {
55
94
  const bc1 = 1 - Math.pow(beta1, this.t);
56
95
  const bc2 = 1 - Math.pow(beta2, this.t);
57
96
  for (let p = 0; p < params.length; p++) {
97
+ if (!this._owns(p)) continue;
58
98
  const w = params[p]!.data;
59
99
  const g = grads[p]!.data;
60
100
  const m = this.m[p]!;
@@ -69,4 +109,12 @@ export class AdamW {
69
109
  }
70
110
  }
71
111
  }
112
+
113
+ /** Bytes of optimizer state this shard holds (m + v) — 1/count of the full state. */
114
+ stateBytes(): number {
115
+ let n = 0;
116
+ for (const arr of this.m) if (arr) n += arr.byteLength;
117
+ for (const arr of this.v) if (arr) n += arr.byteLength;
118
+ return n;
119
+ }
72
120
  }