mlx-bun 0.0.9 → 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/docs/design/batching-perf-path.md +232 -0
- package/docs/design/omlx-adoption-map.md +90 -0
- package/docs/design/segmented-backward-training.md +15 -0
- package/docs/design/ssd-kv-cold-tier.md +158 -0
- package/docs/investigations/decode-roofline-lookagain.md +92 -6
- package/docs/investigations/kernel-perf-review-2026-07.md +105 -26
- package/docs/planning/release-notes-v0.0.10.md +57 -0
- package/docs/reference/server-config.md +3 -0
- package/docs/reference/training.md +6 -1
- package/package.json +1 -1
- package/src/cli.ts +49 -5
- package/src/generate.ts +8 -0
- package/src/kv-store.ts +240 -98
- package/src/mlx/array.ts +21 -0
- package/src/mlx/ops.ts +26 -0
- package/src/model/gemma4-base.ts +34 -1
- package/src/model/qwen3-delta.ts +48 -1
- package/src/model/qwen3_5.ts +11 -2
- package/src/prompt-cache.ts +55 -9
- package/src/registry.ts +4 -1
- package/src/serve/batch-scheduler.ts +41 -9
- package/src/serve/generation-gateway.ts +78 -19
- package/src/server.ts +135 -25
- package/src/ssd-cache.ts +213 -0
- package/src/train/flash-cce.ts +27 -24
- package/src/train/loss.ts +53 -11
- package/src/train/prefix-shared.ts +30 -7
- package/src/train/segmented.ts +150 -46
- package/src/train/trainer.ts +21 -11
package/src/ssd-cache.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// SSD cold tier for the prompt/KV cache (docs/design/ssd-kv-cold-tier.md).
|
|
2
|
+
//
|
|
3
|
+
// Files ARE the database: no sidecar index. Layout
|
|
4
|
+
// <dir>/<configFingerprint>/<nsHash>/<uuid>.mlxkv
|
|
5
|
+
// so two models (or two kv-quant schemes of one model) share a directory
|
|
6
|
+
// without ever colliding, and an incompatible fingerprint dir is simply
|
|
7
|
+
// ignored (never deleted — it may belong to the user's other model).
|
|
8
|
+
// Startup recovery = a header-only scan of OUR fingerprint dir: header hash
|
|
9
|
+
// verified (cheap), corrupt or metadata-mismatched files unlinked, `.tmp`
|
|
10
|
+
// write orphans reaped. LRU state = file mtime (bumped on hit); the byte
|
|
11
|
+
// cap is enforced at write time by oldest-mtime eviction. A cache is always
|
|
12
|
+
// droppable: every failure path degrades to "no hit" or "not stored" — the
|
|
13
|
+
// tier must never take serving down (oMLX paged_ssd_cache lesson, Apache-2.0
|
|
14
|
+
// idea port; ours spills whole prefix entries, not content-hashed blocks —
|
|
15
|
+
// see the design doc's D1 for why).
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
existsSync, mkdirSync, readdirSync, rmSync, statSync, utimesSync,
|
|
19
|
+
} from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { randomUUID } from "node:crypto";
|
|
22
|
+
import { commonPrefixLength } from "./prompt-cache";
|
|
23
|
+
import {
|
|
24
|
+
saveKvCache, loadKvCache, readKvHeader,
|
|
25
|
+
type KvSaveMeta, type KvLoadExpect, type LoadedKvCache,
|
|
26
|
+
} from "./kv-store";
|
|
27
|
+
import type { Cache } from "./model/gemma4-base";
|
|
28
|
+
|
|
29
|
+
export interface SsdIndexEntry {
|
|
30
|
+
path: string;
|
|
31
|
+
ns: string;
|
|
32
|
+
tokens: number[];
|
|
33
|
+
bytes: number;
|
|
34
|
+
mtimeMs: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SsdStoreOptions {
|
|
38
|
+
dir: string;
|
|
39
|
+
maxBytes: number;
|
|
40
|
+
/** Server identity — written into every file, enforced on scan/load. */
|
|
41
|
+
configFingerprint: string;
|
|
42
|
+
tokenizerHash: string;
|
|
43
|
+
modelId: string;
|
|
44
|
+
/** Verify every tensor hash on load (--ssd-cache-verify). */
|
|
45
|
+
verify?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const nsHash = (ns: string): string =>
|
|
49
|
+
ns === "" ? "base" : Bun.hash(ns).toString(16);
|
|
50
|
+
|
|
51
|
+
export class SsdCacheStore {
|
|
52
|
+
readonly #opts: SsdStoreOptions;
|
|
53
|
+
readonly #root: string; // <dir>/<configFingerprint>
|
|
54
|
+
#index: SsdIndexEntry[] = [];
|
|
55
|
+
#warnedWriteFailure = false;
|
|
56
|
+
stats = { restores: 0, spills: 0, restoreMsLast: 0 };
|
|
57
|
+
|
|
58
|
+
constructor(opts: SsdStoreOptions) {
|
|
59
|
+
this.#opts = opts;
|
|
60
|
+
this.#root = join(opts.dir, opts.configFingerprint);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get entries(): number {
|
|
64
|
+
return this.#index.length;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
get totalBytes(): number {
|
|
68
|
+
return this.#index.reduce((a, e) => a + e.bytes, 0);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
get maxBytes(): number {
|
|
72
|
+
return this.#opts.maxBytes;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Startup recovery: rebuild the in-memory index from disk. Corrupt
|
|
76
|
+
* headers and metadata mismatches are unlinked; `.tmp` orphans reaped.
|
|
77
|
+
* Only OUR fingerprint dir is touched. Returns entries indexed. */
|
|
78
|
+
scan(): number {
|
|
79
|
+
this.#index = [];
|
|
80
|
+
if (!existsSync(this.#root)) return 0;
|
|
81
|
+
for (const nsDir of readdirSync(this.#root)) {
|
|
82
|
+
const nsPath = join(this.#root, nsDir);
|
|
83
|
+
let files: string[];
|
|
84
|
+
try { files = readdirSync(nsPath); } catch { continue; }
|
|
85
|
+
for (const f of files) {
|
|
86
|
+
const path = join(nsPath, f);
|
|
87
|
+
if (f.endsWith(".tmp")) {
|
|
88
|
+
// A crash mid-write: the rename never happened. Ours by
|
|
89
|
+
// construction (uuid names), safe to reap.
|
|
90
|
+
try { rmSync(path, { force: true }); } catch {}
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (!f.endsWith(".mlxkv")) continue;
|
|
94
|
+
try {
|
|
95
|
+
const h = readKvHeader(path); // verifies the header hash
|
|
96
|
+
if (
|
|
97
|
+
h.configFingerprint !== this.#opts.configFingerprint ||
|
|
98
|
+
h.tokenizerHash !== this.#opts.tokenizerHash
|
|
99
|
+
)
|
|
100
|
+
throw new Error("metadata mismatch");
|
|
101
|
+
const st = statSync(path);
|
|
102
|
+
this.#index.push({
|
|
103
|
+
path, ns: h.ns ?? "", tokens: h.tokens, bytes: st.size, mtimeMs: st.mtimeMs,
|
|
104
|
+
});
|
|
105
|
+
} catch {
|
|
106
|
+
try { rmSync(path, { force: true }); } catch {}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return this.#index.length;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Best stored entry for `prompt` in `ns`: the longest common prefix
|
|
114
|
+
* (capped at prompt.length−1 — at least one token must forward). An
|
|
115
|
+
* entry LONGER than the matched prefix is still returned (the divergent-
|
|
116
|
+
* tail pattern: same long context, different question) — the caller
|
|
117
|
+
* trims after restore when every cache kind is trimmable, and falls
|
|
118
|
+
* back to a fresh prefill when not (ring post-wrap, SSM). Pure index
|
|
119
|
+
* lookup; no I/O. */
|
|
120
|
+
find(prompt: number[], ns = ""): { entry: SsdIndexEntry; prefixLen: number } | null {
|
|
121
|
+
let best: SsdIndexEntry | null = null;
|
|
122
|
+
let bestLen = 0;
|
|
123
|
+
for (const e of this.#index) {
|
|
124
|
+
if (e.ns !== ns) continue;
|
|
125
|
+
const p = Math.min(commonPrefixLength(e.tokens, prompt), prompt.length - 1);
|
|
126
|
+
if (p === 0 || p <= bestLen) continue;
|
|
127
|
+
bestLen = p;
|
|
128
|
+
best = e;
|
|
129
|
+
}
|
|
130
|
+
return best ? { entry: best, prefixLen: bestLen } : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Restore an entry zero-copy (COW mmap; pages fault in lazily). Bumps
|
|
134
|
+
* LRU mtime. On ANY failure the file is dropped and null returned. */
|
|
135
|
+
restore(entry: SsdIndexEntry, model: { makeCache(): Cache[] }): LoadedKvCache | null {
|
|
136
|
+
const t0 = performance.now();
|
|
137
|
+
try {
|
|
138
|
+
const expect: KvLoadExpect = {
|
|
139
|
+
configFingerprint: this.#opts.configFingerprint,
|
|
140
|
+
tokenizerHash: this.#opts.tokenizerHash,
|
|
141
|
+
ns: entry.ns,
|
|
142
|
+
verify: this.#opts.verify,
|
|
143
|
+
};
|
|
144
|
+
const loaded = loadKvCache(entry.path, model, expect);
|
|
145
|
+
const now = new Date();
|
|
146
|
+
try { utimesSync(entry.path, now, now); entry.mtimeMs = now.getTime(); } catch {}
|
|
147
|
+
this.stats.restores++;
|
|
148
|
+
this.stats.restoreMsLast = performance.now() - t0;
|
|
149
|
+
return loaded;
|
|
150
|
+
} catch {
|
|
151
|
+
this.remove(entry.path);
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Persist an entry. Synchronous (the tier calls it on the idle serial
|
|
157
|
+
* lane between requests); atomic via kv-store's tmp+fsync+rename. An
|
|
158
|
+
* entry bigger than the cap is refused; disk/write failure is a warn-once
|
|
159
|
+
* soft-fail — serving never stalls on the cold tier. Existing entries in
|
|
160
|
+
* the same ns whose tokens are a PREFIX of the new entry are superseded
|
|
161
|
+
* (the agent-conversation pattern: one growing entry, not N generations
|
|
162
|
+
* of it). Returns true if stored. */
|
|
163
|
+
store(tokens: number[], caches: Cache[], ns = ""): boolean {
|
|
164
|
+
const dir = join(this.#root, nsHash(ns));
|
|
165
|
+
const path = join(dir, `${randomUUID()}.mlxkv`);
|
|
166
|
+
try {
|
|
167
|
+
mkdirSync(dir, { recursive: true });
|
|
168
|
+
const meta: KvSaveMeta = {
|
|
169
|
+
modelId: this.#opts.modelId,
|
|
170
|
+
configFingerprint: this.#opts.configFingerprint,
|
|
171
|
+
tokenizerHash: this.#opts.tokenizerHash,
|
|
172
|
+
ns,
|
|
173
|
+
};
|
|
174
|
+
saveKvCache(path, tokens, caches, meta);
|
|
175
|
+
const st = statSync(path);
|
|
176
|
+
if (st.size > this.#opts.maxBytes) {
|
|
177
|
+
rmSync(path, { force: true });
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
// Supersede shadowed ancestors of this prefix (same ns).
|
|
181
|
+
for (const e of [...this.#index]) {
|
|
182
|
+
if (e.ns !== ns || e.tokens.length >= tokens.length) continue;
|
|
183
|
+
if (commonPrefixLength(e.tokens, tokens) === e.tokens.length) this.remove(e.path);
|
|
184
|
+
}
|
|
185
|
+
this.#index.push({ path, ns, tokens, bytes: st.size, mtimeMs: Date.now() });
|
|
186
|
+
this.stats.spills++;
|
|
187
|
+
this.evictToCap();
|
|
188
|
+
return true;
|
|
189
|
+
} catch (err) {
|
|
190
|
+
try { rmSync(path, { force: true }); rmSync(`${path}.tmp`, { force: true }); } catch {}
|
|
191
|
+
if (!this.#warnedWriteFailure) {
|
|
192
|
+
this.#warnedWriteFailure = true;
|
|
193
|
+
console.warn(`[ssd-cache] store failed (disk full or unwritable?) — cold tier disabled for this entry: ${(err as Error).message}`);
|
|
194
|
+
}
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Enforce the byte cap: unlink oldest-mtime entries until under it. */
|
|
200
|
+
evictToCap(): void {
|
|
201
|
+
while (this.totalBytes > this.#opts.maxBytes && this.#index.length > 0) {
|
|
202
|
+
let oldest = 0;
|
|
203
|
+
for (let i = 1; i < this.#index.length; i++)
|
|
204
|
+
if (this.#index[i]!.mtimeMs < this.#index[oldest]!.mtimeMs) oldest = i;
|
|
205
|
+
this.remove(this.#index[oldest]!.path);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
remove(path: string): void {
|
|
210
|
+
try { rmSync(path, { force: true }); } catch {}
|
|
211
|
+
this.#index = this.#index.filter((e) => e.path !== path);
|
|
212
|
+
}
|
|
213
|
+
}
|
package/src/train/flash-cce.ts
CHANGED
|
@@ -723,24 +723,28 @@ const BWD_SG_SOURCE = String.raw`
|
|
|
723
723
|
}
|
|
724
724
|
`;
|
|
725
725
|
|
|
726
|
-
// Apple-CCE coeff filter epsilon (phase-2 per-tile skip). DEFAULT
|
|
727
|
-
//
|
|
726
|
+
// Apple-CCE coeff filter epsilon (phase-2 per-tile skip). DEFAULT ON at 1e-5
|
|
727
|
+
// (flipped 2026-07-02 — the kernel-perf-review backlog #1 flip). The old
|
|
728
728
|
// 0.66%→2.7% dh-accuracy scare was on RANDOM targets (flat softmax, the filter's
|
|
729
729
|
// worst case — see PLAN.md "filter-on-real-data"); the real-data gate
|
|
730
|
-
// (scripts/experiments/flash-cce-filter-realdata.ts,
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
//
|
|
735
|
-
//
|
|
736
|
-
|
|
730
|
+
// (scripts/experiments/flash-cce-filter-realdata.ts) measured, on REAL chunk-ORPO
|
|
731
|
+
// data (M1 Max, 2026-07-02): CPM5 eps 1e-5 = 0.343% dh for 1.41×; e4b = 0.158% dh
|
|
732
|
+
// for 1.70× — and the teacher-forced full-logits fidelity shows the filter's added
|
|
733
|
+
// error is far below the flash-vs-full fp-reassociation scatter that already
|
|
734
|
+
// exists (CPM5 0.850→0.913% relnorm, e4b 1.207→1.220%, cosine ≥0.99993 both).
|
|
735
|
+
// (The ~3.5× figure floating around older notes was the retired SG kernel, not
|
|
736
|
+
// steel.) MLX_BUN_CCE_BWD_FILTER_EPS=0 restores exact gradients — eps "0"
|
|
737
|
+
// compiles the filter OUT (#if CCE_BWD_FILTER 0), the identical pre-flip kernel.
|
|
738
|
+
const BWD_FILTER_EPS = process.env.MLX_BUN_CCE_BWD_FILTER_EPS ?? "1e-5";
|
|
737
739
|
// blockMax vocab-block early-exit eps (skip whole cold (token-block,vocab-block) programs
|
|
738
|
-
// — attacks BOTH phases). DEFAULT
|
|
739
|
-
// blocks whose max prob < eps AND which lack the target → ~0
|
|
740
|
-
//
|
|
741
|
-
//
|
|
742
|
-
// with
|
|
743
|
-
|
|
740
|
+
// — attacks BOTH phases). DEFAULT ON at 1e-5 (flipped 2026-07-02). It is ~LOSSLESS when
|
|
741
|
+
// it fires (only skips blocks whose max prob < eps AND which lack the target → ~0
|
|
742
|
+
// contribution). The M=512 synthetic probe found nothing cold (pure overhead), but the
|
|
743
|
+
// real-data gate proved real text DOES go cold: alone it's 1.23× (CPM5) / 2.02× (e4b)
|
|
744
|
+
// at ≤0.004% dh; COMBINED with the coeff filter at 1e-5/1e-5 the backward is 1.71×
|
|
745
|
+
// (CPM5) / 3.16× (e4b) vs exact. Fires only when the caller supplies blockMax (the
|
|
746
|
+
// production head does); MLX_BUN_CCE_BWD_BLOCK_EPS=0 disables (compiled out).
|
|
747
|
+
const BWD_BLOCK_EPS = process.env.MLX_BUN_CCE_BWD_BLOCK_EPS ?? "1e-5";
|
|
744
748
|
const _bwdSgKernels = new Map<string, MetalKernel>();
|
|
745
749
|
function bwdSgKernel(dimTiles: number, filterEps: string, blockEps: string): MetalKernel {
|
|
746
750
|
const filterOn = Number.parseFloat(filterEps) > 0 ? 1 : 0;
|
|
@@ -1132,14 +1136,13 @@ export function flashCceForward(
|
|
|
1132
1136
|
* (no atomics). Caller owns the result. */
|
|
1133
1137
|
export function flashCceBackward(
|
|
1134
1138
|
hResp: MlxArray, head: FlashCceHead, targets: number[], lse: MlxArray, cot: number[],
|
|
1135
|
-
// Apple-CCE coeff filter eps. Defaults to BWD_FILTER_EPS
|
|
1136
|
-
//
|
|
1137
|
-
//
|
|
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).
|
|
1139
|
+
// Apple-CCE coeff filter eps. Defaults to BWD_FILTER_EPS — ON at 1e-5 since
|
|
1140
|
+
// 2026-07-02 (real-data gates in BWD_FILTER_EPS's note). Pass "0" explicitly
|
|
1141
|
+
// for guaranteed-exact gradients (the parity gate does).
|
|
1140
1142
|
filterEps: string = BWD_FILTER_EPS,
|
|
1141
1143
|
// The forward's `blockMax [M, NBLK]` enables the vocab-block early exit (skips
|
|
1142
|
-
// whole cold programs — attacks
|
|
1144
|
+
// whole cold programs — attacks BOTH phases); ON at 1e-5 by default when
|
|
1145
|
+
// blockMax is supplied. Omit blockMax (or set blockEps "0") for exact.
|
|
1143
1146
|
blockMax?: MlxArray, blockEps: string = BWD_BLOCK_EPS,
|
|
1144
1147
|
): MlxArray {
|
|
1145
1148
|
const M = hResp.shape[0]!;
|
|
@@ -1169,9 +1172,9 @@ export function flashCceBackward(
|
|
|
1169
1172
|
// MLX_BUN_CCE_BWD_NOSTEEL=1 falls back to the old kernels. The steel backward carries
|
|
1170
1173
|
// the SAME Apple-CCE skips as the SG kernel — coeff filter (phase-2 per-tile) +
|
|
1171
1174
|
// blockMax vocab-block early exit (whole-program) — both compiled out at eps=0.
|
|
1172
|
-
// BOTH skips
|
|
1173
|
-
//
|
|
1174
|
-
//
|
|
1175
|
+
// BOTH skips default ON at 1e-5 (2026-07-02 flip; measured gates in the
|
|
1176
|
+
// BWD_FILTER_EPS / BWD_BLOCK_EPS notes). MLX_BUN_CCE_BWD_{FILTER,BLOCK}_EPS=0
|
|
1177
|
+
// restores exact gradients (identical pre-flip kernels).
|
|
1175
1178
|
const blockV0 = Math.ceil(V / NBLK);
|
|
1176
1179
|
// HTw == group_size for the phase-2 QuantizedBlockLoader (one group per H-tile); needs
|
|
1177
1180
|
// H % GS == 0 (always, GR*GS=H) and GS % 32 == 0 (TNv = GS/32 frags per H-tile integral).
|
package/src/train/loss.ts
CHANGED
|
@@ -138,6 +138,36 @@ export function sftLoss(model: RuntimeModel, batch: SftBatch): MlxArray {
|
|
|
138
138
|
return scalar;
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
/** model.logitsFromHidden for TRAINING heads, with the upstream mlx
|
|
142
|
+
* quantized_matmul bug worked around: transpose=false returns WRONG values at
|
|
143
|
+
* row count 2 or 3 (reproduced on stock mlx 0.31.2; M=1 and M>=4 correct,
|
|
144
|
+
* transpose=true correct at every M). mlx's autograd computes dh for a
|
|
145
|
+
* transpose=true head qmm via an INTERNAL transpose=false call at the
|
|
146
|
+
* cotangent's row count — so any differentiated [M,V] logits with M in {2,3}
|
|
147
|
+
* (2-3-token responses, 2-3-token tail chunks, tiny prompt spans) got a
|
|
148
|
+
* silently wrong dh (measured relnorm ~0.35-0.99 vs the f32 ground truth).
|
|
149
|
+
* Fix: pad the span to 4 rows with zeros and slice the logits back — the pad
|
|
150
|
+
* rows' cotangents are exactly zero (slice adjoint), and the internal vjp
|
|
151
|
+
* then runs at M=4 where the kernel is correct. */
|
|
152
|
+
export function logitsFromHiddenPadM(
|
|
153
|
+
model: { logitsFromHidden(h: MlxArray): MlxArray },
|
|
154
|
+
hSpan: MlxArray, // [1, M, hidden]
|
|
155
|
+
): MlxArray {
|
|
156
|
+
const M = hSpan.shape[1]!;
|
|
157
|
+
if (M !== 2 && M !== 3) return model.logitsFromHidden(hSpan);
|
|
158
|
+
const [B, , H] = hSpan.shape as [number, number, number];
|
|
159
|
+
const pad = ops.zeros([B, 4 - M, H], hSpan.dtype);
|
|
160
|
+
const hPad = ops.concatAxis([hSpan, pad], 1);
|
|
161
|
+
pad.dispose();
|
|
162
|
+
const lgPad = model.logitsFromHidden(hPad); // [1, 4, V]
|
|
163
|
+
hPad.dispose();
|
|
164
|
+
const start = MlxArray.fromInt32(new Int32Array([0]), [1]);
|
|
165
|
+
const lg = ops.sliceDynamic(lgPad, start, [1], [B, M, lgPad.shape[2]!]);
|
|
166
|
+
start.dispose();
|
|
167
|
+
lgPad.dispose();
|
|
168
|
+
return lg;
|
|
169
|
+
}
|
|
170
|
+
|
|
141
171
|
/** B=1 masked CE that applies the LM head only at the supervised (response)
|
|
142
172
|
* span. The supervised input positions are the contiguous range
|
|
143
173
|
* [promptLen-1, len-1) (each predicts the next, response, token). The LM head
|
|
@@ -158,7 +188,7 @@ export function responseOnlyCe(model: RuntimeModel, h: MlxArray, batch: SftBatch
|
|
|
158
188
|
const hidden = h.shape[2]!;
|
|
159
189
|
const start = MlxArray.fromInt32(new Int32Array([startT]), [1]);
|
|
160
190
|
const hResp = ops.sliceDynamic(h, start, [1], [1, M, hidden]);
|
|
161
|
-
const logits = model
|
|
191
|
+
const logits = logitsFromHiddenPadM(model, hResp); // [1, M, V] (M in {2,3} padded — mlx qmm bug)
|
|
162
192
|
const V = logits.shape[2]!;
|
|
163
193
|
const logits2d = ops.reshape(logits, [M, V]);
|
|
164
194
|
|
|
@@ -198,7 +228,7 @@ export function responseOnlyLogpMean(
|
|
|
198
228
|
const hidden = h.shape[2]!;
|
|
199
229
|
const start = MlxArray.fromInt32(new Int32Array([startT]), [1]);
|
|
200
230
|
const hResp = ops.sliceDynamic(h, start, [1], [1, M, hidden]); // [1, M, hidden]
|
|
201
|
-
const logits = model
|
|
231
|
+
const logits = logitsFromHiddenPadM(model, hResp); // [1, M, V] (M in {2,3} padded — mlx qmm bug)
|
|
202
232
|
const V = logits.shape[2]!;
|
|
203
233
|
const logits2d = ops.reshape(logits, [M, V]);
|
|
204
234
|
|
|
@@ -504,7 +534,7 @@ export function spanLogpMeanFromHidden(
|
|
|
504
534
|
const hidden = h.shape[2]!;
|
|
505
535
|
const start = MlxArray.fromInt32(new Int32Array([s0]), [1]);
|
|
506
536
|
const hSpan = ops.sliceDynamic(h, start, [1], [1, n, hidden]); // [1, n, hidden]
|
|
507
|
-
const logits = model
|
|
537
|
+
const logits = logitsFromHiddenPadM(model, hSpan); // [1, n, V] (n in {2,3} padded — mlx qmm bug)
|
|
508
538
|
const V = logits.shape[2]!;
|
|
509
539
|
const logits2d = ops.reshape(logits, [n, V]);
|
|
510
540
|
|
|
@@ -624,7 +654,7 @@ export function branchLogpMeanB1(
|
|
|
624
654
|
const hidden = h.shape[2]!;
|
|
625
655
|
const start = MlxArray.fromInt32(new Int32Array([startT]), [1]);
|
|
626
656
|
const hResp = ops.sliceDynamic(h, start, [1], [1, M, hidden]); // [1, M, hidden]
|
|
627
|
-
const logits = model
|
|
657
|
+
const logits = logitsFromHiddenPadM(model, hResp); // [1, M, V] — head only on the response span (M in {2,3} padded — mlx qmm bug)
|
|
628
658
|
const V = logits.shape[2]!;
|
|
629
659
|
const logits2d = ops.reshape(logits, [M, V]);
|
|
630
660
|
|
|
@@ -698,7 +728,7 @@ function chunkedLogpMeanB1(
|
|
|
698
728
|
|
|
699
729
|
const ckpt = new Checkpoint((inp) => {
|
|
700
730
|
const hc = inp[0]!;
|
|
701
|
-
const logits = model
|
|
731
|
+
const logits = logitsFromHiddenPadM(model, hc); // [1, Cc, V] — recomputed in backward (Cc in {2,3} padded — mlx qmm bug)
|
|
702
732
|
const V = logits.shape[2]!;
|
|
703
733
|
const l2 = ops.reshape(logits, [Cc, V]);
|
|
704
734
|
const targets = MlxArray.fromInt32(tHost, [Cc, 1]);
|
|
@@ -765,7 +795,7 @@ export function chunkedLogpMeanFromHidden(
|
|
|
765
795
|
for (let i = 0; i < Cc; i++) tHost[i] = ids[c0 + 1 + i]!;
|
|
766
796
|
const ckpt = new Checkpoint((inp) => {
|
|
767
797
|
const hc = inp[0]!;
|
|
768
|
-
const logits = model
|
|
798
|
+
const logits = logitsFromHiddenPadM(model, hc); // [1, Cc, V] — recomputed in backward (Cc in {2,3} padded — mlx qmm bug)
|
|
769
799
|
const V = logits.shape[2]!;
|
|
770
800
|
const l2 = ops.reshape(logits, [Cc, V]);
|
|
771
801
|
const targets = MlxArray.fromInt32(tHost, [Cc, 1]);
|
|
@@ -1093,8 +1123,9 @@ function makeVocabBlockedHeadVjp(
|
|
|
1093
1123
|
/** flash-CCE head as a CustomVjp: forward runs the Metal kernel → Σ logp (stashing
|
|
1094
1124
|
* `lse` for the backward, avoiding a forward recompute); backward runs the kernel's
|
|
1095
1125
|
* `dh` for unit cotangent then scales by the scalar cotangent `c` (loss = Σ logp →
|
|
1096
|
-
* ∂loss/∂logp_t = c for all t). The coeff filter + vocab-block skip
|
|
1097
|
-
*
|
|
1126
|
+
* ∂loss/∂logp_t = c for all t). The coeff filter + vocab-block skip default ON at
|
|
1127
|
+
* 1e-5 (2026-07-02 flip; real-data gates in flash-cce.ts) — set
|
|
1128
|
+
* MLX_BUN_CCE_BWD_{FILTER,BLOCK}_EPS=0 for the EXACT dh. */
|
|
1098
1129
|
function makeFlashCceHeadVjp(
|
|
1099
1130
|
head: HeadQuant, targetsHost: Int32Array, M: number,
|
|
1100
1131
|
sink: Array<{ dispose(): void }>,
|
|
@@ -1125,8 +1156,8 @@ function makeFlashCceHeadVjp(
|
|
|
1125
1156
|
const c = cots[0]!; // [1] = ∂loss/∂(Σ logp); uniform over tokens
|
|
1126
1157
|
// dh for unit cotangent, then × c (avoids a host read of the cotangent).
|
|
1127
1158
|
const ones = new Array(M).fill(1) as number[];
|
|
1128
|
-
// filter + block-skip
|
|
1129
|
-
//
|
|
1159
|
+
// filter + block-skip default ON at 1e-5 (env-overridable; =0 → exact dh).
|
|
1160
|
+
// savedBlockMax feeds the ~lossless vocab-block skip.
|
|
1130
1161
|
const dhUnit = flashCceBackward(hR, fch, targets, savedLse!, ones, undefined, savedBlockMax!); // [M, hidden] f32
|
|
1131
1162
|
const cM = c.dtype === dhUnit.dtype ? c : c.astype(dhUnit.dtype);
|
|
1132
1163
|
const dh = ops.mul(dhUnit, cM); // broadcast [M,hidden]·[1]
|
|
@@ -1157,8 +1188,19 @@ export function fusedRespLogpMean(
|
|
|
1157
1188
|
// Vocab-blocked (full CCE) path: even [chunk,V] never materializes — only
|
|
1158
1189
|
// [chunk,vocabBlock]. Otherwise the whole-vocab analytic head below.
|
|
1159
1190
|
const vb = vocabBlock > 0 && vocabBlock < V ? vocabBlock : 0;
|
|
1191
|
+
// Auto-dispatch by M (kernel backlog #3; measured 2026-07-02 on the M1 Max,
|
|
1192
|
+
// scripts/experiments/head-dispatch-sweep.ts): the fused QM head is FASTER
|
|
1193
|
+
// at short M (e4b M=64 1.6×, M=512 1.13×; CPM5 faster at EVERY M) — the
|
|
1194
|
+
// flash head's win is residency, plus time at long M on e4b (M≥2048 after
|
|
1195
|
+
// the 2026-07-02 filter/blockMax flip; 8K: 10.7 vs 13.2 s). So a
|
|
1196
|
+
// requested-flash batch with M below MLX_BUN_FLASH_MIN_M (default 1024, the
|
|
1197
|
+
// e4b time-tie point where flash's memory win takes over) takes the EXACT
|
|
1198
|
+
// fused head instead — short rows get the faster exact head for free.
|
|
1199
|
+
// MLX_BUN_FLASH_MIN_M=0 always honors flash (the pre-dispatch behavior).
|
|
1200
|
+
const flashMinM = Number(process.env.MLX_BUN_FLASH_MIN_M ?? "1024");
|
|
1201
|
+
const useFlash = flash && (flashMinM <= 0 || M >= flashMinM);
|
|
1160
1202
|
let op: CustomVjp;
|
|
1161
|
-
if (
|
|
1203
|
+
if (useFlash) {
|
|
1162
1204
|
op = makeFlashCceHeadVjp(head, targetsHost, M, sink);
|
|
1163
1205
|
} else if (vb > 0) {
|
|
1164
1206
|
op = makeVocabBlockedHeadVjp(head, targetsHost, M, hidden, chunk, vb);
|
|
@@ -28,7 +28,7 @@ import * as ops from "../mlx/ops";
|
|
|
28
28
|
import { createCausalMask, type Cache, type Mask } from "../model/gemma4-base";
|
|
29
29
|
import { MiniCPM5Model, setMiniCpmPrefixPlan } from "../model/minicpm5";
|
|
30
30
|
import { Gemma4Model, setGemmaPrefixPlan } from "../model/gemma4";
|
|
31
|
-
import { orpoLossFromLogps, fusedRespLogpMean, combineFullNll, type ChunkCtx, type SftScope } from "./loss";
|
|
31
|
+
import { orpoLossFromLogps, fusedRespLogpMean, combineFullNll, logitsFromHiddenPadM, type ChunkCtx, type SftScope } from "./loss";
|
|
32
32
|
import type { DpoBatch } from "./dataset";
|
|
33
33
|
|
|
34
34
|
/** A model that can project final-norm hidden states to vocab logits — the only
|
|
@@ -63,17 +63,40 @@ export function blockSparseMask(P: number, Rc: number, Rr: number): MlxArray {
|
|
|
63
63
|
* MiniCPM5's runLayerRange calls makeMask once and disposes the returned arr. */
|
|
64
64
|
export class PrefixSharedCache implements Cache {
|
|
65
65
|
offset = 0;
|
|
66
|
-
constructor(
|
|
66
|
+
constructor(
|
|
67
|
+
private readonly P: number, private readonly Rc: number, private readonly Rr: number,
|
|
68
|
+
// Step-shared mask memo (kernel-review backlog #9): the [T,T] block-sparse
|
|
69
|
+
// mask is IDENTICAL for every layer, segment forward, and backward
|
|
70
|
+
// recompute of a step, but each runLayerRange call rebuilt it from its
|
|
71
|
+
// elementwise ops (2·nSeg+ rebuilds per step in the segmented prefix
|
|
72
|
+
// class). With a memo it is built once; makeMask hands out cheap slice
|
|
73
|
+
// VIEWS (consumers dispose their view — runLayerRange does), and the
|
|
74
|
+
// first cache disposed frees the memo. Use prefixSharedCaches() to build
|
|
75
|
+
// a memo-sharing batch.
|
|
76
|
+
private readonly memo?: { mask: MlxArray | null },
|
|
77
|
+
) {}
|
|
67
78
|
updateAndFetch(k: MlxArray, v: MlxArray): [MlxArray, MlxArray] {
|
|
68
79
|
return [k.slice([0, 0, 0, 0], k.shape), v.slice([0, 0, 0, 0], v.shape)];
|
|
69
80
|
}
|
|
70
81
|
makeMask(_N: number, _windowSize: number | null): Mask {
|
|
71
|
-
return { mode: "array", arr: blockSparseMask(this.P, this.Rc, this.Rr) };
|
|
82
|
+
if (!this.memo) return { mode: "array", arr: blockSparseMask(this.P, this.Rc, this.Rr) };
|
|
83
|
+
if (!this.memo.mask) this.memo.mask = blockSparseMask(this.P, this.Rc, this.Rr);
|
|
84
|
+
const m = this.memo.mask;
|
|
85
|
+
return { mode: "array", arr: m.slice([0, 0], m.shape) };
|
|
72
86
|
}
|
|
73
87
|
state(): MlxArray[] { return []; }
|
|
74
88
|
isTrimmable(): boolean { return true; }
|
|
75
89
|
trim(_n: number): void { /* offset pinned at 0 */ }
|
|
76
|
-
dispose(): void {
|
|
90
|
+
dispose(): void {
|
|
91
|
+
if (this.memo?.mask) { this.memo.mask.dispose(); this.memo.mask = null; }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** One PrefixSharedCache per layer, all sharing a single block-sparse-mask
|
|
96
|
+
* memo for the step (see the constructor note). */
|
|
97
|
+
export function prefixSharedCaches(nLayers: number, P: number, Rc: number, Rr: number): Cache[] {
|
|
98
|
+
const memo: { mask: MlxArray | null } = { mask: null };
|
|
99
|
+
return Array.from({ length: nLayers }, () => new PrefixSharedCache(P, Rc, Rr, memo));
|
|
77
100
|
}
|
|
78
101
|
|
|
79
102
|
/** Length-normalized mean log-prob over the predictions at hidden positions
|
|
@@ -87,7 +110,7 @@ function gatheredLogpMean(model: LogitProjector, h: MlxArray, gatherIdx: number[
|
|
|
87
110
|
const hSel = ops.takeAxis(h, idxArr, 1); // [1, M, hidden]
|
|
88
111
|
idxArr.dispose();
|
|
89
112
|
if (hSel.shape[2] !== hidden) throw new Error("gatheredLogpMean: hidden mismatch");
|
|
90
|
-
const logits = model
|
|
113
|
+
const logits = logitsFromHiddenPadM(model, hSel); // [1, M, V] (M in {2,3} padded — mlx qmm bug)
|
|
91
114
|
hSel.dispose();
|
|
92
115
|
const V = logits.shape[2]!;
|
|
93
116
|
const logits2d = ops.reshape(logits, [M, V]);
|
|
@@ -185,7 +208,7 @@ export function orpoLossPrefixShared(
|
|
|
185
208
|
concat.set(chosenResp, P);
|
|
186
209
|
concat.set(rejectedResp, P + Rc);
|
|
187
210
|
const ids = MlxArray.fromInt32(concat, [1, T]);
|
|
188
|
-
const caches: Cache[] = model.layers.
|
|
211
|
+
const caches: Cache[] = prefixSharedCaches(model.layers.length, P, Rc, Rr);
|
|
189
212
|
|
|
190
213
|
let h: MlxArray;
|
|
191
214
|
setMiniCpmPrefixPlan({ P, Rc, Rr });
|
|
@@ -230,7 +253,7 @@ export function prefixSharedLogps(
|
|
|
230
253
|
const concat = new Int32Array(T);
|
|
231
254
|
concat.set(promptIds, 0); concat.set(chosenResp, P); concat.set(rejectedResp, P + Rc);
|
|
232
255
|
const ids = MlxArray.fromInt32(concat, [1, T]);
|
|
233
|
-
const caches: Cache[] = model.layers.
|
|
256
|
+
const caches: Cache[] = prefixSharedCaches(model.layers.length, P, Rc, Rr);
|
|
234
257
|
setMiniCpmPrefixPlan({ P, Rc, Rr });
|
|
235
258
|
let h: MlxArray;
|
|
236
259
|
try { h = model.forwardHidden(ids, caches); }
|