mlx-bun 0.0.4
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/LICENSE +21 -0
- package/README.md +362 -0
- package/THIRD_PARTY_LICENSES.md +41 -0
- package/bin/mlx-bun.mjs +32 -0
- package/docs/curve-designer.html +303 -0
- package/docs/design/adapters-end-to-end.md +201 -0
- package/docs/design/compat-cli-surface-design.md +227 -0
- package/docs/design/docs-reorg-plan.md +152 -0
- package/docs/design/hlg-sampling.md +340 -0
- package/docs/design/memory-system.md +339 -0
- package/docs/design/optimization_plan.md +344 -0
- package/docs/design/parallel-slots.md +515 -0
- package/docs/design/runtime-isolation.md +247 -0
- package/docs/design/segmented-backward-training.md +508 -0
- package/docs/investigations/chunk-finetune-experiment.md +84 -0
- package/docs/investigations/curve-bisector-routing.md +123 -0
- package/docs/investigations/curve-runs/bisector-route.json +98 -0
- package/docs/investigations/curve-runs/bisector-trace.json +1393 -0
- package/docs/investigations/curve-runs/curve-terrain.json +1179 -0
- package/docs/investigations/curve-runs/curve-terrain.svg +42 -0
- package/docs/investigations/curve-terrain.html +105 -0
- package/docs/investigations/expert-offload-single-user-moe.md +292 -0
- package/docs/investigations/hlg-ab.html +34 -0
- package/docs/investigations/hlg-explorer.html +238 -0
- package/docs/investigations/hlg-figs/cliff.svg +1 -0
- package/docs/investigations/hlg-figs/cross.svg +1 -0
- package/docs/investigations/hlg-figs/edges.svg +1 -0
- package/docs/investigations/hlg-figs/k20-grids.svg +1 -0
- package/docs/investigations/hlg-figs/k20-peaks.svg +1 -0
- package/docs/investigations/hlg-figs/lw-curve.svg +1 -0
- package/docs/investigations/hlg-figs/terrain.svg +1 -0
- package/docs/investigations/hlg-report.html +262 -0
- package/docs/investigations/hlg-runs/hlg-ab.json +149 -0
- package/docs/investigations/hlg-runs/hlg-blind-prompts.md +87 -0
- package/docs/investigations/hlg-runs/hlg-blind.json +107 -0
- package/docs/investigations/hlg-runs/hlg-cliff.json +197 -0
- package/docs/investigations/hlg-runs/hlg-cross.json +505 -0
- package/docs/investigations/hlg-runs/hlg-edge.json +388 -0
- package/docs/investigations/hlg-runs/hlg-eval1.json +25 -0
- package/docs/investigations/hlg-runs/hlg-frontier.json +86 -0
- package/docs/investigations/hlg-runs/hlg-grid.json +242 -0
- package/docs/investigations/hlg-runs/hlg-grid2.json +343 -0
- package/docs/investigations/hlg-runs/hlg-lw-coarse.json +110 -0
- package/docs/investigations/hlg-runs/hlg-map.json +383 -0
- package/docs/investigations/hlg-runs/run0-original.log +159 -0
- package/docs/investigations/hlg-runs/runA-recommended.log +131 -0
- package/docs/investigations/hlg-runs/runAA-cliff-cpm5.log +26 -0
- package/docs/investigations/hlg-runs/runAB-grid2-e4b.log +18 -0
- package/docs/investigations/hlg-runs/runAC-peak-e4b-partial.log +25 -0
- package/docs/investigations/hlg-runs/runAD-ab-samples.log +8 -0
- package/docs/investigations/hlg-runs/runAE-edge-e4b.log +51 -0
- package/docs/investigations/hlg-runs/runAF-logit-calibration.log +10 -0
- package/docs/investigations/hlg-runs/runAG-eval-config2.log +16 -0
- package/docs/investigations/hlg-runs/runB-gain-variants.log +192 -0
- package/docs/investigations/hlg-runs/runC-pivots.log +162 -0
- package/docs/investigations/hlg-runs/runD-oetf.log +132 -0
- package/docs/investigations/hlg-runs/runE-eotf.log +162 -0
- package/docs/investigations/hlg-runs/runF-shaper-working.log +86 -0
- package/docs/investigations/hlg-runs/runG-shaper-sweep.log +101 -0
- package/docs/investigations/hlg-runs/runH-wsweep.log +176 -0
- package/docs/investigations/hlg-runs/runI-asweep.log +176 -0
- package/docs/investigations/hlg-runs/runJ-knobmap.log +49 -0
- package/docs/investigations/hlg-runs/runK-frontier.log +19 -0
- package/docs/investigations/hlg-runs/runL-widemap.log +65 -0
- package/docs/investigations/hlg-runs/runM-frontier-emb.log +15 -0
- package/docs/investigations/hlg-runs/runN-verify.log +116 -0
- package/docs/investigations/hlg-runs/runO-frontier-seed5000.log +15 -0
- package/docs/investigations/hlg-runs/runP-grid-AxSm.log +15 -0
- package/docs/investigations/hlg-runs/runQ-grid-tgW.log +15 -0
- package/docs/investigations/hlg-runs/runR-blind-transcript.log +79 -0
- package/docs/investigations/hlg-runs/runS-cross-coherence.log +33 -0
- package/docs/investigations/hlg-runs/runT-cross-cpm5.log +33 -0
- package/docs/investigations/hlg-runs/runU-cross-12b.log +33 -0
- package/docs/investigations/hlg-runs/runV-lw-e4b.log +22 -0
- package/docs/investigations/hlg-runs/runW-lw-12b.log +22 -0
- package/docs/investigations/hlg-runs/runX-lw-cpm5.log +22 -0
- package/docs/investigations/hlg-runs/runY-cliff-e4b.log +26 -0
- package/docs/investigations/hlg-runs/runZ-cliff-12b.log +26 -0
- package/docs/investigations/hlg-sampling-investigation.md +970 -0
- package/docs/investigations/lab-build-journal.md +157 -0
- package/docs/investigations/pi-builtin-investigation.md +308 -0
- package/docs/investigations/starter-model-port-handoff.md +323 -0
- package/docs/planning/IDEAS.md +26 -0
- package/docs/planning/PRODUCT_ROADMAP.md +426 -0
- package/docs/planning/ResearchTopics.md +119 -0
- package/docs/planning/chunk-task-roadmap.md +77 -0
- package/docs/planning/journal.md +155 -0
- package/docs/reference/distribution.md +121 -0
- package/docs/reference/embedding.md +168 -0
- package/docs/reference/library-api.md +153 -0
- package/docs/reference/server-api.md +224 -0
- package/docs/reference/server-config.md +243 -0
- package/docs/segmented-backward-handoff.md +148 -0
- package/package.json +68 -0
- package/src/anthropic.ts +491 -0
- package/src/chat-template.ts +186 -0
- package/src/cli.ts +1075 -0
- package/src/config.ts +229 -0
- package/src/curve-sampler.ts +122 -0
- package/src/dataset/generators.ts +1087 -0
- package/src/dataset/index.ts +12 -0
- package/src/dataset/job.ts +81 -0
- package/src/dataset/llm.ts +71 -0
- package/src/dataset/registry.ts +528 -0
- package/src/download.ts +283 -0
- package/src/eval/README.md +86 -0
- package/src/eval/kl-prompts.ts +29 -0
- package/src/eval/kl.ts +294 -0
- package/src/eval/quality-db.ts +92 -0
- package/src/eval/runner.ts +102 -0
- package/src/eval/score.ts +49 -0
- package/src/eval/tasks/bfcl.ts +500 -0
- package/src/eval/tasks/gsm8k.ts +85 -0
- package/src/eval/tasks/hashhop.ts +109 -0
- package/src/eval/tasks/humaneval.ts +252 -0
- package/src/eval/tasks/ifeval.ts +354 -0
- package/src/eval/tasks/mmlu.ts +145 -0
- package/src/evaldb.ts +199 -0
- package/src/expert-offload-build.ts +77 -0
- package/src/expert-offload.ts +61 -0
- package/src/expert-trace.ts +67 -0
- package/src/fit.ts +254 -0
- package/src/generate.ts +451 -0
- package/src/harness-pi.ts +177 -0
- package/src/hf-push.ts +628 -0
- package/src/index.ts +22 -0
- package/src/jobs/db.ts +139 -0
- package/src/jobs/index.ts +29 -0
- package/src/jobs/job-entry.ts +106 -0
- package/src/jobs/runner.ts +304 -0
- package/src/jobs/sse.ts +114 -0
- package/src/jobs/types.ts +58 -0
- package/src/kv-store.ts +185 -0
- package/src/lora.ts +314 -0
- package/src/md-modules.d.ts +7 -0
- package/src/mlx/array.ts +237 -0
- package/src/mlx/autograd.ts +267 -0
- package/src/mlx/checkpoint.ts +116 -0
- package/src/mlx/compile.ts +158 -0
- package/src/mlx/custom-vjp.ts +140 -0
- package/src/mlx/ffi.ts +433 -0
- package/src/mlx/metal-kernel.ts +130 -0
- package/src/mlx/ops.ts +707 -0
- package/src/mmap.ts +90 -0
- package/src/model/batched-mask.ts +204 -0
- package/src/model/batched-rotating.ts +300 -0
- package/src/model/compiled-decode.ts +681 -0
- package/src/model/factory.ts +27 -0
- package/src/model/fingerprint.ts +33 -0
- package/src/model/flash-attention.ts +674 -0
- package/src/model/fused-decode-kernel.ts +305 -0
- package/src/model/fused-geglu-kernel.ts +87 -0
- package/src/model/gemma4-base.ts +1616 -0
- package/src/model/gemma4.ts +1012 -0
- package/src/model/generated/gemma4-12b.ts +526 -0
- package/src/model/generated/gemma4-26b.ts +500 -0
- package/src/model/generated/gemma4-e4b.ts +757 -0
- package/src/model/generated/index.ts +18 -0
- package/src/model/minicpm5.ts +278 -0
- package/src/model/qwen3-delta.ts +224 -0
- package/src/model/qwen3_5.ts +429 -0
- package/src/model/support.ts +46 -0
- package/src/native-pack.ts +152 -0
- package/src/pi-provider.ts +95 -0
- package/src/pi-terminal.ts +264 -0
- package/src/pi-web.ts +924 -0
- package/src/preflight.ts +123 -0
- package/src/prompt-cache.ts +113 -0
- package/src/quantize/allocator.ts +627 -0
- package/src/quantize/calibration.ts +180 -0
- package/src/quantize/config-writer.ts +132 -0
- package/src/quantize/index.ts +57 -0
- package/src/quantize/job.ts +136 -0
- package/src/quantize/quantizer.ts +462 -0
- package/src/quantize/safetensors-writer.ts +171 -0
- package/src/quantize/sensitivity.ts +234 -0
- package/src/registry.ts +336 -0
- package/src/responses.ts +774 -0
- package/src/safetensors.ts +158 -0
- package/src/sampler.ts +517 -0
- package/src/serve/batch-scheduler.ts +362 -0
- package/src/serve/generation-gateway.ts +160 -0
- package/src/server.ts +1891 -0
- package/src/spec/drafter.ts +261 -0
- package/src/spec/generate.ts +221 -0
- package/src/status-page.html +497 -0
- package/src/tokenizer.ts +38 -0
- package/src/tool-call.ts +242 -0
- package/src/train/dataset.ts +461 -0
- package/src/train/export.ts +32 -0
- package/src/train/forward.ts +229 -0
- package/src/train/index.ts +43 -0
- package/src/train/job.ts +148 -0
- package/src/train/lora-params.ts +194 -0
- package/src/train/loss.ts +342 -0
- package/src/train/merge.ts +195 -0
- package/src/train/optimizer.ts +176 -0
- package/src/train/rank.ts +101 -0
- package/src/train/segmented.ts +520 -0
- package/src/train/trainer.ts +600 -0
- package/src/tui.ts +161 -0
- package/src/vision/embedder.ts +168 -0
- package/src/vision/preprocess.ts +247 -0
- package/src/vision/prompt.ts +158 -0
- package/src/vision/siglip.ts +599 -0
- package/src/web/app.html +2471 -0
- package/src/web/skills/web-research/SKILL.md +44 -0
- package/src/web/skills.ts +59 -0
- package/src/web-tools.ts +426 -0
- package/src/weights.ts +91 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
// Training losses: masked cross-entropy (SFT) and DPO.
|
|
2
|
+
//
|
|
3
|
+
// SFT — port of mlx_lm/tuner/trainer.py default_loss:
|
|
4
|
+
// logits = forward(ids[:, :-1]); targets = ids[:, 1:]
|
|
5
|
+
// ce[b,t] = logsumexp(logits[b,t]) - logits[b,t, targets[b,t]]
|
|
6
|
+
// mask[b,t] = 1 iff position predicts a response token that is NOT padding,
|
|
7
|
+
// i.e. (t+1) >= promptLen[b] AND (t+1) < length[b]
|
|
8
|
+
// loss = sum(ce * mask) / sum(mask) (token-mean over the whole batch)
|
|
9
|
+
// For B=1 with no padding this reduces to exactly the original single-example
|
|
10
|
+
// loss (mask[t] = (t+1) >= promptLen, length == ids.length).
|
|
11
|
+
//
|
|
12
|
+
// DPO — port of optiq/lora/dpo.py: per-sequence response log-prob via
|
|
13
|
+
// _seq_logp, reference log-probs computed with the adapter scale forced to 0
|
|
14
|
+
// and stop_gradient'd, then L = -log σ(β·((πc-refc) - (πr-refr))) averaged
|
|
15
|
+
// over the batch. Padding never contributes: the response mask is 0 at pad
|
|
16
|
+
// positions and the batched attention mask hides padded keys.
|
|
17
|
+
|
|
18
|
+
import { MlxArray } from "../mlx/array";
|
|
19
|
+
import { Dtype } from "../mlx/ffi";
|
|
20
|
+
import * as ops from "../mlx/ops";
|
|
21
|
+
import type { RuntimeModel } from "../model/factory";
|
|
22
|
+
import { trainForward, trainForwardHidden } from "./forward";
|
|
23
|
+
import { setLoraScale, type TrainableLora } from "./lora-params";
|
|
24
|
+
import {
|
|
25
|
+
rowLength, dpoChosenLength, dpoRejectedLength,
|
|
26
|
+
type SftBatch, type DpoBatch,
|
|
27
|
+
} from "./dataset";
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// SFT
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/** Masked cross-entropy over an SFT batch (B>=1).
|
|
34
|
+
* Returns a scalar MlxArray (caller owns).
|
|
35
|
+
*
|
|
36
|
+
* Every row is padded to the batch length L. The forward runs over
|
|
37
|
+
* ids[:, :-1] (a single [B, L-1] pass) with a padding-aware attention mask;
|
|
38
|
+
* the loss mask then keeps only (response AND non-padding) target positions.
|
|
39
|
+
* Loss = sum(ce·mask) / sum(mask) — a token-mean over the whole batch, the
|
|
40
|
+
* same reduction as mlx-lm default_loss. */
|
|
41
|
+
export function sftLoss(model: RuntimeModel, batch: SftBatch): MlxArray {
|
|
42
|
+
const B = batch.ids.length;
|
|
43
|
+
const L = batch.ids[0]!.length;
|
|
44
|
+
if (L < 2) throw new Error("sftLoss: sequence too short (need >= 2 tokens)");
|
|
45
|
+
for (let b = 0; b < B; b++)
|
|
46
|
+
if (batch.ids[b]!.length !== L)
|
|
47
|
+
throw new Error("sftLoss: all rows in a batch must share length L (pad first)");
|
|
48
|
+
|
|
49
|
+
// inputs = ids[:, :-1] (flatten row-major into [B, L-1]).
|
|
50
|
+
const T = L - 1;
|
|
51
|
+
const inputHost = new Int32Array(B * T);
|
|
52
|
+
for (let b = 0; b < B; b++)
|
|
53
|
+
for (let t = 0; t < T; t++) inputHost[b * T + t] = batch.ids[b]![t]!;
|
|
54
|
+
const inputIds = MlxArray.fromInt32(inputHost, [B, T]);
|
|
55
|
+
|
|
56
|
+
// Per-input-row valid length = min(rowLength, L) - 1 (inputs dropped the
|
|
57
|
+
// last token). Keys beyond this are padding and must not be attended.
|
|
58
|
+
const inputValid = Array.from({ length: B }, (_, b) =>
|
|
59
|
+
Math.max(0, Math.min(rowLength(batch, b), L) - 1),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
let scalar: MlxArray;
|
|
63
|
+
try {
|
|
64
|
+
if (B === 1) {
|
|
65
|
+
// Response-only path: run the forward to hidden states, then apply the
|
|
66
|
+
// LM head ONLY at supervised positions. Avoids the [1, T, 262k] logits
|
|
67
|
+
// (+ its grad) — the dominant long-context training-memory term.
|
|
68
|
+
const h = trainForwardHidden(model, inputIds, inputValid); // [1, T, hidden]
|
|
69
|
+
scalar = responseOnlyCe(model, h, batch);
|
|
70
|
+
h.dispose();
|
|
71
|
+
} else {
|
|
72
|
+
const logits = trainForward(model, inputIds, inputValid); // [B, T, V]
|
|
73
|
+
scalar = maskedCe(logits, batch);
|
|
74
|
+
logits.dispose();
|
|
75
|
+
}
|
|
76
|
+
} finally {
|
|
77
|
+
inputIds.dispose();
|
|
78
|
+
}
|
|
79
|
+
return scalar;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** B=1 masked CE that applies the LM head only at the supervised (response)
|
|
83
|
+
* span. The supervised input positions are the contiguous range
|
|
84
|
+
* [promptLen-1, len-1) (each predicts the next, response, token). The LM head
|
|
85
|
+
* is position-independent and prompt positions get zero loss weight, so this
|
|
86
|
+
* is numerically identical to maskedCe for B=1 — but it never materializes
|
|
87
|
+
* logits at prompt positions. Gradients to prompt-position LoRA params still
|
|
88
|
+
* flow: the response hidden states depend on the prompt through causal
|
|
89
|
+
* attention, which is fully captured upstream in `h`. */
|
|
90
|
+
export function responseOnlyCe(model: RuntimeModel, h: MlxArray, batch: SftBatch): MlxArray {
|
|
91
|
+
const L = batch.ids[0]!.length;
|
|
92
|
+
const promptLen = batch.promptLens[0]!;
|
|
93
|
+
const len = Math.min(rowLength(batch, 0), L);
|
|
94
|
+
const startT = Math.max(0, promptLen - 1); // first supervised input position
|
|
95
|
+
const M = (len - 1) - startT; // # supervised positions (predict ids[startT+1 .. len-1])
|
|
96
|
+
if (M <= 0) throw new Error("sftLoss: no response tokens to supervise");
|
|
97
|
+
|
|
98
|
+
// h[:, startT : startT+M, :] → [1, M, hidden] (sliceSize is the full output shape)
|
|
99
|
+
const hidden = h.shape[2]!;
|
|
100
|
+
const start = MlxArray.fromInt32(new Int32Array([startT]), [1]);
|
|
101
|
+
const hResp = ops.sliceDynamic(h, start, [1], [1, M, hidden]);
|
|
102
|
+
const logits = model.logitsFromHidden(hResp); // [1, M, V]
|
|
103
|
+
const V = logits.shape[2]!;
|
|
104
|
+
const logits2d = ops.reshape(logits, [M, V]);
|
|
105
|
+
|
|
106
|
+
const targetsHost = new Int32Array(M);
|
|
107
|
+
for (let i = 0; i < M; i++) targetsHost[i] = batch.ids[0]![startT + 1 + i]!;
|
|
108
|
+
const targets = MlxArray.fromInt32(targetsHost, [M, 1]);
|
|
109
|
+
|
|
110
|
+
const lse = ops.logsumexpAxis(logits2d, -1, false); // [M]
|
|
111
|
+
const gathered = ops.takeAlongAxis(logits2d, targets, -1); // [M, 1]
|
|
112
|
+
const picked = ops.reshape(gathered, [M]); // [M]
|
|
113
|
+
const ce = ops.sub(lse, picked); // [M]
|
|
114
|
+
const ceF = ce.dtype === Dtype.float32 ? ce : ce.astype(Dtype.float32);
|
|
115
|
+
const sumCe = ops.sumAxis(ceF, 0, false); // scalar
|
|
116
|
+
const loss = ops.mulScalar(sumCe, 1 / M); // token-mean over the response
|
|
117
|
+
|
|
118
|
+
for (const a of [start, hResp, logits, logits2d, targets, lse, gathered, picked, ce, sumCe]) a.dispose();
|
|
119
|
+
if (ceF !== ce) ceF.dispose();
|
|
120
|
+
return loss;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Build the token-mean masked-CE scalar from batched logits [B, T, V]
|
|
124
|
+
* (T = L-1) and the batch's host ids/boundaries. */
|
|
125
|
+
export function maskedCe(logits: MlxArray, batch: SftBatch): MlxArray {
|
|
126
|
+
const B = batch.ids.length;
|
|
127
|
+
const L = batch.ids[0]!.length;
|
|
128
|
+
const T = L - 1;
|
|
129
|
+
const V = logits.shape[2]!;
|
|
130
|
+
|
|
131
|
+
const targetsHost = new Int32Array(B * T);
|
|
132
|
+
const maskHost = new Float32Array(B * T);
|
|
133
|
+
let ntoks = 0;
|
|
134
|
+
for (let b = 0; b < B; b++) {
|
|
135
|
+
const promptLen = batch.promptLens[b]!;
|
|
136
|
+
const len = Math.min(rowLength(batch, b), L);
|
|
137
|
+
for (let t = 0; t < T; t++) {
|
|
138
|
+
const flat = b * T + t;
|
|
139
|
+
targetsHost[flat] = batch.ids[b]![t + 1]!;
|
|
140
|
+
// predicted token is ids[t+1]; supervised iff it is a response token
|
|
141
|
+
// (t+1 >= promptLen) AND a real (non-pad) token (t+1 < len).
|
|
142
|
+
if (t + 1 >= promptLen && t + 1 < len) {
|
|
143
|
+
maskHost[flat] = 1;
|
|
144
|
+
ntoks++;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (ntoks === 0) throw new Error("sftLoss: no response tokens to supervise");
|
|
149
|
+
|
|
150
|
+
const N = B * T;
|
|
151
|
+
const logits2d = ops.reshape(logits, [N, V]); // [B*T, V]
|
|
152
|
+
const targets = MlxArray.fromInt32(targetsHost, [N, 1]);
|
|
153
|
+
const mask = MlxArray.fromFloat32(maskHost, [N]);
|
|
154
|
+
|
|
155
|
+
// ce[n] = logsumexp(logits[n]) - logits[n, target[n]]
|
|
156
|
+
const lse = ops.logsumexpAxis(logits2d, -1, false); // [N]
|
|
157
|
+
const gathered = ops.takeAlongAxis(logits2d, targets, -1); // [N, 1]
|
|
158
|
+
const picked = ops.reshape(gathered, [N]); // [N]
|
|
159
|
+
const ce = ops.sub(lse, picked); // [N]
|
|
160
|
+
const ceF = ce.dtype === Dtype.float32 ? ce : ce.astype(Dtype.float32);
|
|
161
|
+
|
|
162
|
+
const masked = ops.mul(ceF, mask); // [N]
|
|
163
|
+
const sumCe = ops.sumAxis(masked, 0, false); // scalar
|
|
164
|
+
const loss = ops.mulScalar(sumCe, 1 / ntoks); // scalar (token-mean)
|
|
165
|
+
|
|
166
|
+
for (const a of [logits2d, targets, mask, lse, gathered, picked, ce, masked, sumCe]) a.dispose();
|
|
167
|
+
if (ceF !== ce) ceF.dispose();
|
|
168
|
+
return loss;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// DPO
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
/** Per-sequence sum of response-position log-probs (B>=1), port of
|
|
176
|
+
* dpo.py _seq_logp. `ids`/`mask` are `[B][L]` host arrays; `logits` are
|
|
177
|
+
* [B, L-1, V] (the forward over ids[:, :-1]). Returns a [B] MlxArray — one
|
|
178
|
+
* summed response log-prob per row. Pad positions contribute 0 because the
|
|
179
|
+
* response mask is 0 there. */
|
|
180
|
+
function seqLogp(logits: MlxArray, ids: number[][], mask: number[][]): MlxArray {
|
|
181
|
+
const B = ids.length;
|
|
182
|
+
const L = ids[0]!.length;
|
|
183
|
+
const T = L - 1;
|
|
184
|
+
const V = logits.shape[2]!;
|
|
185
|
+
const N = B * T;
|
|
186
|
+
|
|
187
|
+
const targetsHost = new Int32Array(N);
|
|
188
|
+
const maskHost = new Float32Array(N);
|
|
189
|
+
for (let b = 0; b < B; b++)
|
|
190
|
+
for (let t = 0; t < T; t++) {
|
|
191
|
+
const flat = b * T + t;
|
|
192
|
+
targetsHost[flat] = ids[b]![t + 1]!;
|
|
193
|
+
maskHost[flat] = mask[b]![t + 1]!; // resp_mask[:, 1:]
|
|
194
|
+
}
|
|
195
|
+
const logits2d = ops.reshape(logits, [N, V]);
|
|
196
|
+
const targets = MlxArray.fromInt32(targetsHost, [N, 1]);
|
|
197
|
+
const m = MlxArray.fromFloat32(maskHost, [N]);
|
|
198
|
+
|
|
199
|
+
const lse = ops.logsumexpAxis(logits2d, -1, false); // [N]
|
|
200
|
+
const gathered = ops.takeAlongAxis(logits2d, targets, -1); // [N,1]
|
|
201
|
+
const picked = ops.reshape(gathered, [N]); // [N]
|
|
202
|
+
const logp = ops.sub(picked, lse); // log_softmax at the label
|
|
203
|
+
const logpF = logp.dtype === Dtype.float32 ? logp : logp.astype(Dtype.float32);
|
|
204
|
+
const masked = ops.mul(logpF, m); // [N]
|
|
205
|
+
const masked2d = ops.reshape(masked, [B, T]); // [B, T]
|
|
206
|
+
const perRow = ops.sumAxis(masked2d, 1, false); // [B]
|
|
207
|
+
|
|
208
|
+
for (const a of [logits2d, targets, m, lse, gathered, picked, logp, masked, masked2d]) a.dispose();
|
|
209
|
+
if (logpF !== logp) logpF.dispose();
|
|
210
|
+
return perRow;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Forward + per-sequence response log-prob for one branch (B>=1). `validLen`
|
|
214
|
+
* is each row's true (unpadded) length, used to mask padded keys in the
|
|
215
|
+
* attention; passing it makes the batched forward ignore padding bit-for-bit
|
|
216
|
+
* the way separate per-row forwards would. */
|
|
217
|
+
function branchLogp(
|
|
218
|
+
model: RuntimeModel,
|
|
219
|
+
ids: number[][],
|
|
220
|
+
mask: number[][],
|
|
221
|
+
validLen: number[],
|
|
222
|
+
): MlxArray {
|
|
223
|
+
const B = ids.length;
|
|
224
|
+
const L = ids[0]!.length;
|
|
225
|
+
const T = L - 1;
|
|
226
|
+
const inputHost = new Int32Array(B * T);
|
|
227
|
+
for (let b = 0; b < B; b++)
|
|
228
|
+
for (let t = 0; t < T; t++) inputHost[b * T + t] = ids[b]![t]!;
|
|
229
|
+
const inputIds = MlxArray.fromInt32(inputHost, [B, T]);
|
|
230
|
+
// input row valid length = min(len, L) - 1 (last token dropped from inputs).
|
|
231
|
+
const inputValid = validLen.map((v) => Math.max(0, Math.min(v, L) - 1));
|
|
232
|
+
try {
|
|
233
|
+
const logits = trainForward(model, inputIds, inputValid);
|
|
234
|
+
const lp = seqLogp(logits, ids, mask);
|
|
235
|
+
logits.dispose();
|
|
236
|
+
return lp;
|
|
237
|
+
} finally {
|
|
238
|
+
inputIds.dispose();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Branch valid lengths from a DPO batch (defaults to full length for B=1). */
|
|
243
|
+
function chosenLengths(batch: DpoBatch): number[] {
|
|
244
|
+
return batch.chosenIds.map((_, i) => dpoChosenLength(batch, i));
|
|
245
|
+
}
|
|
246
|
+
function rejectedLengths(batch: DpoBatch): number[] {
|
|
247
|
+
return batch.rejectedIds.map((_, i) => dpoRejectedLength(batch, i));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Reference log-probs for a DPO batch: forward with the adapter disabled
|
|
251
|
+
* (scale 0), materialized + stop_gradient'd so the policy forwards don't
|
|
252
|
+
* hold the reference activations. Returns detached scalars (caller owns). */
|
|
253
|
+
export function dpoRefLogps(
|
|
254
|
+
model: RuntimeModel,
|
|
255
|
+
lora: TrainableLora,
|
|
256
|
+
batch: DpoBatch,
|
|
257
|
+
): { refChosen: MlxArray; refRejected: MlxArray } {
|
|
258
|
+
const original = lora.targets[0]?.lw.scale ?? lora.scale;
|
|
259
|
+
const cLen = chosenLengths(batch);
|
|
260
|
+
const rLen = rejectedLengths(batch);
|
|
261
|
+
setLoraScale(lora, 0);
|
|
262
|
+
try {
|
|
263
|
+
const c = branchLogp(model, batch.chosenIds, batch.chosenMask, cLen); // [B]
|
|
264
|
+
const r = branchLogp(model, batch.rejectedIds, batch.rejectedMask, rLen); // [B]
|
|
265
|
+
ops.evalAll([c, r]); // free reference activations before policy forwards
|
|
266
|
+
const refChosen = ops.stopGradient(c);
|
|
267
|
+
const refRejected = ops.stopGradient(r);
|
|
268
|
+
c.dispose();
|
|
269
|
+
r.dispose();
|
|
270
|
+
return { refChosen, refRejected };
|
|
271
|
+
} finally {
|
|
272
|
+
setLoraScale(lora, original);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** DPO loss for one batch given precomputed reference log-probs ([B] each).
|
|
277
|
+
* Runs the two policy forwards (adapter active) and reduces to the scalar
|
|
278
|
+
* mean over the batch of -logσ(adv) (matching dpo.py _policy_dpo_loss).
|
|
279
|
+
* Returns a scalar MlxArray (caller owns). */
|
|
280
|
+
export function dpoLoss(
|
|
281
|
+
model: RuntimeModel,
|
|
282
|
+
batch: DpoBatch,
|
|
283
|
+
beta: number,
|
|
284
|
+
refChosen: MlxArray,
|
|
285
|
+
refRejected: MlxArray,
|
|
286
|
+
): MlxArray {
|
|
287
|
+
const cLen = chosenLengths(batch);
|
|
288
|
+
const rLen = rejectedLengths(batch);
|
|
289
|
+
const piC = branchLogp(model, batch.chosenIds, batch.chosenMask, cLen); // [B]
|
|
290
|
+
const piR = branchLogp(model, batch.rejectedIds, batch.rejectedMask, rLen); // [B]
|
|
291
|
+
|
|
292
|
+
const logrC = ops.sub(piC, refChosen); // [B] πc - refc
|
|
293
|
+
const logrR = ops.sub(piR, refRejected); // [B] πr - refr
|
|
294
|
+
const diff = ops.sub(logrC, logrR);
|
|
295
|
+
const adv = ops.mulScalar(diff, beta); // [B] β·(logrC - logrR)
|
|
296
|
+
// -log σ(adv) = softplus(-adv) = logaddexp(0, -adv); mean over the batch.
|
|
297
|
+
const negAdv = ops.neg(adv);
|
|
298
|
+
const zero = ops.scalarLike(0, adv);
|
|
299
|
+
const perRow = ops.logaddexp(zero, negAdv); // [B]
|
|
300
|
+
const loss = ops.meanAll(perRow, false); // scalar
|
|
301
|
+
|
|
302
|
+
for (const a of [piC, piR, logrC, logrR, diff, adv, negAdv, zero, perRow]) a.dispose();
|
|
303
|
+
return loss;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** DPO diagnostics (no grad): loss, accuracy (mean of adv>0), reward margin
|
|
307
|
+
* (mean of β·(logrC-logrR)) over the batch. Computes its own references. */
|
|
308
|
+
export function dpoMetrics(
|
|
309
|
+
model: RuntimeModel,
|
|
310
|
+
lora: TrainableLora,
|
|
311
|
+
batch: DpoBatch,
|
|
312
|
+
beta: number,
|
|
313
|
+
): { loss: number; accuracy: number; margin: number } {
|
|
314
|
+
const { refChosen, refRejected } = dpoRefLogps(model, lora, batch);
|
|
315
|
+
const cLen = chosenLengths(batch);
|
|
316
|
+
const rLen = rejectedLengths(batch);
|
|
317
|
+
const piC = branchLogp(model, batch.chosenIds, batch.chosenMask, cLen); // [B]
|
|
318
|
+
const piR = branchLogp(model, batch.rejectedIds, batch.rejectedMask, rLen); // [B]
|
|
319
|
+
|
|
320
|
+
const logrC = ops.sub(piC, refChosen);
|
|
321
|
+
const logrR = ops.sub(piR, refRejected);
|
|
322
|
+
const diff = ops.sub(logrC, logrR);
|
|
323
|
+
const adv = ops.mulScalar(diff, beta); // [B]
|
|
324
|
+
const negAdv = ops.neg(adv);
|
|
325
|
+
const zero = ops.scalarLike(0, adv);
|
|
326
|
+
const perRow = ops.logaddexp(zero, negAdv); // [B]
|
|
327
|
+
const loss = ops.meanAll(perRow, false);
|
|
328
|
+
|
|
329
|
+
const advVals = adv.toFloat32(); // [B]
|
|
330
|
+
const lossVal = loss.toFloat32()[0]!;
|
|
331
|
+
let acc = 0;
|
|
332
|
+
let margin = 0;
|
|
333
|
+
for (let b = 0; b < advVals.length; b++) {
|
|
334
|
+
if (advVals[b]! > 0) acc++;
|
|
335
|
+
margin += advVals[b]!;
|
|
336
|
+
}
|
|
337
|
+
acc /= advVals.length;
|
|
338
|
+
margin /= advVals.length;
|
|
339
|
+
|
|
340
|
+
for (const a of [refChosen, refRejected, piC, piR, logrC, logrR, diff, adv, negAdv, zero, perRow, loss]) a.dispose();
|
|
341
|
+
return { loss: lossVal, accuracy: acc, margin };
|
|
342
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Merge multiple LoRA adapters into one — rank-concat, mathematically exact.
|
|
2
|
+
// Port of optiq/lora/merge.py.
|
|
3
|
+
//
|
|
4
|
+
// For two adapters on a layer (A1[in,r1] B1[r1,out] scale s1; A2 B2 s2):
|
|
5
|
+
// A_m = concat([A1, A2], rankAxis) shape [in, r1+r2]
|
|
6
|
+
// B_m = concat([s1·B1, s2·B2], rankAxis) shape [r1+r2, out]
|
|
7
|
+
// (x @ A_m) @ B_m == s1·(x@A1@B1) + s2·(x@A2@B2)
|
|
8
|
+
// so the merged adapter writes scale=1.0 (per-source scales folded into B).
|
|
9
|
+
//
|
|
10
|
+
// Tensor names follow the AdapterManager.mount format (lora_a / lora_b).
|
|
11
|
+
|
|
12
|
+
import { mkdirSync, existsSync } from "node:fs";
|
|
13
|
+
import { ptr, read } from "bun:ffi";
|
|
14
|
+
import { C } from "../mlx/ffi";
|
|
15
|
+
import { MlxArray, cpuStream } from "../mlx/array";
|
|
16
|
+
import * as ops from "../mlx/ops";
|
|
17
|
+
import { SafetensorsFile } from "../safetensors";
|
|
18
|
+
|
|
19
|
+
const cstr = (s: string) => Buffer.from(s + "\0", "utf8");
|
|
20
|
+
|
|
21
|
+
export interface MergeStats {
|
|
22
|
+
layersMerged: number;
|
|
23
|
+
layersOnlyInOne: number;
|
|
24
|
+
totalKeysOut: number;
|
|
25
|
+
sources: string[];
|
|
26
|
+
scales: number[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Merge N adapter directories into `outputDir`. `scales` overrides the
|
|
30
|
+
* per-source scales (else each source's adapter config scale is used). */
|
|
31
|
+
export async function mergeAdapters(
|
|
32
|
+
adapterDirs: string[],
|
|
33
|
+
outputDir: string,
|
|
34
|
+
scales?: number[],
|
|
35
|
+
): Promise<MergeStats> {
|
|
36
|
+
if (adapterDirs.length < 2) throw new Error("mergeAdapters needs at least two sources");
|
|
37
|
+
|
|
38
|
+
const resolvedScales = scales ?? (await Promise.all(adapterDirs.map(resolveScale)));
|
|
39
|
+
if (resolvedScales.length !== adapterDirs.length)
|
|
40
|
+
throw new Error("scales length must match adapter count");
|
|
41
|
+
|
|
42
|
+
const sources = adapterDirs.map((d) => ({
|
|
43
|
+
tensors: loadAdapterTensors(resolveSafetensors(d)),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
// Universe of module paths across all sources.
|
|
47
|
+
const allMods = new Set<string>();
|
|
48
|
+
for (const s of sources)
|
|
49
|
+
for (const k of s.tensors.keys())
|
|
50
|
+
if (k.endsWith(".lora_a")) allMods.add(k.slice(0, -".lora_a".length));
|
|
51
|
+
|
|
52
|
+
const merged = new Map<string, MlxArray>();
|
|
53
|
+
const stats: MergeStats = {
|
|
54
|
+
layersMerged: 0,
|
|
55
|
+
layersOnlyInOne: 0,
|
|
56
|
+
totalKeysOut: 0,
|
|
57
|
+
sources: adapterDirs,
|
|
58
|
+
scales: resolvedScales,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
for (const mod of [...allMods].sort()) {
|
|
63
|
+
const aParts: MlxArray[] = [];
|
|
64
|
+
const bParts: MlxArray[] = [];
|
|
65
|
+
for (let s = 0; s < sources.length; s++) {
|
|
66
|
+
const a = sources[s]!.tensors.get(`${mod}.lora_a`);
|
|
67
|
+
const b = sources[s]!.tensors.get(`${mod}.lora_b`);
|
|
68
|
+
if (a && b) {
|
|
69
|
+
aParts.push(a);
|
|
70
|
+
const scale = resolvedScales[s]!;
|
|
71
|
+
bParts.push(scale === 1.0 ? b : ops.mulScalar(b, scale));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (aParts.length === 0) continue;
|
|
75
|
+
if (aParts.length === 1) {
|
|
76
|
+
stats.layersOnlyInOne++;
|
|
77
|
+
// A is the original; B may be a scaled copy (own it) or the original.
|
|
78
|
+
merged.set(`${mod}.lora_a`, aParts[0]!);
|
|
79
|
+
merged.set(`${mod}.lora_b`, bParts[0]!);
|
|
80
|
+
} else {
|
|
81
|
+
// rank axis: A is [in, r] → axis 1; B is [r, out] → axis 0.
|
|
82
|
+
const aMerged = ops.concatAxis(aParts, 1);
|
|
83
|
+
const bMerged = ops.concatAxis(bParts, 0);
|
|
84
|
+
merged.set(`${mod}.lora_a`, aMerged);
|
|
85
|
+
merged.set(`${mod}.lora_b`, bMerged);
|
|
86
|
+
stats.layersMerged++;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
stats.totalKeysOut = merged.size;
|
|
91
|
+
mkdirSync(outputDir, { recursive: true });
|
|
92
|
+
|
|
93
|
+
const map = C.mlx_map_string_to_array_new();
|
|
94
|
+
const meta = C.mlx_map_string_to_string_new();
|
|
95
|
+
try {
|
|
96
|
+
for (const [name, arr] of merged) {
|
|
97
|
+
arr.eval();
|
|
98
|
+
if (C.mlx_map_string_to_array_insert(map, ptr(cstr(name)), arr.handle) !== 0)
|
|
99
|
+
throw new Error(`map insert ${name} failed`);
|
|
100
|
+
}
|
|
101
|
+
const file = `${outputDir}/adapters.safetensors`;
|
|
102
|
+
if (C.mlx_save_safetensors(ptr(cstr(file)), map, meta) !== 0)
|
|
103
|
+
throw new Error(`mlx_save_safetensors(${file}) failed`);
|
|
104
|
+
} finally {
|
|
105
|
+
C.mlx_map_string_to_array_free(map);
|
|
106
|
+
C.mlx_map_string_to_string_free(meta);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Merged config: scale 1.0 (folded into B), rank = sum of source ranks.
|
|
110
|
+
let rankSum = 0;
|
|
111
|
+
for (const d of adapterDirs) rankSum += (await resolveRank(d)) ?? 0;
|
|
112
|
+
const cfg = {
|
|
113
|
+
fine_tune_type: "lora",
|
|
114
|
+
lora_parameters: { scale: 1.0, rank: rankSum || null },
|
|
115
|
+
r: rankSum || null,
|
|
116
|
+
peft_type: "LORA",
|
|
117
|
+
optiq_merge: { sources: adapterDirs, scales: resolvedScales },
|
|
118
|
+
};
|
|
119
|
+
await Bun.write(`${outputDir}/adapter_config.json`, JSON.stringify(cfg, null, 2) + "\n");
|
|
120
|
+
await Bun.write(`${outputDir}/optiq_lora_config.json`, JSON.stringify(cfg, null, 2) + "\n");
|
|
121
|
+
} finally {
|
|
122
|
+
// dispose all source + merged arrays (merged copies are owned here).
|
|
123
|
+
for (const s of sources) for (const a of s.tensors.values()) a.dispose();
|
|
124
|
+
for (const a of merged.values()) a.dispose();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return stats;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function resolveSafetensors(dir: string): string {
|
|
131
|
+
for (const p of [`${dir}/best/adapters.safetensors`, `${dir}/adapters.safetensors`])
|
|
132
|
+
if (existsSync(p)) return p;
|
|
133
|
+
if (existsSync(dir)) return dir; // explicit file path
|
|
134
|
+
throw new Error(`${dir}: no adapters.safetensors`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function resolveScale(dir: string): Promise<number> {
|
|
138
|
+
const cfg = await readConfig(dir);
|
|
139
|
+
if (cfg?.lora_parameters?.scale != null) return Number(cfg.lora_parameters.scale);
|
|
140
|
+
if (cfg?.lora_alpha != null && cfg?.r) return Number(cfg.lora_alpha) / Number(cfg.r);
|
|
141
|
+
if (cfg?.scale != null) return Number(cfg.scale);
|
|
142
|
+
return 1.0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function resolveRank(dir: string): Promise<number | null> {
|
|
146
|
+
const cfg = await readConfig(dir);
|
|
147
|
+
const r = cfg?.lora_parameters?.rank ?? cfg?.r;
|
|
148
|
+
return r != null ? Number(r) : null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function readConfig(dir: string): Promise<any | null> {
|
|
152
|
+
for (const name of ["adapter_config.json", "optiq_lora_config.json"]) {
|
|
153
|
+
const f = Bun.file(`${dir}/${name}`);
|
|
154
|
+
if (await f.exists()) return f.json();
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Materialize every tensor from an adapter safetensors file (mirrors the
|
|
160
|
+
* read idiom in src/lora.ts loadAdapterTensors). */
|
|
161
|
+
function loadAdapterTensors(file: string): Map<string, MlxArray> {
|
|
162
|
+
const sf = SafetensorsFile.open(file);
|
|
163
|
+
const names = [...sf.tensors.keys()];
|
|
164
|
+
sf.mmap.unmap();
|
|
165
|
+
|
|
166
|
+
const arrMapSlot = new BigUint64Array([C.mlx_map_string_to_array_new()]);
|
|
167
|
+
const metaMapSlot = new BigUint64Array([C.mlx_map_string_to_string_new()]);
|
|
168
|
+
const arrMapPtr = ptr(arrMapSlot);
|
|
169
|
+
const metaMapPtr = ptr(metaMapSlot);
|
|
170
|
+
const status = C.mlx_load_safetensors(arrMapPtr, metaMapPtr, ptr(cstr(file)), cpuStream);
|
|
171
|
+
C.mlx_map_string_to_string_free(read.u64(metaMapPtr, 0));
|
|
172
|
+
const mapHandle = read.u64(arrMapPtr, 0);
|
|
173
|
+
if (status !== 0) {
|
|
174
|
+
C.mlx_map_string_to_array_free(mapHandle);
|
|
175
|
+
throw new Error(`mlx_load_safetensors(${file}) failed`);
|
|
176
|
+
}
|
|
177
|
+
const out = new Map<string, MlxArray>();
|
|
178
|
+
try {
|
|
179
|
+
for (const name of names) {
|
|
180
|
+
const slot = new BigUint64Array([C.mlx_array_new()]);
|
|
181
|
+
const slotPtr = ptr(slot);
|
|
182
|
+
if (C.mlx_map_string_to_array_get(slotPtr, mapHandle, ptr(cstr(name))) !== 0)
|
|
183
|
+
throw new Error(`adapter tensor ${name} missing from native map`);
|
|
184
|
+
const arr = new MlxArray(read.u64(slotPtr, 0));
|
|
185
|
+
arr.eval();
|
|
186
|
+
out.set(name, arr);
|
|
187
|
+
}
|
|
188
|
+
} catch (e) {
|
|
189
|
+
for (const a of out.values()) a.dispose();
|
|
190
|
+
C.mlx_map_string_to_array_free(mapHandle);
|
|
191
|
+
throw e;
|
|
192
|
+
}
|
|
193
|
+
C.mlx_map_string_to_array_free(mapHandle);
|
|
194
|
+
return out;
|
|
195
|
+
}
|