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/trainer.ts
CHANGED
|
@@ -20,7 +20,8 @@ import type { RuntimeModel } from "../model/factory";
|
|
|
20
20
|
import { Gemma4Model, type GradCheckpointCtx } from "../model/gemma4";
|
|
21
21
|
import { MiniCPM5Model } from "../model/minicpm5";
|
|
22
22
|
import { setTrainingAttn } from "../model/flash-attention";
|
|
23
|
-
import {
|
|
23
|
+
import { setFusedGeluTraining } from "../model/fused-geglu-kernel";
|
|
24
|
+
import { SegmentedBackward, SegmentedBackwardGemma4, SegmentedBackwardOrpo, SegmentedBackwardOrpoGemma4, SegmentedBackwardOrpoPrefix, SegmentedBackwardOrpoPrefixGemma4, planSegmentsBySize } from "./segmented";
|
|
24
25
|
import type { LoadedTokenizer } from "../tokenizer";
|
|
25
26
|
import type { ChatTemplate } from "../chat-template";
|
|
26
27
|
import type { Emit } from "../jobs/types";
|
|
@@ -30,15 +31,16 @@ import {
|
|
|
30
31
|
} from "./dataset";
|
|
31
32
|
import {
|
|
32
33
|
buildTrainableLora, attachForTraining, detachTraining, flatParams,
|
|
33
|
-
saveAdapter, disposeLora, type TrainableLora, type SaveAdapterConfig,
|
|
34
|
+
saveAdapter, disposeLora, warmStartFromAdapter, type TrainableLora, type SaveAdapterConfig,
|
|
34
35
|
} from "./lora-params";
|
|
35
|
-
import { resolveRanks, DEFAULT_TARGET_MODULES, type RankScaling } from "./rank";
|
|
36
|
-
import { sftLoss, dpoLoss, dpoRefLogps, dpoMetrics } from "./loss";
|
|
36
|
+
import { resolveRanks, bitsMapFromModel, readPerLayerKl, DEFAULT_TARGET_MODULES, type RankScaling } from "./rank";
|
|
37
|
+
import { sftLoss, dpoLoss, dpoRefLogps, dpoMetrics, orpoLoss, orpoMetrics, type ChunkCtx } from "./loss";
|
|
38
|
+
import { orpoLossPrefixShared, orpoLossPrefixSharedGemma, splitPrefixBatch } from "./prefix-shared";
|
|
37
39
|
import { AdamW, warmupCosineSchedule } from "./optimizer";
|
|
38
40
|
import { writeFileSync } from "node:fs";
|
|
39
41
|
|
|
40
42
|
export interface TrainConfig {
|
|
41
|
-
method: "sft" | "dpo";
|
|
43
|
+
method: "sft" | "dpo" | "orpo";
|
|
42
44
|
rank: number;
|
|
43
45
|
scale: number;
|
|
44
46
|
rankScaling: RankScaling;
|
|
@@ -54,10 +56,27 @@ export interface TrainConfig {
|
|
|
54
56
|
stepsPerEval: number;
|
|
55
57
|
betas: [number, number];
|
|
56
58
|
weightDecay: number;
|
|
59
|
+
/** LoRA-input dropout (PEFT-style), 0 = off. Training-only regularizer for
|
|
60
|
+
* small preference sets. Recompute-safe: the mask is keyed by (step, layer)
|
|
61
|
+
* so segmented/checkpointed recompute reproduces it. */
|
|
62
|
+
loraDropout: number;
|
|
63
|
+
/** rsLoRA: scale the LoRA update by α/√rank instead of α, so per-layer rank
|
|
64
|
+
* scaling (by_bits/by_kl) changes capacity, not step size. Saved in the
|
|
65
|
+
* adapter config so inference applies the same per-layer scale. */
|
|
66
|
+
rsLora: boolean;
|
|
67
|
+
/** LoRA+: LR multiplier for the B leaves (A stays at the base LR). >1 speeds
|
|
68
|
+
* the B-driven early learning (B is zero-init). 1 = off. */
|
|
69
|
+
loraPlusRatio: number;
|
|
57
70
|
/** Gradient checkpointing: recompute layer activations in the backward pass
|
|
58
71
|
* to bound memory at long context (mlx-lm grad_checkpoint; default in optiq).
|
|
59
72
|
* Numerically identical to off — pure memory↔compute trade. */
|
|
60
73
|
gradCheckpoint: boolean;
|
|
74
|
+
/** Intra-layer MLP split (lever 4): with gradCheckpoint on (Gemma4 only),
|
|
75
|
+
* checkpoint the attention and MLP sub-blocks separately with the post-attn
|
|
76
|
+
* residual as the boundary, so the backward recompute holds max(attn,MLP)+hMid
|
|
77
|
+
* instead of the whole layer. Numerically identical to gradCheckpoint alone —
|
|
78
|
+
* a finer memory↔compute split. No effect unless gradCheckpoint is on. */
|
|
79
|
+
mlpSplit: boolean;
|
|
61
80
|
/** Segmented backward: layers per segment (0 = off). Streams the SFT backward
|
|
62
81
|
* segment-by-segment so only one segment's activations are live at a time —
|
|
63
82
|
* beats per-layer checkpointing at long context, where naive checkpointing
|
|
@@ -75,6 +94,43 @@ export interface TrainConfig {
|
|
|
75
94
|
dpoBeta: number;
|
|
76
95
|
dpoWarmupIters: number;
|
|
77
96
|
dpoLrSchedule: "constant" | "cosine";
|
|
97
|
+
// ORPO (reference-free; method-prefixed knobs, mirroring the dpo trio).
|
|
98
|
+
/** λ — weights ONLY the odds-ratio term (the SFT-NLL term stays unweighted). */
|
|
99
|
+
orpoLambda: number;
|
|
100
|
+
orpoWarmupIters: number;
|
|
101
|
+
orpoLrSchedule: "constant" | "cosine";
|
|
102
|
+
/** Token-chunk size for the ORPO head (0 = off). >0 computes the B=1
|
|
103
|
+
* response-only LM head in token-chunks, each rematerialized in the backward,
|
|
104
|
+
* bounding the dominant `[M, vocab]` logits term to `[chunkSize, vocab]`.
|
|
105
|
+
* Exact (numerically identical). Non-segmented path only (segmentSize takes
|
|
106
|
+
* precedence). See docs/design/orpo-training.md → chunked fused log-prob. */
|
|
107
|
+
orpoChunkSize: number;
|
|
108
|
+
/** Use the FUSED linear cross-entropy head (Liger/CCE-style): one CustomVjp with
|
|
109
|
+
* an analytic softmax−onehot backward — no autograd through the head, no
|
|
110
|
+
* retained `[M,vocab]` logits in either direction. Composes with
|
|
111
|
+
* `orpoChunkSize` as the token-chunk size (defaults to 512 when unset). B=1,
|
|
112
|
+
* non-segmented path only; Gemma + MiniCPM5 heads. Value bit-exact vs the
|
|
113
|
+
* full-logits head, grads in the bf16 class. See docs/design/orpo-training.md
|
|
114
|
+
* → fused linear-CE head. */
|
|
115
|
+
orpoFusedCe: boolean;
|
|
116
|
+
/** Route the fused linear-CE head through the flash-CCE Metal kernel: in-kernel
|
|
117
|
+
* quantized logits + online softmax (fwd) and dh accumulation (bwd) — neither
|
|
118
|
+
* `[M,vocab]` nor a dequantized head touches HBM. Lowest memory + fastest on
|
|
119
|
+
* large vocab (e4b 262k / CPM 130k). Implies the fused head; the Apple-CCE coeff
|
|
120
|
+
* filter is on by default in the backward. B=1, non-segmented path. */
|
|
121
|
+
orpoFlashCe: boolean;
|
|
122
|
+
/** Shared prompt-prefix ORPO (lever 7): one forward over [prompt; chosen; rejected]
|
|
123
|
+
* with a block-sparse mask + block-wise RoPE, so the shared prompt is encoded ONCE
|
|
124
|
+
* (token cost 2(P+R) → P+2R). B=1, non-segmented path; MiniCPM5 + Gemma4 (e4b).
|
|
125
|
+
* Falls back to the two-forward orpoLoss for rows whose chosen/rejected prompts
|
|
126
|
+
* differ. Composes with orpoFlashCe/orpoFusedCe (each branch routes through the
|
|
127
|
+
* [M,V]-free head). See src/train/prefix-shared.ts. */
|
|
128
|
+
orpoPrefixShared: boolean;
|
|
129
|
+
/** Warm-start: path to an existing adapter dir (with adapters.safetensors) whose
|
|
130
|
+
* LoRA weights initialize this run instead of the random/zero init. Continues
|
|
131
|
+
* training from a checkpoint's weights; the optimizer state and LR schedule restart.
|
|
132
|
+
* Rank/targets must match the checkpoint. "" = off (fresh init). */
|
|
133
|
+
warmStartAdapter: string;
|
|
78
134
|
// output
|
|
79
135
|
adapterPath: string;
|
|
80
136
|
baseModel: string;
|
|
@@ -103,6 +159,10 @@ export const DEFAULT_TRAIN_CONFIG: TrainConfig = {
|
|
|
103
159
|
stepsPerEval: 50,
|
|
104
160
|
betas: [0.9, 0.999],
|
|
105
161
|
weightDecay: 0.01,
|
|
162
|
+
loraDropout: 0.0, // off by default
|
|
163
|
+
rsLora: false, // off by default (opt-in; recommended when rank_scaling != constant)
|
|
164
|
+
loraPlusRatio: 1.0, // 1 = off
|
|
165
|
+
mlpSplit: false, // default off; only effective with gradCheckpoint (Gemma4)
|
|
106
166
|
gradCheckpoint: false, // default off until validated; flip to true (optiq default) after
|
|
107
167
|
segmentSize: 0, // default off; >0 enables segmented backward (MiniCPM5 SFT B=1)
|
|
108
168
|
saveCheckpoints: false, // off by default; scripts opt in (keep-all-checkpoints + metrics.json)
|
|
@@ -110,6 +170,15 @@ export const DEFAULT_TRAIN_CONFIG: TrainConfig = {
|
|
|
110
170
|
dpoBeta: 0.1,
|
|
111
171
|
dpoWarmupIters: 0,
|
|
112
172
|
dpoLrSchedule: "cosine",
|
|
173
|
+
|
|
174
|
+
orpoLambda: 0.1, // paper's primary value + TRL default; weights only L_OR
|
|
175
|
+
orpoWarmupIters: 0,
|
|
176
|
+
orpoLrSchedule: "cosine",
|
|
177
|
+
orpoChunkSize: 0, // default off; >0 = token-chunked head (bounds the [M,vocab] term)
|
|
178
|
+
orpoFusedCe: false, // default off; true = fused linear-CE head (analytic backward, no [M,vocab])
|
|
179
|
+
orpoFlashCe: false, // default off; true = flash-CCE Metal-kernel head (implies fused)
|
|
180
|
+
orpoPrefixShared: false, // default off; true = shared prompt-prefix single forward (B=1)
|
|
181
|
+
warmStartAdapter: "", // default off; path to an adapter dir to warm-start LoRA weights from
|
|
113
182
|
adapterPath: "adapters",
|
|
114
183
|
baseModel: "",
|
|
115
184
|
};
|
|
@@ -127,16 +196,51 @@ export async function trainLora(
|
|
|
127
196
|
): Promise<TrainResult> {
|
|
128
197
|
emit({ type: "stage", stage: "setup", progress: 0.02, message: "resolving ranks" });
|
|
129
198
|
|
|
199
|
+
// Mixed-precision rank scaling: feed the per-layer bits (from the loaded
|
|
200
|
+
// model's quant specs) and, for by_kl, the recorded KL sensitivities
|
|
201
|
+
// (optiq_metadata.json). Without these maps, by_bits/by_kl silently fell back
|
|
202
|
+
// to uniform rank — so the "give the sensitive (8-bit) layers more adapter
|
|
203
|
+
// capacity" policy (optiq: one sensitivity signal, two optimizations — the
|
|
204
|
+
// bit assignment AND the LoRA rank) never actually fired.
|
|
205
|
+
const bitsMap = bitsMapFromModel(model);
|
|
206
|
+
const klMap = cfg.rankScaling === "by_kl" ? await readPerLayerKl(cfg.baseModel) : undefined;
|
|
130
207
|
const ranks = resolveRanks(model, {
|
|
131
208
|
rank: cfg.rank,
|
|
132
209
|
rankScaling: cfg.rankScaling,
|
|
133
210
|
targetModules: cfg.targetModules,
|
|
134
211
|
numLayers: cfg.numLayers,
|
|
212
|
+
bitsMap,
|
|
213
|
+
klMap,
|
|
135
214
|
});
|
|
136
215
|
const appliedRanks: Record<string, number> = Object.fromEntries(ranks);
|
|
137
216
|
|
|
138
|
-
|
|
217
|
+
// Surface the rank spread so mixed-precision scaling is observable (e.g.
|
|
218
|
+
// "r8×140 r16×28" means 28 sensitive layers got the wider adapter).
|
|
219
|
+
const rankCounts = new Map<number, number>();
|
|
220
|
+
for (const r of ranks.values()) rankCounts.set(r, (rankCounts.get(r) ?? 0) + 1);
|
|
221
|
+
const rankSpread = [...rankCounts.entries()]
|
|
222
|
+
.sort((a, b) => a[0] - b[0])
|
|
223
|
+
.map(([r, n]) => `r${r}×${n}`)
|
|
224
|
+
.join(" ");
|
|
225
|
+
emit({ type: "stage", stage: "setup", progress: 0.03,
|
|
226
|
+
message: `ranks (${cfg.rankScaling}): ${rankSpread}` });
|
|
227
|
+
|
|
228
|
+
const lora = buildTrainableLora(model, ranks, cfg.scale, cfg.seed, cfg.rsLora);
|
|
229
|
+
// Warm-start: continue from a saved adapter/checkpoint's weights (optimizer + LR
|
|
230
|
+
// schedule restart fresh). Must run before the optimizer is built (below) so it
|
|
231
|
+
// tracks the loaded leaves. Rank/targets must match the checkpoint.
|
|
232
|
+
if (cfg.warmStartAdapter) {
|
|
233
|
+
const n = warmStartFromAdapter(lora, cfg.warmStartAdapter);
|
|
234
|
+
emit({ type: "stage", stage: "setup", progress: 0.035,
|
|
235
|
+
message: `warm-start: loaded ${n} LoRA targets from ${cfg.warmStartAdapter} (optimizer + LR schedule restart)` });
|
|
236
|
+
}
|
|
139
237
|
attachForTraining(model, lora, "train");
|
|
238
|
+
model.loraState.dropoutRate = cfg.loraDropout; // per-step seed set inside the loop
|
|
239
|
+
// Training-mode fused GeGLU (Gemma e4b): kernel forward + hand-derived vjp, so
|
|
240
|
+
// autograd flows and the backward recomputes the gelu from the primal instead
|
|
241
|
+
// of retaining the spelled-out intermediates. No-op for non-Gemma models.
|
|
242
|
+
// Cleared in the finally below.
|
|
243
|
+
setFusedGeluTraining(true);
|
|
140
244
|
|
|
141
245
|
const saveCfg: SaveAdapterConfig = {
|
|
142
246
|
rank: cfg.rank,
|
|
@@ -146,6 +250,7 @@ export async function trainLora(
|
|
|
146
250
|
numLayers: cfg.numLayers,
|
|
147
251
|
method: cfg.method,
|
|
148
252
|
baseModel: cfg.baseModel,
|
|
253
|
+
rsLora: cfg.rsLora,
|
|
149
254
|
};
|
|
150
255
|
|
|
151
256
|
// Keep-ALL-checkpoints + a durable metrics record. At each eval step we
|
|
@@ -158,7 +263,11 @@ export async function trainLora(
|
|
|
158
263
|
// metrics, and the live LoRA leaves are the current step's weights at emit
|
|
159
264
|
// time, so saving here captures exactly that checkpoint.
|
|
160
265
|
const trainHistory: { step: number; loss: number }[] = [];
|
|
161
|
-
|
|
266
|
+
// For preference methods (dpo/orpo) the val MARGIN (mean chosen−rejected
|
|
267
|
+
// log-odds) and accuracy are the meaningful early-stop signals — val loss is a
|
|
268
|
+
// weak proxy. Capture them when the loop emits them so metrics.json lets you
|
|
269
|
+
// pick the best-MARGIN checkpoint (overfit shows as train↑ / val-margin flat).
|
|
270
|
+
const valHistory: { step: number; loss: number; margin?: number; accuracy?: number; checkpoint: string | null }[] = [];
|
|
162
271
|
const startedAt = Date.now();
|
|
163
272
|
const collect: Emit = (e) => {
|
|
164
273
|
if (e.type === "metric" && e.kind === "train") {
|
|
@@ -172,7 +281,8 @@ export async function trainLora(
|
|
|
172
281
|
emit({ type: "stage", stage: "checkpoint", progress: e.step / cfg.iters,
|
|
173
282
|
message: `checkpoint ${tag}` });
|
|
174
283
|
}
|
|
175
|
-
|
|
284
|
+
const ev = e as { step: number; loss: number; margin?: number; accuracy?: number };
|
|
285
|
+
valHistory.push({ step: ev.step, loss: ev.loss, margin: ev.margin, accuracy: ev.accuracy, checkpoint });
|
|
176
286
|
}
|
|
177
287
|
emit(e);
|
|
178
288
|
};
|
|
@@ -181,7 +291,9 @@ export async function trainLora(
|
|
|
181
291
|
const result =
|
|
182
292
|
cfg.method === "dpo"
|
|
183
293
|
? await dpoLoop(model, tok, tmpl, dataDir, cfg, lora, collect)
|
|
184
|
-
:
|
|
294
|
+
: cfg.method === "orpo"
|
|
295
|
+
? await orpoLoop(model, tok, tmpl, dataDir, cfg, lora, collect)
|
|
296
|
+
: await sftLoop(model, tok, tmpl, dataDir, cfg, lora, collect);
|
|
185
297
|
|
|
186
298
|
// Final save (last adapter).
|
|
187
299
|
detachTraining(model, lora);
|
|
@@ -193,18 +305,24 @@ export async function trainLora(
|
|
|
193
305
|
if (cfg.saveCheckpoints) {
|
|
194
306
|
let bestVal: { step: number; loss: number; checkpoint: string | null } | null = null;
|
|
195
307
|
for (const v of valHistory) if (bestVal === null || v.loss < bestVal.loss) bestVal = v;
|
|
308
|
+
// Best by MARGIN too (preference methods) — the better checkpoint signal.
|
|
309
|
+
let bestMargin: typeof valHistory[number] | null = null;
|
|
310
|
+
for (const v of valHistory)
|
|
311
|
+
if (v.margin !== undefined && (bestMargin === null || v.margin > (bestMargin.margin ?? -Infinity))) bestMargin = v;
|
|
196
312
|
writeFileSync(`${cfg.adapterPath}/metrics.json`, JSON.stringify({
|
|
197
313
|
config: {
|
|
198
314
|
method: cfg.method, rank: cfg.rank, scale: cfg.scale,
|
|
199
315
|
learningRate: cfg.learningRate, maxSeqLen: cfg.maxSeqLen,
|
|
200
316
|
segmentSize: cfg.segmentSize, iters: cfg.iters,
|
|
201
317
|
numLayers: cfg.numLayers, baseModel: cfg.baseModel,
|
|
318
|
+
orpoLambda: cfg.method === "orpo" ? cfg.orpoLambda : undefined,
|
|
202
319
|
},
|
|
203
320
|
wallSeconds: (Date.now() - startedAt) / 1000,
|
|
204
321
|
peakGb: peakMemory() / 1e9,
|
|
205
322
|
finalTrainLoss: trainHistory.at(-1)?.loss ?? null,
|
|
206
323
|
finalValLoss: valHistory.at(-1)?.loss ?? null,
|
|
207
324
|
bestVal,
|
|
325
|
+
bestMargin, // null for SFT (no margin); the recommended pick for dpo/orpo
|
|
208
326
|
valTrajectory: valHistory,
|
|
209
327
|
trainLosses: trainHistory,
|
|
210
328
|
}, null, 2));
|
|
@@ -220,6 +338,7 @@ export async function trainLora(
|
|
|
220
338
|
});
|
|
221
339
|
return { adapterPath: cfg.adapterPath, appliedRanks, numIters: result.numIters };
|
|
222
340
|
} finally {
|
|
341
|
+
setFusedGeluTraining(false);
|
|
223
342
|
disposeLora(lora);
|
|
224
343
|
}
|
|
225
344
|
}
|
|
@@ -290,22 +409,29 @@ async function sftLoop(
|
|
|
290
409
|
// Checkpoints are created per forward and disposed after each step's backward.
|
|
291
410
|
let ckptCtx: GradCheckpointCtx | null = null;
|
|
292
411
|
if (!useSegmented && cfg.gradCheckpoint && model instanceof Gemma4Model) {
|
|
412
|
+
// Partition each layer's LoRA into the attention sub-block (self_attn.*) and
|
|
413
|
+
// the MLP sub-block (everything else in the layer: gate/up/down_proj +
|
|
414
|
+
// per_layer_*) so the split-MLP checkpoint can wrap them independently.
|
|
293
415
|
const byLayer: GradCheckpointCtx["byLayer"] = new Map();
|
|
294
416
|
for (const t of lora.targets) {
|
|
295
417
|
const m = t.modulePath.match(/\.layers\.(\d+)\./);
|
|
296
418
|
if (!m) continue;
|
|
297
419
|
const li = Number(m[1]);
|
|
298
|
-
|
|
420
|
+
const ll = byLayer.get(li) ?? byLayer.set(li, { attn: [], mlp: [] }).get(li)!;
|
|
421
|
+
(t.modulePath.includes(".self_attn.") ? ll.attn : ll.mlp).push(t.lw);
|
|
299
422
|
}
|
|
300
|
-
ckptCtx = { byLayer, keepAlive: [] };
|
|
423
|
+
ckptCtx = { byLayer, splitMlp: cfg.mlpSplit, keepAlive: [] };
|
|
301
424
|
model.gradCkpt = ckptCtx;
|
|
425
|
+
if (cfg.mlpSplit) emit({ type: "stage", stage: "setup", progress: 0.04,
|
|
426
|
+
message: "grad checkpoint: intra-layer attn/MLP split" });
|
|
302
427
|
}
|
|
303
428
|
|
|
304
|
-
// Training attention. Default = mlx fused SDPA (ops.sdpa):
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
429
|
+
// Training attention. Default = mlx fused SDPA (ops.sdpa): exact dQ/dK/dV
|
|
430
|
+
// (finite-difference verified) and fast, but O(L²) backward memory. The
|
|
431
|
+
// hand-rolled flash kernel is an opt-in (MLX_BUN_TRAIN_ATTN=flash) O(L)-memory
|
|
432
|
+
// path for memory-bound long context — now correct (its dK-transpose and
|
|
433
|
+
// dQ-causal-barrier bugs were fixed; FD-validated in
|
|
434
|
+
// scripts/experiments/flash-fd-check.ts), though ~30× slower than ops.sdpa.
|
|
309
435
|
// Cleared in finally.
|
|
310
436
|
if (process.env.MLX_BUN_TRAIN_ATTN === "flash") setTrainingAttn("flash");
|
|
311
437
|
|
|
@@ -318,22 +444,27 @@ async function sftLoop(
|
|
|
318
444
|
|
|
319
445
|
try {
|
|
320
446
|
for (let step = 1; step <= cfg.iters; step++) {
|
|
321
|
-
currentBatch = batches.next().value as SftBatch;
|
|
322
|
-
const tokensThisStep = countResponseTokens(currentBatch);
|
|
323
|
-
|
|
324
447
|
if (MEM_LOG) resetPeakMemory();
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
448
|
+
|
|
449
|
+
// One optimizer step over cfg.gradAccumSteps micro-batches (pass-through
|
|
450
|
+
// when 1). afterMicroEval frees each micro-batch's gradient-checkpoint
|
|
451
|
+
// closures right after its backward so they don't pile up across
|
|
452
|
+
// accumulation.
|
|
453
|
+
let tokensThisStep = 0;
|
|
454
|
+
const { loss: lossVal, grads } = accumulateStep(
|
|
455
|
+
cfg.gradAccumSteps,
|
|
456
|
+
() => {
|
|
457
|
+
currentBatch = batches.next().value as SftBatch;
|
|
458
|
+
tokensThisStep += countResponseTokens(currentBatch);
|
|
459
|
+
return segmented ? segmented.step(currentBatch!) : vag!.apply(flatParams(lora));
|
|
460
|
+
},
|
|
461
|
+
ckptCtx
|
|
462
|
+
? () => {
|
|
463
|
+
for (const ck of ckptCtx!.keepAlive) ck.dispose();
|
|
464
|
+
ckptCtx!.keepAlive.length = 0;
|
|
465
|
+
}
|
|
466
|
+
: undefined,
|
|
467
|
+
);
|
|
337
468
|
|
|
338
469
|
const gradNorm = globalNorm(grads); // grads already materialized above
|
|
339
470
|
opt.step(grads);
|
|
@@ -345,12 +476,6 @@ async function sftLoop(
|
|
|
345
476
|
} else {
|
|
346
477
|
clearCache();
|
|
347
478
|
}
|
|
348
|
-
if (ckptCtx) {
|
|
349
|
-
// Grads are materialized (globalNorm/evalState), so the recompute is
|
|
350
|
-
// done — free this step's checkpoint closures before the next forward.
|
|
351
|
-
for (const ck of ckptCtx.keepAlive) ck.dispose();
|
|
352
|
-
ckptCtx.keepAlive.length = 0;
|
|
353
|
-
}
|
|
354
479
|
|
|
355
480
|
if (step % cfg.stepsPerReport === 0 || step === 1) {
|
|
356
481
|
const elapsed = (Date.now() - t0) / 1000;
|
|
@@ -467,28 +592,35 @@ async function dpoLoop(
|
|
|
467
592
|
|
|
468
593
|
try {
|
|
469
594
|
for (let step = 1; step <= cfg.iters; step++) {
|
|
470
|
-
currentBatch = batches.next().value as DpoBatch;
|
|
471
595
|
if (schedule) opt.lr = schedule(step);
|
|
472
596
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
const {
|
|
478
|
-
|
|
479
|
-
|
|
597
|
+
// One optimizer step over cfg.gradAccumSteps micro-batches (pass-through
|
|
598
|
+
// when 1). Reference log-probs are recomputed per micro-batch OUTSIDE the
|
|
599
|
+
// policy value_and_grad and read as detached constants inside it;
|
|
600
|
+
// afterMicroEval frees them once that micro-batch's grads are materialized.
|
|
601
|
+
const { loss: lossVal, grads } = accumulateStep(
|
|
602
|
+
cfg.gradAccumSteps,
|
|
603
|
+
() => {
|
|
604
|
+
currentBatch = batches.next().value as DpoBatch;
|
|
605
|
+
const refs = dpoRefLogps(model, lora, currentBatch);
|
|
606
|
+
refChosen = refs.refChosen;
|
|
607
|
+
refRejected = refs.refRejected;
|
|
608
|
+
return vag.apply(flatParams(lora));
|
|
609
|
+
},
|
|
610
|
+
() => {
|
|
611
|
+
refChosen?.dispose();
|
|
612
|
+
refRejected?.dispose();
|
|
613
|
+
refChosen = refRejected = null;
|
|
614
|
+
},
|
|
615
|
+
);
|
|
480
616
|
|
|
481
617
|
const gradNorm = globalNorm(grads);
|
|
482
618
|
opt.step(grads);
|
|
483
619
|
opt.evalState();
|
|
484
|
-
|
|
485
|
-
refChosen.dispose();
|
|
486
|
-
refRejected.dispose();
|
|
487
|
-
refChosen = refRejected = null;
|
|
488
620
|
clearCache();
|
|
489
621
|
|
|
490
622
|
if (step % cfg.stepsPerReport === 0 || step === 1) {
|
|
491
|
-
const m = dpoMetrics(model, lora, currentBatch
|
|
623
|
+
const m = dpoMetrics(model, lora, currentBatch!, cfg.dpoBeta);
|
|
492
624
|
const elapsed = (Date.now() - t0) / 1000;
|
|
493
625
|
emit({
|
|
494
626
|
type: "metric",
|
|
@@ -529,8 +661,250 @@ async function dpoLoop(
|
|
|
529
661
|
} finally {
|
|
530
662
|
vag.dispose();
|
|
531
663
|
opt.dispose();
|
|
532
|
-
|
|
533
|
-
|
|
664
|
+
// Cast: refs are only ever assigned inside the accumulateStep closures, so
|
|
665
|
+
// TS's flow analysis narrows these to their `null` initializer here.
|
|
666
|
+
(refChosen as MlxArray | null)?.dispose();
|
|
667
|
+
(refRejected as MlxArray | null)?.dispose();
|
|
668
|
+
}
|
|
669
|
+
return { numIters: cfg.iters };
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// ---------------------------------------------------------------------------
|
|
673
|
+
// ORPO loop (reference-free: 2 forwards/step, no reference pass)
|
|
674
|
+
// ---------------------------------------------------------------------------
|
|
675
|
+
|
|
676
|
+
async function orpoLoop(
|
|
677
|
+
model: RuntimeModel,
|
|
678
|
+
tok: LoadedTokenizer,
|
|
679
|
+
tmpl: ChatTemplate,
|
|
680
|
+
dataDir: string,
|
|
681
|
+
cfg: TrainConfig,
|
|
682
|
+
lora: TrainableLora,
|
|
683
|
+
emit: Emit,
|
|
684
|
+
): Promise<{ numIters: number }> {
|
|
685
|
+
emit({ type: "stage", stage: "data", progress: 0.05, message: "loading preference dataset" });
|
|
686
|
+
const train = await loadDpoDataset(`${dataDir}/train.jsonl`, tok, tmpl, cfg.maxSeqLen);
|
|
687
|
+
const valid = (await fileExists(`${dataDir}/valid.jsonl`))
|
|
688
|
+
? await loadDpoDataset(`${dataDir}/valid.jsonl`, tok, tmpl, cfg.maxSeqLen)
|
|
689
|
+
: [];
|
|
690
|
+
|
|
691
|
+
const params = flatParams(lora);
|
|
692
|
+
// LoRA+: B leaves (the second half of flatParams) get a higher LR than A.
|
|
693
|
+
const nT = lora.targets.length;
|
|
694
|
+
const lrScale = cfg.loraPlusRatio !== 1
|
|
695
|
+
? params.map((_, i) => (i < nT ? 1 : cfg.loraPlusRatio))
|
|
696
|
+
: undefined;
|
|
697
|
+
if (lrScale) emit({ type: "stage", stage: "setup", progress: 0.04,
|
|
698
|
+
message: `LoRA+ : B leaves at ${cfg.loraPlusRatio}× LR` });
|
|
699
|
+
const opt = new AdamW(
|
|
700
|
+
params,
|
|
701
|
+
{ lr: cfg.learningRate, betas: cfg.betas, eps: 1e-8, weightDecay: cfg.weightDecay, lrScale },
|
|
702
|
+
(i, p) => writeParam(lora, i, p),
|
|
703
|
+
);
|
|
704
|
+
|
|
705
|
+
const schedule =
|
|
706
|
+
cfg.orpoLrSchedule === "cosine" || cfg.orpoWarmupIters > 0
|
|
707
|
+
? warmupCosineSchedule(cfg.learningRate, cfg.orpoWarmupIters, cfg.iters)
|
|
708
|
+
: null;
|
|
709
|
+
|
|
710
|
+
// Segmented backward for ORPO: stream chosen+rejected backwards segment-by-segment.
|
|
711
|
+
// MiniCPM5 + Gemma4 (e4b) — same model coverage as SFT segmented. B=1 only.
|
|
712
|
+
const useSegmented = cfg.segmentSize > 0;
|
|
713
|
+
// Prefix-sharing + segmented backward COMPOSE (M3-composition): the segmented
|
|
714
|
+
// backward streams the ONE prefix-shared concat forward [prompt; chosen; rejected]
|
|
715
|
+
// (block-sparse mask via PrefixSharedCache + block-wise RoPE via the prefix plan)
|
|
716
|
+
// segment-by-segment — so the prompt-encode-once saving holds AT long seq. Composed
|
|
717
|
+
// for BOTH MiniCPM5 and Gemma4/e4b (the e4b variant threads donor-KV + the
|
|
718
|
+
// logical-position sliding-window prefix mask through the segment boundaries).
|
|
719
|
+
const useSegmentedPrefix = cfg.orpoPrefixShared && useSegmented;
|
|
720
|
+
if (cfg.orpoPrefixShared && !(model instanceof MiniCPM5Model || model instanceof Gemma4Model))
|
|
721
|
+
throw new Error("orpoPrefixShared is only wired for MiniCPM5 and Gemma4");
|
|
722
|
+
// Fused linear-CE head INSIDE the segmented backward (orpo_fused_ce): bounds the
|
|
723
|
+
// head term to [chunk,V] alongside the per-segment layer savings. 0 = the
|
|
724
|
+
// full-[M,V] responseOnlyLogpMean head (unchanged). Reuses orpoChunkSize as the
|
|
725
|
+
// token-chunk (default 512), same as the non-segmented path.
|
|
726
|
+
// "flash implies fused": orpoFlashCe alone must still bound the segmented head to
|
|
727
|
+
// [chunk,V]. The non-prefix segmented class has no flash path, so flash routes
|
|
728
|
+
// through its fused/checkpoint chunked head (boundedHeadFromHidden) — never the
|
|
729
|
+
// full-[M,V] responseOnlyLogpMean, which would reintroduce [M,vocab] on e4b.
|
|
730
|
+
const segUsesFusedHead = cfg.orpoFusedCe || cfg.orpoFlashCe;
|
|
731
|
+
const segFusedChunk = segUsesFusedHead ? (cfg.orpoChunkSize > 0 ? cfg.orpoChunkSize : 512) : 0;
|
|
732
|
+
// The prefix segmented head gathers response positions then routes per-branch
|
|
733
|
+
// through the [M,V]-free flash/fused head (branchLogpMeanGathered, which takes a
|
|
734
|
+
// ChunkCtx). Its own sink is managed per-step inside the class.
|
|
735
|
+
const segPrefixChunk: ChunkCtx | undefined =
|
|
736
|
+
cfg.orpoFusedCe || cfg.orpoFlashCe
|
|
737
|
+
? { chunkSize: cfg.orpoChunkSize > 0 ? cfg.orpoChunkSize : 512, fused: true, flash: cfg.orpoFlashCe, sink: [] }
|
|
738
|
+
: undefined;
|
|
739
|
+
let segmentedOrpo: SegmentedBackwardOrpo | SegmentedBackwardOrpoGemma4 | null = null;
|
|
740
|
+
let segmentedOrpoPrefix: SegmentedBackwardOrpoPrefix | SegmentedBackwardOrpoPrefixGemma4 | null = null;
|
|
741
|
+
if (useSegmented) {
|
|
742
|
+
const ranges = planSegmentsBySize(model.layers.length, cfg.segmentSize);
|
|
743
|
+
if (useSegmentedPrefix) {
|
|
744
|
+
// The composed prefix-shared segmented backward (MiniCPM5 or Gemma4/e4b), plus a
|
|
745
|
+
// plain segmented-ORPO fallback for rows whose chosen/rejected prompts differ
|
|
746
|
+
// (splitPrefixBatch returns null -> two-forward segmented step).
|
|
747
|
+
if (model instanceof MiniCPM5Model) {
|
|
748
|
+
segmentedOrpoPrefix = new SegmentedBackwardOrpoPrefix(model, lora, ranges, cfg.orpoLambda, segPrefixChunk);
|
|
749
|
+
segmentedOrpo = new SegmentedBackwardOrpo(model, lora, ranges, cfg.orpoLambda, segFusedChunk);
|
|
750
|
+
} else {
|
|
751
|
+
segmentedOrpoPrefix = new SegmentedBackwardOrpoPrefixGemma4(model as Gemma4Model, lora, ranges, cfg.orpoLambda, segPrefixChunk);
|
|
752
|
+
segmentedOrpo = new SegmentedBackwardOrpoGemma4(model as Gemma4Model, lora, ranges, cfg.orpoLambda, segFusedChunk);
|
|
753
|
+
}
|
|
754
|
+
emit({ type: "stage", stage: "setup", progress: 0.04,
|
|
755
|
+
message: `orpo segmented + prefix-share (${model instanceof Gemma4Model ? "e4b" : "MiniCPM5"}): single concat forward streamed over ${Math.ceil(model.layers.length / cfg.segmentSize)} segments of <=${cfg.segmentSize} layers (two-forward fallback on prompt mismatch)` });
|
|
756
|
+
} else if (model instanceof MiniCPM5Model)
|
|
757
|
+
segmentedOrpo = new SegmentedBackwardOrpo(model, lora, ranges, cfg.orpoLambda, segFusedChunk);
|
|
758
|
+
else if (model instanceof Gemma4Model)
|
|
759
|
+
segmentedOrpo = new SegmentedBackwardOrpoGemma4(model, lora, ranges, cfg.orpoLambda, segFusedChunk);
|
|
760
|
+
else
|
|
761
|
+
throw new Error("segmented backward (segmentSize > 0) for ORPO is only wired for MiniCPM5 and Gemma4");
|
|
762
|
+
if (!useSegmentedPrefix) emit({ type: "stage", stage: "setup", progress: 0.04,
|
|
763
|
+
message: `orpo segmented backward: ${Math.ceil(model.layers.length / cfg.segmentSize)} segments of <=${cfg.segmentSize} layers${segFusedChunk > 0 ? ` + fused linear-CE head (${segFusedChunk}/chunk)` : ""}` });
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// Token-chunked head (non-segmented path only): the per-chunk checkpoints are
|
|
767
|
+
// collected here and disposed after each micro-step's grads are eval'd (the
|
|
768
|
+
// afterMicroEval hook below) — that is when their backward recompute has run.
|
|
769
|
+
const chunkSink: Array<{ dispose(): void }> = [];
|
|
770
|
+
// Fused linear-CE head reuses orpoChunkSize as the token-chunk (default 512 when
|
|
771
|
+
// unset); otherwise the Checkpoint token-chunked head fires only when
|
|
772
|
+
// orpoChunkSize > 0. Both are non-segmented-path only.
|
|
773
|
+
const fusedChunkSize = cfg.orpoChunkSize > 0 ? cfg.orpoChunkSize : 512;
|
|
774
|
+
const chunk: ChunkCtx | undefined = useSegmented
|
|
775
|
+
? undefined
|
|
776
|
+
: cfg.orpoFusedCe || cfg.orpoFlashCe
|
|
777
|
+
? { chunkSize: fusedChunkSize, fused: true, flash: cfg.orpoFlashCe, sink: chunkSink }
|
|
778
|
+
: cfg.orpoChunkSize > 0
|
|
779
|
+
? { chunkSize: cfg.orpoChunkSize, sink: chunkSink }
|
|
780
|
+
: undefined;
|
|
781
|
+
if (chunk) emit({ type: "stage", stage: "setup", progress: 0.04,
|
|
782
|
+
message: chunk.flash
|
|
783
|
+
? `orpo flash-CCE (Metal kernel) head: ${chunk.chunkSize} tokens/chunk`
|
|
784
|
+
: chunk.fused
|
|
785
|
+
? `orpo fused linear-CE head: ${chunk.chunkSize} tokens/chunk`
|
|
786
|
+
: `orpo chunked head: ${cfg.orpoChunkSize} tokens/chunk` });
|
|
787
|
+
if (cfg.orpoPrefixShared && !useSegmentedPrefix) emit({ type: "stage", stage: "setup", progress: 0.04,
|
|
788
|
+
message: `orpo prefix-sharing: single forward over [prompt; chosen; rejected] (two-forward fallback on prompt mismatch)` });
|
|
789
|
+
const disposeChunkSink = () => { for (const c of chunkSink) c.dispose(); chunkSink.length = 0; };
|
|
790
|
+
|
|
791
|
+
// No reference forward (the whole point of ORPO) — the closure just builds the
|
|
792
|
+
// monolithic L_NLL + λ·L_OR over the active batch against the swapped primals.
|
|
793
|
+
let currentBatch: DpoBatch | null = null;
|
|
794
|
+
const vag = useSegmented
|
|
795
|
+
? null
|
|
796
|
+
: new ValueAndGrad((primals) => {
|
|
797
|
+
const saved = swapPrimals(lora, primals);
|
|
798
|
+
try {
|
|
799
|
+
// Prefix-sharing (B=1): one forward over [prompt; chosen; rejected] when the
|
|
800
|
+
// row's chosen/rejected share an identical prompt; else fall back two-forward.
|
|
801
|
+
if (cfg.orpoPrefixShared) {
|
|
802
|
+
const split = splitPrefixBatch(currentBatch!);
|
|
803
|
+
if (split) {
|
|
804
|
+
if (model instanceof MiniCPM5Model)
|
|
805
|
+
return orpoLossPrefixShared(model, split.promptIds, split.chosenResp, split.rejectedResp, cfg.orpoLambda, chunk);
|
|
806
|
+
return orpoLossPrefixSharedGemma(model as Gemma4Model, split.promptIds, split.chosenResp, split.rejectedResp, cfg.orpoLambda, chunk);
|
|
807
|
+
}
|
|
808
|
+
notePrefixFallback();
|
|
809
|
+
}
|
|
810
|
+
return orpoLoss(model, currentBatch!, cfg.orpoLambda, chunk);
|
|
811
|
+
} finally {
|
|
812
|
+
restorePrimals(lora, saved);
|
|
813
|
+
}
|
|
814
|
+
}, params.map((_, i) => i));
|
|
815
|
+
|
|
816
|
+
const padId = tok.eosTokenId ?? 0;
|
|
817
|
+
const batches = iterateDpoBatches(train, cfg.batchSize, cfg.seed, true, padId);
|
|
818
|
+
const t0 = Date.now();
|
|
819
|
+
|
|
820
|
+
let dropoutCounter = 0;
|
|
821
|
+
// Prefix-share falls back to the two-forward path for any row whose chosen/rejected
|
|
822
|
+
// prompts aren't byte-identical (splitPrefixBatch → null). Log the FIRST fallback +
|
|
823
|
+
// count them, so a silent loss of the prompt-encode-once saving is visible.
|
|
824
|
+
let prefixFallbacks = 0;
|
|
825
|
+
const notePrefixFallback = () => {
|
|
826
|
+
prefixFallbacks++;
|
|
827
|
+
if (prefixFallbacks === 1) emit({ type: "stage", stage: "train", progress: 0,
|
|
828
|
+
message: `prefix-share: row prompt mismatch → two-forward fallback for this row (saving lost only on mismatched rows; counting…)` });
|
|
829
|
+
};
|
|
830
|
+
try {
|
|
831
|
+
for (let step = 1; step <= cfg.iters; step++) {
|
|
832
|
+
if (schedule) opt.lr = schedule(step);
|
|
833
|
+
|
|
834
|
+
// One optimizer step over cfg.gradAccumSteps micro-batches (pass-through
|
|
835
|
+
// when 1) — the long-context lever for a larger effective batch without
|
|
836
|
+
// the B>1 activation memory (see docs/design/orpo-training.md, Batching).
|
|
837
|
+
const { loss: lossVal, grads } = accumulateStep(cfg.gradAccumSteps, () => {
|
|
838
|
+
currentBatch = batches.next().value as DpoBatch;
|
|
839
|
+
// Per-micro-step dropout seed: constant across this micro-step's forward
|
|
840
|
+
// AND its backward recompute (segmented/chunked), so the mask reproduces.
|
|
841
|
+
if (cfg.loraDropout > 0) model.loraState.dropoutSeed = ++dropoutCounter;
|
|
842
|
+
// Segmented + prefix-share: stream the single concat forward when the row's
|
|
843
|
+
// chosen/rejected share an identical prompt; else fall back to the plain
|
|
844
|
+
// two-forward segmented step (segmentedOrpo, built alongside).
|
|
845
|
+
if (segmentedOrpoPrefix) {
|
|
846
|
+
const split = splitPrefixBatch(currentBatch!);
|
|
847
|
+
if (split) return segmentedOrpoPrefix.stepPrefix(split.promptIds, split.chosenResp, split.rejectedResp);
|
|
848
|
+
notePrefixFallback();
|
|
849
|
+
return segmentedOrpo!.step(currentBatch!);
|
|
850
|
+
}
|
|
851
|
+
return segmentedOrpo ? segmentedOrpo.step(currentBatch!) : vag!.apply(flatParams(lora));
|
|
852
|
+
}, disposeChunkSink); // free this micro-step's head checkpoints once its grads are eval'd
|
|
853
|
+
|
|
854
|
+
model.loraState.dropoutSeed = null; // metrics/val below run dropout-free
|
|
855
|
+
const gradNorm = globalNorm(grads);
|
|
856
|
+
opt.step(grads);
|
|
857
|
+
opt.evalState();
|
|
858
|
+
clearCache();
|
|
859
|
+
|
|
860
|
+
if (step % cfg.stepsPerReport === 0 || step === 1) {
|
|
861
|
+
// Diagnostics recompute the two forwards (like dpoMetrics) — cheap at
|
|
862
|
+
// B=1 (response-only head, no full logits) and gated by stepsPerReport.
|
|
863
|
+
// Reusing the value_and_grad forward is a shared-with-DPO follow-on.
|
|
864
|
+
const m = orpoMetrics(model, currentBatch!, cfg.orpoLambda);
|
|
865
|
+
const elapsed = (Date.now() - t0) / 1000;
|
|
866
|
+
emit({
|
|
867
|
+
type: "metric",
|
|
868
|
+
kind: "train",
|
|
869
|
+
step,
|
|
870
|
+
loss: lossVal,
|
|
871
|
+
grad_norm: gradNorm,
|
|
872
|
+
learning_rate: opt.lr,
|
|
873
|
+
accuracy: m.accuracy,
|
|
874
|
+
margin: m.margin,
|
|
875
|
+
tokens_per_sec: 0,
|
|
876
|
+
progress: step / cfg.iters,
|
|
877
|
+
message: `step ${step}/${cfg.iters} loss=${lossVal.toFixed(4)} nll=${m.nll.toFixed(4)} or=${m.or.toFixed(4)} acc=${m.accuracy.toFixed(2)} margin=${m.margin.toFixed(3)}`,
|
|
878
|
+
});
|
|
879
|
+
clearCache();
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
if (valid.length > 0 && step % cfg.stepsPerEval === 0) {
|
|
883
|
+
let vl = 0, va = 0, vm = 0, vn = 0;
|
|
884
|
+
for (const ex of valid) {
|
|
885
|
+
const vb: DpoBatch = {
|
|
886
|
+
chosenIds: [ex.chosenIds], rejectedIds: [ex.rejectedIds],
|
|
887
|
+
chosenMask: [ex.chosenMask], rejectedMask: [ex.rejectedMask],
|
|
888
|
+
};
|
|
889
|
+
const m = orpoMetrics(model, vb, cfg.orpoLambda);
|
|
890
|
+
vl += m.loss; va += m.accuracy; vm += m.margin; vn++;
|
|
891
|
+
clearCache();
|
|
892
|
+
}
|
|
893
|
+
emit({
|
|
894
|
+
type: "metric", kind: "val", step,
|
|
895
|
+
loss: vn ? vl / vn : 0,
|
|
896
|
+
accuracy: vn ? va / vn : 0,
|
|
897
|
+
margin: vn ? vm / vn : 0,
|
|
898
|
+
progress: step / cfg.iters,
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
} finally {
|
|
903
|
+
disposeChunkSink(); // defensive: free any head checkpoints a throw left behind
|
|
904
|
+
segmentedOrpoPrefix?.dispose();
|
|
905
|
+
segmentedOrpo?.dispose();
|
|
906
|
+
vag?.dispose();
|
|
907
|
+
opt.dispose();
|
|
534
908
|
}
|
|
535
909
|
return { numIters: cfg.iters };
|
|
536
910
|
}
|
|
@@ -570,6 +944,89 @@ function writeParam(lora: TrainableLora, i: number, p: MlxArray): void {
|
|
|
570
944
|
else lora.targets[i - n]!.lw.b = p;
|
|
571
945
|
}
|
|
572
946
|
|
|
947
|
+
/** Run one optimizer step's worth of gradient over `accumSteps` micro-batches:
|
|
948
|
+
* backward each micro-batch, mean-accumulate the per-micro grads (each scaled
|
|
949
|
+
* by 1/accumSteps), and return the mean loss + summed grads — a drop-in for the
|
|
950
|
+
* single value_and_grad the loops used to call inline. The caller passes the
|
|
951
|
+
* returned grads to opt.step (which takes ownership and disposes them).
|
|
952
|
+
*
|
|
953
|
+
* accumSteps <= 1 is a pass-through identical to the pre-accumulation path (one
|
|
954
|
+
* micro-batch, no scaling) so the proven B=1 loops stay byte-for-byte unchanged.
|
|
955
|
+
* For accumSteps > 1 the running accumulator (and that micro-batch's loss) is
|
|
956
|
+
* eval'd before the next micro-batch's forward builds, so only ONE micro-batch's
|
|
957
|
+
* activations are ever live — peak memory does NOT grow with accumSteps (the
|
|
958
|
+
* whole point: a larger effective batch without the B>1 activation memory).
|
|
959
|
+
*
|
|
960
|
+
* The combined `evalAll([value, ...grads])` per micro-batch is deliberate: a
|
|
961
|
+
* separate eval of `value` first would materialize then free the forward,
|
|
962
|
+
* forcing the backward to recompute and hold every layer's activations — which
|
|
963
|
+
* silently defeats gradient checkpointing (verified). Keep loss + grads in one
|
|
964
|
+
* eval.
|
|
965
|
+
*
|
|
966
|
+
* `afterMicroEval` runs once per micro-batch right after its grads are
|
|
967
|
+
* materialized (its backward is complete), in BOTH the pass-through and
|
|
968
|
+
* accumulating paths — it frees per-micro-batch state the loss closure captured
|
|
969
|
+
* (gradient-checkpoint closures for SFT, reference log-probs for DPO) so that
|
|
970
|
+
* state does not pile up across accumulation.
|
|
971
|
+
*
|
|
972
|
+
* `micro` returns the micro-batch loss scalar + grads in flatParams order
|
|
973
|
+
* ([...A, ...B] over targets); it transfers ownership of both to this helper,
|
|
974
|
+
* which disposes the per-micro value and grads. */
|
|
975
|
+
export function accumulateStep(
|
|
976
|
+
accumSteps: number,
|
|
977
|
+
micro: () => { value: MlxArray; grads: MlxArray[] },
|
|
978
|
+
afterMicroEval?: () => void,
|
|
979
|
+
): { loss: number; grads: MlxArray[] } {
|
|
980
|
+
if (accumSteps <= 1) {
|
|
981
|
+
const { value, grads } = micro();
|
|
982
|
+
evalAll([value, ...grads]);
|
|
983
|
+
const loss = value.toFloat32()[0]!;
|
|
984
|
+
value.dispose();
|
|
985
|
+
afterMicroEval?.();
|
|
986
|
+
return { loss, grads };
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const scale = 1 / accumSteps;
|
|
990
|
+
let acc: MlxArray[] | null = null;
|
|
991
|
+
let lossSum = 0;
|
|
992
|
+
try {
|
|
993
|
+
for (let i = 0; i < accumSteps; i++) {
|
|
994
|
+
const { value, grads } = micro();
|
|
995
|
+
if (acc === null) {
|
|
996
|
+
acc = grads.map((g) => {
|
|
997
|
+
const s = ops.mulScalar(g, scale);
|
|
998
|
+
g.dispose();
|
|
999
|
+
return s;
|
|
1000
|
+
});
|
|
1001
|
+
} else {
|
|
1002
|
+
const prev = acc;
|
|
1003
|
+
acc = grads.map((g, j) => {
|
|
1004
|
+
const s = ops.mulScalar(g, scale);
|
|
1005
|
+
const sum = ops.add(prev[j]!, s);
|
|
1006
|
+
s.dispose();
|
|
1007
|
+
prev[j]!.dispose();
|
|
1008
|
+
g.dispose();
|
|
1009
|
+
return sum;
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
// Eval the running accumulator (+ this micro-batch's loss) now so the
|
|
1013
|
+
// micro-batch's forward/backward graph frees before the next one builds —
|
|
1014
|
+
// this is what keeps peak flat across accumSteps.
|
|
1015
|
+
evalAll([value, ...acc]);
|
|
1016
|
+
lossSum += value.toFloat32()[0]!;
|
|
1017
|
+
value.dispose();
|
|
1018
|
+
afterMicroEval?.();
|
|
1019
|
+
clearCache();
|
|
1020
|
+
}
|
|
1021
|
+
const grads = acc!;
|
|
1022
|
+
acc = null; // ownership handed to the caller
|
|
1023
|
+
return { loss: lossSum / accumSteps, grads };
|
|
1024
|
+
} finally {
|
|
1025
|
+
// Non-null only if a micro-batch threw mid-accumulation (nulled on success).
|
|
1026
|
+
if (acc) for (const a of acc) a.dispose();
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
573
1030
|
/** L2 norm over all grads (also forces eval). */
|
|
574
1031
|
function globalNorm(grads: MlxArray[]): number {
|
|
575
1032
|
const partial: MlxArray[] = grads.map((g) => {
|