mlx-bun 0.0.8 → 0.0.9
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-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/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/spec-decode-larger-targets.md +229 -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 +262 -0
- package/docs/investigations/dspark-handoff.md +58 -0
- package/docs/investigations/kernel-perf-review-2026-07.md +92 -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.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 +40 -9
- package/docs/reference/training.md +25 -11
- 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 +1029 -86
- 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 +141 -18
- package/src/harness-pi.ts +43 -17
- package/src/index.ts +2 -0
- 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/ffi.ts +3 -0
- package/src/mlx/ops.ts +46 -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 +62 -6
- package/src/model/gemma4.ts +33 -2
- package/src/model/minicpm5.ts +106 -8
- package/src/model/qwen3.ts +276 -0
- 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/quantize/config-writer.ts +1 -1
- package/src/registry.ts +215 -7
- package/src/responses.ts +23 -0
- package/src/sampler.ts +220 -9
- package/src/serve/batch-scheduler.ts +272 -80
- package/src/serve/generation-gateway.ts +86 -18
- package/src/server.ts +612 -49
- 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/status-page.html +1 -1
- package/src/tokenizer.ts +7 -0
- package/src/train/flash-cce.ts +70 -15
- 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 +289 -30
- package/src/train/prefix-shared.ts +33 -5
- package/src/train/segmented.ts +67 -11
- package/src/train/trainer.ts +92 -28
- package/src/web/skills/memory/SKILL.md +40 -17
package/src/spec/generate.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
// Speculative decoding loop — port of optiq runtime/spec/runtime.py
|
|
2
2
|
// (γ-draft / verify-in-one-pass / accept-longest-prefix / rollback).
|
|
3
3
|
//
|
|
4
|
-
// Correctness
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
4
|
+
// Correctness oracle: OPTIQ's spec_generate, NOT stock decode. Assistant-
|
|
5
|
+
// drafter speculation is an optiq feature (mlx-lm has only generic
|
|
6
|
+
// two-model spec, and can't drive a KV-borrowing drafter). Both optiq AND
|
|
7
|
+
// mlx-lm BATCH the verify lm-head (one matmul over the whole γ+1 window,
|
|
8
|
+
// then argmax per position) — so neither is bit-exact to stock token-at-
|
|
9
|
+
// a-time decode; they diverge from it at bf16 knife-edges. We do the same
|
|
10
|
+
// (picksBatched): greedy spec is bit-exact to optiq's spec, and agrees
|
|
11
|
+
// with stock only on tie-free prompts (where batched == per-position).
|
|
12
|
+
// An earlier version verified per-position to stay bit-exact to STOCK —
|
|
13
|
+
// that matched no real oracle and cost an extra γ× read of the lm-head
|
|
14
|
+
// weight per step; superseded. See docs/design/spec-decode-larger-targets.md.
|
|
9
15
|
//
|
|
10
16
|
// Limitation (same as the reference): partial-accept rollback requires
|
|
11
17
|
// trimmable caches; RotatingKVCache loses trimability once its ring
|
|
@@ -14,7 +20,6 @@
|
|
|
14
20
|
import { MlxArray } from "../mlx/array";
|
|
15
21
|
import * as ops from "../mlx/ops";
|
|
16
22
|
import { Gemma4Model, type Cache } from "../model/gemma4";
|
|
17
|
-
import { toLogprobs } from "../sampler";
|
|
18
23
|
import type { GemmaAssistantDrafter } from "./drafter";
|
|
19
24
|
|
|
20
25
|
export interface SpecOptions {
|
|
@@ -39,12 +44,8 @@ export interface SpecResult {
|
|
|
39
44
|
stats: SpecStats;
|
|
40
45
|
}
|
|
41
46
|
|
|
42
|
-
/**
|
|
43
|
-
*
|
|
44
|
-
* decode step), then logprobs argmax over [1, V]. Computing the lm-head
|
|
45
|
-
* per position (instead of batched over the verify window) keeps every
|
|
46
|
-
* pick in the same kernel shapes as stock decode — batched lm-head
|
|
47
|
-
* rounds differently and flips bf16 knife-edges. */
|
|
47
|
+
/** Single greedy pick from one hidden position [1,1,H] (lm-head + argmax).
|
|
48
|
+
* Used for the NON-speculative prefill token (matches stock there). */
|
|
48
49
|
function pickFromHidden(model: Gemma4Model, hidden: MlxArray, pos: number): number {
|
|
49
50
|
const H = hidden.shape[2]!;
|
|
50
51
|
const hSl = hidden.slice([0, pos, 0], [1, pos + 1, H]);
|
|
@@ -53,15 +54,36 @@ function pickFromHidden(model: Gemma4Model, hidden: MlxArray, pos: number): numb
|
|
|
53
54
|
const V = logits.shape[2]!;
|
|
54
55
|
const flat = ops.reshape(logits, [1, V]);
|
|
55
56
|
logits.dispose();
|
|
56
|
-
const
|
|
57
|
+
const am = ops.argmaxAxis(flat, -1);
|
|
57
58
|
flat.dispose();
|
|
58
|
-
const am = ops.argmaxAxis(lp, -1);
|
|
59
|
-
lp.dispose();
|
|
60
59
|
const t = ops.itemUint32(am);
|
|
61
60
|
am.dispose();
|
|
62
61
|
return t;
|
|
63
62
|
}
|
|
64
63
|
|
|
64
|
+
/** optiq-faithful batched verify: ONE lm-head over the whole γ+1 window
|
|
65
|
+
* (reads the lm-head weight ONCE, as optiq and mlx-lm both do), then
|
|
66
|
+
* argmax per position. This is what makes greedy spec bit-exact to
|
|
67
|
+
* optiq's spec_generate. argmax is invariant to the logsumexp shift and
|
|
68
|
+
* to the monotone final-logit softcap, so raw-logit argmax here picks the
|
|
69
|
+
* same token optiq does. */
|
|
70
|
+
function picksBatched(model: Gemma4Model, hidden: MlxArray, count: number): number[] {
|
|
71
|
+
const logits = model.logitsFromHidden(hidden); // [1, L, V] — one matmul
|
|
72
|
+
const V = logits.shape[2]!;
|
|
73
|
+
const out: number[] = [];
|
|
74
|
+
for (let k = 0; k < count; k++) {
|
|
75
|
+
const sl = logits.slice([0, k, 0], [1, k + 1, V]);
|
|
76
|
+
const flat = ops.reshape(sl, [1, V]);
|
|
77
|
+
sl.dispose();
|
|
78
|
+
const am = ops.argmaxAxis(flat, -1);
|
|
79
|
+
flat.dispose();
|
|
80
|
+
out.push(ops.itemUint32(am));
|
|
81
|
+
am.dispose();
|
|
82
|
+
}
|
|
83
|
+
logits.dispose();
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
65
87
|
/** Donor caches: the target's LAST sliding and LAST full cache owners
|
|
66
88
|
* (port of optiq kv_view.find_donor_layers). */
|
|
67
89
|
function donorIndices(model: Gemma4Model): { sliding: number; full: number } {
|
|
@@ -163,10 +185,9 @@ export function specGenerate(
|
|
|
163
185
|
vIds.dispose();
|
|
164
186
|
stats.targetCalls++;
|
|
165
187
|
|
|
166
|
-
// 2c. accept the longest matching prefix
|
|
167
|
-
// see
|
|
168
|
-
const gt
|
|
169
|
-
for (let k = 0; k <= gamma; k++) gt.push(pickFromHidden(model, vHidden, k));
|
|
188
|
+
// 2c. accept the longest matching prefix. Batched verify lm-head
|
|
189
|
+
// (optiq-faithful — see picksBatched).
|
|
190
|
+
const gt = picksBatched(model, vHidden, gamma + 1);
|
|
170
191
|
let kAccept = 0;
|
|
171
192
|
while (kAccept < gamma && drafts[kAccept] === gt[kAccept]) kAccept++;
|
|
172
193
|
stats.accepted += kAccept;
|
package/src/status-page.html
CHANGED
|
@@ -285,7 +285,7 @@
|
|
|
285
285
|
<thead><tr><th>Command</th><th style="text-align:left">What it does</th></tr></thead>
|
|
286
286
|
<tbody>
|
|
287
287
|
<tr><td><code>mlx-bun pi</code></td><td style="text-align:left">Agent session on a local model — picks/downloads the right model, serves, drops into pi. Flags: <code>--query</code>, <code>--port</code>; everything else passes to pi (<code>-p</code>, <code>--continue</code>, <code>@file</code>…)</td></tr>
|
|
288
|
-
<tr><td><code>mlx-bun serve <query></code></td><td style="text-align:left">Persistent server + this page. <code>--port
|
|
288
|
+
<tr><td><code>mlx-bun serve <query></code></td><td style="text-align:left">Persistent server + this page. <code>--port 8080</code>, <code>--memory-budget GB</code></td></tr>
|
|
289
289
|
<tr><td><code>mlx-bun get <org/repo></code></td><td style="text-align:left">Resumable, sha256-verified model download</td></tr>
|
|
290
290
|
<tr><td><code>mlx-bun ls</code> / <code>fit</code></td><td style="text-align:left">List downloaded models / will-it-fit assessment (<code>--ctx</code>, <code>--skus</code>)</td></tr>
|
|
291
291
|
<tr><td><code>mlx-bun harness pi</code></td><td style="text-align:left">Register this server as a provider in your own pi install (<code>--remove</code> undoes)</td></tr>
|
package/src/tokenizer.ts
CHANGED
|
@@ -11,6 +11,10 @@ export interface LoadedTokenizer {
|
|
|
11
11
|
* you get a corrupting double-BOS `<s><s>…`). */
|
|
12
12
|
encode(text: string, addSpecialTokens?: boolean): number[];
|
|
13
13
|
decode(ids: number[], skipSpecialTokens?: boolean): string;
|
|
14
|
+
/** Token id → raw vocab token string (mlx-lm's convert_ids_to_tokens —
|
|
15
|
+
* the undecoded piece, e.g. "▁Hello", used in logprobs responses).
|
|
16
|
+
* Falls back to decode() for ids outside the base vocab (added tokens). */
|
|
17
|
+
idToToken(id: number): string;
|
|
14
18
|
readonly bosTokenId: number | null;
|
|
15
19
|
readonly eosTokenId: number | null;
|
|
16
20
|
}
|
|
@@ -36,6 +40,9 @@ export async function loadTokenizer(modelDir: string): Promise<LoadedTokenizer>
|
|
|
36
40
|
decode: (ids, skipSpecialTokens = false) =>
|
|
37
41
|
// python's decode([]) === ""; the JS lib throws on empty input
|
|
38
42
|
ids.length === 0 ? "" : tok.decode(ids, { skip_special_tokens: skipSpecialTokens }),
|
|
43
|
+
idToToken: (id) =>
|
|
44
|
+
(tok as unknown as { id_to_token(id: number): string | undefined })
|
|
45
|
+
.id_to_token(id) ?? tok.decode([id], { skip_special_tokens: false }),
|
|
39
46
|
bosTokenId: idOf("bos_token"),
|
|
40
47
|
eosTokenId: idOf("eos_token"),
|
|
41
48
|
};
|
package/src/train/flash-cce.ts
CHANGED
|
@@ -723,12 +723,16 @@ const BWD_SG_SOURCE = String.raw`
|
|
|
723
723
|
}
|
|
724
724
|
`;
|
|
725
725
|
|
|
726
|
-
// Apple-CCE coeff filter epsilon (phase-2 per-tile skip). DEFAULT OFF ("0") —
|
|
727
|
-
//
|
|
728
|
-
//
|
|
729
|
-
//
|
|
730
|
-
//
|
|
731
|
-
//
|
|
726
|
+
// Apple-CCE coeff filter epsilon (phase-2 per-tile skip). DEFAULT OFF ("0") today —
|
|
727
|
+
// no production path sets the env — but the VERDICT is measured and positive: the old
|
|
728
|
+
// 0.66%→2.7% dh-accuracy scare was on RANDOM targets (flat softmax, the filter's
|
|
729
|
+
// worst case — see PLAN.md "filter-on-real-data"); the real-data gate
|
|
730
|
+
// (scripts/experiments/flash-cce-filter-realdata.ts, commit 3d259ef, 2026-06-22)
|
|
731
|
+
// measured eps 1e-5 = 0.16% dh (under the bf16 floor) for a 1.35× faster backward,
|
|
732
|
+
// and PLAN.md's recorded decision is "enable the filter at eps ~1e-5". (The ~3.5×
|
|
733
|
+
// figure floating around older notes was the retired SG kernel, not steel.) Flipping
|
|
734
|
+
// the default is the open backlog item; until then opt in with
|
|
735
|
+
// MLX_BUN_CCE_BWD_FILTER_EPS=1e-5 on real (peaked) training data.
|
|
732
736
|
const BWD_FILTER_EPS = process.env.MLX_BUN_CCE_BWD_FILTER_EPS ?? "0";
|
|
733
737
|
// blockMax vocab-block early-exit eps (skip whole cold (token-block,vocab-block) programs
|
|
734
738
|
// — attacks BOTH phases). DEFAULT OFF ("0"). It is LOSSLESS when it fires (only skips
|
|
@@ -1005,6 +1009,22 @@ function fwdSteelKernel(gs: number, bits: number): MetalKernel {
|
|
|
1005
1009
|
return k;
|
|
1006
1010
|
}
|
|
1007
1011
|
|
|
1012
|
+
// One-time dispatch-route warnings. A failed tiling guard silently reroutes the
|
|
1013
|
+
// whole flash head onto the legacy kernels (~5× slower class: e4b M=512 backward
|
|
1014
|
+
// 754 → 3687+ ms) with no observable signal — the exact "forward passes are
|
|
1015
|
+
// flag-gated, verify in code" hazard. Name the failed guard once per route so a
|
|
1016
|
+
// new model port that misses steel is visible in the training log.
|
|
1017
|
+
const _steelFallbackWarned = new Set<string>();
|
|
1018
|
+
function warnSteelFallback(which: "forward" | "backward", detail: string, fallback: string): void {
|
|
1019
|
+
if (_steelFallbackWarned.has(which)) return;
|
|
1020
|
+
_steelFallbackWarned.add(which);
|
|
1021
|
+
console.warn(
|
|
1022
|
+
`[flash-cce] ${which}: steel kernel guard failed (${detail}) — dispatching the ` +
|
|
1023
|
+
`${fallback} instead (~5x-slower class). If this model should be steel-capable, ` +
|
|
1024
|
+
`check the tiling guards in flash-cce.ts.`,
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1008
1028
|
export function flashCceForward(
|
|
1009
1029
|
hResp: MlxArray, head: FlashCceHead, targets: number[],
|
|
1010
1030
|
): { logp: MlxArray; lse: MlxArray; blockMax: MlxArray } {
|
|
@@ -1039,6 +1059,15 @@ export function flashCceForward(
|
|
|
1039
1059
|
const blockV0 = Math.ceil(V / NBLK);
|
|
1040
1060
|
const useSteel = process.env.MLX_BUN_CCE_NOSTEEL !== "1" && H % 32 === 0 && V % 32 === 0 && blockV0 % 32 === 0;
|
|
1041
1061
|
const useSimd = !useSteel && USE_SIMDGROUP_FWD && H % SG_BD === 0;
|
|
1062
|
+
if (!useSteel && process.env.MLX_BUN_CCE_NOSTEEL !== "1") {
|
|
1063
|
+
// NOTE blockV0 = ceil(V/NBLK) can violate %32 even when V%32==0 (e.g. V=50304)
|
|
1064
|
+
const fails = [
|
|
1065
|
+
H % 32 !== 0 ? `H%32!=0 (H=${H})` : null,
|
|
1066
|
+
V % 32 !== 0 ? `V%32!=0 (V=${V})` : null,
|
|
1067
|
+
blockV0 % 32 !== 0 ? `blockV0%32!=0 (blockV0=${blockV0}, V=${V}, NBLK=${NBLK})` : null,
|
|
1068
|
+
].filter(Boolean).join(", ");
|
|
1069
|
+
warnSteelFallback("forward", fails, useSimd ? "simdgroup kernel" : "scalar/lane kernel");
|
|
1070
|
+
}
|
|
1042
1071
|
const useLane = !useSteel && !useSimd && process.env.MLX_BUN_CCE_LANE === "1" && WPR % 32 === 0;
|
|
1043
1072
|
const kernel = useSteel ? fwdSteelKernel(GS, head.bits) : useSimd ? fwdSgKernel() : useLane ? fwdLaneKernel() : fwdKernel();
|
|
1044
1073
|
const tokTile = useSteel ? STEEL_BM : useSimd ? SG_BB : BLOCK_B;
|
|
@@ -1103,10 +1132,11 @@ export function flashCceForward(
|
|
|
1103
1132
|
* (no atomics). Caller owns the result. */
|
|
1104
1133
|
export function flashCceBackward(
|
|
1105
1134
|
hResp: MlxArray, head: FlashCceHead, targets: number[], lse: MlxArray, cot: number[],
|
|
1106
|
-
// Apple-CCE coeff filter eps.
|
|
1107
|
-
//
|
|
1108
|
-
//
|
|
1109
|
-
//
|
|
1135
|
+
// Apple-CCE coeff filter eps. Defaults to BWD_FILTER_EPS, which is OFF ("0")
|
|
1136
|
+
// unless MLX_BUN_CCE_BWD_FILTER_EPS is set — exact gradients by default. The
|
|
1137
|
+
// measured real-data case FOR enabling it (eps 1e-5 = 0.16% dh, 1.35×; PLAN.md
|
|
1138
|
+
// "filter-on-real-data") is a pending default flip — see BWD_FILTER_EPS's note.
|
|
1139
|
+
// Pass "0" explicitly for guaranteed-exact (the parity gate does).
|
|
1110
1140
|
filterEps: string = BWD_FILTER_EPS,
|
|
1111
1141
|
// The forward's `blockMax [M, NBLK]` enables the vocab-block early exit (skips
|
|
1112
1142
|
// whole cold programs — attacks phase 1). Omit (or set blockEps "0") for exact.
|
|
@@ -1119,7 +1149,10 @@ export function flashCceBackward(
|
|
|
1119
1149
|
const GS = head.groupSize;
|
|
1120
1150
|
const WPR = head.w.shape[1]!;
|
|
1121
1151
|
if (H > H_MAX) throw new Error(`flashCceBackward: hidden ${H} > H_MAX ${H_MAX}`);
|
|
1122
|
-
|
|
1152
|
+
// NOTE: H % TG is a LEGACY-SCALAR-kernel requirement only (it owns DPER = H/TG
|
|
1153
|
+
// dims per thread — floor division, so a remainder would leave dims silently
|
|
1154
|
+
// zero). The steel/simdgroup kernels have their own (weaker) guards below; the
|
|
1155
|
+
// throw is applied only when dispatch actually lands on the scalar kernel.
|
|
1123
1156
|
|
|
1124
1157
|
const hF = hResp.dtype === Dtype.float32 ? hResp : hResp.astype(Dtype.float32);
|
|
1125
1158
|
const scF = head.scales.dtype === Dtype.float32 ? head.scales : head.scales.astype(Dtype.float32);
|
|
@@ -1133,10 +1166,12 @@ export function flashCceBackward(
|
|
|
1133
1166
|
|
|
1134
1167
|
// STEEL is the DEFAULT (verbatim MLX BlockMMA GEMM for both phases, [M,V]-free) when
|
|
1135
1168
|
// the tiling is clean (H,V,blockV all %32 AND H % HTw for the H-tiled accumulator).
|
|
1136
|
-
// MLX_BUN_CCE_BWD_NOSTEEL=1 falls back to the old kernels. The steel backward
|
|
1137
|
-
//
|
|
1138
|
-
//
|
|
1139
|
-
//
|
|
1169
|
+
// MLX_BUN_CCE_BWD_NOSTEEL=1 falls back to the old kernels. The steel backward carries
|
|
1170
|
+
// the SAME Apple-CCE skips as the SG kernel — coeff filter (phase-2 per-tile) +
|
|
1171
|
+
// blockMax vocab-block early exit (whole-program) — both compiled out at eps=0.
|
|
1172
|
+
// BOTH skips are OFF by default (the envs default "0", nothing in production sets
|
|
1173
|
+
// them): exact gradients unless explicitly opted in. Enabling the coeff filter at
|
|
1174
|
+
// ~1e-5 is the measured-and-decided pending flip (PLAN.md "filter-on-real-data").
|
|
1140
1175
|
const blockV0 = Math.ceil(V / NBLK);
|
|
1141
1176
|
// HTw == group_size for the phase-2 QuantizedBlockLoader (one group per H-tile); needs
|
|
1142
1177
|
// H % GS == 0 (always, GR*GS=H) and GS % 32 == 0 (TNv = GS/32 frags per H-tile integral).
|
|
@@ -1144,6 +1179,26 @@ export function flashCceBackward(
|
|
|
1144
1179
|
// simdgroup_matrix phase-2 GEMM is the next tier (5512→3913 ms, 1.41×) when the H
|
|
1145
1180
|
// tiling is clean; MLX_BUN_CCE_BWD_SCALAR=1 forces the float4 scalar kernel.
|
|
1146
1181
|
const useSg = !useSteel && process.env.MLX_BUN_CCE_BWD_SCALAR !== "1" && H % (SGN * 8) === 0 && H % SG_BD === 0;
|
|
1182
|
+
if (!useSteel && process.env.MLX_BUN_CCE_BWD_NOSTEEL !== "1") {
|
|
1183
|
+
// NOTE blockV0 = ceil(V/NBLK) can violate %32 even when V%32==0 (e.g. V=50304)
|
|
1184
|
+
const fails = [
|
|
1185
|
+
H % 32 !== 0 ? `H%32!=0 (H=${H})` : null,
|
|
1186
|
+
V % 32 !== 0 ? `V%32!=0 (V=${V})` : null,
|
|
1187
|
+
blockV0 % 32 !== 0 ? `blockV0%32!=0 (blockV0=${blockV0}, V=${V}, NBLK=${NBLK})` : null,
|
|
1188
|
+
H % GS !== 0 ? `H%GS!=0 (H=${H}, GS=${GS})` : null,
|
|
1189
|
+
GS % 32 !== 0 ? `GS%32!=0 (GS=${GS})` : null,
|
|
1190
|
+
].filter(Boolean).join(", ");
|
|
1191
|
+
warnSteelFallback("backward", fails, useSg ? "simdgroup kernel" : "scalar kernel");
|
|
1192
|
+
}
|
|
1193
|
+
// Legacy scalar kernel: each thread owns DPER = H/TG dims (floor) — a remainder
|
|
1194
|
+
// would leave the last H%TG dims silently zero, so it hard-requires H % TG == 0.
|
|
1195
|
+
// Only enforced when dispatch actually lands there (steel/SG have weaker guards).
|
|
1196
|
+
if (!useSteel && !useSg && H % TG !== 0)
|
|
1197
|
+
throw new Error(
|
|
1198
|
+
`flashCceBackward: hidden ${H} not divisible by TG ${TG} — required by the legacy ` +
|
|
1199
|
+
`scalar kernel, which is the selected route because the steel/simdgroup tiling ` +
|
|
1200
|
+
`guards also failed for this shape (see the [flash-cce] warning above).`,
|
|
1201
|
+
);
|
|
1147
1202
|
// Block-skip is on (either kernel) only when blockMax is supplied AND blockEps>0 (the
|
|
1148
1203
|
// kernel reads it as an extra input). The coeff filter needs no extra input (just eps).
|
|
1149
1204
|
const useBlockSkip = (useSteel || useSg) && blockMax != null && Number.parseFloat(blockEps) > 0;
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Fuse a LoRA adapter into base model weights → a standalone model snapshot
|
|
2
|
+
// (mlx_lm.fuse counterpart, minus GGUF/de-quantize export).
|
|
3
|
+
//
|
|
4
|
+
// Math (matches mlx-lm's LoRALinear.fuse and our QuantizedLinear.forward):
|
|
5
|
+
// forward: y = x @ W_dequantᵀ + scale · ((x @ A) @ B)
|
|
6
|
+
// fused: W'[out,in] = W + (scale · B)ᵀ.astype(bf16) @ Aᵀ.astype(bf16)
|
|
7
|
+
// mlx-lm casts the (scaled) LoRA factors to the base dtype BEFORE the matmul,
|
|
8
|
+
// then re-quantizes with the module's OWN source spec (bits/group unchanged) —
|
|
9
|
+
// we do the same, so the output config.json is the source config verbatim.
|
|
10
|
+
//
|
|
11
|
+
// Everything runs on the CPU stream (same discipline as src/quantize/quantizer:
|
|
12
|
+
// the safetensors loader lives there, and one module is materialized, fused,
|
|
13
|
+
// and disposed at a time). Modules the adapter does not touch pass through
|
|
14
|
+
// bit-identical (no dequant→requant round trip).
|
|
15
|
+
|
|
16
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { clearCache, Dtype } from "../mlx/ffi";
|
|
19
|
+
import { cpuStream, type MlxArray } from "../mlx/array";
|
|
20
|
+
import * as ops from "../mlx/ops";
|
|
21
|
+
import { Weights } from "../weights";
|
|
22
|
+
import { loadModelConfig, quantFor } from "../config";
|
|
23
|
+
import { loadAdapterTensors, readAdapterScale, adapterWeightsFile } from "../lora";
|
|
24
|
+
import { writeShardedSafetensors, type NamedTensor } from "../quantize/safetensors-writer";
|
|
25
|
+
import { copyAuxFiles } from "../quantize/config-writer";
|
|
26
|
+
|
|
27
|
+
/** Quantize the allocator cache this often (in modules processed). */
|
|
28
|
+
const CACHE_CLEAR_EVERY = 16;
|
|
29
|
+
|
|
30
|
+
export interface FuseStats {
|
|
31
|
+
outDir: string;
|
|
32
|
+
/** Modules whose weights had an adapter delta folded in. */
|
|
33
|
+
fusedModules: number;
|
|
34
|
+
/** Adapter tensors that matched no base weight (skipped). */
|
|
35
|
+
skippedAdapterTensors: number;
|
|
36
|
+
totalTensors: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface FuseProgress {
|
|
40
|
+
stage: string;
|
|
41
|
+
message: string;
|
|
42
|
+
/** 0..1 fraction of modules processed. */
|
|
43
|
+
progress: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Resolve the adapter safetensors file: `<dir>/adapters.safetensors`,
|
|
47
|
+
* `<dir>/best/adapters.safetensors`, `adapter_model.safetensors`, or an
|
|
48
|
+
* explicit file path. */
|
|
49
|
+
function resolveAdapterFile(dir: string): string {
|
|
50
|
+
const best = join(dir, "best");
|
|
51
|
+
if (existsSync(join(best, "adapters.safetensors"))) return join(best, "adapters.safetensors");
|
|
52
|
+
if (dir.endsWith(".safetensors") && existsSync(dir)) return dir;
|
|
53
|
+
return adapterWeightsFile(dir);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Group adapter tensors into (modulePath → {a, b}); disposes non-LoRA
|
|
57
|
+
* tensors and returns how many were skipped. */
|
|
58
|
+
function groupPairs(tensors: Map<string, MlxArray>): {
|
|
59
|
+
pairs: Map<string, { a?: MlxArray; b?: MlxArray }>;
|
|
60
|
+
skipped: number;
|
|
61
|
+
} {
|
|
62
|
+
const pairs = new Map<string, { a?: MlxArray; b?: MlxArray }>();
|
|
63
|
+
let skipped = 0;
|
|
64
|
+
for (const [name, arr] of tensors) {
|
|
65
|
+
const m = name.match(/^(.*)\.(lora_a|lora_A|lora_b|lora_B)(\.weight)?$/);
|
|
66
|
+
if (!m) { skipped++; arr.dispose(); continue; }
|
|
67
|
+
const slot = pairs.get(m[1]!) ?? {};
|
|
68
|
+
if (m[2]!.toLowerCase() === "lora_a") slot.a = arr; else slot.b = arr;
|
|
69
|
+
pairs.set(m[1]!, slot);
|
|
70
|
+
}
|
|
71
|
+
return { pairs, skipped };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Fuse the adapter at `adapterDir` into the base model at `modelDir` and
|
|
76
|
+
* write a complete, loadable snapshot to `outDir` (weights + config.json +
|
|
77
|
+
* tokenizer aux files). Quantization layout is preserved verbatim: fused
|
|
78
|
+
* modules are dequantized, updated, and re-quantized with their own source
|
|
79
|
+
* spec; untouched modules pass through bit-identical.
|
|
80
|
+
*/
|
|
81
|
+
export async function fuseAdapter(
|
|
82
|
+
modelDir: string,
|
|
83
|
+
adapterDir: string,
|
|
84
|
+
outDir: string,
|
|
85
|
+
onProgress?: (e: FuseProgress) => void,
|
|
86
|
+
): Promise<FuseStats> {
|
|
87
|
+
const progress = (stage: string, message: string, frac: number) =>
|
|
88
|
+
onProgress?.({ stage, message, progress: frac });
|
|
89
|
+
|
|
90
|
+
progress("loading", `Reading ${modelDir}`, 0);
|
|
91
|
+
const config = await loadModelConfig(modelDir);
|
|
92
|
+
const weights = await Weights.open(modelDir);
|
|
93
|
+
|
|
94
|
+
const adapterFile = resolveAdapterFile(adapterDir);
|
|
95
|
+
const { scale, rsLora } = await readAdapterScale(adapterDir);
|
|
96
|
+
const { pairs, skipped } = groupPairs(loadAdapterTensors(adapterFile));
|
|
97
|
+
|
|
98
|
+
// Map each adapter module path to the base weight-file module path,
|
|
99
|
+
// probing the VLM-wrapped/unwrapped `language_model.` prefixes both ways
|
|
100
|
+
// (same normalization as AdapterManager.mount).
|
|
101
|
+
const weightNames = new Set(weights.tensorNames);
|
|
102
|
+
const byModule = new Map<string, { a: MlxArray; b: MlxArray }>();
|
|
103
|
+
let unmatched = 0;
|
|
104
|
+
const disposeAll = () => {
|
|
105
|
+
for (const { a, b } of pairs.values()) { a?.dispose(); b?.dispose(); }
|
|
106
|
+
};
|
|
107
|
+
try {
|
|
108
|
+
for (const [path, { a, b }] of pairs) {
|
|
109
|
+
if (!a || !b) throw new Error(`${path}: adapter has only one of lora_a/lora_b`);
|
|
110
|
+
const candidates = [
|
|
111
|
+
path,
|
|
112
|
+
`language_model.${path}`,
|
|
113
|
+
path.startsWith("language_model.") ? path.slice("language_model.".length) : null,
|
|
114
|
+
].filter((c): c is string => c !== null);
|
|
115
|
+
const hit = candidates.find((c) => weightNames.has(`${c}.weight`));
|
|
116
|
+
if (!hit) { unmatched++; continue; }
|
|
117
|
+
byModule.set(hit, { a, b });
|
|
118
|
+
}
|
|
119
|
+
if (byModule.size === 0)
|
|
120
|
+
throw new Error(
|
|
121
|
+
`no adapter tensors match the base model's weights — was ` +
|
|
122
|
+
`${adapterFile} trained for a different base?`,
|
|
123
|
+
);
|
|
124
|
+
} catch (e) {
|
|
125
|
+
disposeAll();
|
|
126
|
+
weights.dispose();
|
|
127
|
+
throw e;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
mkdirSync(outDir, { recursive: true });
|
|
131
|
+
|
|
132
|
+
// Walk tensors in source order (diff-friendly like the quantizer): group
|
|
133
|
+
// names into module bases so a fused module's weight/scales/biases are
|
|
134
|
+
// regenerated together.
|
|
135
|
+
const names = weights.tensorNames;
|
|
136
|
+
const moduleSuffixes = new Map<string, Set<string>>();
|
|
137
|
+
const passthroughNames: string[] = [];
|
|
138
|
+
for (const name of names) {
|
|
139
|
+
const m = /^(.*)\.(weight|scales|biases)$/.exec(name);
|
|
140
|
+
if (!m) { passthroughNames.push(name); continue; }
|
|
141
|
+
let set = moduleSuffixes.get(m[1]!);
|
|
142
|
+
if (!set) { set = new Set(); moduleSuffixes.set(m[1]!, set); }
|
|
143
|
+
set.add(m[2]!);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const out: NamedTensor[] = [];
|
|
147
|
+
let fusedModules = 0;
|
|
148
|
+
let processed = 0;
|
|
149
|
+
const totalModules = moduleSuffixes.size;
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
for (const [base, suffixes] of moduleSuffixes) {
|
|
153
|
+
processed++;
|
|
154
|
+
if (processed % CACHE_CLEAR_EVERY === 0) clearCache();
|
|
155
|
+
const pair = suffixes.has("weight") ? byModule.get(base) : undefined;
|
|
156
|
+
if (!pair) {
|
|
157
|
+
for (const suf of suffixes) {
|
|
158
|
+
const n = `${base}.${suf}`;
|
|
159
|
+
out.push({ name: n, array: weights.tensor(n) });
|
|
160
|
+
}
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
progress("fusing", `Module ${processed}/${totalModules}: ${base}`, processed / totalModules);
|
|
164
|
+
|
|
165
|
+
// Materialize the full-precision weight [out, in].
|
|
166
|
+
const srcSpec = suffixes.has("scales") ? quantFor(config.quantization, base) : null;
|
|
167
|
+
let w: MlxArray;
|
|
168
|
+
if (srcSpec) {
|
|
169
|
+
const packed = weights.tensor(`${base}.weight`);
|
|
170
|
+
const scales = weights.tensor(`${base}.scales`);
|
|
171
|
+
const biases = suffixes.has("biases") ? weights.tensor(`${base}.biases`) : null;
|
|
172
|
+
w = ops.dequantize(packed, scales, biases, srcSpec, cpuStream);
|
|
173
|
+
} else {
|
|
174
|
+
w = weights.tensor(`${base}.weight`);
|
|
175
|
+
}
|
|
176
|
+
const [outF, inF] = w.shape as [number, number];
|
|
177
|
+
const { a, b } = pair;
|
|
178
|
+
const [aIn, rank] = a.shape as [number, number];
|
|
179
|
+
const [bRank, bOut] = b.shape as [number, number];
|
|
180
|
+
if (aIn !== inF || bRank !== rank || bOut !== outF)
|
|
181
|
+
throw new Error(
|
|
182
|
+
`${base}: shape mismatch — lora_a [${a.shape}] / lora_b [${b.shape}] ` +
|
|
183
|
+
`vs base [out ${outF}, in ${inF}]`,
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
// delta[out,in] = (scale·B)ᵀ.astype(bf16) @ Aᵀ.astype(bf16)
|
|
187
|
+
// (rsLoRA: effective per-layer scale is α/√rank, matching mount/training.)
|
|
188
|
+
const effScale = rsLora ? scale / Math.sqrt(rank) : scale;
|
|
189
|
+
const bScaled = ops.mulScalar(b, effScale, cpuStream);
|
|
190
|
+
const bT = ops.transposeAxes(bScaled, [1, 0], cpuStream);
|
|
191
|
+
bScaled.dispose();
|
|
192
|
+
const bT16 = bT.astype(Dtype.bfloat16, cpuStream);
|
|
193
|
+
bT.dispose();
|
|
194
|
+
const aT = ops.transposeAxes(a, [1, 0], cpuStream);
|
|
195
|
+
const aT16 = aT.astype(Dtype.bfloat16, cpuStream);
|
|
196
|
+
aT.dispose();
|
|
197
|
+
const delta = ops.matmul(bT16, aT16, cpuStream);
|
|
198
|
+
bT16.dispose();
|
|
199
|
+
aT16.dispose();
|
|
200
|
+
|
|
201
|
+
const w16 = w.dtype === Dtype.bfloat16 ? w : w.astype(Dtype.bfloat16, cpuStream);
|
|
202
|
+
if (w16 !== w) w.dispose();
|
|
203
|
+
const fusedW = ops.add(w16, delta, cpuStream);
|
|
204
|
+
w16.dispose();
|
|
205
|
+
delta.dispose();
|
|
206
|
+
|
|
207
|
+
if (srcSpec) {
|
|
208
|
+
const q = ops.quantize(fusedW, srcSpec.groupSize, srcSpec.bits, srcSpec.mode, cpuStream);
|
|
209
|
+
fusedW.dispose();
|
|
210
|
+
out.push({ name: `${base}.weight`, array: q.packed });
|
|
211
|
+
out.push({ name: `${base}.scales`, array: q.scales });
|
|
212
|
+
out.push({ name: `${base}.biases`, array: q.biases });
|
|
213
|
+
} else {
|
|
214
|
+
out.push({ name: `${base}.weight`, array: fusedW });
|
|
215
|
+
}
|
|
216
|
+
fusedModules++;
|
|
217
|
+
}
|
|
218
|
+
for (const name of passthroughNames) out.push({ name, array: weights.tensor(name) });
|
|
219
|
+
|
|
220
|
+
progress("writing", `Writing ${out.length} tensors to ${outDir}`, 1);
|
|
221
|
+
writeShardedSafetensors(outDir, out);
|
|
222
|
+
|
|
223
|
+
// Config is unchanged (same quantization layout) — copy it and the aux
|
|
224
|
+
// files (tokenizer, chat template, …) verbatim for a loadable snapshot.
|
|
225
|
+
await Bun.write(join(outDir, "config.json"), Bun.file(join(modelDir, "config.json")));
|
|
226
|
+
await copyAuxFiles(modelDir, outDir);
|
|
227
|
+
|
|
228
|
+
progress("done", `Fused ${fusedModules} module(s) → ${outDir}`, 1);
|
|
229
|
+
return {
|
|
230
|
+
outDir,
|
|
231
|
+
fusedModules,
|
|
232
|
+
skippedAdapterTensors: skipped + unmatched * 2,
|
|
233
|
+
totalTensors: out.length,
|
|
234
|
+
};
|
|
235
|
+
} finally {
|
|
236
|
+
for (const t of out) {
|
|
237
|
+
try { t.array.dispose(); } catch {}
|
|
238
|
+
}
|
|
239
|
+
disposeAll();
|
|
240
|
+
weights.dispose();
|
|
241
|
+
clearCache();
|
|
242
|
+
}
|
|
243
|
+
}
|
package/src/train/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ export { trainForward, buildBatchedPadMask } from "./forward";
|
|
|
14
14
|
|
|
15
15
|
export {
|
|
16
16
|
sftLoss, dpoLoss, dpoRefLogps, dpoMetrics,
|
|
17
|
+
orpoLoss, orpoMetrics, type SftScope,
|
|
17
18
|
} from "./loss";
|
|
18
19
|
|
|
19
20
|
export {
|
|
@@ -38,6 +39,8 @@ export {
|
|
|
38
39
|
|
|
39
40
|
export { mergeAdapters, type MergeStats } from "./merge";
|
|
40
41
|
|
|
42
|
+
export { fuseAdapter, type FuseStats, type FuseProgress } from "./fuse";
|
|
43
|
+
|
|
41
44
|
export { exportAdapter, type ExportManifest } from "./export";
|
|
42
45
|
|
|
43
46
|
export { finetuneRunner, inspectDataset } from "./job";
|
package/src/train/job.ts
CHANGED
|
@@ -51,6 +51,7 @@ interface FinetuneSubmit {
|
|
|
51
51
|
orpo_fused_ce?: boolean;
|
|
52
52
|
orpo_flash_ce?: boolean;
|
|
53
53
|
orpo_prefix_shared?: boolean;
|
|
54
|
+
sft_scope?: "full" | "response";
|
|
54
55
|
warm_start_adapter?: string;
|
|
55
56
|
}
|
|
56
57
|
|
|
@@ -68,6 +69,12 @@ function parseConfig(raw: Record<string, unknown>): { modelDir: string; dataDir:
|
|
|
68
69
|
// quantized-head pieces if the base turns out to be unquantized.
|
|
69
70
|
const isOrpo = (c.method ?? "sft") === "orpo";
|
|
70
71
|
|
|
72
|
+
// ORPO L_SFT scope: "full" (default; paper/TRL-faithful) | "response"
|
|
73
|
+
// (pre-2026-07 behavior). Validate here so a typo fails at submit, not mid-run.
|
|
74
|
+
const sftScope = c.sft_scope ?? DEFAULT_TRAIN_CONFIG.sftScope;
|
|
75
|
+
if (sftScope !== "full" && sftScope !== "response")
|
|
76
|
+
throw new Error(`finetune job: sft_scope must be "full" or "response" (got ${JSON.stringify(c.sft_scope)})`);
|
|
77
|
+
|
|
71
78
|
const cfg: TrainConfig = {
|
|
72
79
|
...DEFAULT_TRAIN_CONFIG,
|
|
73
80
|
method: c.method ?? "sft",
|
|
@@ -105,6 +112,7 @@ function parseConfig(raw: Record<string, unknown>): { modelDir: string; dataDir:
|
|
|
105
112
|
orpoFusedCe: c.orpo_fused_ce ?? DEFAULT_TRAIN_CONFIG.orpoFusedCe,
|
|
106
113
|
orpoFlashCe: c.orpo_flash_ce ?? (isOrpo ? true : DEFAULT_TRAIN_CONFIG.orpoFlashCe),
|
|
107
114
|
orpoPrefixShared: c.orpo_prefix_shared ?? (isOrpo ? true : DEFAULT_TRAIN_CONFIG.orpoPrefixShared),
|
|
115
|
+
sftScope,
|
|
108
116
|
warmStartAdapter: c.warm_start_adapter ?? DEFAULT_TRAIN_CONFIG.warmStartAdapter,
|
|
109
117
|
adapterPath: c.adapter_path,
|
|
110
118
|
baseModel: c.model_dir,
|