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.
@@ -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
+ }
@@ -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 OFF ("0") today —
727
- // no production path sets the env — but the VERDICT is measured and positive: the old
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, 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.
736
- const BWD_FILTER_EPS = process.env.MLX_BUN_CCE_BWD_FILTER_EPS ?? "0";
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 OFF ("0"). It is LOSSLESS when it fires (only skips
739
- // blocks whose max prob < eps AND which lack the target → ~0 contribution), so it is the
740
- // SAFE skip; but it only pays off on genuinely peaked real text where whole 8192-vocab
741
- // blocks go cold (the M=512 synthetic probe found nothing cold → pure overhead). Opt in
742
- // with MLX_BUN_CCE_BWD_BLOCK_EPS=1e-5 for long real-text training; needs blockMax supplied.
743
- const BWD_BLOCK_EPS = process.env.MLX_BUN_CCE_BWD_BLOCK_EPS ?? "0";
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, 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).
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 phase 1). Omit (or set blockEps "0") for exact.
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 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").
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.logitsFromHidden(hResp); // [1, M, V]
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.logitsFromHidden(hResp); // [1, M, V]
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.logitsFromHidden(hSpan); // [1, n, V]
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.logitsFromHidden(hResp); // [1, M, V] — head only on the response span
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.logitsFromHidden(hc); // [1, Cc, V] — recomputed in backward
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.logitsFromHidden(hc); // [1, Cc, V] — recomputed in backward
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 are OFF by
1097
- * default (env-gated MLX_BUN_CCE_BWD_*_EPS) — the backward computes the EXACT dh. */
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 are OFF by default (env-gated) → EXACT dh. savedBlockMax is
1129
- // passed so the lossless vocab-block skip can be enabled via env without re-plumbing.
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 (flash) {
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(private readonly P: number, private readonly Rc: number, private readonly Rr: number) {}
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 { /* owns no arrays */ }
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.logitsFromHidden(hSel); // [1, M, V]
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.map(() => new PrefixSharedCache(P, Rc, Rr));
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.map(() => new PrefixSharedCache(P, Rc, Rr));
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); }