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/kv-store.ts
CHANGED
|
@@ -1,23 +1,38 @@
|
|
|
1
1
|
// KV-cache persistence: save prompt caches to disk, reload zero-copy.
|
|
2
|
+
// The serialization core of the SSD cold tier (docs/design/ssd-kv-cold-tier.md).
|
|
2
3
|
//
|
|
3
4
|
// File layout (every tensor PAGE-ALIGNED — the Phase 1 corollary: files
|
|
4
5
|
// we write can be mmap'd and handed to the GPU without copies):
|
|
5
|
-
// [magic "
|
|
6
|
+
// [magic "MLXBUNKV2\n"][u32 LE header length][u32 LE dataStart]
|
|
7
|
+
// [u64 LE header hash][JSON header][padding]
|
|
6
8
|
// [tensor data at 16 KiB-aligned offsets]
|
|
7
|
-
// Header: {
|
|
8
|
-
//
|
|
9
|
+
// Header: { formatVersion, modelId, configFingerprint, ns, tokenizerHash,
|
|
10
|
+
// createdAt, tokens, caches: [{ kind, offset, idx?, maxSize?,
|
|
11
|
+
// groupSize?, bits?, tensors: [{ off, bytes, shape, dtype, hash }] }] }
|
|
12
|
+
//
|
|
13
|
+
// v2 over v1: quantized cache kinds (the serving DEFAULT is kv_config
|
|
14
|
+
// quantization, which v1 could not persist), SSM kind (Qwen3.5 hybrid),
|
|
15
|
+
// invalidation metadata (configFingerprint covers the kv-quant scheme;
|
|
16
|
+
// tokenizerHash guards vocab drift; ns = adapter spec), per-tensor hashes
|
|
17
|
+
// (verified opt-in — eager verification would defeat lazy page fault-in),
|
|
18
|
+
// a header hash (always verified — cheap), and atomic tmp+fsync+rename
|
|
19
|
+
// writes. v1 files are not migrated: nothing shipped wrote them (the
|
|
20
|
+
// format was test/experiment-only) — they read as "not a v2 file" and the
|
|
21
|
+
// SSD tier deletes-and-regenerates.
|
|
9
22
|
//
|
|
10
23
|
// Reload mmaps copy-on-write (MAP_PRIVATE): if mlx ever donates one of
|
|
11
24
|
// these buffers, writes hit private pages, never the file.
|
|
12
25
|
|
|
13
|
-
import { openSync, writeSync, closeSync } from "node:fs";
|
|
26
|
+
import { openSync, writeSync, readSync, closeSync, fsyncSync, renameSync, rmSync } from "node:fs";
|
|
14
27
|
import { MmapFile } from "./mmap";
|
|
15
28
|
import { MlxArray } from "./mlx/array";
|
|
16
29
|
import type { Dtype } from "./mlx/ffi";
|
|
17
30
|
import * as ops from "./mlx/ops";
|
|
18
31
|
import {
|
|
19
|
-
type Cache, KVCache, RotatingKVCache,
|
|
20
|
-
|
|
32
|
+
type Cache, KVCache, RotatingKVCache,
|
|
33
|
+
QuantizedKVCache, RotatingQuantizedKVCache,
|
|
34
|
+
} from "./model/gemma4-base";
|
|
35
|
+
import { SSMCache } from "./model/qwen3-delta";
|
|
21
36
|
|
|
22
37
|
/** Contiguous raw bytes of (possibly a view of) an array. */
|
|
23
38
|
function contiguousBytes(a: MlxArray): Uint8Array {
|
|
@@ -27,120 +42,184 @@ function contiguousBytes(a: MlxArray): Uint8Array {
|
|
|
27
42
|
return bytes;
|
|
28
43
|
}
|
|
29
44
|
|
|
30
|
-
const MAGIC = "
|
|
45
|
+
const MAGIC = "MLXBUNKV2\n";
|
|
31
46
|
const ALIGN = 16384;
|
|
47
|
+
/** magic + u32 headerLen + u32 dataStart + u64 headerHash */
|
|
48
|
+
const PREFIX_LEN = MAGIC.length + 4 + 4 + 8;
|
|
49
|
+
|
|
50
|
+
const hash64 = (bytes: Uint8Array): string => Bun.hash(bytes).toString(16);
|
|
32
51
|
|
|
33
|
-
|
|
34
|
-
|
|
52
|
+
export type CacheKind = "kv" | "rotating" | "qkv" | "rotating-qkv" | "ssm";
|
|
53
|
+
|
|
54
|
+
interface TensorSlot {
|
|
55
|
+
off: number;
|
|
56
|
+
bytes: number;
|
|
57
|
+
shape: number[];
|
|
58
|
+
dtype: number;
|
|
59
|
+
hash: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface CacheHeaderEntry {
|
|
63
|
+
kind: CacheKind;
|
|
35
64
|
offset: number;
|
|
65
|
+
/** rotating variants: ring write index */
|
|
36
66
|
idx?: number;
|
|
67
|
+
/** rotating variants: window size */
|
|
37
68
|
maxSize?: number;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
vBytes: number;
|
|
69
|
+
/** quantized variants */
|
|
70
|
+
groupSize?: number;
|
|
71
|
+
bits?: number;
|
|
72
|
+
/** kv/rotating: [k, v] · qkv/rotating-qkv: [kPacked, kScales, kBiases,
|
|
73
|
+
* vPacked, vScales, vBiases] · ssm: [conv, recurrent] */
|
|
74
|
+
tensors: TensorSlot[];
|
|
45
75
|
}
|
|
46
76
|
|
|
47
|
-
export interface
|
|
77
|
+
export interface KvSaveMeta {
|
|
78
|
+
modelId?: string;
|
|
79
|
+
/** configFingerprint(config) — covers every graph-shaping field incl.
|
|
80
|
+
* the kv-quant scheme, so a scheme flip invalidates naturally. */
|
|
81
|
+
configFingerprint?: string;
|
|
82
|
+
/** Adapter namespace (PromptCache ns — adapters joined with "+"). */
|
|
83
|
+
ns?: string;
|
|
84
|
+
/** sha256 of tokenizer.json: identical ids must mean identical text. */
|
|
85
|
+
tokenizerHash?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface KvFileHeader extends KvSaveMeta {
|
|
89
|
+
formatVersion: 2;
|
|
90
|
+
createdAt: number;
|
|
48
91
|
tokens: number[];
|
|
49
92
|
caches: CacheHeaderEntry[];
|
|
50
93
|
}
|
|
51
94
|
|
|
52
95
|
const alignUp = (n: number) => Math.ceil(n / ALIGN) * ALIGN;
|
|
53
96
|
|
|
54
|
-
|
|
55
|
-
|
|
97
|
+
/** Snapshot one cache into header entry + blobs. Quantized full caches are
|
|
98
|
+
* sliced to the live [.., :offset, :] region (token axis — quantization
|
|
99
|
+
* groups run along the FEATURE dim, so a sequence-axis cut never splits a
|
|
100
|
+
* group); rotating caches persist the whole ring as-laid-out with ringIdx. */
|
|
101
|
+
function snapshotCache(c: Cache, dataOffset: number): { entry: CacheHeaderEntry; blobs: Uint8Array[]; next: number } {
|
|
102
|
+
const slots: TensorSlot[] = [];
|
|
56
103
|
const blobs: Uint8Array[] = [];
|
|
104
|
+
let cursor = dataOffset;
|
|
105
|
+
const push = (a: MlxArray, disposeAfter: boolean): void => {
|
|
106
|
+
const bytes = contiguousBytes(a);
|
|
107
|
+
const off = alignUp(cursor);
|
|
108
|
+
slots.push({ off, bytes: bytes.length, shape: a.shape, dtype: a.dtype, hash: hash64(bytes) });
|
|
109
|
+
blobs.push(bytes);
|
|
110
|
+
cursor = off + bytes.length;
|
|
111
|
+
if (disposeAfter) a.dispose();
|
|
112
|
+
};
|
|
113
|
+
const liveSlice = (a: MlxArray, upTo: number): MlxArray => {
|
|
114
|
+
const [B, H, , D] = a.shape as [number, number, number, number];
|
|
115
|
+
return a.slice([0, 0, 0, 0], [B, H, upTo, D]);
|
|
116
|
+
};
|
|
117
|
+
const pushTriple = (t: ops.QuantizedTensor, upTo: number | null): void => {
|
|
118
|
+
for (const a of [t.packed, t.scales, t.biases]) {
|
|
119
|
+
if (upTo === null) push(a, false);
|
|
120
|
+
else push(liveSlice(a, upTo), true);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
let entry: CacheHeaderEntry;
|
|
125
|
+
if (c instanceof RotatingQuantizedKVCache) {
|
|
126
|
+
if (!c.keys || !c.values) throw new Error("cannot persist an empty cache");
|
|
127
|
+
pushTriple(c.keys, null);
|
|
128
|
+
pushTriple(c.values, null);
|
|
129
|
+
entry = { kind: "rotating-qkv", offset: c.offset, idx: c.ringIdx, maxSize: c.maxSize,
|
|
130
|
+
groupSize: c.groupSize, bits: c.bits, tensors: slots };
|
|
131
|
+
} else if (c instanceof QuantizedKVCache) {
|
|
132
|
+
if (!c.keys || !c.values) throw new Error("cannot persist an empty cache");
|
|
133
|
+
pushTriple(c.keys, c.offset);
|
|
134
|
+
pushTriple(c.values, c.offset);
|
|
135
|
+
entry = { kind: "qkv", offset: c.offset, groupSize: c.groupSize, bits: c.bits, tensors: slots };
|
|
136
|
+
} else if (c instanceof RotatingKVCache) {
|
|
137
|
+
if (!c.keys || !c.values) throw new Error("cannot persist an empty cache");
|
|
138
|
+
push(c.keys, false);
|
|
139
|
+
push(c.values, false);
|
|
140
|
+
entry = { kind: "rotating", offset: c.offset, idx: c.ringIdx, maxSize: c.maxSize, tensors: slots };
|
|
141
|
+
} else if (c instanceof SSMCache) {
|
|
142
|
+
if (!c.conv || !c.recurrent) throw new Error("cannot persist an empty cache");
|
|
143
|
+
push(c.conv, false);
|
|
144
|
+
push(c.recurrent, false);
|
|
145
|
+
entry = { kind: "ssm", offset: c.offset, tensors: slots };
|
|
146
|
+
} else if (c instanceof KVCache) {
|
|
147
|
+
if (!c.keys || !c.values) throw new Error("cannot persist an empty cache");
|
|
148
|
+
push(liveSlice(c.keys, c.offset), true);
|
|
149
|
+
push(liveSlice(c.values, c.offset), true);
|
|
150
|
+
entry = { kind: "kv", offset: c.offset, tensors: slots };
|
|
151
|
+
} else {
|
|
152
|
+
throw new Error("unknown cache type");
|
|
153
|
+
}
|
|
154
|
+
return { entry, blobs, next: cursor };
|
|
155
|
+
}
|
|
57
156
|
|
|
58
|
-
|
|
157
|
+
/** Persist `caches` (+ the exact token prefix they encode) to `path`.
|
|
158
|
+
* ATOMIC: written to `<path>.tmp`, fsync'd, renamed — a crash mid-write
|
|
159
|
+
* leaves only a .tmp orphan (ignored + reaped by SsdCacheStore.scan). */
|
|
160
|
+
export function saveKvCache(path: string, tokens: number[], caches: Cache[], meta: KvSaveMeta = {}): void {
|
|
161
|
+
const entries: CacheHeaderEntry[] = [];
|
|
162
|
+
const blobs: Uint8Array[] = [];
|
|
59
163
|
let dataOffset = 0; // relative; rebased after header is sized
|
|
60
164
|
for (const c of caches) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
let maxSize: number | undefined;
|
|
66
|
-
if (c instanceof RotatingKVCache) {
|
|
67
|
-
kind = "rotating";
|
|
68
|
-
idx = c.ringIdx;
|
|
69
|
-
maxSize = c.maxSize;
|
|
70
|
-
if (!c.keys || !c.values) throw new Error("cannot persist an empty cache");
|
|
71
|
-
keys = c.keys;
|
|
72
|
-
values = c.values;
|
|
73
|
-
} else if (c instanceof KVCache) {
|
|
74
|
-
kind = "kv";
|
|
75
|
-
if (!c.keys || !c.values) throw new Error("cannot persist an empty cache");
|
|
76
|
-
// store only the live [.., :offset, :] region
|
|
77
|
-
const [B, H, , D] = c.keys.shape as [number, number, number, number];
|
|
78
|
-
const vD = c.values.shape[3]!;
|
|
79
|
-
keys = c.keys.slice([0, 0, 0, 0], [B, H, c.offset, D]);
|
|
80
|
-
values = c.values.slice([0, 0, 0, 0], [B, H, c.offset, vD]);
|
|
81
|
-
} else {
|
|
82
|
-
throw new Error("unknown cache type");
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const kBytesArr = contiguousBytes(keys);
|
|
86
|
-
const vBytesArr = contiguousBytes(values);
|
|
87
|
-
const kOffset = alignUp(dataOffset);
|
|
88
|
-
const vOffset = alignUp(kOffset + kBytesArr.length);
|
|
89
|
-
dataOffset = vOffset + vBytesArr.length;
|
|
90
|
-
|
|
91
|
-
entries.push({
|
|
92
|
-
kind, offset: c.offset, idx, maxSize,
|
|
93
|
-
dtype: keys.dtype,
|
|
94
|
-
kShape: keys.shape, vShape: values.shape,
|
|
95
|
-
kOffset, vOffset,
|
|
96
|
-
kBytes: kBytesArr.length, vBytes: vBytesArr.length,
|
|
97
|
-
});
|
|
98
|
-
blobs.push(kBytesArr, vBytesArr);
|
|
99
|
-
if (c instanceof KVCache) {
|
|
100
|
-
keys.dispose();
|
|
101
|
-
values.dispose();
|
|
102
|
-
}
|
|
165
|
+
const s = snapshotCache(c, dataOffset);
|
|
166
|
+
entries.push(s.entry);
|
|
167
|
+
blobs.push(...s.blobs);
|
|
168
|
+
dataOffset = s.next;
|
|
103
169
|
}
|
|
104
170
|
|
|
105
|
-
const header: KvFileHeader = {
|
|
171
|
+
const header: KvFileHeader = {
|
|
172
|
+
formatVersion: 2, createdAt: Date.now(), ...meta, tokens, caches: entries,
|
|
173
|
+
};
|
|
106
174
|
const headerJson = new TextEncoder().encode(JSON.stringify(header));
|
|
107
|
-
const dataStart = alignUp(
|
|
175
|
+
const dataStart = alignUp(PREFIX_LEN + headerJson.length);
|
|
108
176
|
|
|
109
|
-
const
|
|
177
|
+
const tmp = `${path}.tmp`;
|
|
178
|
+
const fd = openSync(tmp, "w");
|
|
110
179
|
try {
|
|
111
180
|
const pre = new Uint8Array(dataStart);
|
|
112
181
|
pre.set(new TextEncoder().encode(MAGIC), 0);
|
|
113
182
|
const dv = new DataView(pre.buffer);
|
|
114
183
|
dv.setUint32(MAGIC.length, headerJson.length, true);
|
|
115
184
|
dv.setUint32(MAGIC.length + 4, dataStart, true);
|
|
116
|
-
|
|
185
|
+
dv.setBigUint64(MAGIC.length + 8, BigInt(Bun.hash(headerJson)), true);
|
|
186
|
+
pre.set(headerJson, PREFIX_LEN);
|
|
117
187
|
writeSync(fd, pre, 0, pre.length, 0);
|
|
118
188
|
|
|
119
189
|
let blobIdx = 0;
|
|
120
|
-
for (const e of entries)
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
} finally {
|
|
190
|
+
for (const e of entries)
|
|
191
|
+
for (const slot of e.tensors)
|
|
192
|
+
writeSync(fd, blobs[blobIdx++]!, 0, slot.bytes, dataStart + slot.off);
|
|
193
|
+
fsyncSync(fd);
|
|
194
|
+
} catch (err) {
|
|
126
195
|
closeSync(fd);
|
|
196
|
+
try { rmSync(tmp, { force: true }); } catch {}
|
|
197
|
+
throw err;
|
|
127
198
|
}
|
|
199
|
+
closeSync(fd);
|
|
200
|
+
renameSync(tmp, path);
|
|
128
201
|
}
|
|
129
202
|
|
|
130
|
-
/** Read only the header (cheap — for prefix matching across many files).
|
|
203
|
+
/** Read only the header (cheap — for prefix matching across many files).
|
|
204
|
+
* Always verifies the header hash; throws on any structural mismatch. */
|
|
131
205
|
export function readKvHeader(path: string): KvFileHeader & { dataStart: number } {
|
|
132
206
|
const fd = openSync(path, "r");
|
|
133
207
|
try {
|
|
134
|
-
const head = new Uint8Array(
|
|
135
|
-
|
|
208
|
+
const head = new Uint8Array(PREFIX_LEN);
|
|
209
|
+
readSync(fd, head, 0, head.length, 0);
|
|
136
210
|
if (new TextDecoder().decode(head.subarray(0, MAGIC.length)) !== MAGIC)
|
|
137
|
-
throw new Error(`${path}: not an mlx-bun KV cache file`);
|
|
211
|
+
throw new Error(`${path}: not an mlx-bun KV v2 cache file`);
|
|
138
212
|
const dv = new DataView(head.buffer);
|
|
139
213
|
const len = dv.getUint32(MAGIC.length, true);
|
|
140
214
|
const dataStart = dv.getUint32(MAGIC.length + 4, true);
|
|
215
|
+
const expectHash = dv.getBigUint64(MAGIC.length + 8, true);
|
|
141
216
|
const body = new Uint8Array(len);
|
|
142
|
-
|
|
217
|
+
readSync(fd, body, 0, len, PREFIX_LEN);
|
|
218
|
+
if (BigInt(Bun.hash(body)) !== expectHash)
|
|
219
|
+
throw new Error(`${path}: header hash mismatch (truncated or corrupt)`);
|
|
143
220
|
const header = JSON.parse(new TextDecoder().decode(body)) as KvFileHeader;
|
|
221
|
+
if (header.formatVersion !== 2)
|
|
222
|
+
throw new Error(`${path}: unsupported formatVersion ${header.formatVersion}`);
|
|
144
223
|
return { ...header, dataStart };
|
|
145
224
|
} finally {
|
|
146
225
|
closeSync(fd);
|
|
@@ -149,37 +228,100 @@ export function readKvHeader(path: string): KvFileHeader & { dataStart: number }
|
|
|
149
228
|
|
|
150
229
|
export interface LoadedKvCache {
|
|
151
230
|
tokens: number[];
|
|
231
|
+
header: KvFileHeader;
|
|
152
232
|
caches: Cache[];
|
|
153
233
|
/** Keep referenced as long as the caches live. */
|
|
154
234
|
mmap: MmapFile;
|
|
155
235
|
}
|
|
156
236
|
|
|
157
|
-
export
|
|
237
|
+
export interface KvLoadExpect {
|
|
238
|
+
/** Reject on metadata mismatch (pass what the server is running). */
|
|
239
|
+
configFingerprint?: string;
|
|
240
|
+
tokenizerHash?: string;
|
|
241
|
+
ns?: string;
|
|
242
|
+
/** Verify every tensor hash (reads all bytes — defeats lazy fault-in;
|
|
243
|
+
* off by default, `--ssd-cache-verify`). */
|
|
244
|
+
verify?: boolean;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Reload zero-copy. `model` is anything with makeCache() — the entry count
|
|
248
|
+
* is validated against the DONOR cache list (model.layers.length was wrong
|
|
249
|
+
* for KV-shared models like e4b, whose makeCache() returns donors only). */
|
|
250
|
+
export function loadKvCache(
|
|
251
|
+
path: string,
|
|
252
|
+
model: { makeCache(): Cache[] },
|
|
253
|
+
expect: KvLoadExpect = {},
|
|
254
|
+
): LoadedKvCache {
|
|
158
255
|
const header = readKvHeader(path);
|
|
256
|
+
for (const key of ["configFingerprint", "tokenizerHash", "ns"] as const) {
|
|
257
|
+
if (expect[key] !== undefined && header[key] !== expect[key])
|
|
258
|
+
throw new Error(`${path}: ${key} mismatch (file ${header[key]}, expected ${expect[key]})`);
|
|
259
|
+
}
|
|
260
|
+
const proto = model.makeCache();
|
|
261
|
+
const cacheCount = proto.length;
|
|
262
|
+
for (const c of proto) c.dispose();
|
|
263
|
+
if (header.caches.length !== cacheCount)
|
|
264
|
+
throw new Error(`${path}: ${header.caches.length} cached layers but model has ${cacheCount}`);
|
|
265
|
+
|
|
159
266
|
const mmap = MmapFile.open(path, "cow");
|
|
160
267
|
const dataStart = header.dataStart;
|
|
268
|
+
const arr = (slot: TensorSlot): MlxArray => {
|
|
269
|
+
if (expect.verify) {
|
|
270
|
+
const view = mmap.view(dataStart + slot.off, slot.bytes);
|
|
271
|
+
if (hash64(view) !== slot.hash) {
|
|
272
|
+
mmap.unmap();
|
|
273
|
+
throw new Error(`${path}: tensor hash mismatch at offset ${slot.off}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return MlxArray.fromPointer(mmap.pointer(dataStart + slot.off), slot.shape, slot.dtype as Dtype);
|
|
277
|
+
};
|
|
278
|
+
const triple = (slots: TensorSlot[], at: number): ops.QuantizedTensor =>
|
|
279
|
+
({ packed: arr(slots[at]!), scales: arr(slots[at + 1]!), biases: arr(slots[at + 2]!) });
|
|
161
280
|
|
|
162
281
|
const caches: Cache[] = [];
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
282
|
+
try {
|
|
283
|
+
for (const e of header.caches) {
|
|
284
|
+
const t = e.tensors;
|
|
285
|
+
switch (e.kind) {
|
|
286
|
+
case "kv": {
|
|
287
|
+
const c = new KVCache();
|
|
288
|
+
c.restoreState(arr(t[0]!), arr(t[1]!), e.offset);
|
|
289
|
+
caches.push(c);
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
case "rotating": {
|
|
293
|
+
const c = new RotatingKVCache(e.maxSize!);
|
|
294
|
+
c.restoreState(arr(t[0]!), arr(t[1]!), e.offset, e.idx!);
|
|
295
|
+
caches.push(c);
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
case "qkv": {
|
|
299
|
+
const c = new QuantizedKVCache(e.groupSize!, e.bits!);
|
|
300
|
+
c.restoreState(triple(t, 0), triple(t, 3), e.offset);
|
|
301
|
+
caches.push(c);
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
case "rotating-qkv": {
|
|
305
|
+
const c = new RotatingQuantizedKVCache(e.maxSize!, e.groupSize!, e.bits!);
|
|
306
|
+
c.restoreState(triple(t, 0), triple(t, 3), e.offset, e.idx!);
|
|
307
|
+
caches.push(c);
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
case "ssm": {
|
|
311
|
+
const c = new SSMCache();
|
|
312
|
+
c.conv = arr(t[0]!);
|
|
313
|
+
c.recurrent = arr(t[1]!);
|
|
314
|
+
c.offset = e.offset;
|
|
315
|
+
caches.push(c);
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
default:
|
|
319
|
+
throw new Error(`${path}: unknown cache kind ${(e as { kind: string }).kind}`);
|
|
320
|
+
}
|
|
178
321
|
}
|
|
322
|
+
} catch (err) {
|
|
323
|
+
for (const c of caches) c.dispose();
|
|
324
|
+
throw err;
|
|
179
325
|
}
|
|
180
|
-
|
|
181
|
-
throw new Error(
|
|
182
|
-
`${path}: ${caches.length} cached layers but model has ${model.layers.length}`,
|
|
183
|
-
);
|
|
184
|
-
return { tokens: header.tokens, caches, mmap };
|
|
326
|
+
return { tokens: header.tokens, header, caches, mmap };
|
|
185
327
|
}
|
package/src/mlx/array.ts
CHANGED
|
@@ -108,6 +108,27 @@ export class MlxArray {
|
|
|
108
108
|
);
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/** Graph-free LEAF with this array's exact bytes — ONE copy, straight from
|
|
112
|
+
* the (evaluated) mlx buffer into a fresh mlx-owned array, no JS-heap
|
|
113
|
+
* intermediate (`fromBytesCopy(rawBytes())` is TWO copies: device→JS heap
|
|
114
|
+
* →new leaf). mlx_array_new_data copies synchronously, so the source only
|
|
115
|
+
* has to outlive this call. The buffer is read LINEARLY — the caller must
|
|
116
|
+
* pass a row-major contiguous array (ops.contiguous first for views), same
|
|
117
|
+
* contract as rawBytes. Segmented-backward boundary detach hot path. */
|
|
118
|
+
detachCopy(): MlxArray {
|
|
119
|
+
this.eval();
|
|
120
|
+
const dt = this.dtype;
|
|
121
|
+
const p =
|
|
122
|
+
dt === Dtype.float32 ? C.mlx_array_data_float32(this.handle)
|
|
123
|
+
: dt === Dtype.float16 ? C.mlx_array_data_float16(this.handle)
|
|
124
|
+
: dt === Dtype.bfloat16 ? C.mlx_array_data_bfloat16(this.handle)
|
|
125
|
+
: dt === Dtype.uint32 ? C.mlx_array_data_uint32(this.handle)
|
|
126
|
+
: null;
|
|
127
|
+
if (p === null) throw new Error(`detachCopy: unsupported dtype ${this.dtypeName}`);
|
|
128
|
+
const sb = shapeBuf(this.shape);
|
|
129
|
+
return new MlxArray(C.mlx_array_new_data(p as never, ptr(sb), this.ndim, dt));
|
|
130
|
+
}
|
|
131
|
+
|
|
111
132
|
get handle(): MlxHandle {
|
|
112
133
|
if (this.#disposed) throw new Error("MlxArray used after dispose");
|
|
113
134
|
return this.#handle;
|
package/src/mlx/ops.ts
CHANGED
|
@@ -172,6 +172,32 @@ export function quantizedMatmul(
|
|
|
172
172
|
x: MlxArray, w: MlxArray, scales: MlxArray, biases: MlxArray | null,
|
|
173
173
|
spec: QuantSpec, transpose = true, s: S = gpuStream,
|
|
174
174
|
): MlxArray {
|
|
175
|
+
// UPSTREAM MLX BUG WORKAROUND (found 2026-07-02, reproduced on stock mlx
|
|
176
|
+
// 0.31.2): quantized_matmul with transpose=false returns WRONG VALUES when
|
|
177
|
+
// the row count is 2 or 3 (relnorm ~0.3-1.1 vs the dequantized matmul);
|
|
178
|
+
// M=1 and M>=4 are correct, transpose=true is correct at every M. Pad the
|
|
179
|
+
// row axis to 4 with zeros and slice the result back. NOTE this wrapper
|
|
180
|
+
// cannot protect mlx's INTERNAL vjp of a transpose=true qmm (C++ calls the
|
|
181
|
+
// primitive directly) — training heads pad the span through the head
|
|
182
|
+
// instead (loss.ts logitsFromHiddenPadM).
|
|
183
|
+
const rowAxis = x.shape.length - 2;
|
|
184
|
+
const rows = x.shape[rowAxis]!;
|
|
185
|
+
if (!transpose && (rows === 2 || rows === 3)) {
|
|
186
|
+
const padShape = [...x.shape];
|
|
187
|
+
padShape[rowAxis] = 4 - rows;
|
|
188
|
+
const pad = zeros(padShape, x.dtype, s);
|
|
189
|
+
const xPad = concatAxis([x, pad], rowAxis, s);
|
|
190
|
+
pad.dispose();
|
|
191
|
+
const yPad = quantizedMatmul(xPad, w, scales, biases, spec, transpose, s);
|
|
192
|
+
xPad.dispose();
|
|
193
|
+
const start = MlxArray.fromInt32(new Int32Array([0]), [1]);
|
|
194
|
+
const outShape = [...yPad.shape];
|
|
195
|
+
outShape[rowAxis] = rows;
|
|
196
|
+
const y = sliceDynamic(yPad, start, [rowAxis], outShape, s);
|
|
197
|
+
start.dispose();
|
|
198
|
+
yPad.dispose();
|
|
199
|
+
return y;
|
|
200
|
+
}
|
|
175
201
|
return new MlxArray(
|
|
176
202
|
outArray("quantized_matmul", (o) =>
|
|
177
203
|
C.mlx_quantized_matmul(
|
package/src/model/gemma4-base.ts
CHANGED
|
@@ -590,6 +590,15 @@ export class QuantizedKVCache implements Cache {
|
|
|
590
590
|
this.offset = Math.max(0, this.offset - n);
|
|
591
591
|
}
|
|
592
592
|
|
|
593
|
+
/** Adopt persisted state (takes ownership of the triples' arrays) —
|
|
594
|
+
* the quantized twin of KVCache.restoreState (kv-store persistence). */
|
|
595
|
+
restoreState(keys: ops.QuantizedTensor, values: ops.QuantizedTensor, offset: number): void {
|
|
596
|
+
this.dispose();
|
|
597
|
+
this.keys = keys;
|
|
598
|
+
this.values = values;
|
|
599
|
+
this.offset = offset;
|
|
600
|
+
}
|
|
601
|
+
|
|
593
602
|
dispose(): void {
|
|
594
603
|
for (const a of this.state()) a.dispose();
|
|
595
604
|
this.keys = this.values = null;
|
|
@@ -1162,6 +1171,16 @@ export class RotatingQuantizedKVCache implements Cache {
|
|
|
1162
1171
|
}
|
|
1163
1172
|
}
|
|
1164
1173
|
|
|
1174
|
+
/** Adopt persisted state (takes ownership of the triples' arrays) —
|
|
1175
|
+
* ring order as-laid-out, ringIdx carried with it (kv-store persistence). */
|
|
1176
|
+
restoreState(keys: ops.QuantizedTensor, values: ops.QuantizedTensor, offset: number, idx: number): void {
|
|
1177
|
+
this.dispose();
|
|
1178
|
+
this.keys = keys;
|
|
1179
|
+
this.values = values;
|
|
1180
|
+
this.offset = offset;
|
|
1181
|
+
this.ringIdx = idx;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1165
1184
|
/** Oracle: to_quantized on an already-quantized rotating cache is
|
|
1166
1185
|
* idempotent. */
|
|
1167
1186
|
toQuantized(): RotatingQuantizedKVCache {
|
|
@@ -1572,8 +1591,22 @@ export function quantizedSdpa(
|
|
|
1572
1591
|
)
|
|
1573
1592
|
return fusedDecodeSdpa(q, kq, vq, groupSize, bits);
|
|
1574
1593
|
const tile = q.shape[2]! > 1 || process.env.MLX_BUN_FUSED_DECODE === "1";
|
|
1575
|
-
if (tile && fusedSdpaSupported(q, mask, groupSize, bits))
|
|
1594
|
+
if (tile && fusedSdpaSupported(q, mask, groupSize, bits)) {
|
|
1595
|
+
// The N-tiled loop CANNOT be traced: its tile count and slice extents
|
|
1596
|
+
// bake at trace-time N, so under shapeless replay a growing (concat-
|
|
1597
|
+
// phase) quantized cache's newest rows are silently never attended —
|
|
1598
|
+
// reproduced on e4b (whole-graph form, FUSED_DECODE=1: diverges within
|
|
1599
|
+
// 3 tokens, then repetition-loops). generate() refuses to compile when
|
|
1600
|
+
// MLX_BUN_FUSED_DECODE=1; this throw is the backstop for any other
|
|
1601
|
+
// compiled caller — it lands in the trace's transactional fallback
|
|
1602
|
+
// instead of producing silently wrong attention.
|
|
1603
|
+
if (isCompiledTrace())
|
|
1604
|
+
throw new Error(
|
|
1605
|
+
"quantizedSdpaTiled inside a compiled trace: the tile loop bakes trace-time N " +
|
|
1606
|
+
"(growing KV rows would be silently dropped) — run this generation uncompiled",
|
|
1607
|
+
);
|
|
1576
1608
|
return quantizedSdpaTiled(q, kq, vq, scale, mask, groupSize, bits);
|
|
1609
|
+
}
|
|
1577
1610
|
return quantizedSdpaUnfused(q, kq, vq, scale, mask, groupSize, bits);
|
|
1578
1611
|
}
|
|
1579
1612
|
|
package/src/model/qwen3-delta.ts
CHANGED
|
@@ -192,7 +192,10 @@ export class SSMCache implements Cache {
|
|
|
192
192
|
}
|
|
193
193
|
|
|
194
194
|
makeMask(_N: number, _windowSize: number | null): Mask {
|
|
195
|
-
//
|
|
195
|
+
// ssm_mask is None both single-stream and in the batch lane: rows
|
|
196
|
+
// solo-prefill UNPADDED at B=1 (state never sees pad tokens), and
|
|
197
|
+
// batched decode feeds one real token per row — so unlike mlx-lm's
|
|
198
|
+
// left-padded batch-prefill there is never a pad position to mask.
|
|
196
199
|
return { mode: "", arr: null };
|
|
197
200
|
}
|
|
198
201
|
|
|
@@ -221,4 +224,48 @@ export class SSMCache implements Cache {
|
|
|
221
224
|
this.conv = null;
|
|
222
225
|
this.recurrent = null;
|
|
223
226
|
}
|
|
227
|
+
|
|
228
|
+
// --- batch-lane dynamic-B ops (BatchScheduler only) ----------------------
|
|
229
|
+
// Both state slots are plain [B, ...] tensors with no temporal axis and no
|
|
230
|
+
// left-padding, so the batched twins of mergeKVRows/filterKVRows collapse
|
|
231
|
+
// to B-axis concat/take. `offset` is bookkeeping only for SSM layers (RoPE
|
|
232
|
+
// reads the FULL-attention caches; the linear-attn forward never reads it),
|
|
233
|
+
// so merged rows carrying different token counts share the max.
|
|
234
|
+
|
|
235
|
+
/** Merge a fully-prefilled solo row into a running batched cache (`prev`
|
|
236
|
+
* null ⇒ the solo row becomes the batch). The returned cache owns fresh
|
|
237
|
+
* arrays — or, when there is nothing to concat, arrays STOLEN from `solo`
|
|
238
|
+
* (nulled there so the scheduler's unconditional dispose of the solo
|
|
239
|
+
* caches stays safe). `prev`/`solo` disposal remains the caller's job. */
|
|
240
|
+
static mergeRows(prev: SSMCache | null, solo: SSMCache): SSMCache {
|
|
241
|
+
if (!solo.conv || !solo.recurrent)
|
|
242
|
+
throw new Error("SSMCache.mergeRows: solo row has no state (prefill first)");
|
|
243
|
+
const out = new SSMCache();
|
|
244
|
+
if (prev) {
|
|
245
|
+
if (!prev.conv || !prev.recurrent)
|
|
246
|
+
throw new Error("SSMCache.mergeRows: batched cache has no state");
|
|
247
|
+
out.conv = ops.concatAxis([prev.conv, solo.conv], 0);
|
|
248
|
+
out.recurrent = ops.concatAxis([prev.recurrent, solo.recurrent], 0);
|
|
249
|
+
} else {
|
|
250
|
+
out.conv = solo.conv;
|
|
251
|
+
out.recurrent = solo.recurrent;
|
|
252
|
+
solo.conv = null;
|
|
253
|
+
solo.recurrent = null;
|
|
254
|
+
}
|
|
255
|
+
out.offset = Math.max(prev?.offset ?? 0, solo.offset);
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Evict rows not in `keep` (ascending row indices), in place. */
|
|
260
|
+
filter(keep: number[]): void {
|
|
261
|
+
if (!this.conv || !this.recurrent) return;
|
|
262
|
+
const idx = ops.fromInt32(keep, [keep.length]);
|
|
263
|
+
const conv = ops.takeAxis(this.conv, idx, 0);
|
|
264
|
+
const recurrent = ops.takeAxis(this.recurrent, idx, 0);
|
|
265
|
+
idx.dispose();
|
|
266
|
+
this.conv.dispose();
|
|
267
|
+
this.recurrent.dispose();
|
|
268
|
+
this.conv = conv;
|
|
269
|
+
this.recurrent = recurrent;
|
|
270
|
+
}
|
|
224
271
|
}
|
package/src/model/qwen3_5.ts
CHANGED
|
@@ -212,8 +212,17 @@ class Qwen3Attention {
|
|
|
212
212
|
v = disposing(v, ops.reshape(v, [B, L, this.nKvHeads, this.headDim]));
|
|
213
213
|
v = disposing(v, ops.transposeAxes(v, [0, 2, 1, 3]));
|
|
214
214
|
|
|
215
|
-
|
|
216
|
-
|
|
215
|
+
// Batched decode: the scheduler's mask wrapper exposes each row's REAL
|
|
216
|
+
// position as ropeOffsetArr (rows have different prompt lengths); the
|
|
217
|
+
// dynamic-offset kernel is the same fast::rope, bit-exact vs the static
|
|
218
|
+
// form (tests/compile.test.ts). Serial lane: scalar offset, unchanged.
|
|
219
|
+
const offArr = (cache as { ropeOffsetArr?: MlxArray }).ropeOffsetArr;
|
|
220
|
+
q = disposing(q, offArr
|
|
221
|
+
? ops.ropeDynamic(q, this.ropeDims, this.ropeBase, offArr, null)
|
|
222
|
+
: ops.rope(q, this.ropeDims, this.ropeBase, cache.offset, null));
|
|
223
|
+
k = disposing(k, offArr
|
|
224
|
+
? ops.ropeDynamic(k, this.ropeDims, this.ropeBase, offArr, null)
|
|
225
|
+
: ops.rope(k, this.ropeDims, this.ropeBase, cache.offset, null));
|
|
217
226
|
|
|
218
227
|
let attn: MlxArray;
|
|
219
228
|
if (cache instanceof QuantizedKVCache) {
|