mlx-bun 0.0.11 → 0.0.13
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 +63 -27
- package/THIRD_PARTY_LICENSES.md +9 -0
- package/docs/design/colibri-glm52-port.md +1486 -0
- package/docs/design/pre-colibri-stabilization.md +329 -0
- package/docs/investigations/colibri-oracle-pin.md +1284 -0
- package/docs/investigations/indexshare-performance-spike.md +357 -0
- package/docs/planning/release-notes-v0.0.12.md +80 -0
- package/docs/planning/release-notes-v0.0.13.md +77 -0
- package/docs/reference/cli.md +28 -2
- package/docs/reference/distribution.md +25 -1
- package/docs/reference/embedding.md +5 -2
- package/docs/reference/features-matrix.md +5 -2
- package/docs/reference/library-api.md +41 -13
- package/docs/reference/models.md +49 -2
- package/docs/reference/server-api.md +113 -20
- package/docs/reference/server-config.md +21 -14
- package/docs/reference/training.md +10 -1
- package/package.json +1 -1
- package/src/anthropic.ts +54 -44
- package/src/chat-template.ts +189 -11
- package/src/cli.ts +127 -8
- package/src/config.ts +81 -26
- package/src/diffusion/diffusion-generate.ts +14 -8
- package/src/download.ts +103 -12
- package/src/eval/ifeval.ts +63 -120
- package/src/eval/runner.ts +14 -5
- package/src/eval/tasks/ifeval.ts +167 -31
- package/src/expert-io.ts +391 -0
- package/src/expert-residency.ts +1134 -0
- package/src/expert-usage.ts +415 -0
- package/src/generate.ts +114 -17
- package/src/hf-push.ts +1 -1
- package/src/index.ts +14 -2
- package/src/jobs/runner.ts +37 -18
- package/src/kv-store.ts +147 -8
- package/src/lora.ts +77 -14
- package/src/media-fetch.ts +198 -56
- package/src/mlx/array.ts +43 -0
- package/src/mlx/autograd.ts +51 -14
- package/src/mlx/checkpoint.ts +46 -13
- package/src/mlx/custom-vjp.ts +83 -35
- package/src/model/factory.ts +96 -5
- package/src/model/gemma4-base.ts +32 -0
- package/src/model/glm52-atlas.ts +511 -0
- package/src/model/glm52-cache.ts +706 -0
- package/src/model/glm52-config.ts +262 -0
- package/src/model/glm52-container.ts +305 -0
- package/src/model/glm52-coupling.ts +388 -0
- package/src/model/glm52-dsa.ts +602 -0
- package/src/model/glm52-expert-layout.ts +253 -0
- package/src/model/glm52-layout.ts +152 -0
- package/src/model/glm52-memory.ts +473 -0
- package/src/model/glm52-mla.ts +786 -0
- package/src/model/glm52-moe.ts +223 -0
- package/src/model/glm52-pilot.ts +537 -0
- package/src/model/glm52-reference.ts +445 -0
- package/src/model/glm52-residency.ts +557 -0
- package/src/model/glm52-resident-weights.ts +135 -0
- package/src/model/glm52-streamed-experts.ts +453 -0
- package/src/model/glm52-streamed-metal.ts +610 -0
- package/src/model/glm52-weights.ts +503 -0
- package/src/model/glm52.ts +1153 -0
- package/src/model/support.ts +8 -1
- package/src/native/expert_io.c +523 -0
- package/src/native-pack.ts +6 -4
- package/src/responses.ts +62 -49
- package/src/serve/batch-scheduler.ts +95 -16
- package/src/serve/generation-gateway.ts +41 -16
- package/src/server.ts +471 -93
- package/src/spec/glm52-mtp-source.ts +392 -0
- package/src/spec/serve-loop.ts +99 -18
- package/src/spec/source.ts +31 -6
- package/src/ssd-cache.ts +6 -2
- package/src/tool-call.ts +30 -2
- package/src/train/job.ts +0 -10
- package/src/train/lora-params.ts +17 -6
- package/src/train/trainer.ts +59 -26
- package/src/web/app.html +13 -2
- package/src/web/app.js +30 -3
- package/src/web/src/status.ts +51 -3
- package/src/web-tools.ts +54 -2
- package/src/weights.ts +82 -19
package/src/jobs/runner.ts
CHANGED
|
@@ -149,6 +149,7 @@ interface QueuedSpawn {
|
|
|
149
149
|
jobId: string;
|
|
150
150
|
entry: string;
|
|
151
151
|
bin: string;
|
|
152
|
+
spawn: typeof Bun.spawn;
|
|
152
153
|
onComplete?: (jobId: string, code: number) => void;
|
|
153
154
|
}
|
|
154
155
|
|
|
@@ -171,6 +172,8 @@ export interface SubprocessOpts {
|
|
|
171
172
|
entry?: string;
|
|
172
173
|
/** Override the runtime binary (tests). Defaults to "bun". */
|
|
173
174
|
bin?: string;
|
|
175
|
+
/** Override process creation for deterministic failure-path tests. */
|
|
176
|
+
spawn?: typeof Bun.spawn;
|
|
174
177
|
/** Called on the server (parent) after the child exits — used to invalidate
|
|
175
178
|
* caches (e.g. the Library) so a finished quantize surfaces immediately. */
|
|
176
179
|
onComplete?: (jobId: string, code: number) => void;
|
|
@@ -192,35 +195,46 @@ export function submitSubprocess(
|
|
|
192
195
|
jobId: row.id,
|
|
193
196
|
entry: opts.entry ?? JOB_ENTRY_PATH,
|
|
194
197
|
bin: opts.bin ?? "bun",
|
|
198
|
+
spawn: opts.spawn ?? Bun.spawn,
|
|
195
199
|
onComplete: opts.onComplete,
|
|
196
200
|
};
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
} else {
|
|
200
|
-
spawnQueue.push(item);
|
|
201
|
-
}
|
|
201
|
+
spawnQueue.push(item);
|
|
202
|
+
drainQueue();
|
|
202
203
|
return { jobId: row.id, outputPath };
|
|
203
204
|
}
|
|
204
205
|
|
|
205
206
|
/** Spawn the child, acquire the lease, stream stdout/stderr into the log,
|
|
206
207
|
* reconcile terminal status on exit, then release the lease and drain. */
|
|
207
208
|
function spawnNow(item: QueuedSpawn): void {
|
|
208
|
-
const { store, jobId, entry, bin } = item;
|
|
209
|
+
const { store, jobId, entry, bin, spawn } = item;
|
|
209
210
|
gpuLeaseHolder = jobId;
|
|
210
211
|
|
|
211
212
|
// The child opens its OWN JobStore over the SAME DB/logs — a sqlite
|
|
212
213
|
// connection can't cross the process boundary, so we hand the paths via
|
|
213
214
|
// env. A :memory: DB can't be shared with a child; subprocess jobs require
|
|
214
215
|
// a file-backed DB.
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
216
|
+
let proc: Bun.Subprocess<"ignore", "pipe", "pipe">;
|
|
217
|
+
try {
|
|
218
|
+
proc = spawn([bin, entry, jobId], {
|
|
219
|
+
stdout: "pipe",
|
|
220
|
+
stderr: "pipe",
|
|
221
|
+
env: {
|
|
222
|
+
...process.env,
|
|
223
|
+
MLX_BUN_JOBS_DB: store.dbPath,
|
|
224
|
+
MLX_BUN_JOBS_DIR: store.logsDir,
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
} catch (e) {
|
|
228
|
+
try {
|
|
229
|
+
store.setStatus(jobId, "failed", {
|
|
230
|
+
error: errString(e),
|
|
231
|
+
endedAt: nowIso(),
|
|
232
|
+
});
|
|
233
|
+
} finally {
|
|
234
|
+
releaseLease(jobId);
|
|
235
|
+
}
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
224
238
|
|
|
225
239
|
const row = store.get(jobId);
|
|
226
240
|
const logPath = row?.log_path;
|
|
@@ -259,9 +273,14 @@ function releaseLease(jobId: string): void {
|
|
|
259
273
|
|
|
260
274
|
/** Run the next queued subprocess job if the lease is free. */
|
|
261
275
|
export function drainQueue(): void {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
276
|
+
while (gpuLeaseHolder === null) {
|
|
277
|
+
const next = spawnQueue.shift();
|
|
278
|
+
if (!next) return;
|
|
279
|
+
// A synchronous spawn failure releases the lease. Keep draining
|
|
280
|
+
// iteratively so a run of bad queue entries cannot recurse or wedge the
|
|
281
|
+
// first startable job behind them.
|
|
282
|
+
spawnNow(next);
|
|
283
|
+
}
|
|
265
284
|
}
|
|
266
285
|
|
|
267
286
|
/** Read a child stream line-by-line, buffering partial trailing lines, and
|
package/src/kv-store.ts
CHANGED
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
QuantizedKVCache, RotatingQuantizedKVCache,
|
|
51
51
|
TurboQuantKVCache, type TurboQuantTensor,
|
|
52
52
|
} from "./model/gemma4-base";
|
|
53
|
+
import { Glm52Cache, MLACache } from "./model/glm52-cache";
|
|
53
54
|
import { SSMCache } from "./model/qwen3-delta";
|
|
54
55
|
|
|
55
56
|
const MAGIC = "MLXBUNKV2\n";
|
|
@@ -62,7 +63,16 @@ const PREFIX_LEN = MAGIC.length + 4 + 4 + 8;
|
|
|
62
63
|
// the header's byte length when the real values replace the placeholders.
|
|
63
64
|
const hash64 = (bytes: Uint8Array): string => Bun.hash(bytes).toString(16).padStart(16, "0");
|
|
64
65
|
|
|
65
|
-
export type CacheKind =
|
|
66
|
+
export type CacheKind =
|
|
67
|
+
| "kv"
|
|
68
|
+
| "rotating"
|
|
69
|
+
| "qkv"
|
|
70
|
+
| "rotating-qkv"
|
|
71
|
+
| "ssm"
|
|
72
|
+
| "turboquant"
|
|
73
|
+
| "mla"
|
|
74
|
+
| "mla-dsa"
|
|
75
|
+
| "mtp-mla";
|
|
66
76
|
|
|
67
77
|
interface TensorSlot {
|
|
68
78
|
off: number;
|
|
@@ -91,6 +101,11 @@ export interface CacheHeaderEntry {
|
|
|
91
101
|
/** turboquant: head_dim, needed to unpack kIdx/vPacked on restore
|
|
92
102
|
* (packed byte width alone doesn't recover the original element count). */
|
|
93
103
|
headDim?: number;
|
|
104
|
+
/** GLM checkpoint-native compressed-cache geometry. */
|
|
105
|
+
kvLoraRank?: number;
|
|
106
|
+
ropeHeadDim?: number;
|
|
107
|
+
dsaHeadDim?: number;
|
|
108
|
+
maxTokens?: number;
|
|
94
109
|
/** kv/rotating: [k, v] · qkv/rotating-qkv: [kPacked, kScales, kBiases,
|
|
95
110
|
* vPacked, vScales, vBiases] · ssm: [conv, recurrent] · turboquant:
|
|
96
111
|
* [kIdx, kScales, kZeros, vPacked, vScales] */
|
|
@@ -142,6 +157,10 @@ function snapshotCache(c: Cache, dataOffset: number): { entry: CacheHeaderEntry;
|
|
|
142
157
|
const [B, H, , D] = a.shape as [number, number, number, number];
|
|
143
158
|
return a.slice([0, 0, 0, 0], [B, H, upTo, D]);
|
|
144
159
|
};
|
|
160
|
+
const liveMlaSlice = (a: MlxArray, upTo: number): MlxArray => {
|
|
161
|
+
const [B, , D] = a.shape as [number, number, number];
|
|
162
|
+
return a.slice([0, 0, 0], [B, upTo, D]);
|
|
163
|
+
};
|
|
145
164
|
const pushTriple = (t: ops.QuantizedTensor, upTo: number | null): void => {
|
|
146
165
|
for (const a of [t.packed, t.scales, t.biases]) {
|
|
147
166
|
if (upTo === null) push(a, false);
|
|
@@ -150,7 +169,26 @@ function snapshotCache(c: Cache, dataOffset: number): { entry: CacheHeaderEntry;
|
|
|
150
169
|
};
|
|
151
170
|
|
|
152
171
|
let entry: CacheHeaderEntry;
|
|
153
|
-
if (c instanceof
|
|
172
|
+
if (c instanceof Glm52Cache) {
|
|
173
|
+
if (!c.latent || !c.rope || c.batchSize === null || c.offset === 0)
|
|
174
|
+
throw new Error("cannot persist an empty GLM compressed cache");
|
|
175
|
+
push(liveMlaSlice(c.latent, c.offset), true);
|
|
176
|
+
push(liveMlaSlice(c.rope, c.offset), true);
|
|
177
|
+
if (c.dsa) {
|
|
178
|
+
if (!c.dsa.data || c.dsa.offset !== c.offset)
|
|
179
|
+
throw new Error("cannot persist misaligned GLM DSA state");
|
|
180
|
+
push(liveMlaSlice(c.dsa.data, c.offset), true);
|
|
181
|
+
}
|
|
182
|
+
entry = {
|
|
183
|
+
kind: c.role === "mtp" ? "mtp-mla" : c.dsa ? "mla-dsa" : "mla",
|
|
184
|
+
offset: c.offset,
|
|
185
|
+
kvLoraRank: c.kvLoraRank,
|
|
186
|
+
ropeHeadDim: c.ropeHeadDim,
|
|
187
|
+
...(c.dsa ? { dsaHeadDim: c.dsa.headDim } : {}),
|
|
188
|
+
maxTokens: c.maxTokens,
|
|
189
|
+
tensors: slots,
|
|
190
|
+
};
|
|
191
|
+
} else if (c instanceof TurboQuantKVCache) {
|
|
154
192
|
const t = c.state();
|
|
155
193
|
if (t.length === 0) throw new Error("cannot persist an empty cache");
|
|
156
194
|
const [kIdx, kScales, kZeros, vPacked, vScales] = t as [MlxArray, MlxArray, MlxArray, MlxArray, MlxArray];
|
|
@@ -211,6 +249,10 @@ export function cloneKvCaches(caches: Cache[]): Cache[] {
|
|
|
211
249
|
const [B, H, , D] = a.shape as [number, number, number, number];
|
|
212
250
|
return a.slice([0, 0, 0, 0], [B, H, upTo, D]);
|
|
213
251
|
};
|
|
252
|
+
const mlaView = (a: MlxArray, upTo: number): MlxArray => {
|
|
253
|
+
const [B, , D] = a.shape as [number, number, number];
|
|
254
|
+
return a.slice([0, 0, 0], [B, upTo, D]);
|
|
255
|
+
};
|
|
214
256
|
const tripleView = (t: ops.QuantizedTensor, upTo: number | null): ops.QuantizedTensor => ({
|
|
215
257
|
packed: upTo === null ? view(t.packed) : liveView(t.packed, upTo),
|
|
216
258
|
scales: upTo === null ? view(t.scales) : liveView(t.scales, upTo),
|
|
@@ -219,7 +261,24 @@ export function cloneKvCaches(caches: Cache[]): Cache[] {
|
|
|
219
261
|
const out: Cache[] = [];
|
|
220
262
|
try {
|
|
221
263
|
for (const c of caches) {
|
|
222
|
-
if (c instanceof
|
|
264
|
+
if (c instanceof Glm52Cache) {
|
|
265
|
+
if (!c.latent || !c.rope || c.batchSize === null || c.offset === 0)
|
|
266
|
+
throw new Error("cannot clone an empty GLM compressed cache");
|
|
267
|
+
const n = new MLACache({
|
|
268
|
+
kvLoraRank: c.kvLoraRank,
|
|
269
|
+
ropeHeadDim: c.ropeHeadDim,
|
|
270
|
+
...(c.dsa ? { dsa: { headDim: c.dsa.headDim } } : {}),
|
|
271
|
+
maxTokens: c.maxTokens,
|
|
272
|
+
role: c.role,
|
|
273
|
+
});
|
|
274
|
+
n.restoreCompressedState(
|
|
275
|
+
mlaView(c.latent, c.offset),
|
|
276
|
+
mlaView(c.rope, c.offset),
|
|
277
|
+
c.dsa ? mlaView(c.dsa.data!, c.offset) : null,
|
|
278
|
+
c.offset,
|
|
279
|
+
);
|
|
280
|
+
out.push(n);
|
|
281
|
+
} else if (c instanceof TurboQuantKVCache) {
|
|
223
282
|
const state = c.state();
|
|
224
283
|
if (state.length === 0) throw new Error("cannot clone an empty cache");
|
|
225
284
|
const [kIdx, kScales, kZeros, vPacked, vScales] = state;
|
|
@@ -514,6 +573,7 @@ export interface LoadedKvCache {
|
|
|
514
573
|
|
|
515
574
|
export interface KvLoadExpect {
|
|
516
575
|
/** Reject on metadata mismatch (pass what the server is running). */
|
|
576
|
+
modelId?: string;
|
|
517
577
|
configFingerprint?: string;
|
|
518
578
|
tokenizerHash?: string;
|
|
519
579
|
ns?: string;
|
|
@@ -522,6 +582,50 @@ export interface KvLoadExpect {
|
|
|
522
582
|
verify?: boolean;
|
|
523
583
|
}
|
|
524
584
|
|
|
585
|
+
/** Reject GLM cache/layout drift before opening the tensor mmap. Generic KV
|
|
586
|
+
* kinds may legitimately differ from makeCache() after runtime quantization;
|
|
587
|
+
* GLM's checkpoint-native cache geometry does not. */
|
|
588
|
+
function validateGlm52Prototype(
|
|
589
|
+
path: string,
|
|
590
|
+
entry: CacheHeaderEntry,
|
|
591
|
+
prototype: Cache,
|
|
592
|
+
): void {
|
|
593
|
+
const glmKind = entry.kind === "mla" || entry.kind === "mla-dsa" ||
|
|
594
|
+
entry.kind === "mtp-mla";
|
|
595
|
+
if (!(prototype instanceof Glm52Cache)) {
|
|
596
|
+
if (glmKind)
|
|
597
|
+
throw new Error(`${path}: GLM cache kind ${entry.kind} does not match model cache`);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
const expectedKind: CacheKind = prototype.role === "mtp"
|
|
601
|
+
? "mtp-mla"
|
|
602
|
+
: prototype.dsa ? "mla-dsa" : "mla";
|
|
603
|
+
if (entry.kind !== expectedKind) {
|
|
604
|
+
throw new Error(
|
|
605
|
+
`${path}: cache kind ${entry.kind} != model cache kind ${expectedKind}`,
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
for (const [field, actual, expected] of [
|
|
609
|
+
["kvLoraRank", entry.kvLoraRank, prototype.kvLoraRank],
|
|
610
|
+
["ropeHeadDim", entry.ropeHeadDim, prototype.ropeHeadDim],
|
|
611
|
+
["dsaHeadDim", entry.dsaHeadDim, prototype.dsa?.headDim],
|
|
612
|
+
["maxTokens", entry.maxTokens, prototype.maxTokens],
|
|
613
|
+
] as const) {
|
|
614
|
+
if (actual !== expected) {
|
|
615
|
+
throw new Error(
|
|
616
|
+
`${path}: ${field} ${String(actual)} != model ${String(expected)}`,
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
const expectedTensors = prototype.dsa ? 3 : 2;
|
|
621
|
+
if (entry.tensors.length !== expectedTensors) {
|
|
622
|
+
throw new Error(
|
|
623
|
+
`${path}: ${entry.kind} has ${entry.tensors.length} tensors, ` +
|
|
624
|
+
`expected ${expectedTensors}`,
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
525
629
|
/** Copy-restore a full-attention KV tensor into STEP-rounded capacity with
|
|
526
630
|
* ≥1 token of slack (what a live mid-generation cache looks like) so the
|
|
527
631
|
* first post-restore updateAndFetch never takes the grow path — the old
|
|
@@ -569,15 +673,28 @@ export function loadKvCache(
|
|
|
569
673
|
expect: KvLoadExpect = {},
|
|
570
674
|
): LoadedKvCache {
|
|
571
675
|
const header = readKvHeader(path);
|
|
572
|
-
for (const key of [
|
|
676
|
+
for (const key of [
|
|
677
|
+
"modelId",
|
|
678
|
+
"configFingerprint",
|
|
679
|
+
"tokenizerHash",
|
|
680
|
+
"ns",
|
|
681
|
+
] as const) {
|
|
573
682
|
if (expect[key] !== undefined && header[key] !== expect[key])
|
|
574
683
|
throw new Error(`${path}: ${key} mismatch (file ${header[key]}, expected ${expect[key]})`);
|
|
575
684
|
}
|
|
576
685
|
const proto = model.makeCache();
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
686
|
+
try {
|
|
687
|
+
if (header.caches.length !== proto.length) {
|
|
688
|
+
throw new Error(
|
|
689
|
+
`${path}: ${header.caches.length} cached layers but model has ${proto.length}`,
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
for (let index = 0; index < proto.length; index++) {
|
|
693
|
+
validateGlm52Prototype(path, header.caches[index]!, proto[index]!);
|
|
694
|
+
}
|
|
695
|
+
} finally {
|
|
696
|
+
for (const c of proto) c.dispose();
|
|
697
|
+
}
|
|
581
698
|
|
|
582
699
|
const mmap = MmapFile.open(path, "ro");
|
|
583
700
|
const dataStart = header.dataStart;
|
|
@@ -663,6 +780,28 @@ export function loadKvCache(
|
|
|
663
780
|
caches.push(c);
|
|
664
781
|
break;
|
|
665
782
|
}
|
|
783
|
+
case "mla":
|
|
784
|
+
case "mla-dsa":
|
|
785
|
+
case "mtp-mla": {
|
|
786
|
+
const hasDsa = e.kind === "mla-dsa";
|
|
787
|
+
if (e.kind === "mtp-mla" && e.dsaHeadDim !== undefined)
|
|
788
|
+
throw new Error(`${path}: native MTP cache cannot contain DSA state`);
|
|
789
|
+
const c = new MLACache({
|
|
790
|
+
kvLoraRank: e.kvLoraRank!,
|
|
791
|
+
ropeHeadDim: e.ropeHeadDim!,
|
|
792
|
+
...(hasDsa ? { dsa: { headDim: e.dsaHeadDim! } } : {}),
|
|
793
|
+
maxTokens: e.maxTokens,
|
|
794
|
+
role: e.kind === "mtp-mla" ? "mtp" : "target",
|
|
795
|
+
});
|
|
796
|
+
c.restoreCompressedState(
|
|
797
|
+
arr(t[0]!),
|
|
798
|
+
arr(t[1]!),
|
|
799
|
+
hasDsa ? arr(t[2]!) : null,
|
|
800
|
+
e.offset,
|
|
801
|
+
);
|
|
802
|
+
caches.push(c);
|
|
803
|
+
break;
|
|
804
|
+
}
|
|
666
805
|
default:
|
|
667
806
|
throw new Error(`${path}: unknown cache kind ${(e as { kind: string }).kind}`);
|
|
668
807
|
}
|
package/src/lora.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { resolve } from "node:path";
|
|
|
15
15
|
import { ptr, read } from "bun:ffi";
|
|
16
16
|
import { MlxArray, cpuStream } from "./mlx/array";
|
|
17
17
|
import { C } from "./mlx/ffi";
|
|
18
|
+
import { transposeAxes } from "./mlx/ops";
|
|
18
19
|
import { SafetensorsFile } from "./safetensors";
|
|
19
20
|
import type { LoraWeights } from "./model/gemma4";
|
|
20
21
|
import type { RuntimeModel } from "./model/factory";
|
|
@@ -80,21 +81,26 @@ export function loadAdapterTensors(file: string): Map<string, MlxArray> {
|
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
/** Adapter scale from its config: mlx-lm writes lora_parameters.scale;
|
|
83
|
-
* PEFT writes lora_alpha + r
|
|
84
|
-
*
|
|
85
|
-
*
|
|
84
|
+
* PEFT writes lora_alpha + r and optionally use_rslora. For ordinary PEFT
|
|
85
|
+
* return alpha/r as the scale. For PEFT rsLoRA return alpha and set rsLora,
|
|
86
|
+
* so the caller's one per-layer division produces alpha/√rank exactly once.
|
|
87
|
+
* Internal configs already store their pre-√rank scale directly. */
|
|
86
88
|
export async function readAdapterScale(dir: string): Promise<{ scale: number; rank: number | null; rsLora: boolean }> {
|
|
87
89
|
for (const name of ["optiq_lora_config.json", "adapter_config.json"]) {
|
|
88
90
|
const f = Bun.file(`${dir}/${name}`);
|
|
89
91
|
if (await f.exists()) {
|
|
90
92
|
const cfg = (await f.json()) as Record<string, any>;
|
|
91
93
|
const lp = cfg.lora_parameters;
|
|
92
|
-
const rsLora = Boolean(cfg.rs_lora ?? lp?.rs_lora ?? false);
|
|
94
|
+
const rsLora = Boolean(cfg.rs_lora ?? lp?.rs_lora ?? cfg.use_rslora ?? false);
|
|
93
95
|
if (lp && typeof lp === "object")
|
|
94
96
|
return { scale: Number(lp.scale ?? 20.0), rank: lp.rank ?? null, rsLora };
|
|
95
97
|
const alpha = Number(cfg.lora_alpha ?? 16);
|
|
96
98
|
const r = Number(cfg.r ?? 8);
|
|
97
|
-
return {
|
|
99
|
+
return {
|
|
100
|
+
scale: rsLora ? alpha : r ? alpha / r : 1.0,
|
|
101
|
+
rank: r || null,
|
|
102
|
+
rsLora,
|
|
103
|
+
};
|
|
98
104
|
}
|
|
99
105
|
}
|
|
100
106
|
throw new Error(`no adapter_config.json in ${dir}`);
|
|
@@ -215,7 +221,12 @@ export class AdapterManager {
|
|
|
215
221
|
|
|
216
222
|
// Group adapter tensors into (modulePath → {a, b}), probing both the
|
|
217
223
|
// pure-LLM and VLM-wrapped prefixes like the reference _find_weight_pair.
|
|
218
|
-
const pairs = new Map<string, {
|
|
224
|
+
const pairs = new Map<string, {
|
|
225
|
+
a?: MlxArray;
|
|
226
|
+
b?: MlxArray;
|
|
227
|
+
aIsPeft?: boolean;
|
|
228
|
+
bIsPeft?: boolean;
|
|
229
|
+
}>();
|
|
219
230
|
let skipped = 0;
|
|
220
231
|
const ourPrefix = `${this.#model.prefixBase}.layers.`;
|
|
221
232
|
const altPrefix = ourPrefix.startsWith("language_model.")
|
|
@@ -225,11 +236,23 @@ export class AdapterManager {
|
|
|
225
236
|
const m = name.match(/^(.*)\.(lora_a|lora_A|lora_b|lora_B)(\.weight)?$/);
|
|
226
237
|
if (!m) { skipped++; arr.dispose(); continue; }
|
|
227
238
|
let modulePath = m[1]!;
|
|
239
|
+
// Standard PEFT state dicts wrap the model path in
|
|
240
|
+
// `base_model.model.`. The remainder is the underlying HF module path
|
|
241
|
+
// (`model.layers...` or `language_model.model.layers...`).
|
|
242
|
+
if (modulePath.startsWith("base_model.model."))
|
|
243
|
+
modulePath = modulePath.slice("base_model.model.".length);
|
|
228
244
|
if (modulePath.startsWith(altPrefix))
|
|
229
245
|
modulePath = ourPrefix + modulePath.slice(altPrefix.length);
|
|
230
246
|
if (!targets.has(modulePath)) { skipped++; arr.dispose(); continue; }
|
|
231
247
|
const slot = pairs.get(modulePath) ?? {};
|
|
232
|
-
|
|
248
|
+
const peftName = m[3] === ".weight" || m[2] === "lora_A" || m[2] === "lora_B";
|
|
249
|
+
if (m[2]!.toLowerCase() === "lora_a") {
|
|
250
|
+
slot.a = arr;
|
|
251
|
+
slot.aIsPeft = peftName;
|
|
252
|
+
} else {
|
|
253
|
+
slot.b = arr;
|
|
254
|
+
slot.bIsPeft = peftName;
|
|
255
|
+
}
|
|
233
256
|
pairs.set(modulePath, slot);
|
|
234
257
|
}
|
|
235
258
|
|
|
@@ -240,21 +263,61 @@ export class AdapterManager {
|
|
|
240
263
|
for (const { a, b } of pairs.values()) { a?.dispose(); b?.dispose(); }
|
|
241
264
|
};
|
|
242
265
|
try {
|
|
243
|
-
for (const [modulePath,
|
|
266
|
+
for (const [modulePath, pair] of pairs) {
|
|
267
|
+
const { a, b } = pair;
|
|
244
268
|
if (!a || !b)
|
|
245
269
|
throw new Error(`${modulePath}: adapter has only one of lora_a/lora_b`);
|
|
246
270
|
const linear = targets.get(modulePath)!;
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
271
|
+
if (a.shape.length !== 2 || b.shape.length !== 2)
|
|
272
|
+
throw new Error(
|
|
273
|
+
`${modulePath}: LoRA tensors must be rank-2; got ` +
|
|
274
|
+
`lora_a [${a.shape}] / lora_b [${b.shape}]`,
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
const internalLayout =
|
|
278
|
+
a.shape[0] === linear.inFeatures &&
|
|
279
|
+
b.shape[0] === a.shape[1] &&
|
|
280
|
+
b.shape[1] === linear.outFeatures;
|
|
281
|
+
const peftLayout =
|
|
282
|
+
a.shape[1] === linear.inFeatures &&
|
|
283
|
+
b.shape[1] === a.shape[0] &&
|
|
284
|
+
b.shape[0] === linear.outFeatures;
|
|
285
|
+
if (!internalLayout && !peftLayout)
|
|
250
286
|
throw new Error(
|
|
251
287
|
`${modulePath}: shape mismatch — lora_a [${a.shape}] / lora_b [${b.shape}] ` +
|
|
252
|
-
`vs base [in ${linear.inFeatures}, out ${linear.outFeatures}]
|
|
253
|
-
`
|
|
288
|
+
`vs base [in ${linear.inFeatures}, out ${linear.outFeatures}]. Expected ` +
|
|
289
|
+
`mlx-lm [in, rank] + [rank, out] or PEFT [rank, in] + [out, rank].`,
|
|
254
290
|
);
|
|
291
|
+
|
|
292
|
+
// Full-rank square adapters satisfy both shape predicates. In that
|
|
293
|
+
// ambiguous case the PEFT spelling is authoritative; otherwise the
|
|
294
|
+
// unique compatible layout decides.
|
|
295
|
+
const usePeftLayout =
|
|
296
|
+
peftLayout && (!internalLayout || (pair.aIsPeft === true && pair.bIsPeft === true));
|
|
297
|
+
let mountedA = a;
|
|
298
|
+
let mountedB = b;
|
|
299
|
+
if (usePeftLayout) {
|
|
300
|
+
let transposedA: MlxArray | null = null;
|
|
301
|
+
let transposedB: MlxArray | null = null;
|
|
302
|
+
try {
|
|
303
|
+
transposedA = transposeAxes(a, [1, 0]);
|
|
304
|
+
transposedB = transposeAxes(b, [1, 0]);
|
|
305
|
+
} catch (e) {
|
|
306
|
+
transposedA?.dispose();
|
|
307
|
+
transposedB?.dispose();
|
|
308
|
+
throw e;
|
|
309
|
+
}
|
|
310
|
+
mountedA = transposedA;
|
|
311
|
+
mountedB = transposedB;
|
|
312
|
+
pair.a = mountedA;
|
|
313
|
+
pair.b = mountedB;
|
|
314
|
+
a.dispose();
|
|
315
|
+
b.dispose();
|
|
316
|
+
}
|
|
317
|
+
const rank = mountedA.shape[1]!;
|
|
255
318
|
// rsLoRA: effective per-layer scale is α/√rank (matches training).
|
|
256
319
|
const effScale = rsLora ? scale / Math.sqrt(rank) : scale;
|
|
257
|
-
validated.push({ linear, lw: { a, b, scale: effScale, rank } });
|
|
320
|
+
validated.push({ linear, lw: { a: mountedA, b: mountedB, scale: effScale, rank } });
|
|
258
321
|
}
|
|
259
322
|
if (validated.length === 0)
|
|
260
323
|
throw new Error(
|