mlx-bun 0.0.4 → 0.0.6
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 +175 -93
- package/docs/dag/training-inference-map.html +575 -0
- 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 +193 -295
- package/docs/design/optimization_plan.md +4 -2
- package/docs/design/orpo-dynamic-lambda.md +179 -0
- package/docs/design/orpo-future-enhancements.md +340 -0
- package/docs/design/orpo-training.md +1212 -0
- package/docs/design/parallel-slots.md +34 -26
- package/docs/design/parity-tier-dag.md +143 -0
- 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/design/w4a16-compute-precision-spike.md +232 -0
- package/docs/investigations/orpo-base-uf-experiment-and-directions.md +120 -0
- package/docs/investigations/orpo-flash-cce-pin-leak.md +167 -0
- package/docs/investigations/orpo-uf-testing-handoff.md +126 -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 +489 -0
- package/docs/investigations/trainer-validation-experiment.md +173 -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/memory.md +77 -0
- package/docs/reference/orpo-quickstart.md +130 -0
- package/docs/reference/server-api.md +105 -7
- package/docs/reference/server-config.md +12 -5
- package/docs/reference/training.md +296 -0
- package/package.json +1 -1
- package/src/cli.ts +651 -18
- package/src/eval/ifeval.ts +138 -0
- package/src/eval/runner.ts +76 -6
- package/src/eval/tasks/bfcl.ts +49 -15
- package/src/eval/tasks/gsm8k.ts +21 -9
- package/src/eval/tasks/hashhop.ts +33 -4
- package/src/eval/tasks/humaneval.ts +30 -8
- package/src/eval/tasks/ifeval.ts +52 -18
- package/src/eval/tasks/mmlu.ts +85 -74
- package/src/eval/ultrafeedback.ts +60 -0
- package/src/jobs/runner.ts +6 -0
- package/src/lora.ts +12 -7
- package/src/memory/chunk.ts +25 -0
- package/src/memory/cluster.ts +24 -0
- package/src/memory/db.ts +114 -0
- package/src/memory/ingest.ts +22 -0
- package/src/memory/pipeline.ts +63 -0
- package/src/memory/prompts.ts +23 -0
- package/src/memory/schedule.ts +163 -0
- package/src/memory/synthesize.ts +29 -0
- package/src/memory/tools.ts +399 -0
- package/src/memory/vault.ts +694 -0
- package/src/memory/wikify.ts +23 -0
- package/src/mlx/metal-kernel.ts +31 -4
- package/src/model/flash-attention.ts +37 -19
- 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/pi-provider.ts +29 -13
- package/src/pi-session.ts +81 -0
- package/src/pi-terminal.ts +13 -24
- package/src/pi-web.ts +246 -166
- package/src/quantize/job.ts +17 -2
- package/src/quantize/quantizer.ts +26 -2
- package/src/server.ts +216 -47
- package/src/tokenizer.ts +6 -2
- package/src/train/dataset.ts +29 -6
- package/src/train/flash-cce.ts +1175 -0
- package/src/train/job.ts +64 -3
- package/src/train/lora-params.ts +53 -4
- package/src/train/loss.ts +947 -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 +570 -54
- package/src/train/watch.ts +542 -0
- package/src/web/app.html +285 -19
- package/src/web/skills/memory/SKILL.md +97 -0
- package/src/web/skills/web-research/SKILL.md +3 -1
- package/src/web/skills.ts +46 -8
- /package/{docs → src/assets}/curve-designer.html +0 -0
package/src/train/trainer.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// back into the LoraWeights so the next forward (and the next closure swap)
|
|
9
9
|
// sees it. Base quantized weights are never in argnums — they stay frozen.
|
|
10
10
|
|
|
11
|
-
import { MlxArray } from "../mlx/array";
|
|
11
|
+
import { MlxArray, pinnedBufferCount } from "../mlx/array";
|
|
12
12
|
import { clearCache, peakMemory, activeMemory, cacheMemory, resetPeakMemory } from "../mlx/ffi";
|
|
13
13
|
|
|
14
14
|
const MEM_LOG = process.env.MLX_BUN_MEM_LOG === "1";
|
|
@@ -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
|
-
import { writeFileSync } from "node:fs";
|
|
40
|
+
import { writeFileSync, appendFileSync, mkdirSync } 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
|
|
@@ -71,10 +90,55 @@ export interface TrainConfig {
|
|
|
71
90
|
* turns it on so the best-ON-TASK checkpoint can be chosen by the real eval
|
|
72
91
|
* afterward (val is only a proxy — we keep them all and let the eval pick). */
|
|
73
92
|
saveCheckpoints: boolean;
|
|
93
|
+
/** Gradient clipping: scale grads so their global L2 norm ≤ this before the
|
|
94
|
+
* optimizer step (0 = off). Standard ORPO/DPO stability guard — without it a
|
|
95
|
+
* single high-norm batch makes AdamW take a huge step and the loss diverges. */
|
|
96
|
+
gradClipNorm: number;
|
|
97
|
+
/** Cap validation examples evaluated per eval step (0 = all). Val accuracy is
|
|
98
|
+
* correct/total over a FIXED subset — bounds the denominator AND the per-eval
|
|
99
|
+
* cost (full val over thousands of pairs is minutes each). */
|
|
100
|
+
valMaxExamples: number;
|
|
74
101
|
// DPO
|
|
75
102
|
dpoBeta: number;
|
|
76
103
|
dpoWarmupIters: number;
|
|
77
104
|
dpoLrSchedule: "constant" | "cosine";
|
|
105
|
+
// ORPO (reference-free; method-prefixed knobs, mirroring the dpo trio).
|
|
106
|
+
/** λ — weights ONLY the odds-ratio term (the SFT-NLL term stays unweighted). */
|
|
107
|
+
orpoLambda: number;
|
|
108
|
+
orpoWarmupIters: number;
|
|
109
|
+
orpoLrSchedule: "constant" | "cosine";
|
|
110
|
+
/** Token-chunk size for the ORPO head (0 = off). >0 computes the B=1
|
|
111
|
+
* response-only LM head in token-chunks, each rematerialized in the backward,
|
|
112
|
+
* bounding the dominant `[M, vocab]` logits term to `[chunkSize, vocab]`.
|
|
113
|
+
* Exact (numerically identical). Non-segmented path only (segmentSize takes
|
|
114
|
+
* precedence). See docs/design/orpo-training.md → chunked fused log-prob. */
|
|
115
|
+
orpoChunkSize: number;
|
|
116
|
+
/** Use the FUSED linear cross-entropy head (Liger/CCE-style): one CustomVjp with
|
|
117
|
+
* an analytic softmax−onehot backward — no autograd through the head, no
|
|
118
|
+
* retained `[M,vocab]` logits in either direction. Composes with
|
|
119
|
+
* `orpoChunkSize` as the token-chunk size (defaults to 512 when unset). B=1,
|
|
120
|
+
* non-segmented path only; Gemma + MiniCPM5 heads. Value bit-exact vs the
|
|
121
|
+
* full-logits head, grads in the bf16 class. See docs/design/orpo-training.md
|
|
122
|
+
* → fused linear-CE head. */
|
|
123
|
+
orpoFusedCe: boolean;
|
|
124
|
+
/** Route the fused linear-CE head through the flash-CCE Metal kernel: in-kernel
|
|
125
|
+
* quantized logits + online softmax (fwd) and dh accumulation (bwd) — neither
|
|
126
|
+
* `[M,vocab]` nor a dequantized head touches HBM. Lowest memory + fastest on
|
|
127
|
+
* large vocab (e4b 262k / CPM 130k). Implies the fused head; the Apple-CCE coeff
|
|
128
|
+
* filter is on by default in the backward. B=1, non-segmented path. */
|
|
129
|
+
orpoFlashCe: boolean;
|
|
130
|
+
/** Shared prompt-prefix ORPO (lever 7): one forward over [prompt; chosen; rejected]
|
|
131
|
+
* with a block-sparse mask + block-wise RoPE, so the shared prompt is encoded ONCE
|
|
132
|
+
* (token cost 2(P+R) → P+2R). B=1, non-segmented path; MiniCPM5 + Gemma4 (e4b).
|
|
133
|
+
* Falls back to the two-forward orpoLoss for rows whose chosen/rejected prompts
|
|
134
|
+
* differ. Composes with orpoFlashCe/orpoFusedCe (each branch routes through the
|
|
135
|
+
* [M,V]-free head). See src/train/prefix-shared.ts. */
|
|
136
|
+
orpoPrefixShared: boolean;
|
|
137
|
+
/** Warm-start: path to an existing adapter dir (with adapters.safetensors) whose
|
|
138
|
+
* LoRA weights initialize this run instead of the random/zero init. Continues
|
|
139
|
+
* training from a checkpoint's weights; the optimizer state and LR schedule restart.
|
|
140
|
+
* Rank/targets must match the checkpoint. "" = off (fresh init). */
|
|
141
|
+
warmStartAdapter: string;
|
|
78
142
|
// output
|
|
79
143
|
adapterPath: string;
|
|
80
144
|
baseModel: string;
|
|
@@ -103,13 +167,28 @@ export const DEFAULT_TRAIN_CONFIG: TrainConfig = {
|
|
|
103
167
|
stepsPerEval: 50,
|
|
104
168
|
betas: [0.9, 0.999],
|
|
105
169
|
weightDecay: 0.01,
|
|
170
|
+
loraDropout: 0.0, // off by default
|
|
171
|
+
rsLora: false, // off by default (opt-in; recommended when rank_scaling != constant)
|
|
172
|
+
loraPlusRatio: 1.0, // 1 = off
|
|
173
|
+
mlpSplit: false, // default off; only effective with gradCheckpoint (Gemma4)
|
|
106
174
|
gradCheckpoint: false, // default off until validated; flip to true (optiq default) after
|
|
107
175
|
segmentSize: 0, // default off; >0 enables segmented backward (MiniCPM5 SFT B=1)
|
|
108
176
|
saveCheckpoints: false, // off by default; scripts opt in (keep-all-checkpoints + metrics.json)
|
|
177
|
+
gradClipNorm: 1.0, // on by default (standard ORPO/DPO guard); 0 = off
|
|
178
|
+
valMaxExamples: 256, // fixed val subset → fast, meaningful correct/total
|
|
109
179
|
|
|
110
180
|
dpoBeta: 0.1,
|
|
111
181
|
dpoWarmupIters: 0,
|
|
112
182
|
dpoLrSchedule: "cosine",
|
|
183
|
+
|
|
184
|
+
orpoLambda: 0.1, // paper's primary value + TRL default; weights only L_OR
|
|
185
|
+
orpoWarmupIters: 0,
|
|
186
|
+
orpoLrSchedule: "cosine",
|
|
187
|
+
orpoChunkSize: 0, // default off; >0 = token-chunked head (bounds the [M,vocab] term)
|
|
188
|
+
orpoFusedCe: false, // default off; true = fused linear-CE head (analytic backward, no [M,vocab])
|
|
189
|
+
orpoFlashCe: false, // default off; true = flash-CCE Metal-kernel head (implies fused)
|
|
190
|
+
orpoPrefixShared: false, // default off; true = shared prompt-prefix single forward (B=1)
|
|
191
|
+
warmStartAdapter: "", // default off; path to an adapter dir to warm-start LoRA weights from
|
|
113
192
|
adapterPath: "adapters",
|
|
114
193
|
baseModel: "",
|
|
115
194
|
};
|
|
@@ -127,16 +206,51 @@ export async function trainLora(
|
|
|
127
206
|
): Promise<TrainResult> {
|
|
128
207
|
emit({ type: "stage", stage: "setup", progress: 0.02, message: "resolving ranks" });
|
|
129
208
|
|
|
209
|
+
// Mixed-precision rank scaling: feed the per-layer bits (from the loaded
|
|
210
|
+
// model's quant specs) and, for by_kl, the recorded KL sensitivities
|
|
211
|
+
// (optiq_metadata.json). Without these maps, by_bits/by_kl silently fell back
|
|
212
|
+
// to uniform rank — so the "give the sensitive (8-bit) layers more adapter
|
|
213
|
+
// capacity" policy (optiq: one sensitivity signal, two optimizations — the
|
|
214
|
+
// bit assignment AND the LoRA rank) never actually fired.
|
|
215
|
+
const bitsMap = bitsMapFromModel(model);
|
|
216
|
+
const klMap = cfg.rankScaling === "by_kl" ? await readPerLayerKl(cfg.baseModel) : undefined;
|
|
130
217
|
const ranks = resolveRanks(model, {
|
|
131
218
|
rank: cfg.rank,
|
|
132
219
|
rankScaling: cfg.rankScaling,
|
|
133
220
|
targetModules: cfg.targetModules,
|
|
134
221
|
numLayers: cfg.numLayers,
|
|
222
|
+
bitsMap,
|
|
223
|
+
klMap,
|
|
135
224
|
});
|
|
136
225
|
const appliedRanks: Record<string, number> = Object.fromEntries(ranks);
|
|
137
226
|
|
|
138
|
-
|
|
227
|
+
// Surface the rank spread so mixed-precision scaling is observable (e.g.
|
|
228
|
+
// "r8×140 r16×28" means 28 sensitive layers got the wider adapter).
|
|
229
|
+
const rankCounts = new Map<number, number>();
|
|
230
|
+
for (const r of ranks.values()) rankCounts.set(r, (rankCounts.get(r) ?? 0) + 1);
|
|
231
|
+
const rankSpread = [...rankCounts.entries()]
|
|
232
|
+
.sort((a, b) => a[0] - b[0])
|
|
233
|
+
.map(([r, n]) => `r${r}×${n}`)
|
|
234
|
+
.join(" ");
|
|
235
|
+
emit({ type: "stage", stage: "setup", progress: 0.03,
|
|
236
|
+
message: `ranks (${cfg.rankScaling}): ${rankSpread}` });
|
|
237
|
+
|
|
238
|
+
const lora = buildTrainableLora(model, ranks, cfg.scale, cfg.seed, cfg.rsLora);
|
|
239
|
+
// Warm-start: continue from a saved adapter/checkpoint's weights (optimizer + LR
|
|
240
|
+
// schedule restart fresh). Must run before the optimizer is built (below) so it
|
|
241
|
+
// tracks the loaded leaves. Rank/targets must match the checkpoint.
|
|
242
|
+
if (cfg.warmStartAdapter) {
|
|
243
|
+
const n = warmStartFromAdapter(lora, cfg.warmStartAdapter);
|
|
244
|
+
emit({ type: "stage", stage: "setup", progress: 0.035,
|
|
245
|
+
message: `warm-start: loaded ${n} LoRA targets from ${cfg.warmStartAdapter} (optimizer + LR schedule restart)` });
|
|
246
|
+
}
|
|
139
247
|
attachForTraining(model, lora, "train");
|
|
248
|
+
model.loraState.dropoutRate = cfg.loraDropout; // per-step seed set inside the loop
|
|
249
|
+
// Training-mode fused GeGLU (Gemma e4b): kernel forward + hand-derived vjp, so
|
|
250
|
+
// autograd flows and the backward recomputes the gelu from the primal instead
|
|
251
|
+
// of retaining the spelled-out intermediates. No-op for non-Gemma models.
|
|
252
|
+
// Cleared in the finally below.
|
|
253
|
+
setFusedGeluTraining(true);
|
|
140
254
|
|
|
141
255
|
const saveCfg: SaveAdapterConfig = {
|
|
142
256
|
rank: cfg.rank,
|
|
@@ -146,6 +260,7 @@ export async function trainLora(
|
|
|
146
260
|
numLayers: cfg.numLayers,
|
|
147
261
|
method: cfg.method,
|
|
148
262
|
baseModel: cfg.baseModel,
|
|
263
|
+
rsLora: cfg.rsLora,
|
|
149
264
|
};
|
|
150
265
|
|
|
151
266
|
// Keep-ALL-checkpoints + a durable metrics record. At each eval step we
|
|
@@ -158,9 +273,36 @@ export async function trainLora(
|
|
|
158
273
|
// metrics, and the live LoRA leaves are the current step's weights at emit
|
|
159
274
|
// time, so saving here captures exactly that checkpoint.
|
|
160
275
|
const trainHistory: { step: number; loss: number }[] = [];
|
|
161
|
-
|
|
276
|
+
// For preference methods (dpo/orpo) the val MARGIN (mean chosen−rejected
|
|
277
|
+
// log-odds) and accuracy are the meaningful early-stop signals — val loss is a
|
|
278
|
+
// weak proxy. Capture them when the loop emits them so metrics.json lets you
|
|
279
|
+
// pick the best-MARGIN checkpoint (overfit shows as train↑ / val-margin flat).
|
|
280
|
+
const valHistory: { step: number; loss: number; margin?: number; accuracy?: number; checkpoint: string | null }[] = [];
|
|
162
281
|
const startedAt = Date.now();
|
|
282
|
+
|
|
283
|
+
// Live, append-only metrics stream (the source `mlx-bun train-watch` tails).
|
|
284
|
+
// Always on — it's the durable per-step record, a few MB at most, single
|
|
285
|
+
// writer, append-only. The first line is a `meta` record (totals + config) so
|
|
286
|
+
// a viewer attaching mid-run has the denominators (iters) and run identity
|
|
287
|
+
// without replaying. Each subsequent line is a metric event + a wall-clock `t`
|
|
288
|
+
// (ms) so the viewer derives s/step and ETA itself. Best-effort: a logging
|
|
289
|
+
// failure must never kill a run.
|
|
290
|
+
mkdirSync(cfg.adapterPath, { recursive: true });
|
|
291
|
+
const metricsJsonl = `${cfg.adapterPath}/metrics.jsonl`;
|
|
292
|
+
const writeMetricLine = (o: Record<string, unknown>) => {
|
|
293
|
+
try { appendFileSync(metricsJsonl, JSON.stringify(o) + "\n"); } catch { /* never throw */ }
|
|
294
|
+
};
|
|
295
|
+
try { writeFileSync(metricsJsonl, ""); } catch { /* truncate prior run's stream */ }
|
|
296
|
+
writeMetricLine({
|
|
297
|
+
type: "meta", t: startedAt, method: cfg.method, model: cfg.baseModel,
|
|
298
|
+
iters: cfg.iters, learning_rate: cfg.learningRate, max_seq_length: cfg.maxSeqLen,
|
|
299
|
+
batch_size: cfg.batchSize, rank: cfg.rank, scale: cfg.scale,
|
|
300
|
+
orpo_lambda: cfg.method === "orpo" ? cfg.orpoLambda : undefined,
|
|
301
|
+
adapter_path: cfg.adapterPath,
|
|
302
|
+
});
|
|
303
|
+
|
|
163
304
|
const collect: Emit = (e) => {
|
|
305
|
+
if (e.type === "metric") writeMetricLine({ ...e, t: Date.now() });
|
|
164
306
|
if (e.type === "metric" && e.kind === "train") {
|
|
165
307
|
trainHistory.push({ step: e.step, loss: e.loss });
|
|
166
308
|
} else if (e.type === "metric" && e.kind === "val") {
|
|
@@ -172,7 +314,8 @@ export async function trainLora(
|
|
|
172
314
|
emit({ type: "stage", stage: "checkpoint", progress: e.step / cfg.iters,
|
|
173
315
|
message: `checkpoint ${tag}` });
|
|
174
316
|
}
|
|
175
|
-
|
|
317
|
+
const ev = e as { step: number; loss: number; margin?: number; accuracy?: number };
|
|
318
|
+
valHistory.push({ step: ev.step, loss: ev.loss, margin: ev.margin, accuracy: ev.accuracy, checkpoint });
|
|
176
319
|
}
|
|
177
320
|
emit(e);
|
|
178
321
|
};
|
|
@@ -181,7 +324,9 @@ export async function trainLora(
|
|
|
181
324
|
const result =
|
|
182
325
|
cfg.method === "dpo"
|
|
183
326
|
? await dpoLoop(model, tok, tmpl, dataDir, cfg, lora, collect)
|
|
184
|
-
:
|
|
327
|
+
: cfg.method === "orpo"
|
|
328
|
+
? await orpoLoop(model, tok, tmpl, dataDir, cfg, lora, collect)
|
|
329
|
+
: await sftLoop(model, tok, tmpl, dataDir, cfg, lora, collect);
|
|
185
330
|
|
|
186
331
|
// Final save (last adapter).
|
|
187
332
|
detachTraining(model, lora);
|
|
@@ -193,18 +338,24 @@ export async function trainLora(
|
|
|
193
338
|
if (cfg.saveCheckpoints) {
|
|
194
339
|
let bestVal: { step: number; loss: number; checkpoint: string | null } | null = null;
|
|
195
340
|
for (const v of valHistory) if (bestVal === null || v.loss < bestVal.loss) bestVal = v;
|
|
341
|
+
// Best by MARGIN too (preference methods) — the better checkpoint signal.
|
|
342
|
+
let bestMargin: typeof valHistory[number] | null = null;
|
|
343
|
+
for (const v of valHistory)
|
|
344
|
+
if (v.margin !== undefined && (bestMargin === null || v.margin > (bestMargin.margin ?? -Infinity))) bestMargin = v;
|
|
196
345
|
writeFileSync(`${cfg.adapterPath}/metrics.json`, JSON.stringify({
|
|
197
346
|
config: {
|
|
198
347
|
method: cfg.method, rank: cfg.rank, scale: cfg.scale,
|
|
199
348
|
learningRate: cfg.learningRate, maxSeqLen: cfg.maxSeqLen,
|
|
200
349
|
segmentSize: cfg.segmentSize, iters: cfg.iters,
|
|
201
350
|
numLayers: cfg.numLayers, baseModel: cfg.baseModel,
|
|
351
|
+
orpoLambda: cfg.method === "orpo" ? cfg.orpoLambda : undefined,
|
|
202
352
|
},
|
|
203
353
|
wallSeconds: (Date.now() - startedAt) / 1000,
|
|
204
354
|
peakGb: peakMemory() / 1e9,
|
|
205
355
|
finalTrainLoss: trainHistory.at(-1)?.loss ?? null,
|
|
206
356
|
finalValLoss: valHistory.at(-1)?.loss ?? null,
|
|
207
357
|
bestVal,
|
|
358
|
+
bestMargin, // null for SFT (no margin); the recommended pick for dpo/orpo
|
|
208
359
|
valTrajectory: valHistory,
|
|
209
360
|
trainLosses: trainHistory,
|
|
210
361
|
}, null, 2));
|
|
@@ -220,6 +371,7 @@ export async function trainLora(
|
|
|
220
371
|
});
|
|
221
372
|
return { adapterPath: cfg.adapterPath, appliedRanks, numIters: result.numIters };
|
|
222
373
|
} finally {
|
|
374
|
+
setFusedGeluTraining(false);
|
|
223
375
|
disposeLora(lora);
|
|
224
376
|
}
|
|
225
377
|
}
|
|
@@ -290,22 +442,29 @@ async function sftLoop(
|
|
|
290
442
|
// Checkpoints are created per forward and disposed after each step's backward.
|
|
291
443
|
let ckptCtx: GradCheckpointCtx | null = null;
|
|
292
444
|
if (!useSegmented && cfg.gradCheckpoint && model instanceof Gemma4Model) {
|
|
445
|
+
// Partition each layer's LoRA into the attention sub-block (self_attn.*) and
|
|
446
|
+
// the MLP sub-block (everything else in the layer: gate/up/down_proj +
|
|
447
|
+
// per_layer_*) so the split-MLP checkpoint can wrap them independently.
|
|
293
448
|
const byLayer: GradCheckpointCtx["byLayer"] = new Map();
|
|
294
449
|
for (const t of lora.targets) {
|
|
295
450
|
const m = t.modulePath.match(/\.layers\.(\d+)\./);
|
|
296
451
|
if (!m) continue;
|
|
297
452
|
const li = Number(m[1]);
|
|
298
|
-
|
|
453
|
+
const ll = byLayer.get(li) ?? byLayer.set(li, { attn: [], mlp: [] }).get(li)!;
|
|
454
|
+
(t.modulePath.includes(".self_attn.") ? ll.attn : ll.mlp).push(t.lw);
|
|
299
455
|
}
|
|
300
|
-
ckptCtx = { byLayer, keepAlive: [] };
|
|
456
|
+
ckptCtx = { byLayer, splitMlp: cfg.mlpSplit, keepAlive: [] };
|
|
301
457
|
model.gradCkpt = ckptCtx;
|
|
458
|
+
if (cfg.mlpSplit) emit({ type: "stage", stage: "setup", progress: 0.04,
|
|
459
|
+
message: "grad checkpoint: intra-layer attn/MLP split" });
|
|
302
460
|
}
|
|
303
461
|
|
|
304
|
-
// Training attention. Default = mlx fused SDPA (ops.sdpa):
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
462
|
+
// Training attention. Default = mlx fused SDPA (ops.sdpa): exact dQ/dK/dV
|
|
463
|
+
// (finite-difference verified) and fast, but O(L²) backward memory. The
|
|
464
|
+
// hand-rolled flash kernel is an opt-in (MLX_BUN_TRAIN_ATTN=flash) O(L)-memory
|
|
465
|
+
// path for memory-bound long context — now correct (its dK-transpose and
|
|
466
|
+
// dQ-causal-barrier bugs were fixed; FD-validated in
|
|
467
|
+
// scripts/experiments/flash-fd-check.ts), though ~30× slower than ops.sdpa.
|
|
309
468
|
// Cleared in finally.
|
|
310
469
|
if (process.env.MLX_BUN_TRAIN_ATTN === "flash") setTrainingAttn("flash");
|
|
311
470
|
|
|
@@ -318,24 +477,30 @@ async function sftLoop(
|
|
|
318
477
|
|
|
319
478
|
try {
|
|
320
479
|
for (let step = 1; step <= cfg.iters; step++) {
|
|
321
|
-
currentBatch = batches.next().value as SftBatch;
|
|
322
|
-
const tokensThisStep = countResponseTokens(currentBatch);
|
|
323
|
-
|
|
324
480
|
if (MEM_LOG) resetPeakMemory();
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
481
|
+
|
|
482
|
+
// One optimizer step over cfg.gradAccumSteps micro-batches (pass-through
|
|
483
|
+
// when 1). afterMicroEval frees each micro-batch's gradient-checkpoint
|
|
484
|
+
// closures right after its backward so they don't pile up across
|
|
485
|
+
// accumulation.
|
|
486
|
+
let tokensThisStep = 0;
|
|
487
|
+
const { loss: lossVal, grads } = accumulateStep(
|
|
488
|
+
cfg.gradAccumSteps,
|
|
489
|
+
() => {
|
|
490
|
+
currentBatch = batches.next().value as SftBatch;
|
|
491
|
+
tokensThisStep += countResponseTokens(currentBatch);
|
|
492
|
+
return segmented ? segmented.step(currentBatch!) : vag!.apply(flatParams(lora));
|
|
493
|
+
},
|
|
494
|
+
ckptCtx
|
|
495
|
+
? () => {
|
|
496
|
+
for (const ck of ckptCtx!.keepAlive) ck.dispose();
|
|
497
|
+
ckptCtx!.keepAlive.length = 0;
|
|
498
|
+
}
|
|
499
|
+
: undefined,
|
|
500
|
+
);
|
|
337
501
|
|
|
338
502
|
const gradNorm = globalNorm(grads); // grads already materialized above
|
|
503
|
+
clipGradsByNorm(grads, gradNorm, cfg.gradClipNorm);
|
|
339
504
|
opt.step(grads);
|
|
340
505
|
opt.evalState();
|
|
341
506
|
if (MEM_LOG) {
|
|
@@ -345,12 +510,6 @@ async function sftLoop(
|
|
|
345
510
|
} else {
|
|
346
511
|
clearCache();
|
|
347
512
|
}
|
|
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
513
|
|
|
355
514
|
if (step % cfg.stepsPerReport === 0 || step === 1) {
|
|
356
515
|
const elapsed = (Date.now() - t0) / 1000;
|
|
@@ -467,28 +626,36 @@ async function dpoLoop(
|
|
|
467
626
|
|
|
468
627
|
try {
|
|
469
628
|
for (let step = 1; step <= cfg.iters; step++) {
|
|
470
|
-
currentBatch = batches.next().value as DpoBatch;
|
|
471
629
|
if (schedule) opt.lr = schedule(step);
|
|
472
630
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
const {
|
|
478
|
-
|
|
479
|
-
|
|
631
|
+
// One optimizer step over cfg.gradAccumSteps micro-batches (pass-through
|
|
632
|
+
// when 1). Reference log-probs are recomputed per micro-batch OUTSIDE the
|
|
633
|
+
// policy value_and_grad and read as detached constants inside it;
|
|
634
|
+
// afterMicroEval frees them once that micro-batch's grads are materialized.
|
|
635
|
+
const { loss: lossVal, grads } = accumulateStep(
|
|
636
|
+
cfg.gradAccumSteps,
|
|
637
|
+
() => {
|
|
638
|
+
currentBatch = batches.next().value as DpoBatch;
|
|
639
|
+
const refs = dpoRefLogps(model, lora, currentBatch);
|
|
640
|
+
refChosen = refs.refChosen;
|
|
641
|
+
refRejected = refs.refRejected;
|
|
642
|
+
return vag.apply(flatParams(lora));
|
|
643
|
+
},
|
|
644
|
+
() => {
|
|
645
|
+
refChosen?.dispose();
|
|
646
|
+
refRejected?.dispose();
|
|
647
|
+
refChosen = refRejected = null;
|
|
648
|
+
},
|
|
649
|
+
);
|
|
480
650
|
|
|
481
651
|
const gradNorm = globalNorm(grads);
|
|
652
|
+
clipGradsByNorm(grads, gradNorm, cfg.gradClipNorm);
|
|
482
653
|
opt.step(grads);
|
|
483
654
|
opt.evalState();
|
|
484
|
-
|
|
485
|
-
refChosen.dispose();
|
|
486
|
-
refRejected.dispose();
|
|
487
|
-
refChosen = refRejected = null;
|
|
488
655
|
clearCache();
|
|
489
656
|
|
|
490
657
|
if (step % cfg.stepsPerReport === 0 || step === 1) {
|
|
491
|
-
const m = dpoMetrics(model, lora, currentBatch
|
|
658
|
+
const m = dpoMetrics(model, lora, currentBatch!, cfg.dpoBeta);
|
|
492
659
|
const elapsed = (Date.now() - t0) / 1000;
|
|
493
660
|
emit({
|
|
494
661
|
type: "metric",
|
|
@@ -529,8 +696,260 @@ async function dpoLoop(
|
|
|
529
696
|
} finally {
|
|
530
697
|
vag.dispose();
|
|
531
698
|
opt.dispose();
|
|
532
|
-
|
|
533
|
-
|
|
699
|
+
// Cast: refs are only ever assigned inside the accumulateStep closures, so
|
|
700
|
+
// TS's flow analysis narrows these to their `null` initializer here.
|
|
701
|
+
(refChosen as MlxArray | null)?.dispose();
|
|
702
|
+
(refRejected as MlxArray | null)?.dispose();
|
|
703
|
+
}
|
|
704
|
+
return { numIters: cfg.iters };
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// ---------------------------------------------------------------------------
|
|
708
|
+
// ORPO loop (reference-free: 2 forwards/step, no reference pass)
|
|
709
|
+
// ---------------------------------------------------------------------------
|
|
710
|
+
|
|
711
|
+
async function orpoLoop(
|
|
712
|
+
model: RuntimeModel,
|
|
713
|
+
tok: LoadedTokenizer,
|
|
714
|
+
tmpl: ChatTemplate,
|
|
715
|
+
dataDir: string,
|
|
716
|
+
cfg: TrainConfig,
|
|
717
|
+
lora: TrainableLora,
|
|
718
|
+
emit: Emit,
|
|
719
|
+
): Promise<{ numIters: number }> {
|
|
720
|
+
emit({ type: "stage", stage: "data", progress: 0.05, message: "loading preference dataset" });
|
|
721
|
+
const train = await loadDpoDataset(`${dataDir}/train.jsonl`, tok, tmpl, cfg.maxSeqLen);
|
|
722
|
+
const valid = (await fileExists(`${dataDir}/valid.jsonl`))
|
|
723
|
+
? await loadDpoDataset(`${dataDir}/valid.jsonl`, tok, tmpl, cfg.maxSeqLen)
|
|
724
|
+
: [];
|
|
725
|
+
|
|
726
|
+
const params = flatParams(lora);
|
|
727
|
+
// LoRA+: B leaves (the second half of flatParams) get a higher LR than A.
|
|
728
|
+
const nT = lora.targets.length;
|
|
729
|
+
const lrScale = cfg.loraPlusRatio !== 1
|
|
730
|
+
? params.map((_, i) => (i < nT ? 1 : cfg.loraPlusRatio))
|
|
731
|
+
: undefined;
|
|
732
|
+
if (lrScale) emit({ type: "stage", stage: "setup", progress: 0.04,
|
|
733
|
+
message: `LoRA+ : B leaves at ${cfg.loraPlusRatio}× LR` });
|
|
734
|
+
const opt = new AdamW(
|
|
735
|
+
params,
|
|
736
|
+
{ lr: cfg.learningRate, betas: cfg.betas, eps: 1e-8, weightDecay: cfg.weightDecay, lrScale },
|
|
737
|
+
(i, p) => writeParam(lora, i, p),
|
|
738
|
+
);
|
|
739
|
+
|
|
740
|
+
const schedule =
|
|
741
|
+
cfg.orpoLrSchedule === "cosine" || cfg.orpoWarmupIters > 0
|
|
742
|
+
? warmupCosineSchedule(cfg.learningRate, cfg.orpoWarmupIters, cfg.iters)
|
|
743
|
+
: null;
|
|
744
|
+
|
|
745
|
+
// Segmented backward for ORPO: stream chosen+rejected backwards segment-by-segment.
|
|
746
|
+
// MiniCPM5 + Gemma4 (e4b) — same model coverage as SFT segmented. B=1 only.
|
|
747
|
+
const useSegmented = cfg.segmentSize > 0;
|
|
748
|
+
// Prefix-sharing + segmented backward COMPOSE (M3-composition): the segmented
|
|
749
|
+
// backward streams the ONE prefix-shared concat forward [prompt; chosen; rejected]
|
|
750
|
+
// (block-sparse mask via PrefixSharedCache + block-wise RoPE via the prefix plan)
|
|
751
|
+
// segment-by-segment — so the prompt-encode-once saving holds AT long seq. Composed
|
|
752
|
+
// for BOTH MiniCPM5 and Gemma4/e4b (the e4b variant threads donor-KV + the
|
|
753
|
+
// logical-position sliding-window prefix mask through the segment boundaries).
|
|
754
|
+
const useSegmentedPrefix = cfg.orpoPrefixShared && useSegmented;
|
|
755
|
+
if (cfg.orpoPrefixShared && !(model instanceof MiniCPM5Model || model instanceof Gemma4Model))
|
|
756
|
+
throw new Error("orpoPrefixShared is only wired for MiniCPM5 and Gemma4");
|
|
757
|
+
// Fused linear-CE head INSIDE the segmented backward (orpo_fused_ce): bounds the
|
|
758
|
+
// head term to [chunk,V] alongside the per-segment layer savings. 0 = the
|
|
759
|
+
// full-[M,V] responseOnlyLogpMean head (unchanged). Reuses orpoChunkSize as the
|
|
760
|
+
// token-chunk (default 512), same as the non-segmented path.
|
|
761
|
+
// "flash implies fused": orpoFlashCe alone must still bound the segmented head to
|
|
762
|
+
// [chunk,V]. The non-prefix segmented class has no flash path, so flash routes
|
|
763
|
+
// through its fused/checkpoint chunked head (boundedHeadFromHidden) — never the
|
|
764
|
+
// full-[M,V] responseOnlyLogpMean, which would reintroduce [M,vocab] on e4b.
|
|
765
|
+
const segUsesFusedHead = cfg.orpoFusedCe || cfg.orpoFlashCe;
|
|
766
|
+
const segFusedChunk = segUsesFusedHead ? (cfg.orpoChunkSize > 0 ? cfg.orpoChunkSize : 512) : 0;
|
|
767
|
+
// The prefix segmented head gathers response positions then routes per-branch
|
|
768
|
+
// through the [M,V]-free flash/fused head (branchLogpMeanGathered, which takes a
|
|
769
|
+
// ChunkCtx). Its own sink is managed per-step inside the class.
|
|
770
|
+
const segPrefixChunk: ChunkCtx | undefined =
|
|
771
|
+
cfg.orpoFusedCe || cfg.orpoFlashCe
|
|
772
|
+
? { chunkSize: cfg.orpoChunkSize > 0 ? cfg.orpoChunkSize : 512, fused: true, flash: cfg.orpoFlashCe, sink: [] }
|
|
773
|
+
: undefined;
|
|
774
|
+
let segmentedOrpo: SegmentedBackwardOrpo | SegmentedBackwardOrpoGemma4 | null = null;
|
|
775
|
+
let segmentedOrpoPrefix: SegmentedBackwardOrpoPrefix | SegmentedBackwardOrpoPrefixGemma4 | null = null;
|
|
776
|
+
if (useSegmented) {
|
|
777
|
+
const ranges = planSegmentsBySize(model.layers.length, cfg.segmentSize);
|
|
778
|
+
if (useSegmentedPrefix) {
|
|
779
|
+
// The composed prefix-shared segmented backward (MiniCPM5 or Gemma4/e4b), plus a
|
|
780
|
+
// plain segmented-ORPO fallback for rows whose chosen/rejected prompts differ
|
|
781
|
+
// (splitPrefixBatch returns null -> two-forward segmented step).
|
|
782
|
+
if (model instanceof MiniCPM5Model) {
|
|
783
|
+
segmentedOrpoPrefix = new SegmentedBackwardOrpoPrefix(model, lora, ranges, cfg.orpoLambda, segPrefixChunk);
|
|
784
|
+
segmentedOrpo = new SegmentedBackwardOrpo(model, lora, ranges, cfg.orpoLambda, segFusedChunk);
|
|
785
|
+
} else {
|
|
786
|
+
segmentedOrpoPrefix = new SegmentedBackwardOrpoPrefixGemma4(model as Gemma4Model, lora, ranges, cfg.orpoLambda, segPrefixChunk);
|
|
787
|
+
segmentedOrpo = new SegmentedBackwardOrpoGemma4(model as Gemma4Model, lora, ranges, cfg.orpoLambda, segFusedChunk);
|
|
788
|
+
}
|
|
789
|
+
emit({ type: "stage", stage: "setup", progress: 0.04,
|
|
790
|
+
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)` });
|
|
791
|
+
} else if (model instanceof MiniCPM5Model)
|
|
792
|
+
segmentedOrpo = new SegmentedBackwardOrpo(model, lora, ranges, cfg.orpoLambda, segFusedChunk);
|
|
793
|
+
else if (model instanceof Gemma4Model)
|
|
794
|
+
segmentedOrpo = new SegmentedBackwardOrpoGemma4(model, lora, ranges, cfg.orpoLambda, segFusedChunk);
|
|
795
|
+
else
|
|
796
|
+
throw new Error("segmented backward (segmentSize > 0) for ORPO is only wired for MiniCPM5 and Gemma4");
|
|
797
|
+
if (!useSegmentedPrefix) emit({ type: "stage", stage: "setup", progress: 0.04,
|
|
798
|
+
message: `orpo segmented backward: ${Math.ceil(model.layers.length / cfg.segmentSize)} segments of <=${cfg.segmentSize} layers${segFusedChunk > 0 ? ` + fused linear-CE head (${segFusedChunk}/chunk)` : ""}` });
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// Token-chunked head (non-segmented path only): the per-chunk checkpoints are
|
|
802
|
+
// collected here and disposed after each micro-step's grads are eval'd (the
|
|
803
|
+
// afterMicroEval hook below) — that is when their backward recompute has run.
|
|
804
|
+
const chunkSink: Array<{ dispose(): void }> = [];
|
|
805
|
+
// Fused linear-CE head reuses orpoChunkSize as the token-chunk (default 512 when
|
|
806
|
+
// unset); otherwise the Checkpoint token-chunked head fires only when
|
|
807
|
+
// orpoChunkSize > 0. Both are non-segmented-path only.
|
|
808
|
+
const fusedChunkSize = cfg.orpoChunkSize > 0 ? cfg.orpoChunkSize : 512;
|
|
809
|
+
const chunk: ChunkCtx | undefined = useSegmented
|
|
810
|
+
? undefined
|
|
811
|
+
: cfg.orpoFusedCe || cfg.orpoFlashCe
|
|
812
|
+
? { chunkSize: fusedChunkSize, fused: true, flash: cfg.orpoFlashCe, sink: chunkSink }
|
|
813
|
+
: cfg.orpoChunkSize > 0
|
|
814
|
+
? { chunkSize: cfg.orpoChunkSize, sink: chunkSink }
|
|
815
|
+
: undefined;
|
|
816
|
+
if (chunk) emit({ type: "stage", stage: "setup", progress: 0.04,
|
|
817
|
+
message: chunk.flash
|
|
818
|
+
? `orpo flash-CCE (Metal kernel) head: ${chunk.chunkSize} tokens/chunk`
|
|
819
|
+
: chunk.fused
|
|
820
|
+
? `orpo fused linear-CE head: ${chunk.chunkSize} tokens/chunk`
|
|
821
|
+
: `orpo chunked head: ${cfg.orpoChunkSize} tokens/chunk` });
|
|
822
|
+
if (cfg.orpoPrefixShared && !useSegmentedPrefix) emit({ type: "stage", stage: "setup", progress: 0.04,
|
|
823
|
+
message: `orpo prefix-sharing: single forward over [prompt; chosen; rejected] (two-forward fallback on prompt mismatch)` });
|
|
824
|
+
const disposeChunkSink = () => { for (const c of chunkSink) c.dispose(); chunkSink.length = 0; };
|
|
825
|
+
|
|
826
|
+
// No reference forward (the whole point of ORPO) — the closure just builds the
|
|
827
|
+
// monolithic L_NLL + λ·L_OR over the active batch against the swapped primals.
|
|
828
|
+
let currentBatch: DpoBatch | null = null;
|
|
829
|
+
const vag = useSegmented
|
|
830
|
+
? null
|
|
831
|
+
: new ValueAndGrad((primals) => {
|
|
832
|
+
const saved = swapPrimals(lora, primals);
|
|
833
|
+
try {
|
|
834
|
+
// Prefix-sharing (B=1): one forward over [prompt; chosen; rejected] when the
|
|
835
|
+
// row's chosen/rejected share an identical prompt; else fall back two-forward.
|
|
836
|
+
if (cfg.orpoPrefixShared) {
|
|
837
|
+
const split = splitPrefixBatch(currentBatch!);
|
|
838
|
+
if (split) {
|
|
839
|
+
if (model instanceof MiniCPM5Model)
|
|
840
|
+
return orpoLossPrefixShared(model, split.promptIds, split.chosenResp, split.rejectedResp, cfg.orpoLambda, chunk);
|
|
841
|
+
return orpoLossPrefixSharedGemma(model as Gemma4Model, split.promptIds, split.chosenResp, split.rejectedResp, cfg.orpoLambda, chunk);
|
|
842
|
+
}
|
|
843
|
+
notePrefixFallback();
|
|
844
|
+
}
|
|
845
|
+
return orpoLoss(model, currentBatch!, cfg.orpoLambda, chunk);
|
|
846
|
+
} finally {
|
|
847
|
+
restorePrimals(lora, saved);
|
|
848
|
+
}
|
|
849
|
+
}, params.map((_, i) => i));
|
|
850
|
+
|
|
851
|
+
const padId = tok.eosTokenId ?? 0;
|
|
852
|
+
const batches = iterateDpoBatches(train, cfg.batchSize, cfg.seed, true, padId);
|
|
853
|
+
const t0 = Date.now();
|
|
854
|
+
|
|
855
|
+
let dropoutCounter = 0;
|
|
856
|
+
// Prefix-share falls back to the two-forward path for any row whose chosen/rejected
|
|
857
|
+
// prompts aren't byte-identical (splitPrefixBatch → null). Log the FIRST fallback +
|
|
858
|
+
// count them, so a silent loss of the prompt-encode-once saving is visible.
|
|
859
|
+
let prefixFallbacks = 0;
|
|
860
|
+
const notePrefixFallback = () => {
|
|
861
|
+
prefixFallbacks++;
|
|
862
|
+
if (prefixFallbacks === 1) emit({ type: "stage", stage: "train", progress: 0,
|
|
863
|
+
message: `prefix-share: row prompt mismatch → two-forward fallback for this row (saving lost only on mismatched rows; counting…)` });
|
|
864
|
+
};
|
|
865
|
+
try {
|
|
866
|
+
for (let step = 1; step <= cfg.iters; step++) {
|
|
867
|
+
if (schedule) opt.lr = schedule(step);
|
|
868
|
+
|
|
869
|
+
// One optimizer step over cfg.gradAccumSteps micro-batches (pass-through
|
|
870
|
+
// when 1) — the long-context lever for a larger effective batch without
|
|
871
|
+
// the B>1 activation memory (see docs/design/orpo-training.md, Batching).
|
|
872
|
+
const { loss: lossVal, grads } = accumulateStep(cfg.gradAccumSteps, () => {
|
|
873
|
+
currentBatch = batches.next().value as DpoBatch;
|
|
874
|
+
// Per-micro-step dropout seed: constant across this micro-step's forward
|
|
875
|
+
// AND its backward recompute (segmented/chunked), so the mask reproduces.
|
|
876
|
+
if (cfg.loraDropout > 0) model.loraState.dropoutSeed = ++dropoutCounter;
|
|
877
|
+
// Segmented + prefix-share: stream the single concat forward when the row's
|
|
878
|
+
// chosen/rejected share an identical prompt; else fall back to the plain
|
|
879
|
+
// two-forward segmented step (segmentedOrpo, built alongside).
|
|
880
|
+
if (segmentedOrpoPrefix) {
|
|
881
|
+
const split = splitPrefixBatch(currentBatch!);
|
|
882
|
+
if (split) return segmentedOrpoPrefix.stepPrefix(split.promptIds, split.chosenResp, split.rejectedResp);
|
|
883
|
+
notePrefixFallback();
|
|
884
|
+
return segmentedOrpo!.step(currentBatch!);
|
|
885
|
+
}
|
|
886
|
+
return segmentedOrpo ? segmentedOrpo.step(currentBatch!) : vag!.apply(flatParams(lora));
|
|
887
|
+
}, disposeChunkSink); // free this micro-step's head checkpoints once its grads are eval'd
|
|
888
|
+
|
|
889
|
+
model.loraState.dropoutSeed = null; // metrics/val below run dropout-free
|
|
890
|
+
const gradNorm = globalNorm(grads);
|
|
891
|
+
clipGradsByNorm(grads, gradNorm, cfg.gradClipNorm);
|
|
892
|
+
opt.step(grads);
|
|
893
|
+
opt.evalState();
|
|
894
|
+
clearCache();
|
|
895
|
+
|
|
896
|
+
if (step % cfg.stepsPerReport === 0 || step === 1) {
|
|
897
|
+
// Diagnostics recompute the two forwards (like dpoMetrics) — cheap at
|
|
898
|
+
// B=1 (response-only head, no full logits) and gated by stepsPerReport.
|
|
899
|
+
// Reusing the value_and_grad forward is a shared-with-DPO follow-on.
|
|
900
|
+
const m = orpoMetrics(model, currentBatch!, cfg.orpoLambda);
|
|
901
|
+
const elapsed = (Date.now() - t0) / 1000;
|
|
902
|
+
emit({
|
|
903
|
+
type: "metric",
|
|
904
|
+
kind: "train",
|
|
905
|
+
step,
|
|
906
|
+
loss: lossVal,
|
|
907
|
+
grad_norm: gradNorm,
|
|
908
|
+
learning_rate: opt.lr,
|
|
909
|
+
accuracy: m.accuracy,
|
|
910
|
+
margin: m.margin,
|
|
911
|
+
tokens_per_sec: 0,
|
|
912
|
+
peak_gb: peakMemory() / 1e9,
|
|
913
|
+
active_gb: activeMemory() / 1e9,
|
|
914
|
+
pinned: pinnedBufferCount(),
|
|
915
|
+
progress: step / cfg.iters,
|
|
916
|
+
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)}`,
|
|
917
|
+
});
|
|
918
|
+
clearCache();
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
if (valid.length > 0 && step % cfg.stepsPerEval === 0) {
|
|
922
|
+
// Val accuracy is correct/total over a FIXED subset (cap bounds both the
|
|
923
|
+
// denominator and the per-eval cost). Each val example is a single B=1
|
|
924
|
+
// pair, so m.accuracy ∈ {0,1} and va sums to the integer correct count.
|
|
925
|
+
const valSet = cfg.valMaxExamples > 0 ? valid.slice(0, cfg.valMaxExamples) : valid;
|
|
926
|
+
let vl = 0, va = 0, vm = 0, vn = 0;
|
|
927
|
+
for (const ex of valSet) {
|
|
928
|
+
const vb: DpoBatch = {
|
|
929
|
+
chosenIds: [ex.chosenIds], rejectedIds: [ex.rejectedIds],
|
|
930
|
+
chosenMask: [ex.chosenMask], rejectedMask: [ex.rejectedMask],
|
|
931
|
+
};
|
|
932
|
+
const m = orpoMetrics(model, vb, cfg.orpoLambda);
|
|
933
|
+
vl += m.loss; va += m.accuracy; vm += m.margin; vn++;
|
|
934
|
+
clearCache();
|
|
935
|
+
}
|
|
936
|
+
emit({
|
|
937
|
+
type: "metric", kind: "val", step,
|
|
938
|
+
loss: vn ? vl / vn : 0,
|
|
939
|
+
accuracy: vn ? va / vn : 0,
|
|
940
|
+
n_correct: Math.round(va),
|
|
941
|
+
n_total: vn,
|
|
942
|
+
margin: vn ? vm / vn : 0,
|
|
943
|
+
progress: step / cfg.iters,
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
} finally {
|
|
948
|
+
disposeChunkSink(); // defensive: free any head checkpoints a throw left behind
|
|
949
|
+
segmentedOrpoPrefix?.dispose();
|
|
950
|
+
segmentedOrpo?.dispose();
|
|
951
|
+
vag?.dispose();
|
|
952
|
+
opt.dispose();
|
|
534
953
|
}
|
|
535
954
|
return { numIters: cfg.iters };
|
|
536
955
|
}
|
|
@@ -570,6 +989,89 @@ function writeParam(lora: TrainableLora, i: number, p: MlxArray): void {
|
|
|
570
989
|
else lora.targets[i - n]!.lw.b = p;
|
|
571
990
|
}
|
|
572
991
|
|
|
992
|
+
/** Run one optimizer step's worth of gradient over `accumSteps` micro-batches:
|
|
993
|
+
* backward each micro-batch, mean-accumulate the per-micro grads (each scaled
|
|
994
|
+
* by 1/accumSteps), and return the mean loss + summed grads — a drop-in for the
|
|
995
|
+
* single value_and_grad the loops used to call inline. The caller passes the
|
|
996
|
+
* returned grads to opt.step (which takes ownership and disposes them).
|
|
997
|
+
*
|
|
998
|
+
* accumSteps <= 1 is a pass-through identical to the pre-accumulation path (one
|
|
999
|
+
* micro-batch, no scaling) so the proven B=1 loops stay byte-for-byte unchanged.
|
|
1000
|
+
* For accumSteps > 1 the running accumulator (and that micro-batch's loss) is
|
|
1001
|
+
* eval'd before the next micro-batch's forward builds, so only ONE micro-batch's
|
|
1002
|
+
* activations are ever live — peak memory does NOT grow with accumSteps (the
|
|
1003
|
+
* whole point: a larger effective batch without the B>1 activation memory).
|
|
1004
|
+
*
|
|
1005
|
+
* The combined `evalAll([value, ...grads])` per micro-batch is deliberate: a
|
|
1006
|
+
* separate eval of `value` first would materialize then free the forward,
|
|
1007
|
+
* forcing the backward to recompute and hold every layer's activations — which
|
|
1008
|
+
* silently defeats gradient checkpointing (verified). Keep loss + grads in one
|
|
1009
|
+
* eval.
|
|
1010
|
+
*
|
|
1011
|
+
* `afterMicroEval` runs once per micro-batch right after its grads are
|
|
1012
|
+
* materialized (its backward is complete), in BOTH the pass-through and
|
|
1013
|
+
* accumulating paths — it frees per-micro-batch state the loss closure captured
|
|
1014
|
+
* (gradient-checkpoint closures for SFT, reference log-probs for DPO) so that
|
|
1015
|
+
* state does not pile up across accumulation.
|
|
1016
|
+
*
|
|
1017
|
+
* `micro` returns the micro-batch loss scalar + grads in flatParams order
|
|
1018
|
+
* ([...A, ...B] over targets); it transfers ownership of both to this helper,
|
|
1019
|
+
* which disposes the per-micro value and grads. */
|
|
1020
|
+
export function accumulateStep(
|
|
1021
|
+
accumSteps: number,
|
|
1022
|
+
micro: () => { value: MlxArray; grads: MlxArray[] },
|
|
1023
|
+
afterMicroEval?: () => void,
|
|
1024
|
+
): { loss: number; grads: MlxArray[] } {
|
|
1025
|
+
if (accumSteps <= 1) {
|
|
1026
|
+
const { value, grads } = micro();
|
|
1027
|
+
evalAll([value, ...grads]);
|
|
1028
|
+
const loss = value.toFloat32()[0]!;
|
|
1029
|
+
value.dispose();
|
|
1030
|
+
afterMicroEval?.();
|
|
1031
|
+
return { loss, grads };
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const scale = 1 / accumSteps;
|
|
1035
|
+
let acc: MlxArray[] | null = null;
|
|
1036
|
+
let lossSum = 0;
|
|
1037
|
+
try {
|
|
1038
|
+
for (let i = 0; i < accumSteps; i++) {
|
|
1039
|
+
const { value, grads } = micro();
|
|
1040
|
+
if (acc === null) {
|
|
1041
|
+
acc = grads.map((g) => {
|
|
1042
|
+
const s = ops.mulScalar(g, scale);
|
|
1043
|
+
g.dispose();
|
|
1044
|
+
return s;
|
|
1045
|
+
});
|
|
1046
|
+
} else {
|
|
1047
|
+
const prev = acc;
|
|
1048
|
+
acc = grads.map((g, j) => {
|
|
1049
|
+
const s = ops.mulScalar(g, scale);
|
|
1050
|
+
const sum = ops.add(prev[j]!, s);
|
|
1051
|
+
s.dispose();
|
|
1052
|
+
prev[j]!.dispose();
|
|
1053
|
+
g.dispose();
|
|
1054
|
+
return sum;
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
// Eval the running accumulator (+ this micro-batch's loss) now so the
|
|
1058
|
+
// micro-batch's forward/backward graph frees before the next one builds —
|
|
1059
|
+
// this is what keeps peak flat across accumSteps.
|
|
1060
|
+
evalAll([value, ...acc]);
|
|
1061
|
+
lossSum += value.toFloat32()[0]!;
|
|
1062
|
+
value.dispose();
|
|
1063
|
+
afterMicroEval?.();
|
|
1064
|
+
clearCache();
|
|
1065
|
+
}
|
|
1066
|
+
const grads = acc!;
|
|
1067
|
+
acc = null; // ownership handed to the caller
|
|
1068
|
+
return { loss: lossSum / accumSteps, grads };
|
|
1069
|
+
} finally {
|
|
1070
|
+
// Non-null only if a micro-batch threw mid-accumulation (nulled on success).
|
|
1071
|
+
if (acc) for (const a of acc) a.dispose();
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
573
1075
|
/** L2 norm over all grads (also forces eval). */
|
|
574
1076
|
function globalNorm(grads: MlxArray[]): number {
|
|
575
1077
|
const partial: MlxArray[] = grads.map((g) => {
|
|
@@ -589,6 +1091,20 @@ function globalNorm(grads: MlxArray[]): number {
|
|
|
589
1091
|
return Math.sqrt(sumSq);
|
|
590
1092
|
}
|
|
591
1093
|
|
|
1094
|
+
/** Clip grads in place to a global L2 norm of `maxNorm` (no-op if off or under
|
|
1095
|
+
* the cap). Scales every grad by maxNorm/normVal and disposes the originals so
|
|
1096
|
+
* the optimizer takes ownership of the clipped copies. `normVal` is the already-
|
|
1097
|
+
* computed global norm (avoids a second pass). */
|
|
1098
|
+
function clipGradsByNorm(grads: MlxArray[], normVal: number, maxNorm: number): void {
|
|
1099
|
+
if (maxNorm <= 0 || !(normVal > maxNorm)) return;
|
|
1100
|
+
const s = maxNorm / normVal;
|
|
1101
|
+
for (let i = 0; i < grads.length; i++) {
|
|
1102
|
+
const g = grads[i]!;
|
|
1103
|
+
grads[i] = ops.mulScalar(g, s);
|
|
1104
|
+
g.dispose();
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
592
1108
|
function countResponseTokens(batch: SftBatch): number {
|
|
593
1109
|
const promptLen = batch.promptLens[0]!;
|
|
594
1110
|
const len = batch.ids[0]!.length;
|