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/train/optimizer.ts
CHANGED
|
@@ -23,6 +23,10 @@ export interface AdamWOptions {
|
|
|
23
23
|
betas?: [number, number];
|
|
24
24
|
eps?: number;
|
|
25
25
|
weightDecay?: number;
|
|
26
|
+
/** Optional per-parameter LR multiplier (effective lr for param i is
|
|
27
|
+
* `lr · lrScale[i]`). Used for LoRA+ — the B leaves get a higher LR than A.
|
|
28
|
+
* Length must match `params`; missing/undefined entries default to 1. */
|
|
29
|
+
lrScale?: number[];
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
export class AdamW {
|
|
@@ -34,6 +38,7 @@ export class AdamW {
|
|
|
34
38
|
readonly #b2: number;
|
|
35
39
|
readonly #eps: number;
|
|
36
40
|
readonly #wd: number;
|
|
41
|
+
readonly #lrScale: number[];
|
|
37
42
|
#lr: number;
|
|
38
43
|
#t = 0;
|
|
39
44
|
|
|
@@ -48,6 +53,7 @@ export class AdamW {
|
|
|
48
53
|
[this.#b1, this.#b2] = opts.betas ?? [0.9, 0.999];
|
|
49
54
|
this.#eps = opts.eps ?? 1e-8;
|
|
50
55
|
this.#wd = opts.weightDecay ?? 0.01;
|
|
56
|
+
this.#lrScale = opts.lrScale ?? [];
|
|
51
57
|
// m, v as zeros like each param (handles 0-d params, where ops.zeros's
|
|
52
58
|
// empty shape buffer would trip ptr()).
|
|
53
59
|
this.#m = params.map(zerosLikeF32);
|
|
@@ -81,6 +87,7 @@ export class AdamW {
|
|
|
81
87
|
|
|
82
88
|
for (let i = 0; i < this.#params.length; i++) {
|
|
83
89
|
const p = this.#params[i]!;
|
|
90
|
+
const lri = this.#lr * (this.#lrScale[i] ?? 1); // per-param lr (LoRA+)
|
|
84
91
|
const g0 = grads[i]!;
|
|
85
92
|
const g = g0.dtype === Dtype.float32 ? g0 : g0.astype(Dtype.float32);
|
|
86
93
|
|
|
@@ -106,10 +113,10 @@ export class AdamW {
|
|
|
106
113
|
v0.dispose();
|
|
107
114
|
this.#v[i] = v1;
|
|
108
115
|
|
|
109
|
-
// p <- p - lr·wd·p (decoupled weight decay)
|
|
116
|
+
// p <- p - lr·wd·p (decoupled weight decay; per-param lr)
|
|
110
117
|
let pNext = p;
|
|
111
118
|
if (this.#wd !== 0) {
|
|
112
|
-
const decay = ops.mulScalar(p,
|
|
119
|
+
const decay = ops.mulScalar(p, lri * this.#wd);
|
|
113
120
|
pNext = ops.sub(p, decay);
|
|
114
121
|
decay.dispose();
|
|
115
122
|
}
|
|
@@ -121,7 +128,7 @@ export class AdamW {
|
|
|
121
128
|
const eps = ops.scalarLike(this.#eps, sqrtV);
|
|
122
129
|
const denom = ops.add(sqrtV, eps);
|
|
123
130
|
const update = ops.div(mHat, denom);
|
|
124
|
-
const lrUpdate = ops.mulScalar(update,
|
|
131
|
+
const lrUpdate = ops.mulScalar(update, lri);
|
|
125
132
|
const pFinal = ops.sub(pNext, lrUpdate);
|
|
126
133
|
|
|
127
134
|
for (const a of [mHat, vHat, sqrtV, eps, denom, update, lrUpdate]) a.dispose();
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
// Shared prompt-prefix ORPO (lever 7) — MiniCPM5, B=1.
|
|
2
|
+
//
|
|
3
|
+
// Chosen and rejected share an identical prompt prefix; the causal mask makes the
|
|
4
|
+
// prefix's hidden states independent of which response follows. So instead of two
|
|
5
|
+
// forwards [prompt;chosenResp] and [prompt;rejectedResp] (the prompt encoded
|
|
6
|
+
// TWICE), run ONE forward over the concatenation
|
|
7
|
+
// X = [ prompt(P) ; chosenResp(Rc) ; rejectedResp(Rr) ] (T = P+Rc+Rr)
|
|
8
|
+
// with (a) a BLOCK-SPARSE mask so each response attends to prompt + itself only
|
|
9
|
+
// (never the other response), and (b) BLOCK-WISE RoPE that resets each response to
|
|
10
|
+
// position P. Token cost through the layers 2(P+R) → P+2R — a ~2× win when the
|
|
11
|
+
// prompt dominates (our chunk/document fine-tunes), ~0 when the response does.
|
|
12
|
+
//
|
|
13
|
+
// Bit-exact with the two-forward path (validated in
|
|
14
|
+
// scripts/experiments/prefix-shared-parity.ts): each predicted token uses a hidden
|
|
15
|
+
// conditioned on exactly the same context + RoPE positions —
|
|
16
|
+
// chosen[k] from H[P-1+k] (k=0..Rc-1; H[P-1] = prompt's last)
|
|
17
|
+
// rejected[k] from H[P-1] (k=0) / H[P+Rc+k-1] (k≥1)
|
|
18
|
+
// Both first-response tokens read the SAME prompt-last hidden H[P-1], computed
|
|
19
|
+
// once. Differentiability is free: it is one forward graph, so reverse-mode AD
|
|
20
|
+
// sums the two branches' cotangents into the shared prefix automatically.
|
|
21
|
+
//
|
|
22
|
+
// e4b (sliding-window mask interaction, donor-KV, per-layer-input) is the
|
|
23
|
+
// documented follow-on; this is the MiniCPM5 reference + correctness gate.
|
|
24
|
+
|
|
25
|
+
import { MlxArray } from "../mlx/array";
|
|
26
|
+
import { Dtype } from "../mlx/ffi";
|
|
27
|
+
import * as ops from "../mlx/ops";
|
|
28
|
+
import { createCausalMask, type Cache, type Mask } from "../model/gemma4-base";
|
|
29
|
+
import { MiniCPM5Model, setMiniCpmPrefixPlan } from "../model/minicpm5";
|
|
30
|
+
import { Gemma4Model, setGemmaPrefixPlan } from "../model/gemma4";
|
|
31
|
+
import { orpoLossFromLogps, fusedRespLogpMean, type ChunkCtx } from "./loss";
|
|
32
|
+
import type { DpoBatch } from "./dataset";
|
|
33
|
+
|
|
34
|
+
/** A model that can project final-norm hidden states to vocab logits — the only
|
|
35
|
+
* capability the prefix-shared head needs (MiniCPM5Model and Gemma4Model both). */
|
|
36
|
+
interface LogitProjector { logitsFromHidden(h: MlxArray): MlxArray }
|
|
37
|
+
|
|
38
|
+
/** Block-sparse attention mask [1,1,T,T] (bool, true = attend) for the concat:
|
|
39
|
+
* causal AND NOT (rejected row → chosen col). Chosen rows already can't see
|
|
40
|
+
* rejected cols (rejected come later → causal forbids), so the only extra cut is
|
|
41
|
+
* rejected→chosen. Caller owns the result. */
|
|
42
|
+
export function blockSparseMask(P: number, Rc: number, Rr: number): MlxArray {
|
|
43
|
+
const T = P + Rc + Rr;
|
|
44
|
+
const causal = createCausalMask(T, 0, null); // [T,T] bool, j<=i
|
|
45
|
+
const idxFlat = ops.arange(0, T, 1, Dtype.int32);
|
|
46
|
+
const row = ops.reshape(idxFlat, [T, 1]);
|
|
47
|
+
const col = ops.reshape(idxFlat, [1, T]);
|
|
48
|
+
const pp = ops.fromInt32([P], []);
|
|
49
|
+
const pRc = ops.fromInt32([P + Rc], []);
|
|
50
|
+
// notForbid = NOT(rejRow AND chosenCol) = (i < P+Rc) OR (j < P) OR (j >= P+Rc)
|
|
51
|
+
const notRejRow = ops.less(row, pRc);
|
|
52
|
+
const colLtP = ops.less(col, pp);
|
|
53
|
+
const colGePRc = ops.greaterEqual(col, pRc);
|
|
54
|
+
const notChosenCol = ops.logicalOr(colLtP, colGePRc);
|
|
55
|
+
const notForbid = ops.logicalOr(notRejRow, notChosenCol);
|
|
56
|
+
const allow = ops.logicalAnd(causal, notForbid); // [T,T] bool — same rank as createCausalMask
|
|
57
|
+
for (const a of [causal, idxFlat, row, col, pp, pRc, notRejRow, colLtP, colGePRc, notChosenCol, notForbid]) a.dispose();
|
|
58
|
+
return allow;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Stateless cache for the prefix-shared forward: pass-through KV (offset 0, like
|
|
62
|
+
* TrainingCache) but `makeMask` returns the block-sparse mask. One per layer;
|
|
63
|
+
* MiniCPM5's runLayerRange calls makeMask once and disposes the returned arr. */
|
|
64
|
+
export class PrefixSharedCache implements Cache {
|
|
65
|
+
offset = 0;
|
|
66
|
+
constructor(private readonly P: number, private readonly Rc: number, private readonly Rr: number) {}
|
|
67
|
+
updateAndFetch(k: MlxArray, v: MlxArray): [MlxArray, MlxArray] {
|
|
68
|
+
return [k.slice([0, 0, 0, 0], k.shape), v.slice([0, 0, 0, 0], v.shape)];
|
|
69
|
+
}
|
|
70
|
+
makeMask(_N: number, _windowSize: number | null): Mask {
|
|
71
|
+
return { mode: "array", arr: blockSparseMask(this.P, this.Rc, this.Rr) };
|
|
72
|
+
}
|
|
73
|
+
state(): MlxArray[] { return []; }
|
|
74
|
+
isTrimmable(): boolean { return true; }
|
|
75
|
+
trim(_n: number): void { /* offset pinned at 0 */ }
|
|
76
|
+
dispose(): void { /* owns no arrays */ }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Length-normalized mean log-prob over the predictions at hidden positions
|
|
80
|
+
* `gatherIdx`, scoring `targets` (same shape). Mirrors responseOnlyLogpMean but
|
|
81
|
+
* gathers arbitrary (possibly non-contiguous) positions instead of a slice. */
|
|
82
|
+
function gatheredLogpMean(model: LogitProjector, h: MlxArray, gatherIdx: number[], targets: number[]): MlxArray {
|
|
83
|
+
const M = gatherIdx.length;
|
|
84
|
+
if (M <= 0) return MlxArray.fromFloat32(new Float32Array([0]), [1]);
|
|
85
|
+
const hidden = h.shape[2]!;
|
|
86
|
+
const idxArr = MlxArray.fromInt32(new Int32Array(gatherIdx), [M]);
|
|
87
|
+
const hSel = ops.takeAxis(h, idxArr, 1); // [1, M, hidden]
|
|
88
|
+
idxArr.dispose();
|
|
89
|
+
if (hSel.shape[2] !== hidden) throw new Error("gatheredLogpMean: hidden mismatch");
|
|
90
|
+
const logits = model.logitsFromHidden(hSel); // [1, M, V]
|
|
91
|
+
hSel.dispose();
|
|
92
|
+
const V = logits.shape[2]!;
|
|
93
|
+
const logits2d = ops.reshape(logits, [M, V]);
|
|
94
|
+
logits.dispose();
|
|
95
|
+
const tgt = MlxArray.fromInt32(new Int32Array(targets), [M, 1]);
|
|
96
|
+
const lse = ops.logsumexpAxis(logits2d, -1, false);
|
|
97
|
+
const gathered = ops.takeAlongAxis(logits2d, tgt, -1);
|
|
98
|
+
const picked = ops.reshape(gathered, [M]);
|
|
99
|
+
const logp = ops.sub(picked, lse);
|
|
100
|
+
const logpF = logp.dtype === Dtype.float32 ? logp : logp.astype(Dtype.float32);
|
|
101
|
+
const sumLogp = ops.sumAxis(logpF, 0, false);
|
|
102
|
+
const meanScalar = ops.mulScalar(sumLogp, 1 / M);
|
|
103
|
+
const mean = ops.reshape(meanScalar, [1]);
|
|
104
|
+
for (const a of [logits2d, tgt, lse, gathered, picked, logp, sumLogp, meanScalar]) a.dispose();
|
|
105
|
+
if (logpF !== logp) logpF.dispose();
|
|
106
|
+
return mean;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Length-normalized mean log-prob over gathered (possibly non-contiguous) hidden
|
|
110
|
+
* positions via the FUSED/FLASH head ([M,V]-free analytic backward) when `chunk`
|
|
111
|
+
* requests it, else the whole-vocab `gatheredLogpMean`. Gathers the branch's
|
|
112
|
+
* response hiddens [M, hidden] from `h [1,T,hidden]` and hands them to
|
|
113
|
+
* `fusedRespLogpMean` — so prefix-sharing composes with the steel flash-CCE head
|
|
114
|
+
* (one model forward + one head fwd/bwd per branch, all [M,V]-free). */
|
|
115
|
+
export function branchLogpMeanGathered(
|
|
116
|
+
model: LogitProjector, h: MlxArray, gatherIdx: number[], targets: number[], chunk?: ChunkCtx,
|
|
117
|
+
): MlxArray {
|
|
118
|
+
if (!chunk || !(chunk.fused || chunk.flash)) return gatheredLogpMean(model, h, gatherIdx, targets);
|
|
119
|
+
const M = gatherIdx.length;
|
|
120
|
+
if (M <= 0) return MlxArray.fromFloat32(new Float32Array([0]), [1]);
|
|
121
|
+
const hidden = h.shape[2]!;
|
|
122
|
+
const idxArr = MlxArray.fromInt32(new Int32Array(gatherIdx), [M]);
|
|
123
|
+
const hSel = ops.takeAxis(h, idxArr, 1); // [1, M, hidden]
|
|
124
|
+
idxArr.dispose();
|
|
125
|
+
const hResp = ops.reshape(hSel, [M, hidden]); // [M, hidden] for the head
|
|
126
|
+
hSel.dispose();
|
|
127
|
+
const chunkSize = chunk.chunkSize > 0 ? chunk.chunkSize : 512;
|
|
128
|
+
// fusedRespLogpMean requires a RuntimeModel (headQuant); the prefix-shared models
|
|
129
|
+
// (MiniCPM5Model/Gemma4Model) satisfy it. vocabBlock is unused by the flash path.
|
|
130
|
+
// hResp is the CustomVjp/flash-head PRIMAL — its backward recomputes the head from
|
|
131
|
+
// it (loss.ts fusedRespLogpMean/makeFlashCceHeadVjp), so it must live until the head
|
|
132
|
+
// vjp is eval'd. Push it into the sink (disposed post-eval by the caller) instead of
|
|
133
|
+
// freeing it here, matching responseOnlyLogpMean/fusedLogpMeanFromHidden.
|
|
134
|
+
chunk.sink.push(hResp);
|
|
135
|
+
const mean = fusedRespLogpMean(model as any, hResp, new Int32Array(targets), chunkSize, chunk.sink, 0, chunk.flash ?? false);
|
|
136
|
+
return mean;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The response-hidden gather indices for the prefix-shared concat layout
|
|
140
|
+
* [prompt(P); chosen(Rc); rejected(Rr)]: chosen[k] is predicted from H[P-1+k];
|
|
141
|
+
* rejected[0] from H[P-1] (the shared prompt-last, same as chosen[0]) and
|
|
142
|
+
* rejected[k>=1] from H[P+Rc+k-1]. Used by the non-segmented path AND the
|
|
143
|
+
* segmented prefix backward so both gather identically. */
|
|
144
|
+
export function prefixGatherIdx(P: number, Rc: number, Rr: number): { chosenIdx: number[]; rejectedIdx: number[] } {
|
|
145
|
+
const chosenIdx = Array.from({ length: Rc }, (_, k) => P - 1 + k);
|
|
146
|
+
const rejectedIdx = [P - 1, ...Array.from({ length: Rr - 1 }, (_, k) => P + Rc + k)];
|
|
147
|
+
return { chosenIdx, rejectedIdx };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** ORPO loss via the shared prompt-prefix single forward (MiniCPM5, B=1).
|
|
151
|
+
* `promptIds` is the shared prefix; `chosenResp`/`rejectedResp` are the response
|
|
152
|
+
* continuations. Returns the scalar loss (caller owns). Differentiable through
|
|
153
|
+
* the LoRA primals like the two-forward orpoLoss. `chunk` (when fused/flash) routes
|
|
154
|
+
* each branch through the [M,V]-free flash-CCE head. */
|
|
155
|
+
export function orpoLossPrefixShared(
|
|
156
|
+
model: MiniCPM5Model,
|
|
157
|
+
promptIds: number[], chosenResp: number[], rejectedResp: number[],
|
|
158
|
+
lambda: number, chunk?: ChunkCtx,
|
|
159
|
+
): MlxArray {
|
|
160
|
+
const P = promptIds.length, Rc = chosenResp.length, Rr = rejectedResp.length;
|
|
161
|
+
if (P < 1 || Rc < 1 || Rr < 1) throw new Error("orpoLossPrefixShared: need P,Rc,Rr >= 1");
|
|
162
|
+
const T = P + Rc + Rr;
|
|
163
|
+
const concat = new Int32Array(T);
|
|
164
|
+
concat.set(promptIds, 0);
|
|
165
|
+
concat.set(chosenResp, P);
|
|
166
|
+
concat.set(rejectedResp, P + Rc);
|
|
167
|
+
const ids = MlxArray.fromInt32(concat, [1, T]);
|
|
168
|
+
const caches: Cache[] = model.layers.map(() => new PrefixSharedCache(P, Rc, Rr));
|
|
169
|
+
|
|
170
|
+
let h: MlxArray;
|
|
171
|
+
setMiniCpmPrefixPlan({ P, Rc, Rr });
|
|
172
|
+
try {
|
|
173
|
+
h = model.forwardHidden(ids, caches); // [1, T, hidden], post-finalNorm
|
|
174
|
+
} finally {
|
|
175
|
+
setMiniCpmPrefixPlan(null);
|
|
176
|
+
ids.dispose();
|
|
177
|
+
for (const c of caches) c.dispose();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// chosen[k] predicted from H[P-1+k]; rejected[0] from H[P-1] (prompt's last,
|
|
181
|
+
// shared with chosen[0]); rejected[k>=1] from H[P+Rc+k-1].
|
|
182
|
+
const { chosenIdx, rejectedIdx } = prefixGatherIdx(P, Rc, Rr);
|
|
183
|
+
|
|
184
|
+
// On the fused/flash head, the gathered hResp (sliced from h) is the backward
|
|
185
|
+
// recompute primal — keep h alive in the sink until the head vjp is eval'd. The
|
|
186
|
+
// whole-vocab path builds a normal autograd graph, so h can free immediately.
|
|
187
|
+
const keepForBwd = !!chunk && (chunk.fused || chunk.flash);
|
|
188
|
+
if (keepForBwd) chunk!.sink.push(h);
|
|
189
|
+
const lw = branchLogpMeanGathered(model, h, chosenIdx, chosenResp, chunk);
|
|
190
|
+
const lr = branchLogpMeanGathered(model, h, rejectedIdx, rejectedResp, chunk);
|
|
191
|
+
if (!keepForBwd) h.dispose();
|
|
192
|
+
const loss = orpoLossFromLogps(lw, lr, lambda);
|
|
193
|
+
lw.dispose();
|
|
194
|
+
lr.dispose();
|
|
195
|
+
return loss;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Debug: the two branch mean-logps (ℓw, ℓr) from the prefix-shared forward, no
|
|
199
|
+
* grad. For parity diagnostics (compare against the two-forward branch logps). */
|
|
200
|
+
export function prefixSharedLogps(
|
|
201
|
+
model: MiniCPM5Model,
|
|
202
|
+
promptIds: number[], chosenResp: number[], rejectedResp: number[],
|
|
203
|
+
): { lw: number; lr: number } {
|
|
204
|
+
const P = promptIds.length, Rc = chosenResp.length, Rr = rejectedResp.length;
|
|
205
|
+
const T = P + Rc + Rr;
|
|
206
|
+
const concat = new Int32Array(T);
|
|
207
|
+
concat.set(promptIds, 0); concat.set(chosenResp, P); concat.set(rejectedResp, P + Rc);
|
|
208
|
+
const ids = MlxArray.fromInt32(concat, [1, T]);
|
|
209
|
+
const caches: Cache[] = model.layers.map(() => new PrefixSharedCache(P, Rc, Rr));
|
|
210
|
+
setMiniCpmPrefixPlan({ P, Rc, Rr });
|
|
211
|
+
let h: MlxArray;
|
|
212
|
+
try { h = model.forwardHidden(ids, caches); }
|
|
213
|
+
finally { setMiniCpmPrefixPlan(null); ids.dispose(); for (const c of caches) c.dispose(); }
|
|
214
|
+
const chosenIdx = Array.from({ length: Rc }, (_, k) => P - 1 + k);
|
|
215
|
+
const rejectedIdx = [P - 1, ...Array.from({ length: Rr - 1 }, (_, k) => P + Rc + k)];
|
|
216
|
+
const lwA = gatheredLogpMean(model, h, chosenIdx, chosenResp);
|
|
217
|
+
const lrA = gatheredLogpMean(model, h, rejectedIdx, rejectedResp);
|
|
218
|
+
h.dispose();
|
|
219
|
+
const lw = lwA.toFloat32()[0]!, lr = lrA.toFloat32()[0]!;
|
|
220
|
+
lwA.dispose(); lrA.dispose();
|
|
221
|
+
return { lw, lr };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Split a DpoBatch row (B=1) into the shared prompt prefix + the two response
|
|
225
|
+
* continuations. The chosen/rejected sequences must share an identical prompt
|
|
226
|
+
* (the masked prefix where mask==0); `response` tokens are mask==1. Returns null
|
|
227
|
+
* if the prompts differ or either response is empty (caller falls back to the
|
|
228
|
+
* two-forward orpoLoss). */
|
|
229
|
+
export function splitPrefixBatch(batch: DpoBatch): {
|
|
230
|
+
promptIds: number[]; chosenResp: number[]; rejectedResp: number[];
|
|
231
|
+
} | null {
|
|
232
|
+
if (batch.chosenIds.length !== 1) return null;
|
|
233
|
+
const cIds = batch.chosenIds[0]!, cMask = batch.chosenMask[0]!;
|
|
234
|
+
const rIds = batch.rejectedIds[0]!, rMask = batch.rejectedMask[0]!;
|
|
235
|
+
// Prompt = leading mask==0 run (response = mask==1). Find first response token.
|
|
236
|
+
const cStart = cMask.indexOf(1);
|
|
237
|
+
const rStart = rMask.indexOf(1);
|
|
238
|
+
if (cStart < 1 || rStart < 1) return null; // need a non-empty prompt + response
|
|
239
|
+
const P = cStart;
|
|
240
|
+
if (rStart !== P) return null; // prompts must be the same length
|
|
241
|
+
for (let i = 0; i < P; i++) if (cIds[i] !== rIds[i]) return null; // ...and identical
|
|
242
|
+
const promptIds = cIds.slice(0, P);
|
|
243
|
+
const chosenResp = cIds.slice(P);
|
|
244
|
+
const rejectedResp = rIds.slice(P);
|
|
245
|
+
if (chosenResp.length < 1 || rejectedResp.length < 1) return null;
|
|
246
|
+
return { promptIds, chosenResp, rejectedResp };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Token-throughput accounting: tokens pushed through the layer stack by the
|
|
250
|
+
* two-forward path vs the prefix-shared single forward, and the saving ratio.
|
|
251
|
+
* (Each two-forward branch processes L-1 = P+R-1 input tokens; the shared path
|
|
252
|
+
* processes T = P+Rc+Rr.) */
|
|
253
|
+
export function prefixSavings(P: number, Rc: number, Rr: number): { twoForward: number; shared: number; ratio: number } {
|
|
254
|
+
const twoForward = (P + Rc - 1) + (P + Rr - 1);
|
|
255
|
+
const shared = P + Rc + Rr;
|
|
256
|
+
return { twoForward, shared, ratio: twoForward / shared };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ===========================================================================
|
|
260
|
+
// Gemma e4b prefix-shared ORPO (lever 7 for the per-layer-input + KV-shared +
|
|
261
|
+
// sliding-window family). The CONSTRUCTION is the same as MiniCPM5 (one forward
|
|
262
|
+
// over [prompt; chosen; rejected] with block-wise RoPE + a block-sparse mask),
|
|
263
|
+
// reusing Gemma4Model.forwardHidden — which already drives per-layer inputs and
|
|
264
|
+
// the donor-KV sharing through forwardLayers, building one mask per layer-type
|
|
265
|
+
// from each donor cache's makeMask. Two e4b-specific wrinkles:
|
|
266
|
+
// (1) Block-wise RoPE rides in via setGemmaPrefixPlan (gemma4.ts Attention),
|
|
267
|
+
// so donor AND sharer layers rope identically to the two-forward path.
|
|
268
|
+
// (2) The SLIDING-window mask must be cut on LOGICAL positions, not physical:
|
|
269
|
+
// a rejected token at physical P+Rc+k has logical position P+k, so its
|
|
270
|
+
// window to the prompt tail differs from its physical distance by Rc. The
|
|
271
|
+
// branch VISIBILITY (who-may-attend-whom) is identical under the physical
|
|
272
|
+
// layout (physical causal + the rejected→chosen cut, exactly as MiniCPM5),
|
|
273
|
+
// so only the window DISTANCE needs logical positions — added as one extra
|
|
274
|
+
// AND on top of the MiniCPM5 mask. Full-attention layers (window=null) get
|
|
275
|
+
// the plain block-sparse causal mask (no sliding term).
|
|
276
|
+
// ===========================================================================
|
|
277
|
+
|
|
278
|
+
/** Block-sparse attention mask [T,T] (bool) for the gemma concat. Same as
|
|
279
|
+
* MiniCPM5's blockSparseMask (physical causal AND NOT rejected→chosen) plus, for
|
|
280
|
+
* sliding layers, a LOGICAL-position window AND: allow only where
|
|
281
|
+
* `logpos[row] - logpos[col] < window`, with `logpos[i] = i` for the
|
|
282
|
+
* prompt+chosen run and `i - Rc` for the rejected block (reset to P). Caller
|
|
283
|
+
* owns the result. */
|
|
284
|
+
export function blockSparsePrefixMaskGemma(P: number, Rc: number, Rr: number, window: number | null): MlxArray {
|
|
285
|
+
const T = P + Rc + Rr;
|
|
286
|
+
const causal = createCausalMask(T, 0, null); // [T,T] physical causal (window applied separately, on logical pos)
|
|
287
|
+
const idxFlat = ops.arange(0, T, 1, Dtype.int32);
|
|
288
|
+
const row = ops.reshape(idxFlat, [T, 1]);
|
|
289
|
+
const col = ops.reshape(idxFlat, [1, T]);
|
|
290
|
+
const pp = ops.fromInt32([P], []);
|
|
291
|
+
const pRc = ops.fromInt32([P + Rc], []);
|
|
292
|
+
// notForbid = NOT(rejRow AND chosenCol) = (i < P+Rc) OR (j < P) OR (j >= P+Rc)
|
|
293
|
+
const notRejRow = ops.less(row, pRc);
|
|
294
|
+
const colLtP = ops.less(col, pp);
|
|
295
|
+
const colGePRc = ops.greaterEqual(col, pRc);
|
|
296
|
+
const notChosenCol = ops.logicalOr(colLtP, colGePRc);
|
|
297
|
+
const notForbid = ops.logicalOr(notRejRow, notChosenCol);
|
|
298
|
+
let allow = ops.logicalAnd(causal, notForbid); // [T,T] bool
|
|
299
|
+
|
|
300
|
+
if (window !== null) {
|
|
301
|
+
// logical positions: prompt+chosen keep physical index; rejected resets to P.
|
|
302
|
+
const logposArr = new Int32Array(T);
|
|
303
|
+
for (let i = 0; i < T; i++) logposArr[i] = i < P + Rc ? i : i - Rc;
|
|
304
|
+
const logpos = MlxArray.fromInt32(logposArr, [T]);
|
|
305
|
+
const lrow = ops.reshape(logpos, [T, 1]);
|
|
306
|
+
const lcol = ops.reshape(logpos, [1, T]);
|
|
307
|
+
const dist = ops.sub(lrow, lcol); // logpos[row] - logpos[col] (>=0 wherever causal allows)
|
|
308
|
+
const w = ops.fromInt32([window], []);
|
|
309
|
+
const slidingOK = ops.less(dist, w); // attend only within `window` logical positions
|
|
310
|
+
const next = ops.logicalAnd(allow, slidingOK);
|
|
311
|
+
allow.dispose();
|
|
312
|
+
allow = next;
|
|
313
|
+
for (const a of [logpos, lrow, lcol, dist, w, slidingOK]) a.dispose();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
for (const a of [causal, idxFlat, row, col, pp, pRc, notRejRow, colLtP, colGePRc, notChosenCol, notForbid]) a.dispose();
|
|
317
|
+
return allow;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Pass-through cache (offset 0) for the gemma prefix-shared forward whose
|
|
321
|
+
* makeMask returns the block-sparse + logical-window mask. forwardLayers builds
|
|
322
|
+
* one mask per layer-type, calling makeMask(L, window) with window = the model's
|
|
323
|
+
* sliding window for sliding layers and null for full layers — so a single cache
|
|
324
|
+
* type serves both (the window arg selects the sliding term). One per DONOR
|
|
325
|
+
* layer (sharers reuse donors' fetched KV). */
|
|
326
|
+
class Gemma4PrefixSharedCache implements Cache {
|
|
327
|
+
offset = 0;
|
|
328
|
+
constructor(private readonly P: number, private readonly Rc: number, private readonly Rr: number) {}
|
|
329
|
+
updateAndFetch(k: MlxArray, v: MlxArray): [MlxArray, MlxArray] {
|
|
330
|
+
return [k.slice([0, 0, 0, 0], k.shape), v.slice([0, 0, 0, 0], v.shape)];
|
|
331
|
+
}
|
|
332
|
+
makeMask(N: number, windowSize: number | null): Mask {
|
|
333
|
+
if (N !== this.P + this.Rc + this.Rr)
|
|
334
|
+
throw new Error(`Gemma4PrefixSharedCache: N=${N} != P+Rc+Rr=${this.P + this.Rc + this.Rr}`);
|
|
335
|
+
return { mode: "array", arr: blockSparsePrefixMaskGemma(this.P, this.Rc, this.Rr, windowSize) };
|
|
336
|
+
}
|
|
337
|
+
state(): MlxArray[] { return []; }
|
|
338
|
+
isTrimmable(): boolean { return true; }
|
|
339
|
+
trim(_n: number): void { /* offset pinned at 0 */ }
|
|
340
|
+
dispose(): void { /* owns no arrays */ }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** ORPO loss via the shared prompt-prefix single forward on Gemma e4b (B=1).
|
|
344
|
+
* Mirrors `orpoLossPrefixShared` (MiniCPM5) but uses the gemma prefix plan
|
|
345
|
+
* (block-wise RoPE in Attention) and donor-count prefix caches, reusing
|
|
346
|
+
* `Gemma4Model.forwardHidden` for the per-layer-input + donor-KV machinery.
|
|
347
|
+
* Returns the scalar loss (caller owns); differentiable through the LoRA
|
|
348
|
+
* primals like the two-forward orpoLoss. */
|
|
349
|
+
export function orpoLossPrefixSharedGemma(
|
|
350
|
+
model: Gemma4Model,
|
|
351
|
+
promptIds: number[], chosenResp: number[], rejectedResp: number[],
|
|
352
|
+
lambda: number, chunk?: ChunkCtx,
|
|
353
|
+
): MlxArray {
|
|
354
|
+
const P = promptIds.length, Rc = chosenResp.length, Rr = rejectedResp.length;
|
|
355
|
+
if (P < 1 || Rc < 1 || Rr < 1) throw new Error("orpoLossPrefixSharedGemma: need P,Rc,Rr >= 1");
|
|
356
|
+
const T = P + Rc + Rr;
|
|
357
|
+
const concat = new Int32Array(T);
|
|
358
|
+
concat.set(promptIds, 0);
|
|
359
|
+
concat.set(chosenResp, P);
|
|
360
|
+
concat.set(rejectedResp, P + Rc);
|
|
361
|
+
const ids = MlxArray.fromInt32(concat, [1, T]);
|
|
362
|
+
// One prefix cache per DONOR layer (sharers consume donors' fetched KV inside
|
|
363
|
+
// forwardLayers); makeCache() also returns numDonors entries.
|
|
364
|
+
const caches: Cache[] = Array.from({ length: model.numDonors }, () => new Gemma4PrefixSharedCache(P, Rc, Rr));
|
|
365
|
+
|
|
366
|
+
let h: MlxArray;
|
|
367
|
+
setGemmaPrefixPlan({ P, Rc, Rr });
|
|
368
|
+
try {
|
|
369
|
+
h = model.forwardHidden(ids, caches); // [1, T, hidden], post-finalNorm
|
|
370
|
+
} finally {
|
|
371
|
+
setGemmaPrefixPlan(null);
|
|
372
|
+
ids.dispose();
|
|
373
|
+
for (const c of caches) c.dispose();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Same gather as MiniCPM5 (identical concat layout): chosen[k] from H[P-1+k];
|
|
377
|
+
// rejected[0] from H[P-1] (shared prompt-last), rejected[k>=1] from H[P+Rc+k-1].
|
|
378
|
+
const chosenIdx = Array.from({ length: Rc }, (_, k) => P - 1 + k);
|
|
379
|
+
const rejectedIdx = [P - 1, ...Array.from({ length: Rr - 1 }, (_, k) => P + Rc + k)];
|
|
380
|
+
|
|
381
|
+
// Same head-primal lifetime as orpoLossPrefixShared: keep h alive in the sink
|
|
382
|
+
// through the fused/flash backward recompute; free it now on the whole-vocab path.
|
|
383
|
+
const keepForBwd = !!chunk && (chunk.fused || chunk.flash);
|
|
384
|
+
if (keepForBwd) chunk!.sink.push(h);
|
|
385
|
+
const lw = branchLogpMeanGathered(model, h, chosenIdx, chosenResp, chunk);
|
|
386
|
+
const lr = branchLogpMeanGathered(model, h, rejectedIdx, rejectedResp, chunk);
|
|
387
|
+
if (!keepForBwd) h.dispose();
|
|
388
|
+
const loss = orpoLossFromLogps(lw, lr, lambda);
|
|
389
|
+
lw.dispose();
|
|
390
|
+
lr.dispose();
|
|
391
|
+
return loss;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Debug: the raw [1,T,hidden] post-finalNorm hidden of the gemma prefix-shared
|
|
395
|
+
* forward (caller owns/disposes). For localizing forward divergence per position. */
|
|
396
|
+
export function prefixForwardHiddenGemma(
|
|
397
|
+
model: Gemma4Model,
|
|
398
|
+
promptIds: number[], chosenResp: number[], rejectedResp: number[],
|
|
399
|
+
): MlxArray {
|
|
400
|
+
const P = promptIds.length, Rc = chosenResp.length, Rr = rejectedResp.length;
|
|
401
|
+
const T = P + Rc + Rr;
|
|
402
|
+
const concat = new Int32Array(T);
|
|
403
|
+
concat.set(promptIds, 0); concat.set(chosenResp, P); concat.set(rejectedResp, P + Rc);
|
|
404
|
+
const ids = MlxArray.fromInt32(concat, [1, T]);
|
|
405
|
+
const caches: Cache[] = Array.from({ length: model.numDonors }, () => new Gemma4PrefixSharedCache(P, Rc, Rr));
|
|
406
|
+
setGemmaPrefixPlan({ P, Rc, Rr });
|
|
407
|
+
try { return model.forwardHidden(ids, caches); }
|
|
408
|
+
finally { setGemmaPrefixPlan(null); ids.dispose(); for (const c of caches) c.dispose(); }
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Debug: the two branch mean-logps (ℓw, ℓr) from the gemma prefix-shared forward,
|
|
412
|
+
* no grad. For parity diagnostics (compare against the two-forward branch logps). */
|
|
413
|
+
export function prefixSharedLogpsGemma(
|
|
414
|
+
model: Gemma4Model,
|
|
415
|
+
promptIds: number[], chosenResp: number[], rejectedResp: number[],
|
|
416
|
+
): { lw: number; lr: number } {
|
|
417
|
+
const P = promptIds.length, Rc = chosenResp.length, Rr = rejectedResp.length;
|
|
418
|
+
const T = P + Rc + Rr;
|
|
419
|
+
const concat = new Int32Array(T);
|
|
420
|
+
concat.set(promptIds, 0); concat.set(chosenResp, P); concat.set(rejectedResp, P + Rc);
|
|
421
|
+
const ids = MlxArray.fromInt32(concat, [1, T]);
|
|
422
|
+
const caches: Cache[] = Array.from({ length: model.numDonors }, () => new Gemma4PrefixSharedCache(P, Rc, Rr));
|
|
423
|
+
setGemmaPrefixPlan({ P, Rc, Rr });
|
|
424
|
+
let h: MlxArray;
|
|
425
|
+
try { h = model.forwardHidden(ids, caches); }
|
|
426
|
+
finally { setGemmaPrefixPlan(null); ids.dispose(); for (const c of caches) c.dispose(); }
|
|
427
|
+
const chosenIdx = Array.from({ length: Rc }, (_, k) => P - 1 + k);
|
|
428
|
+
const rejectedIdx = [P - 1, ...Array.from({ length: Rr - 1 }, (_, k) => P + Rc + k)];
|
|
429
|
+
const lwA = gatheredLogpMean(model, h, chosenIdx, chosenResp);
|
|
430
|
+
const lrA = gatheredLogpMean(model, h, rejectedIdx, rejectedResp);
|
|
431
|
+
h.dispose();
|
|
432
|
+
const lw = lwA.toFloat32()[0]!, lr = lrA.toFloat32()[0]!;
|
|
433
|
+
lwA.dispose(); lrA.dispose();
|
|
434
|
+
return { lw, lr };
|
|
435
|
+
}
|
package/src/train/rank.ts
CHANGED
|
@@ -35,6 +35,42 @@ export interface ResolveRanksOptions {
|
|
|
35
35
|
klMap?: Record<string, number>;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/** Per-layer bits from the loaded model's quant specs — the authoritative,
|
|
39
|
+
* always-present source for `by_bits` scaling (each adapted linear carries its
|
|
40
|
+
* own `QuantSpec`, so this reflects the real mixed-precision assignment without
|
|
41
|
+
* needing the optiq_metadata.json sidecar). Keyed by the same module paths
|
|
42
|
+
* `resolveRanks` iterates, so it drops straight into `opts.bitsMap`. */
|
|
43
|
+
export function bitsMapFromModel(model: RuntimeModel): Record<string, number> {
|
|
44
|
+
const out: Record<string, number> = {};
|
|
45
|
+
for (const [path, linear] of model.loraTargets()) out[path] = linear.spec.bits;
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Per-layer KL sensitivity from `optiq_metadata.json` (for `by_kl`). Mirrors
|
|
50
|
+
* optiq's `read_per_layer_kl`: accepts both the top-level `per_layer` and the
|
|
51
|
+
* older `optimization.per_layer` shapes, reads each entry's `kl`. Returns `{}`
|
|
52
|
+
* if the sidecar is absent or records no KL (our own quantizer writes `bits`
|
|
53
|
+
* but not `kl`), in which case `by_kl` falls back to `by_bits`. */
|
|
54
|
+
export async function readPerLayerKl(modelDir: string): Promise<Record<string, number>> {
|
|
55
|
+
if (!modelDir) return {};
|
|
56
|
+
const path = `${modelDir}/optiq_metadata.json`;
|
|
57
|
+
try {
|
|
58
|
+
if (!(await Bun.file(path).exists())) return {};
|
|
59
|
+
const meta = JSON.parse(await Bun.file(path).text()) as Record<string, unknown>;
|
|
60
|
+
const perLayer = (meta.per_layer ??
|
|
61
|
+
(meta.optimization as Record<string, unknown> | undefined)?.per_layer ??
|
|
62
|
+
{}) as Record<string, unknown>;
|
|
63
|
+
const out: Record<string, number> = {};
|
|
64
|
+
for (const [k, v] of Object.entries(perLayer)) {
|
|
65
|
+
if (v && typeof v === "object" && typeof (v as { kl?: unknown }).kl === "number")
|
|
66
|
+
out[k] = (v as { kl: number }).kl;
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
} catch {
|
|
70
|
+
return {};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
38
74
|
/** Resolve a rank per adapted module path. */
|
|
39
75
|
export function resolveRanks(model: RuntimeModel, opts: ResolveRanksOptions): Map<string, number> {
|
|
40
76
|
const targetModules = opts.targetModules ?? DEFAULT_TARGET_MODULES;
|