mlx-bun 0.0.4 → 0.0.5
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/README.md +166 -93
- package/docs/design/adapters-end-to-end.md +50 -46
- package/docs/design/compat-cli-surface-design.md +10 -8
- package/docs/design/docs-reorg-plan.md +14 -1
- package/docs/design/hlg-sampling.md +12 -2
- package/docs/design/memory-system.md +1 -1
- package/docs/design/optimization_plan.md +4 -2
- package/docs/design/orpo-future-enhancements.md +298 -0
- package/docs/design/orpo-training.md +1199 -0
- package/docs/design/parallel-slots.md +34 -26
- package/docs/design/runtime-isolation.md +1 -1
- package/docs/design/segmented-backward-training.md +58 -39
- package/docs/design/tauri-desktop-app.md +127 -0
- package/docs/{segmented-backward-handoff.md → investigations/segmented-backward-handoff.md} +1 -1
- package/docs/investigations/starter-model-port-handoff.md +32 -13
- package/docs/investigations/steel-flash-cce-handoff.md +431 -0
- package/docs/planning/IDEAS.md +29 -0
- package/docs/planning/PRODUCT_ROADMAP.md +147 -27
- package/docs/planning/chunk-task-roadmap.md +1 -1
- package/docs/planning/journal.md +161 -0
- package/docs/reference/distribution.md +61 -5
- package/docs/reference/library-api.md +29 -14
- package/docs/reference/orpo-quickstart.md +91 -0
- package/docs/reference/server-api.md +105 -7
- package/docs/reference/server-config.md +12 -5
- package/docs/reference/training.md +292 -0
- package/package.json +1 -1
- package/src/eval/ifeval.ts +138 -0
- package/src/eval/ultrafeedback.ts +60 -0
- package/src/lora.ts +12 -7
- package/src/mlx/metal-kernel.ts +18 -4
- package/src/model/flash-attention.ts +32 -16
- package/src/model/fused-geglu-kernel.ts +111 -0
- package/src/model/gemma4-base.ts +39 -1
- package/src/model/gemma4.ts +196 -21
- package/src/model/minicpm5.ts +44 -3
- package/src/server.ts +3 -3
- package/src/train/dataset.ts +18 -3
- package/src/train/flash-cce.ts +1162 -0
- package/src/train/job.ts +26 -2
- package/src/train/lora-params.ts +53 -4
- package/src/train/loss.ts +941 -0
- package/src/train/optimizer.ts +10 -3
- package/src/train/prefix-shared.ts +435 -0
- package/src/train/rank.ts +36 -0
- package/src/train/segmented.ts +1148 -3
- package/src/train/steel-qmm-header.ts +1461 -0
- package/src/train/trainer.ts +509 -52
- package/src/web/app.html +21 -5
- /package/{docs → src/assets}/curve-designer.html +0 -0
package/src/model/gemma4.ts
CHANGED
|
@@ -51,22 +51,48 @@ import {
|
|
|
51
51
|
type SharedKv,
|
|
52
52
|
type LoraWeights,
|
|
53
53
|
} from "./gemma4-base";
|
|
54
|
-
import { fusedGeglu, fusedGeluEnabled, fusedGeluSupported } from "./fused-geglu-kernel";
|
|
54
|
+
import { fusedGeglu, fusedGegluDifferentiable, fusedGeluEnabled, fusedGeluSupported, fusedGeluTraining } from "./fused-geglu-kernel";
|
|
55
55
|
import { Checkpoint } from "../mlx/checkpoint";
|
|
56
56
|
import { flashAttention, getTrainingAttn, flashSupported } from "./flash-attention";
|
|
57
57
|
|
|
58
58
|
/** Optional gradient-checkpointing context for the training forward. When set,
|
|
59
59
|
* forwardLayers wraps each layer in a Checkpoint so its interior activations
|
|
60
60
|
* are recomputed in the backward pass. `byLayer` maps a layer index to that
|
|
61
|
-
* layer's trainable LoRA weights
|
|
62
|
-
*
|
|
63
|
-
*
|
|
61
|
+
* layer's trainable LoRA weights, partitioned into the attention sub-block
|
|
62
|
+
* (q/k/v/o_proj) and the MLP sub-block (gate/up/down_proj + per-layer gate/
|
|
63
|
+
* projection) — re-swapped inside the closure so they are explicit checkpoint
|
|
64
|
+
* inputs (required because the autograd primals are disposed before the
|
|
65
|
+
* recompute). When `splitMlp` is set, the layer is wrapped in TWO checkpoints
|
|
66
|
+
* (attn sub-block + MLP sub-block) with the post-attn residual `hMid` as the
|
|
67
|
+
* boundary, so the backward recompute holds `max(attn,MLP)+hMid` instead of the
|
|
68
|
+
* whole layer (the intra-layer MLP split, lever 4). `keepAlive` collects the
|
|
64
69
|
* Checkpoint objects; the trainer disposes them after value_and_grad. */
|
|
70
|
+
export interface LayerLoras {
|
|
71
|
+
attn: LoraWeights[];
|
|
72
|
+
mlp: LoraWeights[];
|
|
73
|
+
}
|
|
65
74
|
export interface GradCheckpointCtx {
|
|
66
|
-
byLayer: Map<number,
|
|
75
|
+
byLayer: Map<number, LayerLoras>;
|
|
76
|
+
splitMlp: boolean;
|
|
67
77
|
keepAlive: Checkpoint[];
|
|
68
78
|
}
|
|
69
79
|
|
|
80
|
+
// Shared prompt-prefix plan (lever 7, e4b — docs/design/orpo-training.md). When
|
|
81
|
+
// set, Attention ropes the concatenated [prompt(P); chosenResp(Rc); rejectedResp(Rr)]
|
|
82
|
+
// sequence BLOCK-WISE — prompt at offset 0, EACH response reset to logical offset
|
|
83
|
+
// P — instead of the uniform cache.offset, so ONE forward over the concat is
|
|
84
|
+
// bit-exact with the two separate [prompt;resp] forwards (each response sees the
|
|
85
|
+
// prompt's RoPE positions, not shifted-by-the-other-response). The matching
|
|
86
|
+
// block-sparse + LOGICAL-window attention mask rides in via the prefix cache's
|
|
87
|
+
// makeMask (src/train/prefix-shared.ts). Set around the single forward
|
|
88
|
+
// (single-threaded), cleared after; null → the normal uniform-offset rope (every
|
|
89
|
+
// other forward is untouched).
|
|
90
|
+
export interface GemmaPrefixPlan { P: number; Rc: number; Rr: number }
|
|
91
|
+
let _gemmaPrefixPlan: GemmaPrefixPlan | null = null;
|
|
92
|
+
export function setGemmaPrefixPlan(p: GemmaPrefixPlan | null): void {
|
|
93
|
+
_gemmaPrefixPlan = p;
|
|
94
|
+
}
|
|
95
|
+
|
|
70
96
|
class Attention {
|
|
71
97
|
readonly isSliding: boolean;
|
|
72
98
|
readonly windowSize: number;
|
|
@@ -161,6 +187,32 @@ class Attention {
|
|
|
161
187
|
: ops.ropeDynamic(x, this.headDim, this.ropeBase, offset, this.ropeFreqs);
|
|
162
188
|
}
|
|
163
189
|
|
|
190
|
+
/** Block-wise RoPE for the prefix-shared concat [prompt; chosen; rejected] along
|
|
191
|
+
* the sequence axis (axis 2 of [B,H,T,D]): prompt at offset 0, EACH response
|
|
192
|
+
* reset to logical offset P. RoPE is per-token, so roping each contiguous block
|
|
193
|
+
* at its scalar offset and concatenating == roping with per-token position-ids
|
|
194
|
+
* [0..P-1, P..P+Rc-1, P..P+Rr-1]. Uses this.rope per block so the layer's
|
|
195
|
+
* proportional/ropeFreqs + sliding-vs-full geometry applies identically to the
|
|
196
|
+
* two-forward path. Caller disposes the input. */
|
|
197
|
+
ropeBlocks(x: MlxArray, plan: GemmaPrefixPlan): MlxArray {
|
|
198
|
+
const { P, Rc, Rr } = plan;
|
|
199
|
+
const [B, H, , D] = x.shape as [number, number, number, number];
|
|
200
|
+
const blocks = [
|
|
201
|
+
{ start: 0, len: P, off: 0 },
|
|
202
|
+
{ start: P, len: Rc, off: P },
|
|
203
|
+
{ start: P + Rc, len: Rr, off: P },
|
|
204
|
+
].filter((b) => b.len > 0);
|
|
205
|
+
const parts = blocks.map((b) => {
|
|
206
|
+
const sl = x.slice([0, 0, b.start, 0], [B, H, b.start + b.len, D]);
|
|
207
|
+
const r = this.rope(sl, b.off);
|
|
208
|
+
sl.dispose();
|
|
209
|
+
return r;
|
|
210
|
+
});
|
|
211
|
+
const out = ops.concatAxis(parts, 2);
|
|
212
|
+
for (const p of parts) p.dispose();
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
|
|
164
216
|
/** Returns the attention output plus the fetched KV (for KV-shared
|
|
165
217
|
* sharer layers and the speculative drafter). The SharedKv arrays are
|
|
166
218
|
* owned by the caller (forwardLayers) and disposed after the pass. */
|
|
@@ -203,7 +255,9 @@ class Attention {
|
|
|
203
255
|
const kNormed = this.kNorm!.forward(k);
|
|
204
256
|
const kT = ops.transposeAxes(kNormed, [0, 2, 1, 3]);
|
|
205
257
|
kNormed.dispose();
|
|
206
|
-
const kRoped =
|
|
258
|
+
const kRoped = _gemmaPrefixPlan
|
|
259
|
+
? this.ropeBlocks(kT, _gemmaPrefixPlan)
|
|
260
|
+
: this.rope(kT, offsetArr ?? offset);
|
|
207
261
|
kT.dispose();
|
|
208
262
|
|
|
209
263
|
const vNormed = this.vNorm!.forward(v);
|
|
@@ -230,7 +284,9 @@ class Attention {
|
|
|
230
284
|
}
|
|
231
285
|
|
|
232
286
|
q = disposing(q, ops.transposeAxes(q, [0, 2, 1, 3]));
|
|
233
|
-
q = disposing(q,
|
|
287
|
+
q = disposing(q, _gemmaPrefixPlan
|
|
288
|
+
? this.ropeBlocks(q, _gemmaPrefixPlan)
|
|
289
|
+
: this.rope(q, shared.offsetArr ?? shared.offset));
|
|
234
290
|
|
|
235
291
|
let attn: MlxArray;
|
|
236
292
|
const ta = getTrainingAttn();
|
|
@@ -273,9 +329,12 @@ class MLP {
|
|
|
273
329
|
const u = this.up.forward(x);
|
|
274
330
|
// Fused GeGLU perf kernel (opt-in, not in a compiled trace): one pass
|
|
275
331
|
// instead of ~9 element-wise kernels. Off → the bit-exact spelled path.
|
|
332
|
+
// In training mode the differentiable wrapper (kernel forward + hand-derived
|
|
333
|
+
// vjp) is used so autograd can flow AND the backward recomputes the gelu
|
|
334
|
+
// from the primal instead of retaining the spelled-out intermediates.
|
|
276
335
|
let h: MlxArray;
|
|
277
336
|
if (fusedGeluEnabled() && !isCompiledTrace() && fusedGeluSupported(g)) {
|
|
278
|
-
h = fusedGeglu(g, u);
|
|
337
|
+
h = fusedGeluTraining() ? fusedGegluDifferentiable(g, u) : fusedGeglu(g, u);
|
|
279
338
|
g.dispose();
|
|
280
339
|
u.dispose();
|
|
281
340
|
} else {
|
|
@@ -500,6 +559,23 @@ export class DecoderLayer {
|
|
|
500
559
|
forward(
|
|
501
560
|
x: MlxArray, mask: Mask, cache: Cache | null,
|
|
502
561
|
sharedIn: SharedKv | null, perLayerInput: MlxArray | null,
|
|
562
|
+
): { h: MlxArray; shared: SharedKv } {
|
|
563
|
+
// Composed from the two sub-blocks (the intra-layer min-cut is the post-attn
|
|
564
|
+
// residual `hMid` [1,T,hidden]). Behaviour is identical to the spelled-out
|
|
565
|
+
// body; the split lets the checkpoint path rematerialize attn and MLP
|
|
566
|
+
// separately (peak attn+MLP → max(attn,MLP)+hMid). `forwardMlp` borrows hMid.
|
|
567
|
+
const { h: hMid, shared } = this.forwardAttn(x, mask, cache, sharedIn);
|
|
568
|
+
const h = this.forwardMlp(hMid, perLayerInput);
|
|
569
|
+
hMid.dispose();
|
|
570
|
+
return { h, shared };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** Attention sub-block: inputNorm → attn → postAttnNorm → residual add.
|
|
574
|
+
* Returns the post-attn residual stream `h` [1,T,hidden] (the intra-layer
|
|
575
|
+
* boundary) and the attention's fetched/produced K/V (`shared`). `x` is
|
|
576
|
+
* borrowed (never disposed). The e4b donor-KV threading lives entirely here. */
|
|
577
|
+
forwardAttn(
|
|
578
|
+
x: MlxArray, mask: Mask, cache: Cache | null, sharedIn: SharedKv | null,
|
|
503
579
|
): { h: MlxArray; shared: SharedKv } {
|
|
504
580
|
let h = this.inputNorm.forward(x);
|
|
505
581
|
const { out, shared } = this.attn.forward(h, mask, cache, sharedIn);
|
|
@@ -507,18 +583,24 @@ export class DecoderLayer {
|
|
|
507
583
|
h = out;
|
|
508
584
|
h = disposing(h, this.postAttnNorm.forward(h));
|
|
509
585
|
h = disposing(h, ops.add(x, h));
|
|
586
|
+
return { h, shared };
|
|
587
|
+
}
|
|
510
588
|
|
|
511
|
-
|
|
589
|
+
/** MLP sub-block: FFN (dense or MoE) + postFfNorm + residual add, then the
|
|
590
|
+
* e4b per-layer-input gate and layer scalar. `hMid` (the attn sub-block's
|
|
591
|
+
* output) is BORROWED — the caller owns/disposes it (it is the checkpoint
|
|
592
|
+
* boundary leaf), so this never disposes it. */
|
|
593
|
+
forwardMlp(hMid: MlxArray, perLayerInput: MlxArray | null): MlxArray {
|
|
512
594
|
let f: MlxArray;
|
|
513
595
|
if (this.router && this.experts) {
|
|
514
|
-
// MoE: dense MLP and routed experts as parallel branches off
|
|
596
|
+
// MoE: dense MLP and routed experts as parallel branches off hMid
|
|
515
597
|
// (reference DecoderLayer, enable_moe path)
|
|
516
|
-
let h1 = this.preFfNorm.forward(
|
|
598
|
+
let h1 = this.preFfNorm.forward(hMid);
|
|
517
599
|
h1 = disposing(h1, this.mlp.forward(h1));
|
|
518
600
|
h1 = disposing(h1, this.postFfNorm1!.forward(h1));
|
|
519
601
|
|
|
520
|
-
const { indices, weights: topKWeights } = this.router.forward(
|
|
521
|
-
let h2 = this.preFfNorm2!.forward(
|
|
602
|
+
const { indices, weights: topKWeights } = this.router.forward(hMid);
|
|
603
|
+
let h2 = this.preFfNorm2!.forward(hMid);
|
|
522
604
|
h2 = disposing(h2, this.experts.forward(h2, indices, topKWeights));
|
|
523
605
|
h2 = disposing(h2, this.postFfNorm2!.forward(h2));
|
|
524
606
|
indices.dispose();
|
|
@@ -528,20 +610,25 @@ export class DecoderLayer {
|
|
|
528
610
|
h1.dispose();
|
|
529
611
|
h2.dispose();
|
|
530
612
|
} else {
|
|
531
|
-
f = this.preFfNorm.forward(
|
|
613
|
+
f = this.preFfNorm.forward(hMid);
|
|
532
614
|
f = disposing(f, this.mlp.forward(f));
|
|
533
615
|
}
|
|
534
616
|
f = disposing(f, this.postFfNorm.forward(f));
|
|
535
|
-
h = ops.add(
|
|
536
|
-
residual.dispose();
|
|
617
|
+
let h = ops.add(hMid, f); // hMid borrowed — NOT disposed here
|
|
537
618
|
f.dispose();
|
|
538
619
|
|
|
539
|
-
// per-layer input gating (reference gemma4_text DecoderLayer)
|
|
620
|
+
// per-layer input gating (reference gemma4_text DecoderLayer). gelu(gate)·pli
|
|
621
|
+
// is the second GeGLU site — fused (differentiable) on the training path, the
|
|
622
|
+
// bit-exact spelled-out gelu+mul on the inference path (unchanged).
|
|
540
623
|
if (this.perLayerGate && this.perLayerProjection && this.postPerLayerNorm && perLayerInput) {
|
|
541
624
|
const res2 = h;
|
|
542
625
|
let gate = this.perLayerGate.forward(h);
|
|
543
|
-
|
|
544
|
-
|
|
626
|
+
if (fusedGeluTraining() && fusedGeluEnabled() && !isCompiledTrace() && fusedGeluSupported(gate)) {
|
|
627
|
+
gate = disposing(gate, fusedGegluDifferentiable(gate, perLayerInput));
|
|
628
|
+
} else {
|
|
629
|
+
gate = disposing(gate, ops.geluApprox(gate));
|
|
630
|
+
gate = disposing(gate, ops.mul(gate, perLayerInput));
|
|
631
|
+
}
|
|
545
632
|
gate = disposing(gate, this.perLayerProjection.forward(gate));
|
|
546
633
|
gate = disposing(gate, this.postPerLayerNorm.forward(gate));
|
|
547
634
|
h = ops.add(res2, gate);
|
|
@@ -550,7 +637,7 @@ export class DecoderLayer {
|
|
|
550
637
|
}
|
|
551
638
|
|
|
552
639
|
if (this.layerScalar) h = disposing(h, ops.mul(h, this.layerScalar));
|
|
553
|
-
return
|
|
640
|
+
return h;
|
|
554
641
|
}
|
|
555
642
|
}
|
|
556
643
|
|
|
@@ -927,7 +1014,19 @@ export class Gemma4Model {
|
|
|
927
1014
|
layerCache: Cache | null, sharedIn: SharedKv | null, pls: MlxArray | null,
|
|
928
1015
|
): { h: MlxArray; shared: SharedKv | null } {
|
|
929
1016
|
const ctx = this.gradCkpt!;
|
|
930
|
-
const
|
|
1017
|
+
const ll = ctx.byLayer.get(i) ?? { attn: [], mlp: [] };
|
|
1018
|
+
return ctx.splitMlp
|
|
1019
|
+
? this.runSplitCheckpointedLayer(layer, h, mask, layerCache, sharedIn, pls, ll, ctx)
|
|
1020
|
+
: this.runSingleCheckpointedLayer(layer, h, mask, layerCache, sharedIn, pls, ll, ctx);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/** One Checkpoint over the whole layer (the original path). */
|
|
1024
|
+
private runSingleCheckpointedLayer(
|
|
1025
|
+
layer: DecoderLayer, h: MlxArray, mask: Mask,
|
|
1026
|
+
layerCache: Cache | null, sharedIn: SharedKv | null, pls: MlxArray | null,
|
|
1027
|
+
ll: LayerLoras, ctx: GradCheckpointCtx,
|
|
1028
|
+
): { h: MlxArray; shared: SharedKv | null } {
|
|
1029
|
+
const lws = [...ll.attn, ...ll.mlp];
|
|
931
1030
|
const isSharer = layerCache === null;
|
|
932
1031
|
const sIn = isSharer && sharedIn && sharedIn.kind === "plain" ? sharedIn : null;
|
|
933
1032
|
const hasMaskArr = mask.arr !== null;
|
|
@@ -970,6 +1069,82 @@ export class Gemma4Model {
|
|
|
970
1069
|
return { h: outs[0]!, shared };
|
|
971
1070
|
}
|
|
972
1071
|
|
|
1072
|
+
/** Intra-layer MLP split (lever 4): TWO checkpoints — the attention sub-block
|
|
1073
|
+
* (forwardAttn: inputNorm → attn → residual add, threading the donor K/V) and
|
|
1074
|
+
* the MLP sub-block (forwardMlp: FFN + per-layer gate) — with the post-attn
|
|
1075
|
+
* residual `hMid` as the boundary. The backward recompute holds only one
|
|
1076
|
+
* sub-block's activations at a time, dropping per-layer peak from `attn+MLP`
|
|
1077
|
+
* to `max(attn,MLP)+hMid`. Attn LoRA / pls / donor-KV go to the attn ck; MLP
|
|
1078
|
+
* LoRA to the MLP ck. */
|
|
1079
|
+
private runSplitCheckpointedLayer(
|
|
1080
|
+
layer: DecoderLayer, h: MlxArray, mask: Mask,
|
|
1081
|
+
layerCache: Cache | null, sharedIn: SharedKv | null, pls: MlxArray | null,
|
|
1082
|
+
ll: LayerLoras, ctx: GradCheckpointCtx,
|
|
1083
|
+
): { h: MlxArray; shared: SharedKv | null } {
|
|
1084
|
+
const isSharer = layerCache === null;
|
|
1085
|
+
const sIn = isSharer && sharedIn && sharedIn.kind === "plain" ? sharedIn : null;
|
|
1086
|
+
const hasMaskArr = mask.arr !== null;
|
|
1087
|
+
const hasPls = pls !== null;
|
|
1088
|
+
const maskMode = mask.mode;
|
|
1089
|
+
const attnLws = ll.attn;
|
|
1090
|
+
const mlpLws = ll.mlp;
|
|
1091
|
+
|
|
1092
|
+
// --- attn sub-block checkpoint: [h, maskArr?, sharedInK?, sharedInV?, attnLora*]
|
|
1093
|
+
const attnInputs: MlxArray[] = [h];
|
|
1094
|
+
if (hasMaskArr) attnInputs.push(mask.arr!);
|
|
1095
|
+
if (sIn) attnInputs.push(sIn.keys, sIn.values);
|
|
1096
|
+
for (const lw of attnLws) attnInputs.push(lw.a, lw.b);
|
|
1097
|
+
|
|
1098
|
+
const attnCk = new Checkpoint((ins) => {
|
|
1099
|
+
let k = 0;
|
|
1100
|
+
const hIn = ins[k++]!;
|
|
1101
|
+
const mArr = hasMaskArr ? ins[k++]! : null;
|
|
1102
|
+
const sharedInRebuilt: SharedKv | null = sIn
|
|
1103
|
+
? { kind: "plain", keys: ins[k++]!, values: ins[k++]!, offset: 0 }
|
|
1104
|
+
: null;
|
|
1105
|
+
const saved = attnLws.map((lw) => [lw.a, lw.b] as [MlxArray, MlxArray]);
|
|
1106
|
+
for (const lw of attnLws) { lw.a = ins[k++]!; lw.b = ins[k++]!; }
|
|
1107
|
+
try {
|
|
1108
|
+
const out = layer.forwardAttn(hIn, { mode: maskMode, arr: mArr }, layerCache, sharedInRebuilt);
|
|
1109
|
+
if (!isSharer && out.shared && out.shared.kind === "plain")
|
|
1110
|
+
return [out.h, out.shared.keys, out.shared.values];
|
|
1111
|
+
return [out.h];
|
|
1112
|
+
} finally {
|
|
1113
|
+
attnLws.forEach((lw, j) => { lw.a = saved[j]![0]; lw.b = saved[j]![1]; });
|
|
1114
|
+
}
|
|
1115
|
+
});
|
|
1116
|
+
ctx.keepAlive.push(attnCk);
|
|
1117
|
+
const attnOuts = attnCk.apply(attnInputs);
|
|
1118
|
+
const hMid = attnOuts[0]!;
|
|
1119
|
+
const shared: SharedKv | null =
|
|
1120
|
+
!isSharer && attnOuts.length === 3
|
|
1121
|
+
? { kind: "plain", keys: attnOuts[1]!, values: attnOuts[2]!, offset: 0 }
|
|
1122
|
+
: null;
|
|
1123
|
+
|
|
1124
|
+
// --- MLP sub-block checkpoint: [hMid, pls?, mlpLora*] -> [hOut]
|
|
1125
|
+
const mlpInputs: MlxArray[] = [hMid];
|
|
1126
|
+
if (hasPls) mlpInputs.push(pls!);
|
|
1127
|
+
for (const lw of mlpLws) mlpInputs.push(lw.a, lw.b);
|
|
1128
|
+
|
|
1129
|
+
const mlpCk = new Checkpoint((ins) => {
|
|
1130
|
+
let k = 0;
|
|
1131
|
+
const hMidIn = ins[k++]!;
|
|
1132
|
+
const plsIn = hasPls ? ins[k++]! : null;
|
|
1133
|
+
const saved = mlpLws.map((lw) => [lw.a, lw.b] as [MlxArray, MlxArray]);
|
|
1134
|
+
for (const lw of mlpLws) { lw.a = ins[k++]!; lw.b = ins[k++]!; }
|
|
1135
|
+
try {
|
|
1136
|
+
return [layer.forwardMlp(hMidIn, plsIn)];
|
|
1137
|
+
} finally {
|
|
1138
|
+
mlpLws.forEach((lw, j) => { lw.a = saved[j]![0]; lw.b = saved[j]![1]; });
|
|
1139
|
+
}
|
|
1140
|
+
});
|
|
1141
|
+
ctx.keepAlive.push(mlpCk);
|
|
1142
|
+
const mlpOuts = mlpCk.apply(mlpInputs);
|
|
1143
|
+
hMid.dispose(); // boundary leaf consumed by the MLP ck (mlx retains it in-graph)
|
|
1144
|
+
|
|
1145
|
+
return { h: mlpOuts[0]!, shared };
|
|
1146
|
+
}
|
|
1147
|
+
|
|
973
1148
|
/** hidden [1, L, hidden] → softcapped logits [1, L, vocab]. */
|
|
974
1149
|
logitsFromHidden(h: MlxArray): MlxArray {
|
|
975
1150
|
let logits = this.embed.asLinear(h);
|
package/src/model/minicpm5.ts
CHANGED
|
@@ -21,6 +21,45 @@ import {
|
|
|
21
21
|
} from "./gemma4-base";
|
|
22
22
|
import { flashAttention, getTrainingAttn, flashSupported } from "./flash-attention";
|
|
23
23
|
|
|
24
|
+
// Shared prompt-prefix plan (lever 7). When set, the attention ropes the
|
|
25
|
+
// concatenated [prompt(P); chosenResp(Rc); rejectedResp(Rr)] sequence
|
|
26
|
+
// BLOCK-WISE — prompt at offset 0, EACH response reset to offset P — instead of
|
|
27
|
+
// the uniform cache.offset. This is what lets one forward over the concat be
|
|
28
|
+
// bit-exact with the two separate [prompt;resp] forwards (each response sees the
|
|
29
|
+
// prompt's RoPE positions, not shifted-by-the-other-response). Set by
|
|
30
|
+
// orpoLossPrefixShared around the forward (single-threaded), cleared after; null
|
|
31
|
+
// → the normal uniform-offset rope (every other forward is untouched). The
|
|
32
|
+
// matching block-sparse attention mask rides in via PrefixSharedCache.makeMask.
|
|
33
|
+
export interface PrefixPlan { P: number; Rc: number; Rr: number }
|
|
34
|
+
let _prefixPlan: PrefixPlan | null = null;
|
|
35
|
+
export function setMiniCpmPrefixPlan(p: PrefixPlan | null): void {
|
|
36
|
+
_prefixPlan = p;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Block-wise RoPE for the prefix-shared concat [prompt; chosen; rejected] along
|
|
40
|
+
* the sequence axis (axis 2 of [B,H,T,D]): prompt rotated at offset 0, each
|
|
41
|
+
* response at offset P (reset). RoPE is per-token, so roping each contiguous
|
|
42
|
+
* block at its scalar offset and concatenating == roping with per-token
|
|
43
|
+
* position-ids [0..P-1, P..P+Rc-1, P..P+Rr-1]. Caller disposes the input. */
|
|
44
|
+
function ropeBlocks(x: MlxArray, dims: number, base: number, plan: PrefixPlan): MlxArray {
|
|
45
|
+
const { P, Rc, Rr } = plan;
|
|
46
|
+
const [B, H, , D] = x.shape as [number, number, number, number];
|
|
47
|
+
const blocks: { start: number; len: number; off: number }[] = [
|
|
48
|
+
{ start: 0, len: P, off: 0 },
|
|
49
|
+
{ start: P, len: Rc, off: P },
|
|
50
|
+
{ start: P + Rc, len: Rr, off: P },
|
|
51
|
+
].filter((b) => b.len > 0);
|
|
52
|
+
const parts = blocks.map((b) => {
|
|
53
|
+
const sl = x.slice([0, 0, b.start, 0], [B, H, b.start + b.len, D]);
|
|
54
|
+
const r = ops.rope(sl, dims, base, b.off, null);
|
|
55
|
+
sl.dispose();
|
|
56
|
+
return r;
|
|
57
|
+
});
|
|
58
|
+
const out = ops.concatAxis(parts, 2);
|
|
59
|
+
for (const p of parts) p.dispose();
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
24
63
|
class LlamaAttention {
|
|
25
64
|
readonly qProj: QuantizedLinear;
|
|
26
65
|
readonly kProj: QuantizedLinear;
|
|
@@ -65,9 +104,11 @@ class LlamaAttention {
|
|
|
65
104
|
// runs before updateAndFetch, so K and Q share the pre-write offset.
|
|
66
105
|
const offsetArr = cache.ropeOffsetArr;
|
|
67
106
|
const ropeStep = (x: MlxArray): MlxArray =>
|
|
68
|
-
|
|
69
|
-
?
|
|
70
|
-
:
|
|
107
|
+
_prefixPlan
|
|
108
|
+
? ropeBlocks(x, this.headDim, this.ropeBase, _prefixPlan)
|
|
109
|
+
: offsetArr
|
|
110
|
+
? ops.ropeDynamic(x, this.headDim, this.ropeBase, offsetArr, null)
|
|
111
|
+
: ops.rope(x, this.headDim, this.ropeBase, cache.offset, null);
|
|
71
112
|
q = disposing(q, ropeStep(q));
|
|
72
113
|
k = disposing(k, ropeStep(k));
|
|
73
114
|
let attn: MlxArray;
|
package/src/server.ts
CHANGED
|
@@ -15,7 +15,7 @@ import type { Server } from "bun";
|
|
|
15
15
|
// imports as HTMLBundle (the html loader), but the text attribute makes
|
|
16
16
|
// the runtime value a string — hence the double cast.
|
|
17
17
|
import appHtml from "./web/app.html" with { type: "text" };
|
|
18
|
-
import curveDesignerHtml from "
|
|
18
|
+
import curveDesignerHtml from "./assets/curve-designer.html" with { type: "text" };
|
|
19
19
|
import pkgJson from "../package.json" with { type: "json" };
|
|
20
20
|
import { readFileSync } from "node:fs";
|
|
21
21
|
const APP_PAGE = appHtml as unknown as string;
|
|
@@ -977,7 +977,7 @@ export function createServer(
|
|
|
977
977
|
// embedded copy when running as the compiled single binary.
|
|
978
978
|
if (url.pathname === "/curves" && request.method === "GET") {
|
|
979
979
|
let html = CURVE_PAGE;
|
|
980
|
-
try { html = readFileSync(new URL("
|
|
980
|
+
try { html = readFileSync(new URL("./assets/curve-designer.html", import.meta.url), "utf8"); } catch { /* binary: use embedded */ }
|
|
981
981
|
return new Response(html, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
|
|
982
982
|
}
|
|
983
983
|
if (url.pathname === "/curve-terrain" && request.method === "GET") {
|
|
@@ -985,7 +985,7 @@ export function createServer(
|
|
|
985
985
|
const html = readFileSync(new URL("../docs/investigations/curve-terrain.html", import.meta.url), "utf8");
|
|
986
986
|
return new Response(html, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
|
|
987
987
|
} catch {
|
|
988
|
-
return new Response("curve terrain artifact not found; run scripts/curve-terrain.ts first", { status: 404 });
|
|
988
|
+
return new Response("curve terrain artifact not found; run scripts/experiments/curve-terrain.ts first", { status: 404 });
|
|
989
989
|
}
|
|
990
990
|
}
|
|
991
991
|
if (request.method === "GET" &&
|
package/src/train/dataset.ts
CHANGED
|
@@ -333,7 +333,22 @@ export async function loadDpoDataset(
|
|
|
333
333
|
maxLength: number,
|
|
334
334
|
): Promise<DpoExample[]> {
|
|
335
335
|
const rows = await readJsonl(path);
|
|
336
|
-
|
|
336
|
+
const examples = rows.map((r) => encodeDpoRow(r, tok, tmpl, maxLength));
|
|
337
|
+
// Prompt-masking guard (the classic ORPO/DPO footgun on long-prompt /
|
|
338
|
+
// short-completion data): a collapsed prompt boundary (promptLen=0 → mask[0]
|
|
339
|
+
// is 1) means the completion filled the entire window — the NLL/odds-ratio
|
|
340
|
+
// would then be computed over non-completion tokens and the real signal gets
|
|
341
|
+
// drowned. Normally the loss is completion-only (mask 0 over the prompt); this
|
|
342
|
+
// only happens when a completion is itself ≥ max_seq_length. Surface it loudly.
|
|
343
|
+
let unmasked = 0;
|
|
344
|
+
for (const ex of examples) if (ex.chosenMask[0] === 1 || ex.rejectedMask[0] === 1) unmasked++;
|
|
345
|
+
if (unmasked > 0)
|
|
346
|
+
console.warn(
|
|
347
|
+
`[dataset] WARNING: ${unmasked}/${examples.length} preference examples have NO prompt boundary ` +
|
|
348
|
+
`(promptLen=0): the completion is ≥ max_seq_length (${maxLength}), so the loss would include ` +
|
|
349
|
+
`non-completion tokens. Raise max_seq_length above the completion length so the prompt is masked.`,
|
|
350
|
+
);
|
|
351
|
+
return examples;
|
|
337
352
|
}
|
|
338
353
|
|
|
339
354
|
/** Row length of a DPO branch (port of _make_batch helpers). */
|
|
@@ -420,10 +435,10 @@ function makeDpoBatch(rows: DpoExample[], padId: number): DpoBatch {
|
|
|
420
435
|
// ---------------------------------------------------------------------------
|
|
421
436
|
|
|
422
437
|
/** Format probe for inspect-dataset: peek the first row's keys. */
|
|
423
|
-
export type DatasetFormat = "messages" | "prompt-completion" | "text" | "dpo" | "unknown";
|
|
438
|
+
export type DatasetFormat = "messages" | "prompt-completion" | "text" | "dpo" | "preference" | "unknown";
|
|
424
439
|
|
|
425
440
|
export function probeFormat(row: Record<string, unknown>): DatasetFormat {
|
|
426
|
-
if (typeof row.chosen === "string" && typeof row.rejected === "string") return "
|
|
441
|
+
if (typeof row.chosen === "string" && typeof row.rejected === "string") return "preference";
|
|
427
442
|
try {
|
|
428
443
|
return sftRowFormat(row);
|
|
429
444
|
} catch {
|