mlx-bun 0.0.8 → 0.0.10
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 +112 -20
- package/docs/design/batching-perf-path.md +232 -0
- package/docs/design/batching-v2-plan.md +515 -0
- package/docs/design/bucketing-stage.md +196 -0
- package/docs/design/compat-cli-surface-design.md +11 -11
- package/docs/design/docs-reorg-plan.md +1 -1
- package/docs/design/dreaming-nightly-pipeline.md +291 -0
- package/docs/design/dspark-speculative-decoding.md +106 -0
- package/docs/design/generic-model-support.md +381 -0
- package/docs/design/memory-inference-path.md +422 -0
- package/docs/design/memory-synthesis.md +287 -0
- package/docs/design/minicpm5-decode-megakernel.md +484 -0
- package/docs/design/mlx-lm-tool-parity-plan.md +535 -0
- package/docs/design/omlx-adoption-map.md +90 -0
- package/docs/design/orpo-dynamic-lambda.md +41 -0
- package/docs/design/orpo-training.md +35 -5
- package/docs/design/parallel-slots.md +12 -1
- package/docs/design/parity-tier-dag.md +13 -5
- package/docs/design/segmented-backward-training.md +15 -0
- package/docs/design/spec-decode-larger-targets.md +229 -0
- package/docs/design/ssd-kv-cold-tier.md +158 -0
- package/docs/design/tauri-desktop-app.md +2 -2
- package/docs/design/the-dreaming-handoff.md +141 -0
- package/docs/design/the-dreaming-master-plan.md +446 -0
- package/docs/design/write-pipeline-entity.md +468 -0
- package/docs/investigations/curve-bisector-routing.md +1 -1
- package/docs/investigations/decode-roofline-lookagain.md +348 -0
- package/docs/investigations/dspark-handoff.md +58 -0
- package/docs/investigations/kernel-perf-review-2026-07.md +171 -0
- package/docs/planning/PRODUCT_ROADMAP.md +2 -2
- package/docs/planning/curve-sampler-research-plan.md +331 -0
- package/docs/planning/memory-docs-and-dag-plan.md +185 -0
- package/docs/planning/pi-integration-review.md +178 -0
- package/docs/planning/release-notes-v0.0.10.md +57 -0
- package/docs/planning/release-notes-v0.0.9.md +122 -0
- package/docs/planning/web-ui-pass-plan.md +213 -0
- package/docs/planning/website-readme-pass-plan.md +224 -0
- package/docs/reference/cli.md +255 -0
- package/docs/reference/embedding.md +7 -2
- package/docs/reference/library-api.md +22 -3
- package/docs/reference/memory.md +5 -5
- package/docs/reference/models.md +137 -0
- package/docs/reference/orpo-quickstart.md +6 -0
- package/docs/reference/server-api.md +187 -9
- package/docs/reference/server-config.md +43 -9
- package/docs/reference/training.md +31 -12
- package/package.json +1 -1
- package/src/anthropic.ts +22 -0
- package/src/assets/curve-designer.html +1 -1
- package/src/chat-template.ts +25 -7
- package/src/cli.ts +1077 -90
- package/src/config.ts +4 -1
- package/src/download.ts +99 -8
- package/src/embed.ts +77 -0
- package/src/eval/perplexity.ts +164 -0
- package/src/eval/runner.ts +15 -6
- package/src/eval/tasks/bfcl.ts +1 -1
- package/src/generate.ts +149 -18
- package/src/harness-pi.ts +43 -17
- package/src/index.ts +2 -0
- package/src/kv-store.ts +240 -98
- package/src/lora.ts +2 -2
- package/src/memory/article.ts +558 -0
- package/src/memory/chunk-validation.ts +150 -0
- package/src/memory/chunk.ts +305 -12
- package/src/memory/cluster.ts +344 -17
- package/src/memory/coverage.ts +134 -0
- package/src/memory/crosslink.ts +0 -0
- package/src/memory/db.ts +184 -45
- package/src/memory/entity.ts +203 -0
- package/src/memory/gate.ts +187 -0
- package/src/memory/history.ts +152 -0
- package/src/memory/ingest.ts +324 -11
- package/src/memory/model.ts +321 -0
- package/src/memory/normalize.ts +671 -0
- package/src/memory/parse.ts +22 -0
- package/src/memory/pipeline.ts +242 -30
- package/src/memory/prompts.ts +42 -0
- package/src/memory/query.ts +334 -0
- package/src/memory/reconcile.ts +734 -0
- package/src/memory/redirect.ts +108 -0
- package/src/memory/reindex.ts +325 -0
- package/src/memory/resolve.ts +307 -0
- package/src/memory/route.ts +272 -0
- package/src/memory/schedule.ts +6 -4
- package/src/memory/stages.ts +774 -0
- package/src/memory/synthesize.ts +1629 -20
- package/src/memory/tools.ts +446 -96
- package/src/memory/vault.ts +462 -35
- package/src/memory/wikify.ts +1057 -16
- package/src/mlx/array.ts +21 -0
- package/src/mlx/ffi.ts +3 -0
- package/src/mlx/ops.ts +72 -0
- package/src/model/compiled-decode.ts +43 -8
- package/src/model/factory.ts +29 -8
- package/src/model/flash-attention.ts +4 -2
- package/src/model/fused-decode-kernel.ts +14 -7
- package/src/model/fused-mlp-kernel.ts +169 -0
- package/src/model/fused-swiglu-kernel.ts +240 -0
- package/src/model/gemma4-base.ts +96 -7
- package/src/model/gemma4.ts +33 -2
- package/src/model/minicpm5.ts +106 -8
- package/src/model/qwen3-delta.ts +48 -1
- package/src/model/qwen3.ts +276 -0
- package/src/model/qwen3_5.ts +11 -2
- package/src/model/steel-linear-kernel.ts +96 -0
- package/src/model/support.ts +38 -8
- package/src/model/universal/archs.ts +332 -0
- package/src/model/universal/dense.ts +576 -0
- package/src/model/universal/modules.ts +163 -0
- package/src/model/universal/rope.ts +366 -0
- package/src/pi-provider.ts +1 -1
- package/src/pi-terminal.ts +29 -8
- package/src/pi-web.ts +19 -2
- package/src/prompt-cache.ts +55 -9
- package/src/quantize/config-writer.ts +1 -1
- package/src/registry.ts +219 -8
- package/src/responses.ts +23 -0
- package/src/sampler.ts +220 -9
- package/src/serve/batch-scheduler.ts +307 -83
- package/src/serve/generation-gateway.ts +156 -29
- package/src/server.ts +746 -73
- package/src/spec/drafter.ts +81 -49
- package/src/spec/dspark/data-dflash.ts +156 -0
- package/src/spec/dspark/data.ts +201 -0
- package/src/spec/dspark/generate-dflash.ts +193 -0
- package/src/spec/dspark/generate.ts +290 -0
- package/src/spec/dspark/loss.ts +213 -0
- package/src/spec/dspark/module-dflash.ts +361 -0
- package/src/spec/dspark/module.ts +540 -0
- package/src/spec/dspark/sample.ts +112 -0
- package/src/spec/generate.ts +40 -19
- package/src/ssd-cache.ts +213 -0
- package/src/status-page.html +1 -1
- package/src/tokenizer.ts +7 -0
- package/src/train/flash-cce.ts +81 -23
- package/src/train/fuse.ts +243 -0
- package/src/train/index.ts +3 -0
- package/src/train/job.ts +8 -0
- package/src/train/loss.ts +341 -40
- package/src/train/prefix-shared.ts +62 -11
- package/src/train/segmented.ts +215 -55
- package/src/train/trainer.ts +112 -38
- package/src/web/skills/memory/SKILL.md +40 -17
package/src/config.ts
CHANGED
|
@@ -186,6 +186,9 @@ export async function loadModelConfig(modelDir: string): Promise<ModelConfig> {
|
|
|
186
186
|
const t = isDiffusion ? { ...diffusionGemmaRawDefaults(), ...baseT } : baseT;
|
|
187
187
|
const isLlama = modelType === "llama";
|
|
188
188
|
const isQwen35 = typeof modelType === "string" && modelType.startsWith("qwen3_5");
|
|
189
|
+
// Plain Qwen3 (Qwen3ForCausalLM, e.g. Qwen3-Embedding): a flat HF config with
|
|
190
|
+
// a scalar rope_theta and no rope_parameters map — handled like llama below.
|
|
191
|
+
const isQwen3 = modelType === "qwen3";
|
|
189
192
|
// Qwen3.5 rope_parameters is a flat dict ({type, rope_theta, mrope_section,
|
|
190
193
|
// partial_rotary_factor}), not the gemma per-attention-type map — and
|
|
191
194
|
// type "default" means plain partial nn.RoPE (mrope_section ignored for text).
|
|
@@ -234,7 +237,7 @@ export async function loadModelConfig(modelDir: string): Promise<ModelConfig> {
|
|
|
234
237
|
}
|
|
235
238
|
: t.rope_parameters
|
|
236
239
|
? parseRope(t.rope_parameters)
|
|
237
|
-
: isLlama
|
|
240
|
+
: isLlama || isQwen3
|
|
238
241
|
? {
|
|
239
242
|
full_attention: {
|
|
240
243
|
ropeTheta: t.rope_theta ?? 10000,
|
package/src/download.ts
CHANGED
|
@@ -15,7 +15,10 @@
|
|
|
15
15
|
// Files download sequentially (decode is bandwidth-bound, the network
|
|
16
16
|
// rarely is; resumability matters more than parallelism here).
|
|
17
17
|
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
mkdirSync, existsSync, statSync, createWriteStream, renameSync, rmSync,
|
|
20
|
+
symlinkSync, readFileSync, openSync, closeSync, readSync, writeSync,
|
|
21
|
+
} from "node:fs";
|
|
19
22
|
import { dirname, join } from "node:path";
|
|
20
23
|
import { DEFAULT_HUB } from "./registry";
|
|
21
24
|
|
|
@@ -54,6 +57,63 @@ export function hfToken(): string | null {
|
|
|
54
57
|
}
|
|
55
58
|
}
|
|
56
59
|
|
|
60
|
+
/** 401/403 almost always means a gated/private repo, not a broken network —
|
|
61
|
+
* say so instead of leaving the user staring at a bare status code. */
|
|
62
|
+
function authHint(status: number): string {
|
|
63
|
+
return status === 401 || status === 403
|
|
64
|
+
? " — gated repo? run `hf auth login` or set HF_TOKEN"
|
|
65
|
+
: "";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// --- download lockfile -----------------------------------------------------
|
|
69
|
+
// Two writers on one blob (a foreground `mlx-bun get` racing the server's
|
|
70
|
+
// background auto-download) both append to the same .incomplete file and
|
|
71
|
+
// corrupt it. An O_EXCL .lock beside the .incomplete serializes them; a lock
|
|
72
|
+
// older than LOCK_STALE_MS (crashed owner) or whose pid is gone is stolen.
|
|
73
|
+
|
|
74
|
+
const LOCK_STALE_MS = 60 * 60 * 1000; // ~1 h: longer than any sane blob fetch
|
|
75
|
+
|
|
76
|
+
function lockIsStale(lockPath: string): boolean {
|
|
77
|
+
try {
|
|
78
|
+
const st = statSync(lockPath);
|
|
79
|
+
if (Date.now() - st.mtimeMs > LOCK_STALE_MS) return true;
|
|
80
|
+
const pid = Number(readFileSync(lockPath, "utf8").split("\n")[0]);
|
|
81
|
+
if (Number.isFinite(pid) && pid > 0) {
|
|
82
|
+
try { process.kill(pid, 0); } catch { return true; } // owner is gone
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
} catch {
|
|
86
|
+
return true; // unreadable/vanished — treat as stale, O_EXCL still arbitrates
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Take `<blobPath>.lock` (O_EXCL). Returns a release fn, or throws the
|
|
91
|
+
* friendly "another download in progress" error when live-held. */
|
|
92
|
+
function acquireLock(blobPath: string, name: string): () => void {
|
|
93
|
+
const lockPath = `${blobPath}.lock`;
|
|
94
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
95
|
+
try {
|
|
96
|
+
const fd = openSync(lockPath, "wx");
|
|
97
|
+
writeSync(fd, `${process.pid}\n${new Date().toISOString()}\n`);
|
|
98
|
+
closeSync(fd);
|
|
99
|
+
return () => { try { rmSync(lockPath, { force: true }); } catch {} };
|
|
100
|
+
} catch (e) {
|
|
101
|
+
if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
|
|
102
|
+
if (lockIsStale(lockPath)) {
|
|
103
|
+
try { rmSync(lockPath, { force: true }); } catch {}
|
|
104
|
+
continue; // one retry after stealing
|
|
105
|
+
}
|
|
106
|
+
let holder = "";
|
|
107
|
+
try { holder = ` (pid ${readFileSync(lockPath, "utf8").split("\n")[0]})`; } catch {}
|
|
108
|
+
throw new Error(
|
|
109
|
+
`another download of ${name} is in progress${holder} — ` +
|
|
110
|
+
`wait for it to finish, or delete ${lockPath} if you're sure it isn't`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
throw new Error(`could not acquire ${lockPath}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
57
117
|
/** git blob identity: sha1("blob <size>\0" + content) — what blobId is. */
|
|
58
118
|
export function gitBlobSha1(bytes: Uint8Array): string {
|
|
59
119
|
const hasher = new Bun.CryptoHasher("sha1");
|
|
@@ -73,7 +133,10 @@ export async function listRepoFiles(
|
|
|
73
133
|
headers: token ? { authorization: `Bearer ${token}` } : {},
|
|
74
134
|
});
|
|
75
135
|
if (!res.ok)
|
|
76
|
-
throw new Error(
|
|
136
|
+
throw new Error(
|
|
137
|
+
`HF API ${res.status} for ${repoId}@${revision}: ${(await res.text()).slice(0, 200)}` +
|
|
138
|
+
authHint(res.status),
|
|
139
|
+
);
|
|
77
140
|
const body = (await res.json()) as {
|
|
78
141
|
sha: string;
|
|
79
142
|
siblings: { rfilename: string; size?: number; blobId?: string; lfs?: { oid: string; size: number } }[];
|
|
@@ -115,6 +178,21 @@ export async function downloadOne(
|
|
|
115
178
|
url: string, token: string | null, blobPath: string,
|
|
116
179
|
expected: { size: number; sha256?: string; sha1?: string; name: string },
|
|
117
180
|
onProgress?: DownloadOptions["onProgress"],
|
|
181
|
+
): Promise<void> {
|
|
182
|
+
// Serialize concurrent writers (foreground get vs background auto-download)
|
|
183
|
+
// on this blob — both appending to one .incomplete corrupts it.
|
|
184
|
+
const release = acquireLock(blobPath, expected.name);
|
|
185
|
+
try {
|
|
186
|
+
await downloadOneLocked(url, token, blobPath, expected, onProgress);
|
|
187
|
+
} finally {
|
|
188
|
+
release();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function downloadOneLocked(
|
|
193
|
+
url: string, token: string | null, blobPath: string,
|
|
194
|
+
expected: { size: number; sha256?: string; sha1?: string; name: string },
|
|
195
|
+
onProgress?: DownloadOptions["onProgress"],
|
|
118
196
|
): Promise<void> {
|
|
119
197
|
const partPath = `${blobPath}.incomplete`;
|
|
120
198
|
let offset = existsSync(partPath) ? statSync(partPath).size : 0;
|
|
@@ -124,14 +202,27 @@ export async function downloadOne(
|
|
|
124
202
|
}
|
|
125
203
|
|
|
126
204
|
// Streaming verification — resume re-hashes the existing prefix so the
|
|
127
|
-
// final digest always covers every byte on disk.
|
|
205
|
+
// final digest always covers every byte on disk. Chunked reads: a resumed
|
|
206
|
+
// multi-GB blob must not be materialized as one allocation.
|
|
128
207
|
const sha256 = new Bun.CryptoHasher("sha256");
|
|
129
208
|
const sha1 = new Bun.CryptoHasher("sha1");
|
|
130
209
|
sha1.update(`blob ${expected.size}\0`);
|
|
131
210
|
if (offset > 0) {
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
211
|
+
const fd = openSync(partPath, "r");
|
|
212
|
+
try {
|
|
213
|
+
const buf = Buffer.alloc(8 * 2 ** 20);
|
|
214
|
+
let pos = 0;
|
|
215
|
+
while (pos < offset) {
|
|
216
|
+
const n = readSync(fd, buf, 0, Math.min(buf.length, offset - pos), pos);
|
|
217
|
+
if (n <= 0) break;
|
|
218
|
+
const chunk = buf.subarray(0, n);
|
|
219
|
+
sha256.update(chunk);
|
|
220
|
+
sha1.update(chunk);
|
|
221
|
+
pos += n;
|
|
222
|
+
}
|
|
223
|
+
} finally {
|
|
224
|
+
closeSync(fd);
|
|
225
|
+
}
|
|
135
226
|
}
|
|
136
227
|
|
|
137
228
|
if (offset < expected.size) {
|
|
@@ -139,10 +230,10 @@ export async function downloadOne(
|
|
|
139
230
|
if (res.status === 200 && offset > 0) {
|
|
140
231
|
// server ignored Range — start the file (and hashes) over
|
|
141
232
|
rmSync(partPath);
|
|
142
|
-
return
|
|
233
|
+
return downloadOneLocked(url, token, blobPath, expected, onProgress);
|
|
143
234
|
}
|
|
144
235
|
if (res.status !== 200 && res.status !== 206)
|
|
145
|
-
throw new Error(`download failed (${res.status}) for ${expected.name}`);
|
|
236
|
+
throw new Error(`download failed (${res.status}) for ${expected.name}${authHint(res.status)}`);
|
|
146
237
|
const out = createWriteStream(partPath, { flags: offset > 0 ? "a" : "w" });
|
|
147
238
|
try {
|
|
148
239
|
for await (const chunk of res.body as ReadableStream<Uint8Array>) {
|
package/src/embed.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Text-embedding entry point. Wraps Qwen3Model.embedPooled (last-token hidden →
|
|
2
|
+
// L2-normalized vector) with the Qwen3-Embedding input convention so the CLI
|
|
3
|
+
// (`mlx-bun embed`), the server (`/v1/embeddings`), and in-repo experiments all
|
|
4
|
+
// produce the SAME vectors — the ones verified bit-exact vs mlx-lm in
|
|
5
|
+
// tests/qwen3-embed-parity.test.ts.
|
|
6
|
+
|
|
7
|
+
import { Qwen3Model } from "./model/qwen3";
|
|
8
|
+
import type { RuntimeModel } from "./model/factory";
|
|
9
|
+
import type { LoadedTokenizer } from "./tokenizer";
|
|
10
|
+
import * as ops from "./mlx/ops";
|
|
11
|
+
|
|
12
|
+
/** <|endoftext|> — the token Qwen3-Embedding terminates every input with and
|
|
13
|
+
* pools the last position of. */
|
|
14
|
+
export const EMBED_EOD = 151643;
|
|
15
|
+
|
|
16
|
+
/** Only plain-Qwen3 (the Qwen3-Embedding backbone) exposes a pooled-embedding
|
|
17
|
+
* path today. Narrow the served RuntimeModel before embedding. */
|
|
18
|
+
export function isEmbeddingModel(model: RuntimeModel): model is Qwen3Model {
|
|
19
|
+
return model instanceof Qwen3Model;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Qwen3-Embedding query format: a task instruction steers WHICH similarity axis
|
|
23
|
+
* the geometry reflects ("represent this for topic routing" vs "...for
|
|
24
|
+
* sentiment"). Documents are embedded raw; only queries get an instruction. */
|
|
25
|
+
export function withInstruction(text: string, instruction?: string): string {
|
|
26
|
+
return instruction ? `Instruct: ${instruction}\nQuery:${text}` : text;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Tripwire: counts embedOne invocations (embedMany accrues via embedOne) so
|
|
30
|
+
* callers can assert the pipeline embeds exactly as many times as expected. */
|
|
31
|
+
let embedCallCount = 0;
|
|
32
|
+
|
|
33
|
+
/** Reset the embed tripwire counter to 0. */
|
|
34
|
+
export function resetEmbedCounter(): void {
|
|
35
|
+
embedCallCount = 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Read the embed tripwire counter (total embedOne calls since last reset). */
|
|
39
|
+
export function getEmbedCounter(): number {
|
|
40
|
+
return embedCallCount;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface EmbedResult {
|
|
44
|
+
/** L2-normalized embedding (length = model hidden size). */
|
|
45
|
+
vector: Float32Array;
|
|
46
|
+
/** Token count of the embedded input (incl. the EOD pooling token). */
|
|
47
|
+
tokens: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Embed one text. `instruction` (optional) applies the query format. */
|
|
51
|
+
export function embedOne(
|
|
52
|
+
model: Qwen3Model,
|
|
53
|
+
tok: LoadedTokenizer,
|
|
54
|
+
text: string,
|
|
55
|
+
instruction?: string,
|
|
56
|
+
): EmbedResult {
|
|
57
|
+
embedCallCount++;
|
|
58
|
+
// addSpecialTokens=false matches the bit-exact parity path (Qwen3 has no BOS;
|
|
59
|
+
// we append the EOD pooling token ourselves).
|
|
60
|
+
const ids = [...tok.encode(withInstruction(text, instruction), false), EMBED_EOD];
|
|
61
|
+
const idArr = ops.fromInt32(ids, [1, ids.length]);
|
|
62
|
+
const vec = model.embedPooled(idArr);
|
|
63
|
+
idArr.dispose();
|
|
64
|
+
const out = vec.toFloat32();
|
|
65
|
+
vec.dispose();
|
|
66
|
+
return { vector: out, tokens: ids.length };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Embed many texts (one forward each — the runtime is single-sequence). */
|
|
70
|
+
export function embedMany(
|
|
71
|
+
model: Qwen3Model,
|
|
72
|
+
tok: LoadedTokenizer,
|
|
73
|
+
texts: string[],
|
|
74
|
+
instruction?: string,
|
|
75
|
+
): EmbedResult[] {
|
|
76
|
+
return texts.map((t) => embedOne(model, tok, t, instruction));
|
|
77
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Perplexity over a local text/JSONL dataset — mlx_lm.perplexity methodology.
|
|
2
|
+
//
|
|
3
|
+
// The reference (mlx_lm/perplexity.py, mlx-lm 0.31.3) does:
|
|
4
|
+
// 1. tokenize dataset samples, visit them in a seeded random permutation,
|
|
5
|
+
// 2. concatenate tokens into one stream and cut it into NON-OVERLAPPING
|
|
6
|
+
// rows of `sequence_length` (no sliding-window stride, no BOS re-seeding
|
|
7
|
+
// per row), keeping at most `num_samples` rows,
|
|
8
|
+
// 3. per batch: logits = model(batch[:, :-1]) in f32, per-token
|
|
9
|
+
// cross-entropy vs batch[:, 1:] — EVERY position counts,
|
|
10
|
+
// 4. ppl = exp(mean loss); SE via the delta method:
|
|
11
|
+
// se_ppl = ppl · std(losses, ddof=1) / √N.
|
|
12
|
+
//
|
|
13
|
+
// We reproduce 2-4 exactly. Step 1 differs only in the data source: the
|
|
14
|
+
// reference pulls a Hugging Face dataset (network); we take a LOCAL .txt or
|
|
15
|
+
// .jsonl file — never a download. The permutation RNG is a seeded
|
|
16
|
+
// deterministic shuffle (not NumPy's), so row selection is reproducible per
|
|
17
|
+
// seed but not bit-matched to the Python tool's sampling.
|
|
18
|
+
//
|
|
19
|
+
// The forward reuses the existing full-sequence machinery
|
|
20
|
+
// (src/train/forward.ts trainForward → model.forwardHidden + logitsFromHidden).
|
|
21
|
+
|
|
22
|
+
import { clearCache, Dtype } from "../mlx/ffi";
|
|
23
|
+
import * as ops from "../mlx/ops";
|
|
24
|
+
import { trainForward } from "../train/forward";
|
|
25
|
+
import type { RuntimeModel } from "../model/factory";
|
|
26
|
+
|
|
27
|
+
export interface PplDataOptions {
|
|
28
|
+
sequenceLength: number;
|
|
29
|
+
/** Max rows to keep (-1 / 0 = all available). */
|
|
30
|
+
numSamples: number;
|
|
31
|
+
/** Seed for the sample-order shuffle (reference default: 123). */
|
|
32
|
+
seed: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface PplResult {
|
|
36
|
+
ppl: number;
|
|
37
|
+
/** Delta-method standard error of the perplexity. */
|
|
38
|
+
standardError: number;
|
|
39
|
+
meanLoss: number;
|
|
40
|
+
tokens: number;
|
|
41
|
+
rows: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Deterministic PRNG (mulberry32) for the seeded sample shuffle. */
|
|
45
|
+
function mulberry32(seed: number): () => number {
|
|
46
|
+
let s = seed >>> 0;
|
|
47
|
+
return () => {
|
|
48
|
+
s = (s + 0x6d2b79f5) >>> 0;
|
|
49
|
+
let t = s;
|
|
50
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
51
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
52
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Split a raw dataset file into text samples: `.jsonl` → one sample per
|
|
57
|
+
* line from the `text` field; anything else → the whole file is one sample. */
|
|
58
|
+
export function parseSamples(raw: string, path: string): string[] {
|
|
59
|
+
if (path.endsWith(".jsonl") || path.endsWith(".ndjson")) {
|
|
60
|
+
const samples: string[] = [];
|
|
61
|
+
for (const [i, line] of raw.split("\n").entries()) {
|
|
62
|
+
if (!line.trim()) continue;
|
|
63
|
+
let row: unknown;
|
|
64
|
+
try {
|
|
65
|
+
row = JSON.parse(line);
|
|
66
|
+
} catch {
|
|
67
|
+
throw new Error(`${path}:${i + 1}: not valid JSON`);
|
|
68
|
+
}
|
|
69
|
+
const text = (row as { text?: unknown }).text;
|
|
70
|
+
if (typeof text !== "string")
|
|
71
|
+
throw new Error(`${path}:${i + 1}: expected a {"text": "..."} row`);
|
|
72
|
+
samples.push(text);
|
|
73
|
+
}
|
|
74
|
+
return samples;
|
|
75
|
+
}
|
|
76
|
+
return raw.length > 0 ? [raw] : [];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Tokenize samples (seeded shuffle), concatenate, and pack into
|
|
80
|
+
* non-overlapping rows of `sequenceLength` tokens (reference load_data). */
|
|
81
|
+
export function packRows(
|
|
82
|
+
sampleTokens: number[][],
|
|
83
|
+
opts: PplDataOptions,
|
|
84
|
+
): Int32Array[] {
|
|
85
|
+
const { sequenceLength: L, numSamples, seed } = opts;
|
|
86
|
+
const order = sampleTokens.map((_, i) => i);
|
|
87
|
+
const rnd = mulberry32(seed);
|
|
88
|
+
for (let i = order.length - 1; i > 0; i--) {
|
|
89
|
+
const j = Math.floor(rnd() * (i + 1));
|
|
90
|
+
[order[i], order[j]] = [order[j]!, order[i]!];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const wanted = numSamples > 0 ? L * numSamples : Infinity;
|
|
94
|
+
const stream: number[] = [];
|
|
95
|
+
for (const idx of order) {
|
|
96
|
+
if (stream.length >= wanted) break;
|
|
97
|
+
stream.push(...sampleTokens[idx]!);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const nRows = Math.min(
|
|
101
|
+
Math.floor(stream.length / L),
|
|
102
|
+
numSamples > 0 ? numSamples : Infinity,
|
|
103
|
+
);
|
|
104
|
+
const rows: Int32Array[] = [];
|
|
105
|
+
for (let r = 0; r < nRows; r++)
|
|
106
|
+
rows.push(Int32Array.from(stream.slice(r * L, (r + 1) * L)));
|
|
107
|
+
return rows;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Evaluate perplexity over packed rows. Per batch (all rows are the same
|
|
111
|
+
* length, so the stock unpadded forward applies): f32 logits over
|
|
112
|
+
* batch[:, :-1], per-token CE vs batch[:, 1:], every position counted. */
|
|
113
|
+
export function evalPpl(
|
|
114
|
+
model: RuntimeModel,
|
|
115
|
+
rows: Int32Array[],
|
|
116
|
+
batchSize: number,
|
|
117
|
+
onBatch?: (done: number, total: number) => void,
|
|
118
|
+
): PplResult {
|
|
119
|
+
const L = rows[0]!.length;
|
|
120
|
+
const allLosses: number[] = [];
|
|
121
|
+
const numBatches = Math.ceil(rows.length / batchSize);
|
|
122
|
+
|
|
123
|
+
for (let s = 0, bi = 0; s < rows.length; s += batchSize, bi++) {
|
|
124
|
+
const batch = rows.slice(s, s + batchSize);
|
|
125
|
+
const B = batch.length;
|
|
126
|
+
const inputs: number[] = [];
|
|
127
|
+
const targets: number[] = [];
|
|
128
|
+
for (const row of batch) {
|
|
129
|
+
for (let i = 0; i < L - 1; i++) inputs.push(row[i]!);
|
|
130
|
+
for (let i = 1; i < L; i++) targets.push(row[i]!);
|
|
131
|
+
}
|
|
132
|
+
const ids = ops.fromInt32(inputs, [B, L - 1]);
|
|
133
|
+
const tgt = ops.fromInt32(targets, [B, L - 1, 1]);
|
|
134
|
+
const logits = trainForward(model, ids);
|
|
135
|
+
const f32 = logits.astype(Dtype.float32);
|
|
136
|
+
logits.dispose();
|
|
137
|
+
// CE[t] = logsumexp(logits[t]) − logits[t, target] (f32, like nn.losses)
|
|
138
|
+
const lse = ops.logsumexpAxis(f32, 2, false); // [B, L-1]
|
|
139
|
+
const picked = ops.takeAlongAxis(f32, tgt, 2); // [B, L-1, 1]
|
|
140
|
+
f32.dispose();
|
|
141
|
+
const picked2d = ops.reshape(picked, [B, L - 1]);
|
|
142
|
+
picked.dispose();
|
|
143
|
+
const losses = ops.sub(lse, picked2d);
|
|
144
|
+
lse.dispose();
|
|
145
|
+
picked2d.dispose();
|
|
146
|
+
const vals = losses.toFloat32();
|
|
147
|
+
losses.dispose();
|
|
148
|
+
ids.dispose();
|
|
149
|
+
tgt.dispose();
|
|
150
|
+
for (const v of vals) allLosses.push(v);
|
|
151
|
+
clearCache();
|
|
152
|
+
onBatch?.(bi + 1, numBatches);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const n = allLosses.length;
|
|
156
|
+
const mean = allLosses.reduce((a, b) => a + b, 0) / n;
|
|
157
|
+
const ppl = Math.exp(mean);
|
|
158
|
+
// Sample std (ddof=1) → delta-method SE of the perplexity.
|
|
159
|
+
const varSum = allLosses.reduce((a, b) => a + (b - mean) ** 2, 0);
|
|
160
|
+
const std = n > 1 ? Math.sqrt(varSum / (n - 1)) : 0;
|
|
161
|
+
const standardError = ppl * (std / Math.sqrt(n));
|
|
162
|
+
|
|
163
|
+
return { ppl, standardError, meanLoss: mean, tokens: n, rows: rows.length };
|
|
164
|
+
}
|
package/src/eval/runner.ts
CHANGED
|
@@ -99,6 +99,11 @@ export interface GenOpts {
|
|
|
99
99
|
* (its capability numbers are non-thinking; e.g. MiniCPM5 IFEval 64.7 is
|
|
100
100
|
* non-thinking). Set MLX_BUN_EVAL_THINK=1 or pass true to evaluate thinking mode. */
|
|
101
101
|
enableThinking?: boolean;
|
|
102
|
+
/** Explicit KV-quant scheme (cli `generate --l2/--l3/--kv-quant`, resolved with
|
|
103
|
+
* serve's semantics). Overrides the MLX_BUN_EVAL_KV_QUANT env default; an empty
|
|
104
|
+
* object forces bf16. When it names quantized KV, generation runs the product
|
|
105
|
+
* generate() path (same tokens as `serve` with the same scheme). */
|
|
106
|
+
kvScheme?: Pick<GenerateOptions, "kvBits" | "kvConfig" | "quantizedKvStart">;
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
/** Bit-exact greedy decode: raw `model.forward` + argmax loop, which matches mlx-lm
|
|
@@ -106,7 +111,7 @@ export interface GenOpts {
|
|
|
106
111
|
* (forwardHidden/logitsFromHidden + pipelined sampler) diverges from the raw forward
|
|
107
112
|
* on near-ties past ~32 tokens, so the EVAL decodes bit-exactly itself rather than
|
|
108
113
|
* alter the serving path. Greedy + full-precision KV only. */
|
|
109
|
-
function greedyDecodeBitExact(tm: TaskModel, ids: number[], maxTokens: number): string {
|
|
114
|
+
export function greedyDecodeBitExact(tm: TaskModel, ids: number[], maxTokens: number): string {
|
|
110
115
|
if (tm.activeAdapters) tm.model.loraState.active = tm.activeAdapters; // apply the mounted adapter
|
|
111
116
|
const cache = tm.model.makeCache();
|
|
112
117
|
try {
|
|
@@ -148,23 +153,27 @@ export async function generateText(tm: TaskModel, body: string, opts: GenOpts =
|
|
|
148
153
|
// plain mlx_lm.generate (kv-quant lives in the serving runtime, not the eval). So
|
|
149
154
|
// for like-for-like parity we default the eval to UNQUANTIZED KV; MMLU's argmax
|
|
150
155
|
// path was already unquantized, which is why it matched while generation didn't.
|
|
151
|
-
// MLX_BUN_EVAL_KV_QUANT=1 generates through the model's quantized KV (serving
|
|
152
|
-
|
|
153
|
-
|
|
156
|
+
// MLX_BUN_EVAL_KV_QUANT=1 generates through the model's quantized KV (serving
|
|
157
|
+
// config); an explicit opts.kvScheme (cli generate's resolved tier route) wins.
|
|
158
|
+
const envKv: GenOpts["kvScheme"] | undefined =
|
|
159
|
+
(process.env.MLX_BUN_EVAL_KV_QUANT === "1" && tm.config.kvQuant?.length)
|
|
160
|
+
? { kvConfig: tm.config.kvQuant, quantizedKvStart: 0 } : undefined;
|
|
161
|
+
const kvScheme = opts.kvScheme ?? envKv;
|
|
162
|
+
const kvActive = !!(kvScheme && (kvScheme.kvBits || kvScheme.kvConfig?.length));
|
|
154
163
|
|
|
155
164
|
// Parity default — greedy + full-precision KV + no sampler arm: decode bit-exactly
|
|
156
165
|
// via the raw forward (matches mlx-lm token-for-token). Sampler arms / kv-quant fall
|
|
157
166
|
// through to the product generate(). DiffusionGemma is non-autoregressive (no AR
|
|
158
167
|
// forward) — always go through generate(), which routes it to the denoising engine.
|
|
159
168
|
const isDiffusion = tm.model instanceof DiffusionGemmaModel;
|
|
160
|
-
if (!isDiffusion && !
|
|
169
|
+
if (!isDiffusion && !kvActive && !opts.sampler && !tm.samplerOverride) {
|
|
161
170
|
return greedyDecodeBitExact(tm, ids, maxTokens);
|
|
162
171
|
}
|
|
163
172
|
const gen = generate(tm.model, ids, {
|
|
164
173
|
maxTokens,
|
|
165
174
|
temperature: 0, // greedy default — deterministic head-to-head arms
|
|
166
175
|
adapters: tm.activeAdapters ?? [], // per-request activation of the mounted eval-adapter
|
|
167
|
-
...(
|
|
176
|
+
...(kvActive ? kvScheme : {}),
|
|
168
177
|
...(opts.sampler ?? {}), // overrides temperature when provided
|
|
169
178
|
...(tm.samplerOverride ?? {}), // arm override wins over the task's own sampler
|
|
170
179
|
});
|
package/src/eval/tasks/bfcl.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// of the task — is ported exactly. (See summary for this one note.)
|
|
16
16
|
|
|
17
17
|
import { generateText, loadJsonl, sampleIndices, type TaskModel } from "../runner";
|
|
18
|
-
import { type ToolDefinition } from "
|
|
18
|
+
import { type ToolDefinition } from "../../chat-template";
|
|
19
19
|
|
|
20
20
|
// ---------------------------------------------------------------------------
|
|
21
21
|
// Data shapes (the merged jsonl: each line = {query, answer}).
|