mlx-bun 0.0.5 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -2
- package/docs/dag/training-inference-map.html +575 -0
- package/docs/design/memory-system.md +193 -295
- package/docs/design/orpo-dynamic-lambda.md +179 -0
- package/docs/design/orpo-future-enhancements.md +42 -0
- package/docs/design/orpo-training.md +13 -0
- package/docs/design/parity-tier-dag.md +143 -0
- package/docs/design/w4a16-compute-precision-spike.md +232 -0
- package/docs/investigations/orpo-base-uf-experiment-and-directions.md +120 -0
- package/docs/investigations/orpo-flash-cce-pin-leak.md +167 -0
- package/docs/investigations/orpo-uf-testing-handoff.md +126 -0
- package/docs/investigations/steel-flash-cce-handoff.md +58 -0
- package/docs/investigations/trainer-validation-experiment.md +173 -0
- package/docs/reference/memory.md +77 -0
- package/docs/reference/orpo-quickstart.md +44 -5
- package/docs/reference/training.md +9 -5
- package/package.json +1 -1
- package/src/cli.ts +651 -18
- package/src/eval/runner.ts +76 -6
- package/src/eval/tasks/bfcl.ts +49 -15
- package/src/eval/tasks/gsm8k.ts +21 -9
- package/src/eval/tasks/hashhop.ts +33 -4
- package/src/eval/tasks/humaneval.ts +30 -8
- package/src/eval/tasks/ifeval.ts +52 -18
- package/src/eval/tasks/mmlu.ts +85 -74
- package/src/jobs/runner.ts +6 -0
- package/src/memory/chunk.ts +25 -0
- package/src/memory/cluster.ts +24 -0
- package/src/memory/db.ts +114 -0
- package/src/memory/ingest.ts +22 -0
- package/src/memory/pipeline.ts +63 -0
- package/src/memory/prompts.ts +23 -0
- package/src/memory/schedule.ts +163 -0
- package/src/memory/synthesize.ts +29 -0
- package/src/memory/tools.ts +399 -0
- package/src/memory/vault.ts +694 -0
- package/src/memory/wikify.ts +23 -0
- package/src/mlx/metal-kernel.ts +13 -0
- package/src/model/flash-attention.ts +5 -3
- package/src/pi-provider.ts +29 -13
- package/src/pi-session.ts +81 -0
- package/src/pi-terminal.ts +13 -24
- package/src/pi-web.ts +246 -166
- package/src/quantize/job.ts +17 -2
- package/src/quantize/quantizer.ts +26 -2
- package/src/server.ts +213 -44
- package/src/tokenizer.ts +6 -2
- package/src/train/dataset.ts +11 -3
- package/src/train/flash-cce.ts +18 -5
- package/src/train/job.ts +41 -4
- package/src/train/loss.ts +10 -4
- package/src/train/trainer.ts +62 -3
- package/src/train/watch.ts +542 -0
- package/src/web/app.html +264 -14
- package/src/web/skills/memory/SKILL.md +97 -0
- package/src/web/skills/web-research/SKILL.md +3 -1
- package/src/web/skills.ts +46 -8
|
@@ -392,8 +392,28 @@ async function computeMixedAllocation(
|
|
|
392
392
|
|
|
393
393
|
progress("sensitivity", "Loading model for sensitivity analysis", 0.0);
|
|
394
394
|
|
|
395
|
-
|
|
396
|
-
|
|
395
|
+
// The sensitivity sweep runs FORWARD passes, which need a model the runtime
|
|
396
|
+
// can load — and mlx-bun's model graph can only load QUANTIZED weights. If the
|
|
397
|
+
// source is bf16 (unquantized), build a temporary uniform 8-bit baseline
|
|
398
|
+
// (near-lossless) to probe against. The FINAL weights are still quantized from
|
|
399
|
+
// the original bf16 source per the allocation (quantizeModelDir reads srcDir),
|
|
400
|
+
// so fidelity is not capped by the baseline — the baseline only anchors the
|
|
401
|
+
// per-layer sensitivity (8→4-bit) measurement.
|
|
402
|
+
const srcConfig = await loadModelConfig(srcDir);
|
|
403
|
+
let probeDir = srcDir;
|
|
404
|
+
let tempBaseline: string | null = null;
|
|
405
|
+
if (!srcConfig.quantization) {
|
|
406
|
+
const { mkdtempSync } = await import("node:fs");
|
|
407
|
+
const { tmpdir } = await import("node:os");
|
|
408
|
+
const { join } = await import("node:path");
|
|
409
|
+
tempBaseline = mkdtempSync(join(tmpdir(), "mlx-bun-qbaseline-"));
|
|
410
|
+
progress("sensitivity", "Unquantized source — building an 8-bit baseline for the sweep", 0.0);
|
|
411
|
+
await quantizeModelDir(srcDir, tempBaseline, { bits: 8, groupSize, mode: "affine" });
|
|
412
|
+
probeDir = tempBaseline;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const config = await loadModelConfig(probeDir);
|
|
416
|
+
const weights = await Weights.open(probeDir);
|
|
397
417
|
try {
|
|
398
418
|
const model = createModel(weights, config);
|
|
399
419
|
const tokenizer = await loadTokenizer(srcDir);
|
|
@@ -458,5 +478,9 @@ async function computeMixedAllocation(
|
|
|
458
478
|
} finally {
|
|
459
479
|
weights.dispose();
|
|
460
480
|
clearCache();
|
|
481
|
+
if (tempBaseline) {
|
|
482
|
+
const { rmSync } = await import("node:fs");
|
|
483
|
+
rmSync(tempBaseline, { recursive: true, force: true });
|
|
484
|
+
}
|
|
461
485
|
}
|
|
462
486
|
}
|
package/src/server.ts
CHANGED
|
@@ -667,18 +667,49 @@ export function promptEndsInOpenThink(rendered: string): boolean {
|
|
|
667
667
|
return open !== -1 && open > rendered.lastIndexOf("</think>");
|
|
668
668
|
}
|
|
669
669
|
|
|
670
|
+
/** Concatenate the text of an OpenAI content-part array, ignoring non-text
|
|
671
|
+
* parts. Tolerant of the part shapes clients actually send: `{type:"text",
|
|
672
|
+
* text}` (OpenAI/pi), `{type:"input_text", text}` (Responses-style), or any
|
|
673
|
+
* part carrying a string `text`. */
|
|
674
|
+
function contentPartsToText(parts: Array<Record<string, unknown>>): string {
|
|
675
|
+
return parts
|
|
676
|
+
.map((p) => (p && typeof p.text === "string" ? p.text : ""))
|
|
677
|
+
.join("");
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** True if any content part is an image (so the vision path must keep the
|
|
681
|
+
* array form for extractImages). */
|
|
682
|
+
function hasImagePart(parts: Array<Record<string, unknown>>): boolean {
|
|
683
|
+
return parts.some((p) => p && (p.type === "image" || p.type === "image_url"));
|
|
684
|
+
}
|
|
685
|
+
|
|
670
686
|
/** OpenAI sends assistant tool_call arguments as JSON strings; the
|
|
671
687
|
* template renders the object form natively — normalize before render.
|
|
672
|
-
*
|
|
673
|
-
*
|
|
674
|
-
* model
|
|
675
|
-
*
|
|
676
|
-
*
|
|
677
|
-
*
|
|
678
|
-
*
|
|
688
|
+
*
|
|
689
|
+
* Two more wire-format → template-format fixes, both "match the format the
|
|
690
|
+
* model's chat template expects":
|
|
691
|
+
*
|
|
692
|
+
* 1. Map the OpenAI reasoning-model "developer" role to "system": pi-ai (and
|
|
693
|
+
* OpenAI's own SDKs) rename the system prompt to `developer` whenever a
|
|
694
|
+
* model advertises reasoning, but our chat templates only know
|
|
695
|
+
* system/user/assistant/tool and raise "Unexpected message role." This is
|
|
696
|
+
* why Qwen3.5/MiniCPM5 chat got no messages while Gemma (non-reasoning, so
|
|
697
|
+
* pi keeps `system`) worked. `developer` IS the system prompt, so the remap
|
|
698
|
+
* is semantics-preserving.
|
|
699
|
+
*
|
|
700
|
+
* 2. Flatten text-only content-part arrays to a plain string. pi (and any
|
|
701
|
+
* OpenAI multimodal client) sends user content as `[{type:"text",text}]`,
|
|
702
|
+
* but non-vision chat templates expect `content` to be a STRING and render
|
|
703
|
+
* nothing for an array — so the user's turn silently vanishes and the model
|
|
704
|
+
* replies "I don't see any message." Arrays that carry an image part are
|
|
705
|
+
* left intact for the vision path (extractImages); only text-only arrays
|
|
706
|
+
* are collapsed here, which is a no-op for the vision templates too. */
|
|
679
707
|
export function normalizeMessages(messages: ChatMessage[]): ChatMessage[] {
|
|
680
708
|
return messages.map((raw) => {
|
|
681
|
-
|
|
709
|
+
let m = raw.role === "developer" ? { ...raw, role: "system" } : raw;
|
|
710
|
+
if (Array.isArray(m.content) && !hasImagePart(m.content)) {
|
|
711
|
+
m = { ...m, content: contentPartsToText(m.content) };
|
|
712
|
+
}
|
|
682
713
|
if (!m.tool_calls) return m;
|
|
683
714
|
return {
|
|
684
715
|
...m,
|
|
@@ -880,43 +911,61 @@ export function createServer(
|
|
|
880
911
|
`Omit --kv-quant to batch in bf16. (docs/design/parallel-slots.md)`,
|
|
881
912
|
);
|
|
882
913
|
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
repetitionPenalty: req.repetition_penalty ?? ctx.genDefaults.repetitionPenalty,
|
|
890
|
-
hlg: resolveHlg(req.hlg, serverOptions.hlg),
|
|
891
|
-
// generate() yields token ids; stop sequences match on decoded text
|
|
892
|
-
// (they can span token boundaries), so the StopMatcher sits at the
|
|
893
|
-
// decode layer below and halts the loop via onToken → false.
|
|
894
|
-
stopSequences: (typeof req.stop === "string" ? [req.stop] : req.stop ?? [])
|
|
895
|
-
.filter((s) => typeof s === "string" && s.length > 0),
|
|
896
|
-
...kvScheme,
|
|
897
|
-
});
|
|
898
|
-
|
|
899
|
-
const templateOptionsFor = (req: ChatRequest, tools: ToolDefinition[] | null) => {
|
|
900
|
-
// Precedence: explicit per-request chat_template_kwargs wins; else the
|
|
901
|
-
// standard reasoning_effort (the OpenAI field Pi sends — "none" → no
|
|
902
|
-
// thinking, any level → thinking on); else the server-wide --thinking
|
|
903
|
-
// default; else the model's own default (false for MiniCPM5, otherwise
|
|
904
|
-
// leave the template's default). The Anthropic/Responses shims map their
|
|
905
|
-
// reasoning fields onto reasoning_effort before reaching here.
|
|
914
|
+
// Effective enable_thinking for a request, with the same precedence the chat
|
|
915
|
+
// template uses (extracted so sampling and template rendering can't disagree):
|
|
916
|
+
// explicit chat_template_kwargs.enable_thinking → reasoning_effort ("none" =
|
|
917
|
+
// off) → server --thinking default → model default (MiniCPM5 → off). undefined
|
|
918
|
+
// means "not a switchable-thinking model / leave the template default".
|
|
919
|
+
const resolveEnableThinking = (req: ChatRequest): boolean | undefined => {
|
|
906
920
|
const explicit = req.chat_template_kwargs?.enable_thinking;
|
|
921
|
+
if (typeof explicit === "boolean") return explicit;
|
|
907
922
|
const effort = req.reasoning_effort;
|
|
923
|
+
if (effort !== undefined) return effort !== "none";
|
|
924
|
+
if (serverOptions.defaultThinking !== undefined) return serverOptions.defaultThinking;
|
|
925
|
+
return isMiniCPM5Config(ctx.model.config) ? false : undefined;
|
|
926
|
+
};
|
|
927
|
+
|
|
928
|
+
const toOptions = (req: ChatRequest): GenerateOptions & { stopSequences: string[] } => {
|
|
929
|
+
// Sampling follows the thinking state — which the web UI's thinking button
|
|
930
|
+
// drives via enable_thinking. Model authors publish a SINGLE
|
|
931
|
+
// generation_config temperature (the think-mode value) but recommend a
|
|
932
|
+
// cooler one for direct, no-think replies (MiniCPM5 card: 0.9 think / 0.7
|
|
933
|
+
// no-think, top_p 0.95 for both). So with no explicit temperature set, a
|
|
934
|
+
// no-think turn runs at most NO_THINK_TEMPERATURE while a think turn keeps
|
|
935
|
+
// the model's hotter configured default. An explicit request/CLI
|
|
936
|
+
// temperature always wins. top_p is unchanged by mode.
|
|
937
|
+
const NO_THINK_TEMPERATURE = 0.7;
|
|
938
|
+
const genTemp = ctx.genDefaults.temperature ?? 0.7;
|
|
939
|
+
const defaultTemp = resolveEnableThinking(req) === false
|
|
940
|
+
? Math.min(genTemp, NO_THINK_TEMPERATURE)
|
|
941
|
+
: genTemp;
|
|
908
942
|
return {
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
943
|
+
// A thinking turn emits <think>…</think> AND the answer, so a tight cap
|
|
944
|
+
// truncates the visible reply. Default very generously (the model's
|
|
945
|
+
// context is far larger); only an explicit max_tokens narrows it. The
|
|
946
|
+
// model still stops at its eos_token well before this in normal replies.
|
|
947
|
+
maxTokens: req.max_completion_tokens ?? req.max_tokens ?? 65_536,
|
|
948
|
+
temperature: req.temperature ?? serverOptions.defaultTemperature ?? defaultTemp,
|
|
949
|
+
topP: req.top_p ?? serverOptions.defaultTopP ?? ctx.genDefaults.topP ?? 0,
|
|
950
|
+
topK: req.top_k ?? serverOptions.defaultTopK ?? ctx.genDefaults.topK ?? 0,
|
|
951
|
+
seed: req.seed ?? (Date.now() & 0xffffffff),
|
|
952
|
+
repetitionPenalty: req.repetition_penalty ?? ctx.genDefaults.repetitionPenalty,
|
|
953
|
+
hlg: resolveHlg(req.hlg, serverOptions.hlg),
|
|
954
|
+
// generate() yields token ids; stop sequences match on decoded text
|
|
955
|
+
// (they can span token boundaries), so the StopMatcher sits at the
|
|
956
|
+
// decode layer below and halts the loop via onToken → false.
|
|
957
|
+
stopSequences: (typeof req.stop === "string" ? [req.stop] : req.stop ?? [])
|
|
958
|
+
.filter((s) => typeof s === "string" && s.length > 0),
|
|
959
|
+
...kvScheme,
|
|
917
960
|
};
|
|
918
961
|
};
|
|
919
962
|
|
|
963
|
+
const templateOptionsFor = (req: ChatRequest, tools: ToolDefinition[] | null) => {
|
|
964
|
+
// enableThinking resolution (and its precedence) lives in
|
|
965
|
+
// resolveEnableThinking so template rendering and sampling stay in sync.
|
|
966
|
+
return { tools, enableThinking: resolveEnableThinking(req) };
|
|
967
|
+
};
|
|
968
|
+
|
|
920
969
|
const promptIdsFor = (
|
|
921
970
|
req: ChatRequest,
|
|
922
971
|
tools: ToolDefinition[] | null,
|
|
@@ -980,6 +1029,16 @@ export function createServer(
|
|
|
980
1029
|
try { html = readFileSync(new URL("./assets/curve-designer.html", import.meta.url), "utf8"); } catch { /* binary: use embedded */ }
|
|
981
1030
|
return new Response(html, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
|
|
982
1031
|
}
|
|
1032
|
+
// Training/inference DAG map — self-contained cytoscape artifact, served
|
|
1033
|
+
// same-origin so the Routes tab can embed it in an <iframe src="/dag">.
|
|
1034
|
+
if (url.pathname === "/dag" && request.method === "GET") {
|
|
1035
|
+
try {
|
|
1036
|
+
const html = readFileSync(new URL("../docs/dag/training-inference-map.html", import.meta.url), "utf8");
|
|
1037
|
+
return new Response(html, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
|
|
1038
|
+
} catch {
|
|
1039
|
+
return new Response("DAG map artifact not found at docs/dag/training-inference-map.html", { status: 404 });
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
983
1042
|
if (url.pathname === "/curve-terrain" && request.method === "GET") {
|
|
984
1043
|
try {
|
|
985
1044
|
const html = readFileSync(new URL("../docs/investigations/curve-terrain.html", import.meta.url), "utf8");
|
|
@@ -1000,7 +1059,11 @@ export function createServer(
|
|
|
1000
1059
|
const { Registry } = await import("./registry");
|
|
1001
1060
|
const { loadModelConfig } = await import("./config");
|
|
1002
1061
|
const reg = new Registry();
|
|
1003
|
-
|
|
1062
|
+
// Always rescan when (re)building the 30s cache so models that appeared
|
|
1063
|
+
// after boot — fresh downloads AND quants written into the HF cache —
|
|
1064
|
+
// surface on their own. (scan() is INSERT-OR-REPLACE + prunes deleted,
|
|
1065
|
+
// so it's idempotent and cheap for a local cache.)
|
|
1066
|
+
await reg.scan();
|
|
1004
1067
|
const rows = [];
|
|
1005
1068
|
for (const m of reg.list()) {
|
|
1006
1069
|
const supported = isSupportedModelRecord(m.modelType, m.repoId);
|
|
@@ -1747,6 +1810,78 @@ export function createServer(
|
|
|
1747
1810
|
const { inspectModel } = await import("./quantize");
|
|
1748
1811
|
return Response.json(await inspectModel(body.model_id ?? ""));
|
|
1749
1812
|
}
|
|
1813
|
+
// Turn an OS-picked folder into the absolute snapshot path on disk. The
|
|
1814
|
+
// browser can't reveal a filesystem path (security) and the HF cache
|
|
1815
|
+
// stores real bytes in blobs/ with symlinked snapshots — so we resolve by
|
|
1816
|
+
// the folder's NAME (which encodes the repo id), never by reading files.
|
|
1817
|
+
// No upload, no dependence on the cache's symlink layout, and a
|
|
1818
|
+
// just-downloaded model resolves before it's ever been indexed.
|
|
1819
|
+
if ((url.pathname === "/api/quantize/resolve-folder" || url.pathname === "/api/model/resolve-folder") && request.method === "POST") {
|
|
1820
|
+
const body = (await request.json().catch(() => ({}))) as { folder_name?: string; rel_path?: string };
|
|
1821
|
+
const { statSync, readdirSync, readFileSync } = await import("node:fs");
|
|
1822
|
+
const { join } = await import("node:path");
|
|
1823
|
+
const { homedir } = await import("node:os");
|
|
1824
|
+
const hubRoot = process.env.HF_HUB_CACHE
|
|
1825
|
+
?? (process.env.HF_HOME ? join(process.env.HF_HOME, "hub") : join(homedir(), ".cache/huggingface/hub"));
|
|
1826
|
+
const roots = [hubRoot, join(homedir(), ".cache/mlx-bun")];
|
|
1827
|
+
const hasConfig = (d: string) => { try { return statSync(join(d, "config.json")).isFile(); } catch { return false; } };
|
|
1828
|
+
// basename of the picked folder; the rel path's last-but-one segment is
|
|
1829
|
+
// the <hash> dir if they drilled into a snapshot.
|
|
1830
|
+
const folder = (body.folder_name ?? "").replace(/\\/g, "/").split("/").filter(Boolean).pop() ?? "";
|
|
1831
|
+
const relSegs = (body.rel_path ?? "").replace(/\\/g, "/").split("/").filter(Boolean);
|
|
1832
|
+
const configDir = relSegs.length >= 2 ? relSegs[relSegs.length - 2] : "";
|
|
1833
|
+
const repoIdOf = (modelsDir: string) => modelsDir.slice("models--".length).replaceAll("--", "/");
|
|
1834
|
+
// Pick a repo dir's canonical snapshot: the one refs/main points at, else
|
|
1835
|
+
// the first snapshot that actually has a config.json.
|
|
1836
|
+
const pickSnapshot = (repoDir: string): string | null => {
|
|
1837
|
+
const snaps = join(repoDir, "snapshots");
|
|
1838
|
+
let head = "";
|
|
1839
|
+
try { head = readFileSync(join(repoDir, "refs", "main"), "utf8").trim(); } catch {}
|
|
1840
|
+
if (head && hasConfig(join(snaps, head))) return join(snaps, head);
|
|
1841
|
+
try { for (const h of readdirSync(snaps)) if (hasConfig(join(snaps, h))) return join(snaps, h); } catch {}
|
|
1842
|
+
return null;
|
|
1843
|
+
};
|
|
1844
|
+
|
|
1845
|
+
// (1) HF-cache folder picked — its name IS the repo id.
|
|
1846
|
+
if (folder.startsWith("models--")) {
|
|
1847
|
+
const p = pickSnapshot(join(hubRoot, folder));
|
|
1848
|
+
if (p) return Response.json({ ok: true, path: p, repo_id: repoIdOf(folder) });
|
|
1849
|
+
}
|
|
1850
|
+
// (2) A bare <hash> snapshot folder picked directly (or carried in rel).
|
|
1851
|
+
const hashDir = configDir || folder;
|
|
1852
|
+
if (hashDir) {
|
|
1853
|
+
try {
|
|
1854
|
+
for (const repo of readdirSync(hubRoot)) {
|
|
1855
|
+
if (!repo.startsWith("models--")) continue;
|
|
1856
|
+
const cand = join(hubRoot, repo, "snapshots", hashDir);
|
|
1857
|
+
if (hasConfig(cand)) return Response.json({ ok: true, path: cand, repo_id: repoIdOf(repo) });
|
|
1858
|
+
}
|
|
1859
|
+
} catch {}
|
|
1860
|
+
}
|
|
1861
|
+
// (3) Flat local snapshot under a known root (folder name == model dir).
|
|
1862
|
+
for (const root of roots) {
|
|
1863
|
+
if (folder && hasConfig(join(root, folder))) return Response.json({ ok: true, path: join(root, folder) });
|
|
1864
|
+
}
|
|
1865
|
+
// (4) Registry rescan — last resort (also covers customized cache layouts).
|
|
1866
|
+
const { Registry } = await import("./registry");
|
|
1867
|
+
const reg = new Registry();
|
|
1868
|
+
try {
|
|
1869
|
+
await reg.scan();
|
|
1870
|
+
const all = reg.list();
|
|
1871
|
+
const rec = (folder.startsWith("models--")
|
|
1872
|
+
? all.find((m) => m.repoId === repoIdOf(folder))
|
|
1873
|
+
: undefined)
|
|
1874
|
+
?? all.find((m) => m.path.split("/").pop() === hashDir)
|
|
1875
|
+
?? all.find((m) => m.repoId.split("/").pop() === folder);
|
|
1876
|
+
if (rec) return Response.json({ ok: true, path: rec.path, repo_id: rec.repoId, model_type: rec.modelType });
|
|
1877
|
+
} finally {
|
|
1878
|
+
reg.close();
|
|
1879
|
+
}
|
|
1880
|
+
return Response.json({
|
|
1881
|
+
ok: false,
|
|
1882
|
+
error: "Couldn't locate this folder on disk — paste the path instead.",
|
|
1883
|
+
});
|
|
1884
|
+
}
|
|
1750
1885
|
if (url.pathname === "/api/quantize/submit" && request.method === "POST") {
|
|
1751
1886
|
const body = (await request.json().catch(() => ({}))) as {
|
|
1752
1887
|
model_id?: string; bits?: number; group_size?: number;
|
|
@@ -1758,18 +1893,52 @@ export function createServer(
|
|
|
1758
1893
|
const store = await ensureJobs();
|
|
1759
1894
|
const { submitSubprocess } = await import("./jobs");
|
|
1760
1895
|
const { homedir } = await import("node:os");
|
|
1896
|
+
const { join } = await import("node:path");
|
|
1897
|
+
const { mkdirSync, writeFileSync } = await import("node:fs");
|
|
1898
|
+
const { createHash } = await import("node:crypto");
|
|
1761
1899
|
const bits = body.bits ?? 4, gs = body.group_size ?? 64;
|
|
1762
|
-
|
|
1763
|
-
//
|
|
1900
|
+
// Derive org/name from the source so the quant is named readably (the
|
|
1901
|
+
// source is usually an HF-cache snapshot path whose basename is a hash).
|
|
1902
|
+
const snapMatch = body.model_id.match(/(models--[^/]+)\/snapshots\//);
|
|
1903
|
+
let org = "local", name: string;
|
|
1904
|
+
if (snapMatch) {
|
|
1905
|
+
const parts = snapMatch[1].split("--"); // ["models", org, ...name]
|
|
1906
|
+
org = parts[1] ?? "local";
|
|
1907
|
+
name = parts.slice(2).join("--");
|
|
1908
|
+
} else if (body.model_id.includes("/") && !body.model_id.startsWith("/") && !body.model_id.startsWith("~")) {
|
|
1909
|
+
const seg = body.model_id.split("/"); // a repo id "org/name"
|
|
1910
|
+
org = seg[0]!; name = seg.slice(1).join("-");
|
|
1911
|
+
} else {
|
|
1912
|
+
name = body.model_id.split("/").filter(Boolean).at(-1) ?? "model"; // a bare path
|
|
1913
|
+
}
|
|
1914
|
+
name = (name || "model").replace(/[^a-z0-9_.-]/gi, "");
|
|
1915
|
+
org = (org || "local").replace(/[^a-z0-9_.-]/gi, "");
|
|
1764
1916
|
const suffix = body.target_bpw ? `mixed-${body.target_bpw}bpw` : `${bits}bit`;
|
|
1765
|
-
|
|
1917
|
+
// Write the quant INTO the HF hub cache as a normal models--org--name/
|
|
1918
|
+
// snapshots/<hash> entry, so the standard registry scan + every other
|
|
1919
|
+
// tool discovers it alongside downloaded models. refs/main makes it a
|
|
1920
|
+
// well-formed cache entry.
|
|
1921
|
+
const quantRepo = `${name}-OptiQ-${suffix}`;
|
|
1922
|
+
const hubRoot = process.env.HF_HUB_CACHE
|
|
1923
|
+
?? (process.env.HF_HOME ? join(process.env.HF_HOME, "hub") : join(homedir(), ".cache/huggingface/hub"));
|
|
1924
|
+
const repoDir = join(hubRoot, `models--${org}--${quantRepo}`);
|
|
1925
|
+
const snapHash = createHash("sha1").update(`${org}/${quantRepo}`).digest("hex");
|
|
1926
|
+
const outDir = join(repoDir, "snapshots", snapHash);
|
|
1927
|
+
try {
|
|
1928
|
+
mkdirSync(join(repoDir, "refs"), { recursive: true });
|
|
1929
|
+
writeFileSync(join(repoDir, "refs", "main"), snapHash);
|
|
1930
|
+
} catch {}
|
|
1766
1931
|
const { jobId } = submitSubprocess(store, "quantize", {
|
|
1767
1932
|
model_id: body.model_id, out_dir: outDir, bits, group_size: gs,
|
|
1768
1933
|
// forwarded to the mixed-precision path when target_bpw is set
|
|
1769
1934
|
target_bpw: body.target_bpw, candidate_bits: body.candidate_bits,
|
|
1770
1935
|
reference: body.reference, calibration_mix: body.calibration_mix,
|
|
1771
1936
|
n_calibration: body.n_calibration,
|
|
1772
|
-
}, outDir
|
|
1937
|
+
}, outDir, {
|
|
1938
|
+
// The model is in the HF cache now; drop the Library cache so the next
|
|
1939
|
+
// poll rescans and shows it — no `mlx-bun scan`, no restart.
|
|
1940
|
+
onComplete: () => { libraryCache = null; },
|
|
1941
|
+
});
|
|
1773
1942
|
return Response.json({ ok: true, job_id: jobId, output_dir: outDir });
|
|
1774
1943
|
}
|
|
1775
1944
|
|
package/src/tokenizer.ts
CHANGED
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
import { Tokenizer } from "@huggingface/tokenizers";
|
|
7
7
|
|
|
8
8
|
export interface LoadedTokenizer {
|
|
9
|
-
|
|
9
|
+
/** Encode text → token ids. `addSpecialTokens` (default true) prepends the BOS;
|
|
10
|
+
* pass FALSE for chat-template output, which already emits the BOS as text (else
|
|
11
|
+
* you get a corrupting double-BOS `<s><s>…`). */
|
|
12
|
+
encode(text: string, addSpecialTokens?: boolean): number[];
|
|
10
13
|
decode(ids: number[], skipSpecialTokens?: boolean): string;
|
|
11
14
|
readonly bosTokenId: number | null;
|
|
12
15
|
readonly eosTokenId: number | null;
|
|
@@ -28,7 +31,8 @@ export async function loadTokenizer(modelDir: string): Promise<LoadedTokenizer>
|
|
|
28
31
|
};
|
|
29
32
|
|
|
30
33
|
return {
|
|
31
|
-
encode: (text) =>
|
|
34
|
+
encode: (text, addSpecialTokens = true) =>
|
|
35
|
+
tok.encode(text, { add_special_tokens: addSpecialTokens }).ids.map(Number),
|
|
32
36
|
decode: (ids, skipSpecialTokens = false) =>
|
|
33
37
|
// python's decode([]) === ""; the JS lib throws on empty input
|
|
34
38
|
ids.length === 0 ? "" : tok.decode(ids, { skip_special_tokens: skipSpecialTokens }),
|
package/src/train/dataset.ts
CHANGED
|
@@ -269,14 +269,22 @@ export function tokenizePair(
|
|
|
269
269
|
tok: LoadedTokenizer,
|
|
270
270
|
maxLength: number,
|
|
271
271
|
): { ids: number[]; promptLen: number } {
|
|
272
|
-
|
|
273
|
-
|
|
272
|
+
// A chat template emits a literal BOS, and the tokenizer's post-processor
|
|
273
|
+
// prepends one too → a duplicate leading BOS. Inference strips it
|
|
274
|
+
// (server.ts: "template includes <bos>; tokenizer also prepends one"), so
|
|
275
|
+
// training must match or it learns on a token stream the model never sees.
|
|
276
|
+
// No-op for raw (untemplated) prompts, which carry a single BOS.
|
|
277
|
+
const bos = tok.bosTokenId;
|
|
278
|
+
const dedupeBos = (ids: number[]): number[] =>
|
|
279
|
+
bos != null && ids.length >= 2 && ids[0] === bos && ids[1] === bos ? ids.slice(1) : ids;
|
|
280
|
+
const promptIds = dedupeBos(tok.encode(prompt));
|
|
281
|
+
let fullIds = dedupeBos(tok.encode(prompt + response));
|
|
274
282
|
let promptLen: number;
|
|
275
283
|
if (isPrefix(fullIds, promptIds)) {
|
|
276
284
|
promptLen = promptIds.length;
|
|
277
285
|
} else {
|
|
278
286
|
promptLen = promptIds.length;
|
|
279
|
-
const responseIds = tok.encode(response);
|
|
287
|
+
const responseIds = tok.encode(response, false); // no extra BOS mid-stream
|
|
280
288
|
fullIds = [...promptIds, ...responseIds];
|
|
281
289
|
}
|
|
282
290
|
if (fullIds.length > maxLength) {
|
package/src/train/flash-cce.ts
CHANGED
|
@@ -927,7 +927,13 @@ function fwdKernel(): MetalKernel {
|
|
|
927
927
|
}
|
|
928
928
|
|
|
929
929
|
function u32(values: number[]): MlxArray {
|
|
930
|
-
|
|
930
|
+
// COPY into an mlx-owned array (fromBytesCopy = mlx_array_new_data), NOT the
|
|
931
|
+
// pinning fromView. These tiny kernel-arg arrays (shape, targets) feed a LAZY
|
|
932
|
+
// kernel and are disposed before it evaluates; pinning leaks one host buffer
|
|
933
|
+
// per call, never released (unbounded `pinned` map + a deferred native
|
|
934
|
+
// use-after-free under memory pressure — the seg/accum crash). A few-int copy
|
|
935
|
+
// is free; correctness is not.
|
|
936
|
+
return MlxArray.fromBytesCopy(new Uint8Array(new Uint32Array(values).buffer), [values.length], Dtype.uint32);
|
|
931
937
|
}
|
|
932
938
|
|
|
933
939
|
/** Flash-CCE forward: per-token response log-prob [M] (f32) + lse [M] (f32),
|
|
@@ -1064,12 +1070,19 @@ export function flashCceForward(
|
|
|
1064
1070
|
// reduction is simpler and just as exact.)
|
|
1065
1071
|
const gMax = ops.maxAxis(bMax, 1, false); // [M]
|
|
1066
1072
|
const gMaxCol = ops.reshape(gMax, [M, 1]);
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1073
|
+
// Bind every nested intermediate so its MlxArray wrapper gets disposed — a
|
|
1074
|
+
// bare `ops.exp(ops.sub(...))` leaks the inner `sub` (stays live in `active`
|
|
1075
|
+
// until the GC finalizer backstop), and this runs TWICE per step (chosen +
|
|
1076
|
+
// rejected). gMax was also missing from the dispose list below.
|
|
1077
|
+
const sub = ops.sub(bMax, gMaxCol);
|
|
1078
|
+
const shifted = ops.exp(sub); // [M, NBLK]
|
|
1079
|
+
const prod = ops.mul(bSum, shifted);
|
|
1080
|
+
const gSum = ops.sumAxis(prod, 1, false); // [M]
|
|
1081
|
+
const logG = ops.log(gSum);
|
|
1082
|
+
const lse = ops.add(gMax, logG); // [M]
|
|
1070
1083
|
const tgt = ops.sumAxis(bTgt, 1, false); // [M] (owning block only is nonzero)
|
|
1071
1084
|
const logp = ops.sub(tgt, lse);
|
|
1072
|
-
for (const a of [gMaxCol, shifted, gSum, tgt]) a.dispose();
|
|
1085
|
+
for (const a of [gMax, gMaxCol, sub, shifted, prod, gSum, logG, tgt]) a.dispose();
|
|
1073
1086
|
const blockMax = bMax!; bMax = undefined; // hand off to caller (backward block-skip)
|
|
1074
1087
|
return { logp, lse, blockMax };
|
|
1075
1088
|
} finally {
|
package/src/train/job.ts
CHANGED
|
@@ -39,6 +39,8 @@ interface FinetuneSubmit {
|
|
|
39
39
|
mlp_split?: boolean;
|
|
40
40
|
segment_size?: number;
|
|
41
41
|
save_checkpoints?: boolean;
|
|
42
|
+
grad_clip_norm?: number;
|
|
43
|
+
val_max_examples?: number;
|
|
42
44
|
dpo_beta?: number;
|
|
43
45
|
dpo_warmup_iters?: number;
|
|
44
46
|
dpo_lr_schedule?: "constant" | "cosine";
|
|
@@ -58,6 +60,14 @@ function parseConfig(raw: Record<string, unknown>): { modelDir: string; dataDir:
|
|
|
58
60
|
if (!c.data_dir) throw new Error("finetune job: missing data_dir");
|
|
59
61
|
if (!c.adapter_path) throw new Error("finetune job: missing adapter_path");
|
|
60
62
|
|
|
63
|
+
// For ORPO the full L3 stack is the validated production recipe (what the
|
|
64
|
+
// `mlx-bun train` CLI turns on): flash-CCE head + prefix-sharing + segmented
|
|
65
|
+
// backward + token-chunked head. DEFAULT_TRAIN_CONFIG keeps them OFF (they're
|
|
66
|
+
// opt-in for SFT), so default them ON here when method=orpo — an explicit
|
|
67
|
+
// value from the caller (CLI/API) still wins via ??. finetuneRunner drops the
|
|
68
|
+
// quantized-head pieces if the base turns out to be unquantized.
|
|
69
|
+
const isOrpo = (c.method ?? "sft") === "orpo";
|
|
70
|
+
|
|
61
71
|
const cfg: TrainConfig = {
|
|
62
72
|
...DEFAULT_TRAIN_CONFIG,
|
|
63
73
|
method: c.method ?? "sft",
|
|
@@ -81,18 +91,20 @@ function parseConfig(raw: Record<string, unknown>): { modelDir: string; dataDir:
|
|
|
81
91
|
loraPlusRatio: c.lora_plus_ratio ?? DEFAULT_TRAIN_CONFIG.loraPlusRatio,
|
|
82
92
|
gradCheckpoint: c.grad_checkpoint ?? DEFAULT_TRAIN_CONFIG.gradCheckpoint,
|
|
83
93
|
mlpSplit: c.mlp_split ?? DEFAULT_TRAIN_CONFIG.mlpSplit,
|
|
84
|
-
segmentSize: c.segment_size ?? DEFAULT_TRAIN_CONFIG.segmentSize,
|
|
94
|
+
segmentSize: c.segment_size ?? (isOrpo ? 2 : DEFAULT_TRAIN_CONFIG.segmentSize),
|
|
85
95
|
saveCheckpoints: c.save_checkpoints ?? DEFAULT_TRAIN_CONFIG.saveCheckpoints,
|
|
96
|
+
gradClipNorm: c.grad_clip_norm ?? DEFAULT_TRAIN_CONFIG.gradClipNorm,
|
|
97
|
+
valMaxExamples: c.val_max_examples ?? DEFAULT_TRAIN_CONFIG.valMaxExamples,
|
|
86
98
|
dpoBeta: c.dpo_beta ?? DEFAULT_TRAIN_CONFIG.dpoBeta,
|
|
87
99
|
dpoWarmupIters: c.dpo_warmup_iters ?? DEFAULT_TRAIN_CONFIG.dpoWarmupIters,
|
|
88
100
|
dpoLrSchedule: c.dpo_lr_schedule ?? DEFAULT_TRAIN_CONFIG.dpoLrSchedule,
|
|
89
101
|
orpoLambda: c.orpo_lambda ?? DEFAULT_TRAIN_CONFIG.orpoLambda,
|
|
90
102
|
orpoWarmupIters: c.orpo_warmup_iters ?? DEFAULT_TRAIN_CONFIG.orpoWarmupIters,
|
|
91
103
|
orpoLrSchedule: c.orpo_lr_schedule ?? DEFAULT_TRAIN_CONFIG.orpoLrSchedule,
|
|
92
|
-
orpoChunkSize: c.orpo_chunk_size ?? DEFAULT_TRAIN_CONFIG.orpoChunkSize,
|
|
104
|
+
orpoChunkSize: c.orpo_chunk_size ?? (isOrpo ? 512 : DEFAULT_TRAIN_CONFIG.orpoChunkSize),
|
|
93
105
|
orpoFusedCe: c.orpo_fused_ce ?? DEFAULT_TRAIN_CONFIG.orpoFusedCe,
|
|
94
|
-
orpoFlashCe: c.orpo_flash_ce ?? DEFAULT_TRAIN_CONFIG.orpoFlashCe,
|
|
95
|
-
orpoPrefixShared: c.orpo_prefix_shared ?? DEFAULT_TRAIN_CONFIG.orpoPrefixShared,
|
|
106
|
+
orpoFlashCe: c.orpo_flash_ce ?? (isOrpo ? true : DEFAULT_TRAIN_CONFIG.orpoFlashCe),
|
|
107
|
+
orpoPrefixShared: c.orpo_prefix_shared ?? (isOrpo ? true : DEFAULT_TRAIN_CONFIG.orpoPrefixShared),
|
|
96
108
|
warmStartAdapter: c.warm_start_adapter ?? DEFAULT_TRAIN_CONFIG.warmStartAdapter,
|
|
97
109
|
adapterPath: c.adapter_path,
|
|
98
110
|
baseModel: c.model_dir,
|
|
@@ -103,6 +115,18 @@ function parseConfig(raw: Record<string, unknown>): { modelDir: string; dataDir:
|
|
|
103
115
|
export const finetuneRunner: JobRunner = async (emit, config) => {
|
|
104
116
|
const { modelDir, dataDir, cfg } = parseConfig(config);
|
|
105
117
|
|
|
118
|
+
// Gemma/e4b needs these training env flags set BEFORE the model loads — same
|
|
119
|
+
// as the `mlx-bun train` CLI. The subprocess job path doesn't inherit them
|
|
120
|
+
// from the server, so without this a web-submitted ORPO/SFT run on Gemma uses
|
|
121
|
+
// the wrong kernels. ??= so an explicit env (or the CLI) still wins.
|
|
122
|
+
try {
|
|
123
|
+
const isGemma = (await Bun.file(`${modelDir}/config.json`).text()).toLowerCase().includes("gemma");
|
|
124
|
+
if (isGemma) {
|
|
125
|
+
process.env.MLX_BUN_PERF_KERNEL ??= "0";
|
|
126
|
+
process.env.MLX_BUN_FUSED_GELU ??= "0";
|
|
127
|
+
}
|
|
128
|
+
} catch {}
|
|
129
|
+
|
|
106
130
|
emit({ type: "stage", stage: "load", progress: 0.01, message: `loading model ${modelDir}` });
|
|
107
131
|
|
|
108
132
|
const { loadModelConfig } = await import("../config");
|
|
@@ -112,6 +136,19 @@ export const finetuneRunner: JobRunner = async (emit, config) => {
|
|
|
112
136
|
const { ChatTemplate } = await import("../chat-template");
|
|
113
137
|
|
|
114
138
|
const modelConfig = await loadModelConfig(modelDir);
|
|
139
|
+
|
|
140
|
+
// The flash-CCE / fused-CE heads dequantize a QUANTIZED head in-kernel; on an
|
|
141
|
+
// unquantized (bf16) base they can't run, so fall back to the full-logits head.
|
|
142
|
+
// (Quantize the base first to get the large-vocab memory win — see the
|
|
143
|
+
// Quantize tab.)
|
|
144
|
+
if (cfg.method === "orpo" && !modelConfig.quantization && (cfg.orpoFlashCe || cfg.orpoFusedCe)) {
|
|
145
|
+
emit({
|
|
146
|
+
type: "stage", stage: "load",
|
|
147
|
+
message: "unquantized base — flash/fused-CE head needs a quantized head; using full-logits head",
|
|
148
|
+
});
|
|
149
|
+
cfg.orpoFlashCe = false;
|
|
150
|
+
cfg.orpoFusedCe = false;
|
|
151
|
+
}
|
|
115
152
|
const weights = await Weights.open(modelDir);
|
|
116
153
|
const model = createModel(weights, modelConfig);
|
|
117
154
|
const tok = await loadTokenizer(modelDir);
|
package/src/train/loss.ts
CHANGED
|
@@ -839,7 +839,8 @@ function makeVocabBlockedHeadVjp(
|
|
|
839
839
|
const raw = ops.quantizedMatmul(hc, wb.w, wb.scales, wb.biases, head.spec, true); // [Cc, Vb]
|
|
840
840
|
if (cap === null) return { lb: raw, sech2: null };
|
|
841
841
|
const capArr = ops.scalarLike(cap, raw);
|
|
842
|
-
const
|
|
842
|
+
const div = ops.div(raw, capArr);
|
|
843
|
+
const th = ops.tanh(div); div.dispose(); // bind the nested div so its wrapper is freed
|
|
843
844
|
raw.dispose();
|
|
844
845
|
const lb = ops.mul(th, capArr); // cap·tanh(raw/cap)
|
|
845
846
|
const th2 = ops.mul(th, th); th.dispose();
|
|
@@ -964,7 +965,10 @@ function makeVocabBlockedHeadVjp(
|
|
|
964
965
|
const colFlat = ops.arange(v0, v1, 1, Dtype.int32);
|
|
965
966
|
const col = ops.reshape(colFlat, [1, Vb]); colFlat.dispose();
|
|
966
967
|
const tcol = MlxArray.fromInt32(targetsHost.slice(c0, c1), [Cc, 1]);
|
|
967
|
-
const
|
|
968
|
+
const ge1 = ops.greaterEqual(col, tcol);
|
|
969
|
+
const ge2 = ops.greaterEqual(tcol, col);
|
|
970
|
+
const eqMask = ops.logicalAnd(ge1, ge2); // bind the nested compares so their wrappers free
|
|
971
|
+
ge1.dispose(); ge2.dispose();
|
|
968
972
|
col.dispose(); tcol.dispose();
|
|
969
973
|
const onehot = eqMask.astype(softmax.dtype); eqMask.dispose();
|
|
970
974
|
let g = ops.sub(onehot, softmax); // onehot − softmax [Cc, Vb]
|
|
@@ -1000,7 +1004,8 @@ function makeVocabBlockedHeadVjp(
|
|
|
1000
1004
|
/** flash-CCE head as a CustomVjp: forward runs the Metal kernel → Σ logp (stashing
|
|
1001
1005
|
* `lse` for the backward, avoiding a forward recompute); backward runs the kernel's
|
|
1002
1006
|
* `dh` for unit cotangent then scales by the scalar cotangent `c` (loss = Σ logp →
|
|
1003
|
-
* ∂loss/∂logp_t = c for all t). The coeff filter
|
|
1007
|
+
* ∂loss/∂logp_t = c for all t). The coeff filter + vocab-block skip are OFF by
|
|
1008
|
+
* default (env-gated MLX_BUN_CCE_BWD_*_EPS) — the backward computes the EXACT dh. */
|
|
1004
1009
|
function makeFlashCceHeadVjp(
|
|
1005
1010
|
head: HeadQuant, targetsHost: Int32Array, M: number,
|
|
1006
1011
|
sink: Array<{ dispose(): void }>,
|
|
@@ -1031,7 +1036,8 @@ function makeFlashCceHeadVjp(
|
|
|
1031
1036
|
const c = cots[0]!; // [1] = ∂loss/∂(Σ logp); uniform over tokens
|
|
1032
1037
|
// dh for unit cotangent, then × c (avoids a host read of the cotangent).
|
|
1033
1038
|
const ones = new Array(M).fill(1) as number[];
|
|
1034
|
-
// filter + block-skip default
|
|
1039
|
+
// filter + block-skip are OFF by default (env-gated) → EXACT dh. savedBlockMax is
|
|
1040
|
+
// passed so the lossless vocab-block skip can be enabled via env without re-plumbing.
|
|
1035
1041
|
const dhUnit = flashCceBackward(hR, fch, targets, savedLse!, ones, undefined, savedBlockMax!); // [M, hidden] f32
|
|
1036
1042
|
const cM = c.dtype === dhUnit.dtype ? c : c.astype(dhUnit.dtype);
|
|
1037
1043
|
const dh = ops.mul(dhUnit, cM); // broadcast [M,hidden]·[1]
|