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,58 @@
|
|
|
1
|
+
// Shared job-system contract. Both the job runner (src/jobs/*) and every
|
|
2
|
+
// job producer (src/dataset/*, src/quantize/*, src/train/*) speak this
|
|
3
|
+
// vocabulary. Mirrors the optiq lab SSE event grammar so the web wizards
|
|
4
|
+
// port over mechanically (started → log/stage/metric → done|failed).
|
|
5
|
+
|
|
6
|
+
/** A single streamed job event, serialized one-per-line into the job's
|
|
7
|
+
* NDJSON log and re-emitted to the browser as SSE `data:` frames. */
|
|
8
|
+
export type JobEvent =
|
|
9
|
+
| { type: "started"; ts: number }
|
|
10
|
+
| { type: "log"; line: string }
|
|
11
|
+
| {
|
|
12
|
+
type: "stage";
|
|
13
|
+
stage: string;
|
|
14
|
+
progress?: number; // 0..1
|
|
15
|
+
message?: string;
|
|
16
|
+
[k: string]: unknown;
|
|
17
|
+
}
|
|
18
|
+
| {
|
|
19
|
+
type: "metric";
|
|
20
|
+
kind: "train" | "val";
|
|
21
|
+
step: number;
|
|
22
|
+
loss: number;
|
|
23
|
+
[k: string]: unknown; // grad_norm, learning_rate, tokens_per_sec, accuracy, margin, progress, message
|
|
24
|
+
}
|
|
25
|
+
| { type: "done"; ts: number; output_dir?: string; summary?: unknown; [k: string]: unknown }
|
|
26
|
+
| { type: "failed"; error: string; ts: number };
|
|
27
|
+
|
|
28
|
+
/** Sink a job calls to report progress. Implementations append to the log
|
|
29
|
+
* file and (for stage/metric events carrying `progress`/`message`) update
|
|
30
|
+
* the SQLite row. Never throws — a logging failure must not kill a job. */
|
|
31
|
+
export type Emit = (e: JobEvent) => void;
|
|
32
|
+
|
|
33
|
+
/** The unit of work. Returns an optional output path recorded on the row.
|
|
34
|
+
* Receives the parsed `config` from the submit request. */
|
|
35
|
+
export type JobRunner = (
|
|
36
|
+
emit: Emit,
|
|
37
|
+
config: Record<string, unknown>,
|
|
38
|
+
) => Promise<{ outputPath?: string } | void>;
|
|
39
|
+
|
|
40
|
+
/** Job kinds in the system. */
|
|
41
|
+
export type JobKind = "quantize" | "finetune" | "dataset" | "noop" | "crash";
|
|
42
|
+
|
|
43
|
+
export type JobStatus = "queued" | "running" | "done" | "failed" | "zombie";
|
|
44
|
+
|
|
45
|
+
/** A persisted job row (shape returned by the HTTP `GET /api/jobs/:id`). */
|
|
46
|
+
export interface JobRow {
|
|
47
|
+
id: string;
|
|
48
|
+
kind: string;
|
|
49
|
+
status: JobStatus;
|
|
50
|
+
config_json: string;
|
|
51
|
+
progress: number;
|
|
52
|
+
message: string | null;
|
|
53
|
+
log_path: string;
|
|
54
|
+
output_path: string | null;
|
|
55
|
+
error: string | null;
|
|
56
|
+
started_at: string;
|
|
57
|
+
ended_at: string | null;
|
|
58
|
+
}
|
package/src/kv-store.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// KV-cache persistence: save prompt caches to disk, reload zero-copy.
|
|
2
|
+
//
|
|
3
|
+
// File layout (every tensor PAGE-ALIGNED — the Phase 1 corollary: files
|
|
4
|
+
// we write can be mmap'd and handed to the GPU without copies):
|
|
5
|
+
// [magic "MLXBUNKV1\n"][u32 LE header length][JSON header][padding]
|
|
6
|
+
// [tensor data at 16 KiB-aligned offsets]
|
|
7
|
+
// Header: { tokens, caches: [{ kind, offset, idx?, maxSize?, dtype,
|
|
8
|
+
// kShape, vShape, kOffset, vOffset }] }
|
|
9
|
+
//
|
|
10
|
+
// Reload mmaps copy-on-write (MAP_PRIVATE): if mlx ever donates one of
|
|
11
|
+
// these buffers, writes hit private pages, never the file.
|
|
12
|
+
|
|
13
|
+
import { openSync, writeSync, closeSync } from "node:fs";
|
|
14
|
+
import { MmapFile } from "./mmap";
|
|
15
|
+
import { MlxArray } from "./mlx/array";
|
|
16
|
+
import type { Dtype } from "./mlx/ffi";
|
|
17
|
+
import * as ops from "./mlx/ops";
|
|
18
|
+
import {
|
|
19
|
+
type Cache, KVCache, RotatingKVCache, Gemma4Model,
|
|
20
|
+
} from "./model/gemma4";
|
|
21
|
+
|
|
22
|
+
/** Contiguous raw bytes of (possibly a view of) an array. */
|
|
23
|
+
function contiguousBytes(a: MlxArray): Uint8Array {
|
|
24
|
+
const c = ops.contiguous(a);
|
|
25
|
+
const bytes = c.rawBytes();
|
|
26
|
+
c.dispose();
|
|
27
|
+
return bytes;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const MAGIC = "MLXBUNKV1\n";
|
|
31
|
+
const ALIGN = 16384;
|
|
32
|
+
|
|
33
|
+
interface CacheHeaderEntry {
|
|
34
|
+
kind: "kv" | "rotating";
|
|
35
|
+
offset: number;
|
|
36
|
+
idx?: number;
|
|
37
|
+
maxSize?: number;
|
|
38
|
+
dtype: number;
|
|
39
|
+
kShape: number[];
|
|
40
|
+
vShape: number[];
|
|
41
|
+
kOffset: number;
|
|
42
|
+
vOffset: number;
|
|
43
|
+
kBytes: number;
|
|
44
|
+
vBytes: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface KvFileHeader {
|
|
48
|
+
tokens: number[];
|
|
49
|
+
caches: CacheHeaderEntry[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const alignUp = (n: number) => Math.ceil(n / ALIGN) * ALIGN;
|
|
53
|
+
|
|
54
|
+
export function saveKvCache(path: string, tokens: number[], caches: Cache[]): void {
|
|
55
|
+
const entries: CacheHeaderEntry[] = [];
|
|
56
|
+
const blobs: Uint8Array[] = [];
|
|
57
|
+
|
|
58
|
+
// compact each cache to its live region and snapshot bytes
|
|
59
|
+
let dataOffset = 0; // relative; rebased after header is sized
|
|
60
|
+
for (const c of caches) {
|
|
61
|
+
let keys: MlxArray;
|
|
62
|
+
let values: MlxArray;
|
|
63
|
+
let kind: "kv" | "rotating";
|
|
64
|
+
let idx: number | undefined;
|
|
65
|
+
let maxSize: number | undefined;
|
|
66
|
+
if (c instanceof RotatingKVCache) {
|
|
67
|
+
kind = "rotating";
|
|
68
|
+
idx = c.ringIdx;
|
|
69
|
+
maxSize = c.maxSize;
|
|
70
|
+
if (!c.keys || !c.values) throw new Error("cannot persist an empty cache");
|
|
71
|
+
keys = c.keys;
|
|
72
|
+
values = c.values;
|
|
73
|
+
} else if (c instanceof KVCache) {
|
|
74
|
+
kind = "kv";
|
|
75
|
+
if (!c.keys || !c.values) throw new Error("cannot persist an empty cache");
|
|
76
|
+
// store only the live [.., :offset, :] region
|
|
77
|
+
const [B, H, , D] = c.keys.shape as [number, number, number, number];
|
|
78
|
+
const vD = c.values.shape[3]!;
|
|
79
|
+
keys = c.keys.slice([0, 0, 0, 0], [B, H, c.offset, D]);
|
|
80
|
+
values = c.values.slice([0, 0, 0, 0], [B, H, c.offset, vD]);
|
|
81
|
+
} else {
|
|
82
|
+
throw new Error("unknown cache type");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const kBytesArr = contiguousBytes(keys);
|
|
86
|
+
const vBytesArr = contiguousBytes(values);
|
|
87
|
+
const kOffset = alignUp(dataOffset);
|
|
88
|
+
const vOffset = alignUp(kOffset + kBytesArr.length);
|
|
89
|
+
dataOffset = vOffset + vBytesArr.length;
|
|
90
|
+
|
|
91
|
+
entries.push({
|
|
92
|
+
kind, offset: c.offset, idx, maxSize,
|
|
93
|
+
dtype: keys.dtype,
|
|
94
|
+
kShape: keys.shape, vShape: values.shape,
|
|
95
|
+
kOffset, vOffset,
|
|
96
|
+
kBytes: kBytesArr.length, vBytes: vBytesArr.length,
|
|
97
|
+
});
|
|
98
|
+
blobs.push(kBytesArr, vBytesArr);
|
|
99
|
+
if (c instanceof KVCache) {
|
|
100
|
+
keys.dispose();
|
|
101
|
+
values.dispose();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const header: KvFileHeader = { tokens, caches: entries };
|
|
106
|
+
const headerJson = new TextEncoder().encode(JSON.stringify(header));
|
|
107
|
+
const dataStart = alignUp(MAGIC.length + 8 + headerJson.length);
|
|
108
|
+
|
|
109
|
+
const fd = openSync(path, "w");
|
|
110
|
+
try {
|
|
111
|
+
const pre = new Uint8Array(dataStart);
|
|
112
|
+
pre.set(new TextEncoder().encode(MAGIC), 0);
|
|
113
|
+
const dv = new DataView(pre.buffer);
|
|
114
|
+
dv.setUint32(MAGIC.length, headerJson.length, true);
|
|
115
|
+
dv.setUint32(MAGIC.length + 4, dataStart, true);
|
|
116
|
+
pre.set(headerJson, MAGIC.length + 8);
|
|
117
|
+
writeSync(fd, pre, 0, pre.length, 0);
|
|
118
|
+
|
|
119
|
+
let blobIdx = 0;
|
|
120
|
+
for (const e of entries) {
|
|
121
|
+
writeSync(fd, blobs[blobIdx]!, 0, e.kBytes, dataStart + e.kOffset);
|
|
122
|
+
writeSync(fd, blobs[blobIdx + 1]!, 0, e.vBytes, dataStart + e.vOffset);
|
|
123
|
+
blobIdx += 2;
|
|
124
|
+
}
|
|
125
|
+
} finally {
|
|
126
|
+
closeSync(fd);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Read only the header (cheap — for prefix matching across many files). */
|
|
131
|
+
export function readKvHeader(path: string): KvFileHeader & { dataStart: number } {
|
|
132
|
+
const fd = openSync(path, "r");
|
|
133
|
+
try {
|
|
134
|
+
const head = new Uint8Array(MAGIC.length + 8);
|
|
135
|
+
require("node:fs").readSync(fd, head, 0, head.length, 0);
|
|
136
|
+
if (new TextDecoder().decode(head.subarray(0, MAGIC.length)) !== MAGIC)
|
|
137
|
+
throw new Error(`${path}: not an mlx-bun KV cache file`);
|
|
138
|
+
const dv = new DataView(head.buffer);
|
|
139
|
+
const len = dv.getUint32(MAGIC.length, true);
|
|
140
|
+
const dataStart = dv.getUint32(MAGIC.length + 4, true);
|
|
141
|
+
const body = new Uint8Array(len);
|
|
142
|
+
require("node:fs").readSync(fd, body, 0, len, MAGIC.length + 8);
|
|
143
|
+
const header = JSON.parse(new TextDecoder().decode(body)) as KvFileHeader;
|
|
144
|
+
return { ...header, dataStart };
|
|
145
|
+
} finally {
|
|
146
|
+
closeSync(fd);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface LoadedKvCache {
|
|
151
|
+
tokens: number[];
|
|
152
|
+
caches: Cache[];
|
|
153
|
+
/** Keep referenced as long as the caches live. */
|
|
154
|
+
mmap: MmapFile;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function loadKvCache(path: string, model: Gemma4Model): LoadedKvCache {
|
|
158
|
+
const header = readKvHeader(path);
|
|
159
|
+
const mmap = MmapFile.open(path, "cow");
|
|
160
|
+
const dataStart = header.dataStart;
|
|
161
|
+
|
|
162
|
+
const caches: Cache[] = [];
|
|
163
|
+
for (const e of header.caches) {
|
|
164
|
+
const keys = MlxArray.fromPointer(
|
|
165
|
+
mmap.pointer(dataStart + e.kOffset), e.kShape, e.dtype as Dtype,
|
|
166
|
+
);
|
|
167
|
+
const values = MlxArray.fromPointer(
|
|
168
|
+
mmap.pointer(dataStart + e.vOffset), e.vShape, e.dtype as Dtype,
|
|
169
|
+
);
|
|
170
|
+
if (e.kind === "rotating") {
|
|
171
|
+
const c = new RotatingKVCache(e.maxSize!);
|
|
172
|
+
c.restoreState(keys, values, e.offset, e.idx!);
|
|
173
|
+
caches.push(c);
|
|
174
|
+
} else {
|
|
175
|
+
const c = new KVCache();
|
|
176
|
+
c.restoreState(keys, values, e.offset);
|
|
177
|
+
caches.push(c);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (caches.length !== model.layers.length)
|
|
181
|
+
throw new Error(
|
|
182
|
+
`${path}: ${caches.length} cached layers but model has ${model.layers.length}`,
|
|
183
|
+
);
|
|
184
|
+
return { tokens: header.tokens, caches, mmap };
|
|
185
|
+
}
|
package/src/lora.ts
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// Hot-swap mounted LoRA adapters — port of optiq/adapters/{mount,
|
|
2
|
+
// registry,resolver}.py (serving side; lora/apply.py is the training-
|
|
3
|
+
// side rank logic). Mount N adapters on one quantized base, select per
|
|
4
|
+
// request by id, never reload the base.
|
|
5
|
+
//
|
|
6
|
+
// Deviations from the reference, both deliberate (PLAN Phase 8):
|
|
7
|
+
// - No ContextVar/serve-pin: our generation queue is serialized, so the
|
|
8
|
+
// active adapter is a plain field (LoraState) set by generate().
|
|
9
|
+
// - Residual composition is mlx-lm LoRALinear / optiq apply.py
|
|
10
|
+
// (`y + (scale·z).astype(x.dtype)`), not mount.py's uncast f32 add —
|
|
11
|
+
// the cast form is what the adapters were trained behind.
|
|
12
|
+
|
|
13
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
14
|
+
import { resolve } from "node:path";
|
|
15
|
+
import { ptr, read } from "bun:ffi";
|
|
16
|
+
import { MlxArray, cpuStream } from "./mlx/array";
|
|
17
|
+
import { C } from "./mlx/ffi";
|
|
18
|
+
import { SafetensorsFile } from "./safetensors";
|
|
19
|
+
import type { LoraWeights } from "./model/gemma4";
|
|
20
|
+
import type { RuntimeModel } from "./model/factory";
|
|
21
|
+
|
|
22
|
+
const cstr = (s: string) => Buffer.from(s + "\0", "utf8");
|
|
23
|
+
|
|
24
|
+
export interface AdapterInfo {
|
|
25
|
+
id: string;
|
|
26
|
+
path: string;
|
|
27
|
+
rank: number | null;
|
|
28
|
+
scale: number;
|
|
29
|
+
sizeBytes: number;
|
|
30
|
+
mountedLayers: number;
|
|
31
|
+
/** Adapter tensors for modules outside the 7 target suffixes
|
|
32
|
+
* (per-layer-input projections etc.) — skipped, like the reference. */
|
|
33
|
+
skippedTensors: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Read every tensor from an adapter safetensors file (small, f32) into
|
|
37
|
+
* materialized mlx arrays. Tensor names come from our header parser;
|
|
38
|
+
* arrays from mlx's native loader via the proven map-get pattern
|
|
39
|
+
* (weights.ts). The map and header mmap are freed before returning. */
|
|
40
|
+
function loadAdapterTensors(file: string): Map<string, MlxArray> {
|
|
41
|
+
const sf = SafetensorsFile.open(file);
|
|
42
|
+
const names = [...sf.tensors.keys()];
|
|
43
|
+
sf.mmap.unmap();
|
|
44
|
+
|
|
45
|
+
const arrMapSlot = new BigUint64Array([C.mlx_map_string_to_array_new()]);
|
|
46
|
+
const metaMapSlot = new BigUint64Array([C.mlx_map_string_to_string_new()]);
|
|
47
|
+
const arrMapPtr = ptr(arrMapSlot);
|
|
48
|
+
const metaMapPtr = ptr(metaMapSlot);
|
|
49
|
+
const status = C.mlx_load_safetensors(arrMapPtr, metaMapPtr, ptr(cstr(file)), cpuStream);
|
|
50
|
+
C.mlx_map_string_to_string_free(read.u64(metaMapPtr, 0));
|
|
51
|
+
const mapHandle = read.u64(arrMapPtr, 0);
|
|
52
|
+
if (status !== 0) {
|
|
53
|
+
C.mlx_map_string_to_array_free(mapHandle);
|
|
54
|
+
throw new Error(`mlx_load_safetensors(${file}) failed`);
|
|
55
|
+
}
|
|
56
|
+
const out = new Map<string, MlxArray>();
|
|
57
|
+
try {
|
|
58
|
+
for (const name of names) {
|
|
59
|
+
const slot = new BigUint64Array([C.mlx_array_new()]);
|
|
60
|
+
const slotPtr = ptr(slot);
|
|
61
|
+
if (C.mlx_map_string_to_array_get(slotPtr, mapHandle, ptr(cstr(name))) !== 0)
|
|
62
|
+
throw new Error(`adapter tensor ${name} missing from native map`);
|
|
63
|
+
const arr = new MlxArray(read.u64(slotPtr, 0));
|
|
64
|
+
arr.eval(); // materialize — the map (and its Load refs) is freed below
|
|
65
|
+
out.set(name, arr);
|
|
66
|
+
}
|
|
67
|
+
} catch (e) {
|
|
68
|
+
for (const a of out.values()) a.dispose();
|
|
69
|
+
C.mlx_map_string_to_array_free(mapHandle);
|
|
70
|
+
throw e;
|
|
71
|
+
}
|
|
72
|
+
C.mlx_map_string_to_array_free(mapHandle);
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Adapter scale from its config: mlx-lm writes lora_parameters.scale;
|
|
77
|
+
* PEFT writes lora_alpha + r (scale = alpha / r). Reference: mount.py. */
|
|
78
|
+
async function readAdapterScale(dir: string): Promise<{ scale: number; rank: number | null }> {
|
|
79
|
+
for (const name of ["optiq_lora_config.json", "adapter_config.json"]) {
|
|
80
|
+
const f = Bun.file(`${dir}/${name}`);
|
|
81
|
+
if (await f.exists()) {
|
|
82
|
+
const cfg = (await f.json()) as Record<string, any>;
|
|
83
|
+
const lp = cfg.lora_parameters;
|
|
84
|
+
if (lp && typeof lp === "object")
|
|
85
|
+
return { scale: Number(lp.scale ?? 20.0), rank: lp.rank ?? null };
|
|
86
|
+
const alpha = Number(cfg.lora_alpha ?? 16);
|
|
87
|
+
const r = Number(cfg.r ?? 8);
|
|
88
|
+
return { scale: r ? alpha / r : 1.0, rank: r || null };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
throw new Error(`no adapter_config.json in ${dir}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function adapterWeightsFile(dir: string): string {
|
|
95
|
+
for (const name of ["adapters.safetensors", "adapter_model.safetensors"]) {
|
|
96
|
+
const p = `${dir}/${name}`;
|
|
97
|
+
if (existsSync(p)) return p;
|
|
98
|
+
}
|
|
99
|
+
throw new Error(`no adapter weights at ${dir}/adapters.safetensors or adapter_model.safetensors`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A mountable adapter found on disk (not yet mounted). */
|
|
103
|
+
export interface AvailableAdapter {
|
|
104
|
+
id: string; // directory basename — the handle to mount it under
|
|
105
|
+
path: string; // absolute adapter dir
|
|
106
|
+
scale: number;
|
|
107
|
+
rank: number | null;
|
|
108
|
+
/** Base model repo id (org/name) the adapter was trained on, or null if the
|
|
109
|
+
* config doesn't record it. Lets the chat selector hide adapters that don't
|
|
110
|
+
* fit the served model (a MiniCPM5 adapter can't mount on Gemma). */
|
|
111
|
+
baseModel: string | null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The base-model repo id an adapter was trained on, from its config's
|
|
115
|
+
* source_model / base_model_name_or_path. These are stored as machine-local
|
|
116
|
+
* snapshot PATHS (…/models--ORG--NAME/snapshots/HASH), so recover the stable
|
|
117
|
+
* repo id (ORG/NAME) from the path; returns null if unrecorded. */
|
|
118
|
+
async function readAdapterBaseRepoId(dir: string): Promise<string | null> {
|
|
119
|
+
for (const [name, key] of [
|
|
120
|
+
["optiq_lora_config.json", "source_model"],
|
|
121
|
+
["adapter_config.json", "base_model_name_or_path"],
|
|
122
|
+
] as const) {
|
|
123
|
+
const f = Bun.file(`${dir}/${name}`);
|
|
124
|
+
if (await f.exists()) {
|
|
125
|
+
const cfg = (await f.json()) as Record<string, any>;
|
|
126
|
+
const src = cfg[key];
|
|
127
|
+
if (typeof src === "string" && src) {
|
|
128
|
+
const m = src.match(/models--([^/]+)/);
|
|
129
|
+
return m ? m[1]!.replace(/--/g, "/") : src;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Scan adapter stores for mountable adapters: directories holding an adapter
|
|
137
|
+
* weights file. id = dir basename; scale/rank/baseModel read from each adapter's
|
|
138
|
+
* config. Dirs without weights (dataset folders) and unreadable stores are skipped.
|
|
139
|
+
* Backs GET /v1/adapters/available, which populates the chat adapter selector. */
|
|
140
|
+
export async function listAvailableAdapters(stores: string[]): Promise<AvailableAdapter[]> {
|
|
141
|
+
const out: AvailableAdapter[] = [];
|
|
142
|
+
const seen = new Set<string>();
|
|
143
|
+
for (const store of stores) {
|
|
144
|
+
let entries;
|
|
145
|
+
try {
|
|
146
|
+
entries = readdirSync(store, { withFileTypes: true });
|
|
147
|
+
} catch {
|
|
148
|
+
continue; // store missing → skip
|
|
149
|
+
}
|
|
150
|
+
for (const e of entries) {
|
|
151
|
+
if (!e.isDirectory()) continue;
|
|
152
|
+
const dir = resolve(store, e.name);
|
|
153
|
+
try {
|
|
154
|
+
adapterWeightsFile(dir);
|
|
155
|
+
} catch {
|
|
156
|
+
continue; // no weights → not an adapter
|
|
157
|
+
}
|
|
158
|
+
if (seen.has(dir)) continue;
|
|
159
|
+
seen.add(dir);
|
|
160
|
+
let scale = 1;
|
|
161
|
+
let rank: number | null = null;
|
|
162
|
+
try {
|
|
163
|
+
({ scale, rank } = await readAdapterScale(dir));
|
|
164
|
+
} catch {
|
|
165
|
+
// keep defaults if the config is unreadable
|
|
166
|
+
}
|
|
167
|
+
const baseModel = await readAdapterBaseRepoId(dir).catch(() => null);
|
|
168
|
+
out.push({ id: e.name, path: dir, scale, rank, baseModel });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Parse an adapter selection spec: "sft" | "sft+dpo" | "sft,dpo"
|
|
175
|
+
* (reference mount.py stacking syntax — residuals sum in order). */
|
|
176
|
+
export function parseAdapterSpec(spec: string): string[] {
|
|
177
|
+
const sep = spec.includes("+") ? "+" : ",";
|
|
178
|
+
return spec.split(sep).map((s) => s.trim()).filter(Boolean);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export class AdapterManager {
|
|
182
|
+
readonly #model: RuntimeModel;
|
|
183
|
+
readonly #mounted = new Map<string, AdapterInfo>();
|
|
184
|
+
/** Owned adapter arrays per id (disposed on unmount). */
|
|
185
|
+
readonly #arrays = new Map<string, MlxArray[]>();
|
|
186
|
+
|
|
187
|
+
constructor(model: RuntimeModel) {
|
|
188
|
+
this.#model = model;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Mount an adapter directory under `id`. Validates layer-name and
|
|
192
|
+
* rank/shape compatibility against the base BEFORE any weights are
|
|
193
|
+
* attached — a bad adapter fails here, never at request time
|
|
194
|
+
* (reference: resolver.py + mount.py add_adapter checks). */
|
|
195
|
+
async mount(id: string, dir: string): Promise<AdapterInfo> {
|
|
196
|
+
if (this.#mounted.has(id)) return this.#mounted.get(id)!;
|
|
197
|
+
if (id.includes("+") || id.includes(","))
|
|
198
|
+
throw new Error(`adapter id ${JSON.stringify(id)} may not contain '+' or ','`);
|
|
199
|
+
const path = resolve(dir);
|
|
200
|
+
if (!existsSync(path)) throw new Error(`adapter dir not found: ${path}`);
|
|
201
|
+
|
|
202
|
+
const weightsFile = adapterWeightsFile(path);
|
|
203
|
+
const { scale, rank: configRank } = await readAdapterScale(path);
|
|
204
|
+
const tensors = loadAdapterTensors(weightsFile);
|
|
205
|
+
const targets = this.#model.loraTargets();
|
|
206
|
+
|
|
207
|
+
// Group adapter tensors into (modulePath → {a, b}), probing both the
|
|
208
|
+
// pure-LLM and VLM-wrapped prefixes like the reference _find_weight_pair.
|
|
209
|
+
const pairs = new Map<string, { a?: MlxArray; b?: MlxArray }>();
|
|
210
|
+
let skipped = 0;
|
|
211
|
+
const ourPrefix = `${this.#model.prefixBase}.layers.`;
|
|
212
|
+
const altPrefix = ourPrefix.startsWith("language_model.")
|
|
213
|
+
? ourPrefix.slice("language_model.".length)
|
|
214
|
+
: `language_model.${ourPrefix}`;
|
|
215
|
+
for (const [name, arr] of tensors) {
|
|
216
|
+
const m = name.match(/^(.*)\.(lora_a|lora_A|lora_b|lora_B)(\.weight)?$/);
|
|
217
|
+
if (!m) { skipped++; arr.dispose(); continue; }
|
|
218
|
+
let modulePath = m[1]!;
|
|
219
|
+
if (modulePath.startsWith(altPrefix))
|
|
220
|
+
modulePath = ourPrefix + modulePath.slice(altPrefix.length);
|
|
221
|
+
if (!targets.has(modulePath)) { skipped++; arr.dispose(); continue; }
|
|
222
|
+
const slot = pairs.get(modulePath) ?? {};
|
|
223
|
+
if (m[2]!.toLowerCase() === "lora_a") slot.a = arr; else slot.b = arr;
|
|
224
|
+
pairs.set(modulePath, slot);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Validate every pair against the base linear's dims before mounting
|
|
228
|
+
// anything (all-or-nothing: a bad adapter must not half-mount).
|
|
229
|
+
const validated: { linear: import("./model/gemma4").QuantizedLinear; lw: LoraWeights }[] = [];
|
|
230
|
+
const dispose = () => {
|
|
231
|
+
for (const { a, b } of pairs.values()) { a?.dispose(); b?.dispose(); }
|
|
232
|
+
};
|
|
233
|
+
try {
|
|
234
|
+
for (const [modulePath, { a, b }] of pairs) {
|
|
235
|
+
if (!a || !b)
|
|
236
|
+
throw new Error(`${modulePath}: adapter has only one of lora_a/lora_b`);
|
|
237
|
+
const linear = targets.get(modulePath)!;
|
|
238
|
+
const [aIn, rank] = a.shape as [number, number];
|
|
239
|
+
const [bRank, bOut] = b.shape as [number, number];
|
|
240
|
+
if (aIn !== linear.inFeatures || bRank !== rank || bOut !== linear.outFeatures)
|
|
241
|
+
throw new Error(
|
|
242
|
+
`${modulePath}: shape mismatch — lora_a [${a.shape}] / lora_b [${b.shape}] ` +
|
|
243
|
+
`vs base [in ${linear.inFeatures}, out ${linear.outFeatures}]; ` +
|
|
244
|
+
`was this adapter trained for a different base model?`,
|
|
245
|
+
);
|
|
246
|
+
validated.push({ linear, lw: { a, b, scale, rank } });
|
|
247
|
+
}
|
|
248
|
+
if (validated.length === 0)
|
|
249
|
+
throw new Error(
|
|
250
|
+
`failed to mount adapter ${JSON.stringify(id)}: no tensors match the ` +
|
|
251
|
+
`target modules (q/k/v/o/gate/up/down_proj). Check that the adapter ` +
|
|
252
|
+
`was trained for this base model.`,
|
|
253
|
+
);
|
|
254
|
+
} catch (e) {
|
|
255
|
+
dispose();
|
|
256
|
+
throw e;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
for (const { linear, lw } of validated) {
|
|
260
|
+
(linear.adapters ??= new Map()).set(id, lw);
|
|
261
|
+
linear.loraState = this.#model.loraState;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const info: AdapterInfo = {
|
|
265
|
+
id,
|
|
266
|
+
path,
|
|
267
|
+
rank: configRank,
|
|
268
|
+
scale,
|
|
269
|
+
sizeBytes: statSync(weightsFile).size,
|
|
270
|
+
mountedLayers: validated.length,
|
|
271
|
+
skippedTensors: skipped,
|
|
272
|
+
};
|
|
273
|
+
this.#mounted.set(id, info);
|
|
274
|
+
this.#arrays.set(id, validated.flatMap(({ lw }) => [lw.a, lw.b]));
|
|
275
|
+
return info;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Remove `id` from every linear (frees its arrays). The mount points
|
|
279
|
+
* stay in place for other adapters, like the reference. */
|
|
280
|
+
unmount(id: string): number {
|
|
281
|
+
if (!this.#mounted.delete(id)) return 0;
|
|
282
|
+
let removed = 0;
|
|
283
|
+
for (const linear of this.#model.loraTargets().values()) {
|
|
284
|
+
if (linear.adapters?.delete(id)) removed++;
|
|
285
|
+
}
|
|
286
|
+
for (const a of this.#arrays.get(id) ?? []) a.dispose();
|
|
287
|
+
this.#arrays.delete(id);
|
|
288
|
+
if (this.#model.loraState.active.includes(id))
|
|
289
|
+
this.#model.loraState.active = this.#model.loraState.active.filter((x) => x !== id);
|
|
290
|
+
return removed;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
list(): AdapterInfo[] {
|
|
294
|
+
return [...this.#mounted.values()];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
get(id: string): AdapterInfo | undefined {
|
|
298
|
+
return this.#mounted.get(id);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Resolve a request's adapter spec to validated ids ([] for none).
|
|
302
|
+
* Unknown ids are an error — selection must fail loudly, not no-op. */
|
|
303
|
+
resolveSpec(spec: string | null | undefined): string[] {
|
|
304
|
+
if (!spec || spec.toLowerCase() === "none") return [];
|
|
305
|
+
const ids = parseAdapterSpec(spec);
|
|
306
|
+
for (const id of ids)
|
|
307
|
+
if (!this.#mounted.has(id))
|
|
308
|
+
throw new Error(
|
|
309
|
+
`unknown adapter ${JSON.stringify(id)} — mounted: ` +
|
|
310
|
+
`${[...this.#mounted.keys()].join(", ") || "(none)"}`,
|
|
311
|
+
);
|
|
312
|
+
return ids;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Ambient type for `import md from "./foo.md" with { type: "text" }`.
|
|
2
|
+
// Bun reads the file as a string, but bun-types only declares .txt/.html,
|
|
3
|
+
// not .md — so we declare it here (used by src/web/skills.ts).
|
|
4
|
+
declare module "*.md" {
|
|
5
|
+
const content: string;
|
|
6
|
+
export default content;
|
|
7
|
+
}
|