@seanhogg/builderforce-memory-engine 2026.6.39 → 2026.7.12
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.
- package/dist/codec/evermind_video.d.ts +35 -0
- package/dist/codec/evermind_video.d.ts.map +1 -0
- package/dist/codec/evermind_video.js +37 -0
- package/dist/codec/evermind_video.js.map +1 -0
- package/dist/codec/image_rvq.d.ts +28 -0
- package/dist/codec/image_rvq.d.ts.map +1 -0
- package/dist/codec/image_rvq.js +39 -0
- package/dist/codec/image_rvq.js.map +1 -0
- package/dist/codec/index.d.ts +13 -0
- package/dist/codec/index.d.ts.map +1 -0
- package/dist/codec/index.js +11 -0
- package/dist/codec/index.js.map +1 -0
- package/dist/codec/multimodal_vocab.d.ts +79 -0
- package/dist/codec/multimodal_vocab.d.ts.map +1 -0
- package/dist/codec/multimodal_vocab.js +96 -0
- package/dist/codec/multimodal_vocab.js.map +1 -0
- package/dist/codec/video_rvq.d.ts +116 -0
- package/dist/codec/video_rvq.d.ts.map +1 -0
- package/dist/codec/video_rvq.js +450 -0
- package/dist/codec/video_rvq.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/kernels/selective_scan.d.ts.map +1 -1
- package/dist/kernels/selective_scan.js +9 -3
- package/dist/kernels/selective_scan.js.map +1 -1
- package/dist/kernels/ssd.d.ts.map +1 -1
- package/dist/kernels/ssd.js +8 -2
- package/dist/kernels/ssd.js.map +1 -1
- package/dist/kernels/weight_update.d.ts.map +1 -1
- package/dist/kernels/weight_update.js +22 -3
- package/dist/kernels/weight_update.js.map +1 -1
- package/dist/limbic/limbic_trainer.d.ts +5 -1
- package/dist/limbic/limbic_trainer.d.ts.map +1 -1
- package/dist/limbic/limbic_trainer.js +4 -2
- package/dist/limbic/limbic_trainer.js.map +1 -1
- package/dist/model/mamba_model.d.ts +17 -0
- package/dist/model/mamba_model.d.ts.map +1 -1
- package/dist/model/mamba_model.js +27 -0
- package/dist/model/mamba_model.js.map +1 -1
- package/dist/moe/index.d.ts +1 -1
- package/dist/moe/index.d.ts.map +1 -1
- package/dist/moe/moe_package.d.ts +40 -2
- package/dist/moe/moe_package.d.ts.map +1 -1
- package/dist/moe/moe_package.js +90 -7
- package/dist/moe/moe_package.js.map +1 -1
- package/dist/training/trainer.d.ts +34 -2
- package/dist/training/trainer.d.ts.map +1 -1
- package/dist/training/trainer.js +76 -26
- package/dist/training/trainer.js.map +1 -1
- package/package.json +1 -1
- package/src/codec/evermind_video.ts +55 -0
- package/src/codec/image_rvq.ts +50 -0
- package/src/codec/index.ts +16 -0
- package/src/codec/multimodal_vocab.ts +115 -0
- package/src/codec/video_rvq.ts +496 -0
- package/src/index.ts +22 -0
- package/src/kernels/selective_scan.ts +9 -3
- package/src/kernels/ssd.ts +8 -2
- package/src/kernels/weight_update.ts +22 -3
- package/src/limbic/limbic_trainer.ts +9 -4
- package/src/model/mamba_model.ts +27 -0
- package/src/moe/index.ts +1 -0
- package/src/moe/moe_package.ts +107 -6
- package/src/training/trainer.ts +92 -28
package/src/model/mamba_model.ts
CHANGED
|
@@ -413,6 +413,33 @@ export class HybridMambaModel {
|
|
|
413
413
|
return params;
|
|
414
414
|
}
|
|
415
415
|
|
|
416
|
+
/**
|
|
417
|
+
* The parameters a `learn()` / `adapt()` step is allowed to update.
|
|
418
|
+
*
|
|
419
|
+
* Under WSLA (write-through / narrow adaptation — the Evermind "update ==
|
|
420
|
+
* replace" path) this is ONLY the per-layer narrow subset (the input
|
|
421
|
+
* projections that produce Δ/B/C). The backbone — token embedding, final
|
|
422
|
+
* norm, and every block's `A_log` / conv / output projection — stays FROZEN.
|
|
423
|
+
* Freezing `A_log` is the key stability guarantee: the state-transition
|
|
424
|
+
* decay can never drift into a degenerate regime (state death / no-decay)
|
|
425
|
+
* across repeated adapts, which is what made the model "die after several
|
|
426
|
+
* executions". Outside WSLA every parameter trains (full fine-tune / distill).
|
|
427
|
+
*
|
|
428
|
+
* Param names are namespaced `layer{i}.{name}` to match {@link parameters},
|
|
429
|
+
* so a name-keyed optimizer (see MambaTrainer) reuses the same Adam moments
|
|
430
|
+
* whether it's stepping the full set or just the WSLA subset.
|
|
431
|
+
*/
|
|
432
|
+
getTrainableParams(): LayerParam[] {
|
|
433
|
+
if (!this._wslaMode) return this.parameters();
|
|
434
|
+
const params: LayerParam[] = [];
|
|
435
|
+
for (let i = 0; i < this.layers.length; i++) {
|
|
436
|
+
for (const p of this.layers[i]!.getTrainableParams()) {
|
|
437
|
+
params.push({ ...p, name: `layer${i}.${p.name}` });
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return params;
|
|
441
|
+
}
|
|
442
|
+
|
|
416
443
|
setWSLAMode(enabled: boolean): void {
|
|
417
444
|
for (const layer of this.layers) layer.setWSLAMode(enabled);
|
|
418
445
|
this._wslaMode = enabled;
|
package/src/moe/index.ts
CHANGED
package/src/moe/moe_package.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { SharedExpertMoE, type MoEConfig } from "./moe_model.js";
|
|
15
15
|
import { EvermindLM, type EvermindLMConfig } from "../lm/evermind_lm.js";
|
|
16
|
+
import { VideoRVQCodec } from "../codec/video_rvq.js";
|
|
16
17
|
|
|
17
18
|
/** First 4 bytes of a serialised package: "EVM1". */
|
|
18
19
|
const PKG_MAGIC = 0x45564d31;
|
|
@@ -21,6 +22,9 @@ const PKG_VERSION = 1;
|
|
|
21
22
|
/** The kinds of model an `.evermind` package can carry. */
|
|
22
23
|
export type EvermindModelType = "shared-expert-moe" | "evermind-lm";
|
|
23
24
|
|
|
25
|
+
/** Output modality. `text` (default) needs no codec; `video`/`image` bundle a VRQ codec. */
|
|
26
|
+
export type EvermindModality = "text" | "video" | "image";
|
|
27
|
+
|
|
24
28
|
/** Human-facing description published with the model (the "model card"). */
|
|
25
29
|
export interface EvermindModelCard {
|
|
26
30
|
description: string;
|
|
@@ -38,14 +42,26 @@ export interface EvermindModelManifest {
|
|
|
38
42
|
name: string;
|
|
39
43
|
version: string;
|
|
40
44
|
modelType: EvermindModelType;
|
|
45
|
+
/** Output modality; absent ⇒ "text". Video/image also carry a `codec` section. */
|
|
46
|
+
modality?: EvermindModality;
|
|
41
47
|
/** Flat numeric model config (the constructor args), serialised verbatim. */
|
|
42
48
|
config: Record<string, number>;
|
|
43
49
|
/** Total trainable scalar parameters (for sizing / pricing / display). */
|
|
44
50
|
paramCount: number;
|
|
45
51
|
checkpointFormat: "MoE0" | "EVL0";
|
|
46
52
|
checkpointFp16: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Byte length of the checkpoint section — REQUIRED when a codec section follows
|
|
55
|
+
* (so the reader knows where the checkpoint ends). Absent ⇒ checkpoint runs to
|
|
56
|
+
* end-of-blob (the original text-only layout, still read verbatim).
|
|
57
|
+
*/
|
|
58
|
+
checkpointBytes?: number;
|
|
47
59
|
/** 32-bit FNV-1a over the checkpoint bytes — integrity for download/purchase. */
|
|
48
60
|
checksum: number;
|
|
61
|
+
/** Media codec section format (present for video/image packages). */
|
|
62
|
+
codecFormat?: "VRQ0";
|
|
63
|
+
/** 32-bit FNV-1a over the codec bytes — integrity for the bundled codec. */
|
|
64
|
+
codecChecksum?: number;
|
|
49
65
|
card: EvermindModelCard;
|
|
50
66
|
/** ISO timestamp, caller-supplied (the engine avoids Date for determinism). */
|
|
51
67
|
createdAt?: string;
|
|
@@ -84,6 +100,8 @@ export class EvermindModelPackage {
|
|
|
84
100
|
constructor(
|
|
85
101
|
readonly manifest: EvermindModelManifest,
|
|
86
102
|
readonly checkpoint: ArrayBuffer,
|
|
103
|
+
/** Serialized media codec ("VRQ0" blob), present for video/image packages. */
|
|
104
|
+
readonly codec?: ArrayBuffer,
|
|
87
105
|
) {}
|
|
88
106
|
|
|
89
107
|
/** Package a trained model with its publishing metadata. */
|
|
@@ -126,17 +144,61 @@ export class EvermindModelPackage {
|
|
|
126
144
|
return new EvermindModelPackage(manifest, checkpoint);
|
|
127
145
|
}
|
|
128
146
|
|
|
129
|
-
/**
|
|
147
|
+
/**
|
|
148
|
+
* Package a video/image generative model: its {@link EvermindLM} weights AND the
|
|
149
|
+
* {@link VideoRVQCodec} needed to turn generated tokens back into frames. Without
|
|
150
|
+
* the bundled codec a served media model would emit undecodable token ids — this
|
|
151
|
+
* is what makes an `.evermind` media artifact self-contained and servable.
|
|
152
|
+
*/
|
|
153
|
+
static fromMediaLM(
|
|
154
|
+
lm: EvermindLM,
|
|
155
|
+
codec: VideoRVQCodec,
|
|
156
|
+
meta: PackageMeta & { modality: "video" | "image" },
|
|
157
|
+
): EvermindModelPackage {
|
|
158
|
+
if (lm.config.vocabSize !== codec.vocabSize) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`fromMediaLM: LM vocabSize (${lm.config.vocabSize}) must equal codec.vocabSize (${codec.vocabSize})`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
const fp16 = meta.fp16 ?? false;
|
|
164
|
+
const checkpoint = lm.exportWeights({ fp16 });
|
|
165
|
+
const codecBlob = codec.serialize();
|
|
166
|
+
const manifest: EvermindModelManifest = {
|
|
167
|
+
schema: "evermind.model/1",
|
|
168
|
+
name: meta.name,
|
|
169
|
+
version: meta.version,
|
|
170
|
+
modelType: "evermind-lm",
|
|
171
|
+
modality: meta.modality,
|
|
172
|
+
config: lm.config as unknown as Record<string, number>,
|
|
173
|
+
paramCount: lm.parameters().reduce((n, p) => n + p.data.length, 0),
|
|
174
|
+
checkpointFormat: "EVL0",
|
|
175
|
+
checkpointFp16: fp16,
|
|
176
|
+
checkpointBytes: checkpoint.byteLength,
|
|
177
|
+
checksum: fnv1a(new Uint8Array(checkpoint)),
|
|
178
|
+
codecFormat: "VRQ0",
|
|
179
|
+
codecChecksum: fnv1a(new Uint8Array(codecBlob)),
|
|
180
|
+
card: meta.card,
|
|
181
|
+
...(meta.createdAt ? { createdAt: meta.createdAt } : {}),
|
|
182
|
+
};
|
|
183
|
+
return new EvermindModelPackage(manifest, checkpoint, codecBlob);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Serialise to a single `.evermind` blob: magic, version, manifest, checkpoint[, codec]. */
|
|
130
187
|
toBlob(): ArrayBuffer {
|
|
131
188
|
const manifestBytes = new TextEncoder().encode(JSON.stringify(this.manifest));
|
|
132
189
|
const headerBytes = 12; // magic, version, manifestLen
|
|
133
|
-
const
|
|
190
|
+
const codecBytes = this.codec?.byteLength ?? 0;
|
|
191
|
+
const out = new ArrayBuffer(headerBytes + manifestBytes.byteLength + this.checkpoint.byteLength + codecBytes);
|
|
134
192
|
const head = new Uint32Array(out, 0, 3);
|
|
135
193
|
head[0] = PKG_MAGIC;
|
|
136
194
|
head[1] = PKG_VERSION;
|
|
137
195
|
head[2] = manifestBytes.byteLength;
|
|
138
|
-
|
|
139
|
-
new Uint8Array(out,
|
|
196
|
+
let o = headerBytes;
|
|
197
|
+
new Uint8Array(out, o, manifestBytes.byteLength).set(manifestBytes);
|
|
198
|
+
o += manifestBytes.byteLength;
|
|
199
|
+
new Uint8Array(out, o, this.checkpoint.byteLength).set(new Uint8Array(this.checkpoint));
|
|
200
|
+
o += this.checkpoint.byteLength;
|
|
201
|
+
if (this.codec) new Uint8Array(out, o, codecBytes).set(new Uint8Array(this.codec));
|
|
140
202
|
return out;
|
|
141
203
|
}
|
|
142
204
|
|
|
@@ -152,8 +214,19 @@ export class EvermindModelPackage {
|
|
|
152
214
|
}
|
|
153
215
|
const manifestBytes = new Uint8Array(buffer, headerBytes, manifestLen);
|
|
154
216
|
const manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as EvermindModelManifest;
|
|
155
|
-
const
|
|
156
|
-
|
|
217
|
+
const bodyStart = headerBytes + manifestLen;
|
|
218
|
+
// A codec section follows the checkpoint only when the manifest fixed the
|
|
219
|
+
// checkpoint length; otherwise the checkpoint runs to end-of-blob (text layout).
|
|
220
|
+
const cpBytes = manifest.checkpointBytes;
|
|
221
|
+
if (cpBytes != null) {
|
|
222
|
+
if (bodyStart + cpBytes > buffer.byteLength) {
|
|
223
|
+
throw new Error("EvermindModelPackage.fromBlob: truncated (checkpointBytes exceeds blob)");
|
|
224
|
+
}
|
|
225
|
+
const checkpoint = buffer.slice(bodyStart, bodyStart + cpBytes);
|
|
226
|
+
const codec = bodyStart + cpBytes < buffer.byteLength ? buffer.slice(bodyStart + cpBytes) : undefined;
|
|
227
|
+
return new EvermindModelPackage(manifest, checkpoint, codec);
|
|
228
|
+
}
|
|
229
|
+
return new EvermindModelPackage(manifest, buffer.slice(bodyStart));
|
|
157
230
|
}
|
|
158
231
|
|
|
159
232
|
/** Verify integrity + structural sanity before trusting a downloaded package. */
|
|
@@ -176,6 +249,19 @@ export class EvermindModelPackage {
|
|
|
176
249
|
if (actual !== this.manifest.checksum) {
|
|
177
250
|
errors.push(`checksum mismatch (manifest ${this.manifest.checksum}, actual ${actual}) — corrupt or tampered checkpoint`);
|
|
178
251
|
}
|
|
252
|
+
// Media packages must carry an integral codec — a served media model can't
|
|
253
|
+
// decode tokens → frames without it.
|
|
254
|
+
const modality = this.manifest.modality ?? "text";
|
|
255
|
+
if (modality === "video" || modality === "image") {
|
|
256
|
+
if (!this.codec) {
|
|
257
|
+
errors.push(`modality '${modality}' package is missing its codec section`);
|
|
258
|
+
} else {
|
|
259
|
+
const codecActual = fnv1a(new Uint8Array(this.codec));
|
|
260
|
+
if (codecActual !== this.manifest.codecChecksum) {
|
|
261
|
+
errors.push(`codec checksum mismatch (manifest ${this.manifest.codecChecksum}, actual ${codecActual}) — corrupt codec`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
179
265
|
return { ok: errors.length === 0, errors };
|
|
180
266
|
}
|
|
181
267
|
|
|
@@ -202,4 +288,19 @@ export class EvermindModelPackage {
|
|
|
202
288
|
lm.loadWeights(this.checkpoint);
|
|
203
289
|
return lm;
|
|
204
290
|
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Reconstruct a video/image model: the generative {@link EvermindLM} plus its
|
|
294
|
+
* {@link VideoRVQCodec}. Pair with `generateVideo` / `generateImage` to produce
|
|
295
|
+
* frames. This is what a media-serving path (gateway) loads and runs.
|
|
296
|
+
*/
|
|
297
|
+
loadMediaLM(): { lm: EvermindLM; codec: VideoRVQCodec; modality: EvermindModality } {
|
|
298
|
+
const v = this.validate();
|
|
299
|
+
if (!v.ok) throw new Error(`EvermindModelPackage.loadMediaLM: ${v.errors.join("; ")}`);
|
|
300
|
+
const modality = this.manifest.modality ?? "text";
|
|
301
|
+
if (modality !== "video" && modality !== "image") {
|
|
302
|
+
throw new Error(`loadMediaLM: package modality is '${modality}', use loadLM()`);
|
|
303
|
+
}
|
|
304
|
+
return { lm: this.loadLM(), codec: VideoRVQCodec.deserialize(this.codec!), modality };
|
|
305
|
+
}
|
|
205
306
|
}
|
package/src/training/trainer.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
createComputePipeline,
|
|
10
10
|
createBindGroup,
|
|
11
11
|
dispatchKernel,
|
|
12
|
+
readBuffer,
|
|
12
13
|
cdiv,
|
|
13
14
|
} from '../utils/gpu_utils.js';
|
|
14
15
|
|
|
@@ -29,9 +30,26 @@ export interface TrainOptions {
|
|
|
29
30
|
beta2?: number;
|
|
30
31
|
eps?: number;
|
|
31
32
|
wsla?: boolean;
|
|
32
|
-
|
|
33
|
+
/** Trust region: max |Δθ| per optimizer step. 0 disables. Defaults to
|
|
34
|
+
* {@link WSLA_MAX_DELTA} in WSLA (write-through) mode, else 0 (full training). */
|
|
35
|
+
maxDelta?: number;
|
|
36
|
+
/** Read back the pre-clip gradient L2 norm each step (the `grad_norm_reduce`
|
|
37
|
+
* kernel already computes it — this just maps it to the CPU). OFF by default: the
|
|
38
|
+
* readback is a GPU sync point that would slow a long finetune. `adapt()` turns it
|
|
39
|
+
* ON so the norm reaches {@link AdaptResult} as an instability signal. */
|
|
40
|
+
trackGradNorm?: boolean;
|
|
41
|
+
onEpochEnd?: ((epoch: number, loss: number, gradNorm?: number) => void) | null;
|
|
33
42
|
}
|
|
34
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Default per-step trust region for WSLA / write-through adaptation. Small
|
|
46
|
+
* enough that a single `adapt()` nudges the narrow params without lurching, so
|
|
47
|
+
* repeated adapts stay stable (and any that regress are cheaply rolled back by
|
|
48
|
+
* the session). Full-training callers pass `maxDelta: 0` (or omit it in
|
|
49
|
+
* non-WSLA mode) for unbounded steps.
|
|
50
|
+
*/
|
|
51
|
+
export const WSLA_MAX_DELTA = 0.05;
|
|
52
|
+
|
|
35
53
|
interface AdamMoments {
|
|
36
54
|
m: GPUBuffer;
|
|
37
55
|
v: GPUBuffer;
|
|
@@ -45,13 +63,22 @@ interface AdamHyperparams {
|
|
|
45
63
|
eps: number;
|
|
46
64
|
beta1_t: number;
|
|
47
65
|
beta2_t: number;
|
|
66
|
+
maxDelta: number;
|
|
48
67
|
}
|
|
49
68
|
|
|
50
69
|
export class MambaTrainer {
|
|
51
70
|
model: HybridMambaModel;
|
|
52
71
|
tokenizer: BPETokenizer | null;
|
|
53
72
|
device: GPUDevice;
|
|
54
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Adam moments keyed by parameter NAME (not array index). Name-keying is what
|
|
75
|
+
* lets WSLA toggle safely: a narrow write-through step updates only the
|
|
76
|
+
* `layer{i}.wXProj/bXProj` subset, a full fine-tune updates everything, and
|
|
77
|
+
* both reuse the SAME `m`/`v` buffers per parameter. Index-keyed moments (the
|
|
78
|
+
* old scheme) silently misaligned the moment with the wrong parameter the
|
|
79
|
+
* moment the trainable set changed shape — corrupting the update.
|
|
80
|
+
*/
|
|
81
|
+
private _moments: Map<string, AdamMoments>;
|
|
55
82
|
private _step: number;
|
|
56
83
|
private _adamwPipeline: GPUComputePipeline;
|
|
57
84
|
private _clipReducePipeline: GPUComputePipeline;
|
|
@@ -62,7 +89,7 @@ export class MambaTrainer {
|
|
|
62
89
|
this.tokenizer = tokenizer;
|
|
63
90
|
this.device = model.device;
|
|
64
91
|
|
|
65
|
-
this._moments =
|
|
92
|
+
this._moments = new Map();
|
|
66
93
|
this._step = 0;
|
|
67
94
|
|
|
68
95
|
this._adamwPipeline = createComputePipeline(this.device, WEIGHT_UPDATE_WGSL, 'adamw_update');
|
|
@@ -70,12 +97,17 @@ export class MambaTrainer {
|
|
|
70
97
|
this._clipScalePipeline = createComputePipeline(this.device, GRAD_CLIP_WGSL, 'grad_clip_scale');
|
|
71
98
|
}
|
|
72
99
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
100
|
+
/** Get-or-create the Adam first/second moments for a parameter, by name. */
|
|
101
|
+
private _momentFor(p: BlockParam): AdamMoments {
|
|
102
|
+
let mom = this._moments.get(p.name);
|
|
103
|
+
if (!mom) {
|
|
104
|
+
mom = {
|
|
105
|
+
m: createEmptyStorageBuffer(this.device, p.numel * 4, false),
|
|
106
|
+
v: createEmptyStorageBuffer(this.device, p.numel * 4, false),
|
|
107
|
+
};
|
|
108
|
+
this._moments.set(p.name, mom);
|
|
109
|
+
}
|
|
110
|
+
return mom;
|
|
79
111
|
}
|
|
80
112
|
|
|
81
113
|
async train(input: string | number[], opts: TrainOptions = {}): Promise<number[]> {
|
|
@@ -90,8 +122,11 @@ export class MambaTrainer {
|
|
|
90
122
|
beta2 = 0.999,
|
|
91
123
|
eps = 1e-8,
|
|
92
124
|
wsla = false,
|
|
125
|
+
trackGradNorm = false,
|
|
93
126
|
onEpochEnd = null,
|
|
94
127
|
} = opts;
|
|
128
|
+
// Trust region defaults ON for write-through (WSLA) adapts, OFF otherwise.
|
|
129
|
+
const maxDelta = opts.maxDelta ?? (wsla ? WSLA_MAX_DELTA : 0);
|
|
95
130
|
|
|
96
131
|
if (wsla) this.model.setWSLAMode(true);
|
|
97
132
|
|
|
@@ -117,27 +152,33 @@ export class MambaTrainer {
|
|
|
117
152
|
throw new Error('Input is too short to form any training chunk.');
|
|
118
153
|
}
|
|
119
154
|
|
|
120
|
-
|
|
155
|
+
// Moments are created lazily per-parameter (name-keyed) on first touch —
|
|
156
|
+
// no upfront allocation needed, and it stays correct as WSLA narrows the
|
|
157
|
+
// trainable set.
|
|
121
158
|
|
|
122
159
|
const epochLosses: number[] = [];
|
|
123
160
|
|
|
124
161
|
for (let epoch = 0; epoch < epochs; epoch++) {
|
|
125
162
|
let epochLoss = 0;
|
|
163
|
+
let epochGradNorm = 0;
|
|
126
164
|
let numSteps = 0;
|
|
127
165
|
|
|
128
166
|
for (const { inputs, targets } of chunks) {
|
|
129
|
-
const loss = await this._trainStep(
|
|
167
|
+
const { loss, gradNorm } = await this._trainStep(
|
|
130
168
|
inputs, targets, batchSize,
|
|
131
|
-
{ learningRate, maxGradNorm, weightDecay, beta1, beta2, eps, wsla }
|
|
169
|
+
{ learningRate, maxGradNorm, weightDecay, beta1, beta2, eps, wsla, maxDelta, trackGradNorm }
|
|
132
170
|
);
|
|
133
171
|
epochLoss += loss;
|
|
172
|
+
if (gradNorm != null) epochGradNorm += gradNorm;
|
|
134
173
|
numSteps++;
|
|
135
174
|
}
|
|
136
175
|
|
|
137
176
|
const avgLoss = epochLoss / numSteps;
|
|
138
177
|
epochLosses.push(avgLoss);
|
|
139
178
|
|
|
140
|
-
|
|
179
|
+
// Mean pre-clip grad norm this epoch (only measured when trackGradNorm is on).
|
|
180
|
+
const avgGradNorm = trackGradNorm ? epochGradNorm / numSteps : undefined;
|
|
181
|
+
if (onEpochEnd) onEpochEnd(epoch + 1, avgLoss, avgGradNorm);
|
|
141
182
|
}
|
|
142
183
|
|
|
143
184
|
if (wsla) this.model.setWSLAMode(false);
|
|
@@ -148,9 +189,9 @@ export class MambaTrainer {
|
|
|
148
189
|
inputs: number[],
|
|
149
190
|
targets: number[],
|
|
150
191
|
batch: number,
|
|
151
|
-
hyperparams: TrainOptions & { learningRate: number; maxGradNorm: number; weightDecay: number; beta1: number; beta2: number; eps: number }
|
|
152
|
-
): Promise<number> {
|
|
153
|
-
const { learningRate, maxGradNorm, weightDecay, beta1, beta2, eps } = hyperparams;
|
|
192
|
+
hyperparams: TrainOptions & { learningRate: number; maxGradNorm: number; weightDecay: number; beta1: number; beta2: number; eps: number; maxDelta: number; trackGradNorm?: boolean }
|
|
193
|
+
): Promise<{ loss: number; gradNorm: number | null }> {
|
|
194
|
+
const { learningRate, maxGradNorm, weightDecay, beta1, beta2, eps, maxDelta, trackGradNorm } = hyperparams;
|
|
154
195
|
|
|
155
196
|
this._step++;
|
|
156
197
|
const seqLen = inputs.length;
|
|
@@ -177,21 +218,24 @@ export class MambaTrainer {
|
|
|
177
218
|
|
|
178
219
|
const dLogitsBuf = createStorageBuffer(this.device, dLogits, false);
|
|
179
220
|
|
|
180
|
-
await this._clipGradients(dLogitsBuf, dLogits.length, maxGradNorm);
|
|
221
|
+
const gradNorm = await this._clipGradients(dLogitsBuf, dLogits.length, maxGradNorm, !!trackGradNorm);
|
|
181
222
|
|
|
182
|
-
|
|
223
|
+
// Only the trainable set is updated. Under WSLA (write-through) that is
|
|
224
|
+
// the narrow per-layer subset and the backbone (incl. every A_log) stays
|
|
225
|
+
// frozen — the guarantee that repeated adapts can't destabilise the SSM.
|
|
226
|
+
const params = this.model.getTrainableParams();
|
|
183
227
|
const beta1_t = Math.pow(beta1, this._step);
|
|
184
228
|
const beta2_t = Math.pow(beta2, this._step);
|
|
185
229
|
|
|
186
230
|
await this._adamwStep(
|
|
187
231
|
params, [dLogitsBuf],
|
|
188
|
-
{ learningRate, weightDecay, beta1, beta2, eps, beta1_t, beta2_t }
|
|
232
|
+
{ learningRate, weightDecay, beta1, beta2, eps, beta1_t, beta2_t, maxDelta }
|
|
189
233
|
);
|
|
190
234
|
|
|
191
235
|
dLogitsBuf.destroy();
|
|
192
236
|
gpuLogits.destroy();
|
|
193
237
|
|
|
194
|
-
return loss;
|
|
238
|
+
return { loss, gradNorm };
|
|
195
239
|
}
|
|
196
240
|
|
|
197
241
|
private async _adamwStep(
|
|
@@ -199,7 +243,7 @@ export class MambaTrainer {
|
|
|
199
243
|
gradBufs: GPUBuffer[],
|
|
200
244
|
hp: AdamHyperparams
|
|
201
245
|
): Promise<void> {
|
|
202
|
-
const { learningRate, weightDecay, beta1, beta2, eps, beta1_t, beta2_t } = hp;
|
|
246
|
+
const { learningRate, weightDecay, beta1, beta2, eps, beta1_t, beta2_t, maxDelta } = hp;
|
|
203
247
|
|
|
204
248
|
for (let i = 0; i < params.length; i++) {
|
|
205
249
|
const p = params[i]!;
|
|
@@ -207,16 +251,17 @@ export class MambaTrainer {
|
|
|
207
251
|
|
|
208
252
|
if (!gradBuf || gradBuf.size < p.numel * 4) continue;
|
|
209
253
|
|
|
254
|
+
const mom = this._momentFor(p);
|
|
210
255
|
const paramsBuf = createUniformBuffer(this.device, packAdamParams(
|
|
211
|
-
p.numel, learningRate, beta1, beta2, eps, weightDecay, beta1_t, beta2_t
|
|
256
|
+
p.numel, learningRate, beta1, beta2, eps, weightDecay, beta1_t, beta2_t, maxDelta
|
|
212
257
|
));
|
|
213
258
|
|
|
214
259
|
const bg = createBindGroup(this.device, this._adamwPipeline, [
|
|
215
260
|
paramsBuf,
|
|
216
261
|
p.buf,
|
|
217
262
|
gradBuf,
|
|
218
|
-
|
|
219
|
-
|
|
263
|
+
mom.m,
|
|
264
|
+
mom.v,
|
|
220
265
|
]);
|
|
221
266
|
|
|
222
267
|
dispatchKernel(this.device, this._adamwPipeline, bg,
|
|
@@ -226,7 +271,14 @@ export class MambaTrainer {
|
|
|
226
271
|
}
|
|
227
272
|
}
|
|
228
273
|
|
|
229
|
-
|
|
274
|
+
/**
|
|
275
|
+
* Clip gradients to `maxNorm` in place. The reduce kernel writes Σg² into
|
|
276
|
+
* `normSqBuf`; the scale kernel then rescales the grads. When `trackNorm` is set
|
|
277
|
+
* we read Σg² back and return the true pre-clip L2 norm (√Σg²) — the kernel
|
|
278
|
+
* already computed it, so this is only a small buffer readback (a GPU sync,
|
|
279
|
+
* hence opt-in). Returns null when not tracking.
|
|
280
|
+
*/
|
|
281
|
+
private async _clipGradients(gradBuf: GPUBuffer, numel: number, maxNorm: number, trackNorm = false): Promise<number | null> {
|
|
230
282
|
const normSqBuf = createEmptyStorageBuffer(this.device, 4, true);
|
|
231
283
|
this.device.queue.writeBuffer(normSqBuf, 0, new Float32Array([0.0]));
|
|
232
284
|
|
|
@@ -240,6 +292,14 @@ export class MambaTrainer {
|
|
|
240
292
|
dispatchKernel(this.device, this._clipReducePipeline, bg1,
|
|
241
293
|
[cdiv(numel, 256), 1, 1]);
|
|
242
294
|
|
|
295
|
+
// Read Σg² AFTER the reduce (the scale kernel only reads it, never overwrites),
|
|
296
|
+
// so the returned norm is the pre-clip magnitude — the instability signal.
|
|
297
|
+
let gradNorm: number | null = null;
|
|
298
|
+
if (trackNorm) {
|
|
299
|
+
const out = await readBuffer(this.device, normSqBuf, 4);
|
|
300
|
+
gradNorm = Math.sqrt(Math.max(0, out[0] ?? 0));
|
|
301
|
+
}
|
|
302
|
+
|
|
243
303
|
const bg2 = createBindGroup(this.device, this._clipScalePipeline,
|
|
244
304
|
[pBuf, gradBuf, normSqBuf]);
|
|
245
305
|
dispatchKernel(this.device, this._clipScalePipeline, bg2,
|
|
@@ -247,6 +307,7 @@ export class MambaTrainer {
|
|
|
247
307
|
|
|
248
308
|
pBuf.destroy();
|
|
249
309
|
normSqBuf.destroy();
|
|
310
|
+
return gradNorm;
|
|
250
311
|
}
|
|
251
312
|
|
|
252
313
|
async evaluate(input: string | number[]): Promise<number> {
|
|
@@ -300,10 +361,13 @@ function buildChunks(ids: number[], seqLen: number): Array<{inputs: number[], ta
|
|
|
300
361
|
|
|
301
362
|
function packAdamParams(
|
|
302
363
|
numElements: number, lr: number, beta1: number, beta2: number,
|
|
303
|
-
eps: number, weightDecay: number, beta1_t: number, beta2_t: number
|
|
364
|
+
eps: number, weightDecay: number, beta1_t: number, beta2_t: number,
|
|
365
|
+
maxDelta: number
|
|
304
366
|
): ArrayBuffer {
|
|
305
|
-
|
|
367
|
+
// 48 bytes: u32 + 8×f32 = 36, padded to a 16-byte multiple for the uniform
|
|
368
|
+
// layout (matches AdamParams' _pad0.._pad2 in WEIGHT_UPDATE_WGSL).
|
|
369
|
+
const buf = new ArrayBuffer(48);
|
|
306
370
|
new Uint32Array(buf, 0, 1).set([numElements]);
|
|
307
|
-
new Float32Array(buf, 4,
|
|
371
|
+
new Float32Array(buf, 4, 8).set([lr, beta1, beta2, eps, weightDecay, beta1_t, beta2_t, maxDelta]);
|
|
308
372
|
return buf;
|
|
309
373
|
}
|