mlx-bun 0.3.0 → 0.5.0
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 +57 -32
- package/docs/README.md +58 -42
- package/docs/archive/investigations/benchmark-ledger-through-v0.4.0.md +3617 -0
- package/docs/archive/investigations/openjarvis-fit.md +198 -0
- package/docs/archive/investigations/qwen38-27b-campaign-log.md +1092 -0
- package/docs/archive/planning/release-notes-v0.4.0.md +45 -0
- package/docs/archive/planning/release-notes-v0.5.0.md +37 -0
- package/docs/design/batching.md +1535 -50
- package/docs/design/decode-speed-program.md +394 -1171
- package/docs/design/generic-model-support.md +195 -3
- package/docs/design/kv-cache.md +538 -225
- package/docs/design/speculative-decoding.md +552 -50
- package/docs/design/turboquant.md +140 -17
- package/docs/design/unified-engine-frontier-plan.md +259 -150
- package/docs/planning/PRODUCT_ROADMAP.md +21 -6
- package/docs/planning/public-readiness-handoff.md +244 -0
- package/docs/planning/public-readiness.md +149 -0
- package/docs/reference/benchmarks.md +3537 -1112
- package/docs/reference/cli.md +102 -2
- package/docs/reference/distribution.md +45 -13
- package/docs/reference/environment.md +159 -13
- package/docs/reference/library-api.md +18 -4
- package/docs/reference/memory.md +1 -1
- package/docs/reference/models.md +28 -7
- package/docs/reference/server-api.md +257 -33
- package/docs/reference/server-config.md +372 -151
- package/docs/reference/troubleshooting.md +13 -21
- package/package.json +2 -2
- package/src/audio/audiotoolbox.ts +112 -0
- package/src/audio/conformer.ts +8 -0
- package/src/audio/mic-capture.ts +98 -0
- package/src/audio/silero-vad.ts +379 -0
- package/src/audio/whisper-decode.ts +774 -0
- package/src/audio/whisper-fast.ts +732 -0
- package/src/audio/whisper-format.ts +73 -0
- package/src/audio/whisper-mel.ts +185 -0
- package/src/audio/whisper-timing.ts +274 -0
- package/src/audio/whisper-tokenizer.ts +239 -0
- package/src/audio/whisper-transcribe.ts +410 -0
- package/src/backends/mlx/assistant-target.ts +46 -0
- package/src/backends/mlx/autoregressive-draft-rows.ts +120 -0
- package/src/backends/mlx/autoregressive.ts +22 -1
- package/src/backends/mlx/batch-group.ts +464 -236
- package/src/backends/mlx/cache-attachment.ts +67 -0
- package/src/backends/mlx/cache-layout.ts +31 -0
- package/src/backends/mlx/checkpoint-state.ts +53 -0
- package/src/backends/mlx/continuation-persistence.ts +107 -0
- package/src/backends/mlx/continuation-request.ts +43 -0
- package/src/backends/mlx/continuation.ts +77 -0
- package/src/backends/mlx/draft-target.ts +7 -22
- package/src/backends/mlx/encoder-cache.ts +25 -0
- package/src/backends/mlx/fill-append.ts +21 -8
- package/src/backends/mlx/fill-group.ts +497 -0
- package/src/backends/mlx/full-kv-row-append.ts +32 -0
- package/src/backends/mlx/full-kv-row-donor.ts +54 -0
- package/src/backends/mlx/full-transitioning-kv-rows.ts +65 -0
- package/src/backends/mlx/gateway-binding.ts +122 -19
- package/src/backends/mlx/kv-codec.ts +9 -0
- package/src/backends/mlx/kv-maintenance.ts +161 -0
- package/src/backends/mlx/kv-tensor-rows.ts +212 -0
- package/src/backends/mlx/mixed-iteration.ts +62 -0
- package/src/backends/mlx/model-prompt.ts +46 -32
- package/src/backends/mlx/model-serving.ts +5 -4
- package/src/backends/mlx/paged-cache-codec.ts +27 -0
- package/src/backends/mlx/prefill-cohort.ts +100 -0
- package/src/backends/mlx/prefill-policy.ts +26 -0
- package/src/backends/mlx/prefill-rows.ts +244 -0
- package/src/backends/mlx/prefill.ts +1 -1
- package/src/backends/mlx/projected-context-rows.ts +72 -0
- package/src/backends/mlx/prompt-input.ts +29 -0
- package/src/backends/mlx/qwen-prompt-input.ts +40 -0
- package/src/backends/mlx/request-state-policy.ts +54 -0
- package/src/backends/mlx/rollback.ts +45 -4
- package/src/backends/mlx/row-sampling.ts +49 -0
- package/src/backends/mlx/serial-executor.ts +51 -73
- package/src/backends/mlx/speculative-group.ts +438 -0
- package/src/backends/mlx/speculative-round.ts +161 -0
- package/src/backends/mlx/speculative.ts +24 -7
- package/src/backends/mlx/state-rows.ts +104 -0
- package/src/backends/mlx/state-views.ts +21 -1
- package/src/backends/mlx/target-layout-capability.ts +35 -0
- package/src/backends/mlx/transitioning-kv-rows.ts +173 -0
- package/src/cli.ts +333 -36
- package/src/contracts/execution.ts +21 -0
- package/src/contracts/object-cache.ts +8 -0
- package/src/contracts/pi-provider.ts +1 -1
- package/src/contracts/pi.ts +3 -1
- package/src/contracts/prefix-cache.ts +33 -0
- package/src/contracts/resources.ts +7 -0
- package/src/contracts/sampling.ts +23 -0
- package/src/contracts/scheduling.ts +27 -0
- package/src/engine/execution-plan.ts +38 -25
- package/src/engine/execution-tasks.ts +44 -0
- package/src/engine/namespaced-cache.ts +13 -0
- package/src/engine/resources.ts +12 -1
- package/src/engine/scheduler.ts +32 -7
- package/src/fill/fill-session.ts +14 -3
- package/src/fit.ts +29 -15
- package/src/generate.ts +105 -195
- package/src/grammar.ts +13 -0
- package/src/inference/draft-acceptance.ts +41 -0
- package/src/inference/draft-output.ts +49 -0
- package/src/inference/prefill.ts +6 -0
- package/src/inference/rollback.ts +2 -2
- package/src/kv-scheme.ts +92 -12
- package/src/kv-store.ts +183 -93
- package/src/lab/README.md +5 -1
- package/src/lab/paged-kv/paged-attention.ts +117 -0
- package/src/lab/paged-kv/paged-kv-rows.ts +111 -0
- package/src/lab/paged-kv/paged-kv.ts +126 -46
- package/src/md-modules.d.ts +5 -0
- package/src/mlx/array.ts +39 -1
- package/src/mlx/ffi.ts +19 -0
- package/src/mlx/normalized-argmax.ts +116 -0
- package/src/mlx/ops.ts +97 -10
- package/src/model/artifact-identity.ts +73 -0
- package/src/model/batched-kv.ts +235 -0
- package/src/model/batched-mask.ts +12 -4
- package/src/model/batched-quant.ts +8 -2
- package/src/model/batched-quantized-kv.ts +144 -0
- package/src/model/batched-rotating-quant.ts +76 -15
- package/src/model/batched-rotating-state.ts +76 -0
- package/src/model/batched-rotating.ts +64 -22
- package/src/model/batched-row-storage.ts +52 -0
- package/src/model/batched-ssm.ts +52 -0
- package/src/model/batched-turboquant-kv.ts +94 -0
- package/src/model/compiled-decode.ts +7 -1
- package/src/model/decoded-kv-donor.ts +12 -0
- package/src/model/delayed-quantized-kv.ts +72 -0
- package/src/model/delayed-rotating-quantized-kv.ts +93 -0
- package/src/model/delayed-turboquant-kv.ts +57 -0
- package/src/model/factory.ts +26 -0
- package/src/model/fingerprint.ts +12 -0
- package/src/model/full-prefill-padding.ts +56 -0
- package/src/model/full-prefill-row.ts +105 -0
- package/src/model/gemma4-base.ts +183 -82
- package/src/model/gemma4.ts +116 -28
- package/src/model/generated/gemma4-12b.ts +4 -1
- package/src/model/generated/gemma4-26b.ts +4 -1
- package/src/model/generated/gemma4-e4b.ts +4 -1
- package/src/model/glm52-cache.ts +80 -4
- package/src/model/glm52-mla.ts +12 -6
- package/src/model/glm52.ts +19 -40
- package/src/model/kv-attention-view.ts +70 -0
- package/src/model/minicpm5.ts +11 -7
- package/src/model/profile.ts +29 -4
- package/src/model/quantized-append-attention.ts +31 -0
- package/src/model/qwen3-delta-state.ts +74 -0
- package/src/model/qwen3-delta.ts +59 -36
- package/src/model/qwen3-mrope.ts +7 -9
- package/src/model/qwen3_5.ts +177 -75
- package/src/model/rotating-kv-donor.ts +35 -0
- package/src/model/rotating-kv-layout.ts +262 -0
- package/src/model/rotating-row-transaction.ts +35 -0
- package/src/model/speculative-rotating-kv.ts +101 -0
- package/src/model/ssm-prefill-padding.ts +39 -0
- package/src/model/support.ts +8 -0
- package/src/model/token-groups.ts +66 -0
- package/src/model/trellis-linear.ts +4 -4
- package/src/model/turboquant-codec.ts +89 -0
- package/src/model/universal/dense.ts +14 -13
- package/src/model/whisper.ts +435 -0
- package/src/native/mic_capture.swift +92 -0
- package/src/native-pack.ts +6 -5
- package/src/pi-web.ts +9 -1
- package/src/preflight.ts +31 -15
- package/src/prompt-cache.ts +306 -86
- package/src/runtime-config.ts +1 -5
- package/src/safetensors.ts +9 -2
- package/src/sampler.ts +37 -25
- package/src/serve/audio-routes.ts +323 -0
- package/src/serve/chat-request.ts +2 -0
- package/src/serve/chat-stage.ts +19 -14
- package/src/serve/completion-executor.ts +9 -1
- package/src/serve/discovery-routes.ts +17 -1
- package/src/serve/generated-token-history.ts +57 -0
- package/src/serve/generation-gateway.ts +88 -38
- package/src/serve/model-binding.ts +7 -1
- package/src/serve/model-host.ts +3 -4
- package/src/serve/preparation.ts +3 -3
- package/src/serve/prompt-response-trace.ts +14 -0
- package/src/serve/request-plan.ts +6 -3
- package/src/serve/request-prep.ts +40 -25
- package/src/serve/text-completion-stage.ts +2 -2
- package/src/serve/transcription-server.ts +64 -0
- package/src/serve/transcription-service.ts +502 -0
- package/src/server.ts +185 -261
- package/src/spec/assistant-rows.ts +93 -0
- package/src/spec/assistant-source.ts +28 -18
- package/src/spec/cache-identity.ts +22 -0
- package/src/spec/deepspec-rows.ts +29 -0
- package/src/spec/deepspec-source.ts +12 -10
- package/src/spec/dflash-source.ts +28 -4
- package/src/spec/drafter.ts +153 -142
- package/src/spec/dspark/deepspec-module.ts +95 -120
- package/src/spec/dspark/module-dflash.ts +126 -170
- package/src/spec/glm52-mtp-graph.ts +29 -0
- package/src/spec/glm52-mtp-rows.ts +132 -0
- package/src/spec/glm52-mtp-source.ts +54 -136
- package/src/spec/glm52-mtp-state.ts +33 -0
- package/src/spec/ngram-source.ts +115 -21
- package/src/spec/projected-draft-rows.ts +73 -0
- package/src/spec/qwen-mtp-module.ts +171 -0
- package/src/spec/qwen-mtp-rows.ts +230 -0
- package/src/spec/qwen-mtp-source.ts +84 -226
- package/src/spec/qwen-mtp-state.ts +30 -0
- package/src/spec/serve-loop.ts +73 -69
- package/src/spec/source.ts +93 -15
- package/src/spec/two-model-rows.ts +62 -0
- package/src/spec/two-model.ts +45 -103
- package/src/ssd-cache.ts +107 -36
- package/src/ssd-durability.ts +29 -27
- package/src/storage/host-buffer.ts +24 -0
- package/src/storage/kv-writer.ts +83 -0
- package/src/storage/kv-writer.worker.js +264 -0
- package/src/storage/retention-policy.ts +37 -0
- package/src/tiered-prompt-cache.ts +45 -0
- package/src/vision/embedder.ts +8 -0
- package/src/vision/prompt.ts +101 -65
- package/src/vision/qwen3vl-prompt.ts +101 -64
- package/src/vision/qwen3vl-tower.ts +11 -0
- package/src/vision/siglip.ts +8 -0
- package/src/web/app.html +12 -0
- package/src/web/app.js +217 -57
- package/src/web/src/chat.ts +4 -1
- package/src/web/src/composer.ts +3 -0
- package/src/web/src/voice.ts +141 -0
- package/src/web/sw.js +1 -1
- package/src/backends/mlx/memory-guard.ts +0 -40
- package/src/spec/prefix-state.ts +0 -45
- /package/docs/{planning → archive/planning}/release-notes-v0.3.0.md +0 -0
package/src/kv-store.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type CheckpointAttachment, attachmentBytes, disposeAttachments } from "./backends/mlx/checkpoint-state";
|
|
1
2
|
import { cleanupFailure, disposeResources } from "./engine/resources";
|
|
2
3
|
// KV-cache persistence: save prompt caches to disk, reload by streamed copy.
|
|
3
4
|
// The serialization core of the SSD cold tier (docs/design/kv-cache.md).
|
|
@@ -42,8 +43,14 @@ import { cleanupFailure, disposeResources } from "./engine/resources";
|
|
|
42
43
|
// whole entry again.
|
|
43
44
|
|
|
44
45
|
import { openSync, writeSync, readSync, closeSync, fsyncSync, renameSync, rmSync } from "node:fs";
|
|
46
|
+
import { pagedCacheCodec } from "./backends/mlx/paged-cache-codec";
|
|
47
|
+
import { HostBuffer } from "./storage/host-buffer";
|
|
48
|
+
import { readFileSync } from "node:fs";
|
|
49
|
+
import { join, dirname } from "node:path";
|
|
50
|
+
import { createHash } from "node:crypto";
|
|
45
51
|
import { MmapFile, MADV_DONTNEED } from "./mmap";
|
|
46
52
|
import { MlxArray } from "./mlx/array";
|
|
53
|
+
import { kvWriter, kvReader, type KvWriteRequest } from "./storage/kv-writer";
|
|
47
54
|
import type { Dtype } from "./mlx/ffi";
|
|
48
55
|
import * as ops from "./mlx/ops";
|
|
49
56
|
import {
|
|
@@ -66,6 +73,7 @@ const hash64 = (bytes: Uint8Array): string => Bun.hash(bytes).toString(16).padSt
|
|
|
66
73
|
|
|
67
74
|
export type CacheKind =
|
|
68
75
|
| "kv"
|
|
76
|
+
| "paged"
|
|
69
77
|
| "rotating"
|
|
70
78
|
| "qkv"
|
|
71
79
|
| "rotating-qkv"
|
|
@@ -75,7 +83,8 @@ export type CacheKind =
|
|
|
75
83
|
| "mla-dsa"
|
|
76
84
|
| "mtp-mla";
|
|
77
85
|
|
|
78
|
-
interface TensorSlot {
|
|
86
|
+
export interface TensorSlot {
|
|
87
|
+
blocks?: Array<{ hash: string; bytes: number }>;
|
|
79
88
|
off: number;
|
|
80
89
|
bytes: number;
|
|
81
90
|
shape: number[];
|
|
@@ -84,8 +93,11 @@ interface TensorSlot {
|
|
|
84
93
|
}
|
|
85
94
|
|
|
86
95
|
export interface CacheHeaderEntry {
|
|
96
|
+
paged?: { capacityTokens: number; blockSize: number; blockTable: number[]; numBlocks: number;
|
|
97
|
+
headDim: number; vHeadDim: number; dtype: Dtype; direct: boolean };
|
|
87
98
|
kind: CacheKind;
|
|
88
99
|
offset: number;
|
|
100
|
+
minimumReusableOffset?: number;
|
|
89
101
|
/** rotating variants: ring write index */
|
|
90
102
|
idx?: number;
|
|
91
103
|
/** rotating variants: window size */
|
|
@@ -114,6 +126,7 @@ export interface CacheHeaderEntry {
|
|
|
114
126
|
}
|
|
115
127
|
|
|
116
128
|
export interface KvSaveMeta {
|
|
129
|
+
attachments?: CheckpointAttachment[];
|
|
117
130
|
modelId?: string;
|
|
118
131
|
/** configFingerprint(config) — covers every graph-shaping field incl.
|
|
119
132
|
* the kv-quant scheme, so a scheme flip invalidates naturally. */
|
|
@@ -139,10 +152,11 @@ export interface KvSaveMeta {
|
|
|
139
152
|
};
|
|
140
153
|
}
|
|
141
154
|
|
|
142
|
-
export interface KvFileHeader extends KvSaveMeta {
|
|
155
|
+
export interface KvFileHeader extends Omit<KvSaveMeta, "attachments"> {
|
|
156
|
+
attachments?: Array<Omit<CheckpointAttachment, "tensors"> & { tensors: TensorSlot[] }>;
|
|
143
157
|
/** Absent in earlier v3 files, which used mlx-cache-v3. */
|
|
144
158
|
codecProvider?: string;
|
|
145
|
-
formatVersion: 3;
|
|
159
|
+
formatVersion: 3 | 4 | 5;
|
|
146
160
|
createdAt: number;
|
|
147
161
|
tokens: number[];
|
|
148
162
|
caches: CacheHeaderEntry[];
|
|
@@ -273,8 +287,8 @@ const CACHE_CODECS = {
|
|
|
273
287
|
},
|
|
274
288
|
clone: (cache, context) => {
|
|
275
289
|
const c = cache as KVCache;
|
|
276
|
-
if (!c.keys || !c.values) throw new Error("cannot clone an empty cache");
|
|
277
290
|
const clone = new KVCache();
|
|
291
|
+
if (!c.keys || !c.values) return clone;
|
|
278
292
|
clone.restoreState(
|
|
279
293
|
context.liveView(c.keys, c.offset),
|
|
280
294
|
context.liveView(c.values, c.offset),
|
|
@@ -305,8 +319,8 @@ const CACHE_CODECS = {
|
|
|
305
319
|
},
|
|
306
320
|
clone: (cache, context) => {
|
|
307
321
|
const c = cache as RotatingKVCache;
|
|
308
|
-
if (!c.keys || !c.values) throw new Error("cannot clone an empty cache");
|
|
309
322
|
const clone = new RotatingKVCache(c.maxSize);
|
|
323
|
+
if (!c.keys || !c.values) return clone;
|
|
310
324
|
clone.restoreState(context.view(c.keys), context.view(c.values), c.offset, c.ringIdx);
|
|
311
325
|
return clone;
|
|
312
326
|
},
|
|
@@ -332,8 +346,8 @@ const CACHE_CODECS = {
|
|
|
332
346
|
},
|
|
333
347
|
clone: (cache, context) => {
|
|
334
348
|
const c = cache as QuantizedKVCache;
|
|
335
|
-
if (!c.keys || !c.values) throw new Error("cannot clone an empty cache");
|
|
336
349
|
const clone = new QuantizedKVCache(c.groupSize, c.bits);
|
|
350
|
+
if (!c.keys || !c.values) return clone;
|
|
337
351
|
clone.restoreState(
|
|
338
352
|
context.tripleView(c.keys, c.offset),
|
|
339
353
|
context.tripleView(c.values, c.offset),
|
|
@@ -363,8 +377,8 @@ const CACHE_CODECS = {
|
|
|
363
377
|
},
|
|
364
378
|
clone: (cache, context) => {
|
|
365
379
|
const c = cache as RotatingQuantizedKVCache;
|
|
366
|
-
if (!c.keys || !c.values) throw new Error("cannot clone an empty cache");
|
|
367
380
|
const clone = new RotatingQuantizedKVCache(c.maxSize, c.groupSize, c.bits);
|
|
381
|
+
if (!c.keys || !c.values) return clone;
|
|
368
382
|
clone.restoreState(
|
|
369
383
|
context.tripleView(c.keys, null), context.tripleView(c.values, null),
|
|
370
384
|
c.offset, c.ringIdx,
|
|
@@ -426,7 +440,7 @@ const CACHE_CODECS = {
|
|
|
426
440
|
if (state.length === 0) throw new Error("cannot clone an empty cache");
|
|
427
441
|
try {
|
|
428
442
|
const [kIdx, kScales, kZeros, vPacked, vScales] = state;
|
|
429
|
-
const clone = new TurboQuantKVCache(c.kBits, c.vBits);
|
|
443
|
+
const clone = new TurboQuantKVCache(c.kBits, c.vBits, c.fusedDecode);
|
|
430
444
|
clone.restoreState({
|
|
431
445
|
kIdx: context.view(kIdx!),
|
|
432
446
|
kScales: context.view(kScales!),
|
|
@@ -453,6 +467,7 @@ const CACHE_CODECS = {
|
|
|
453
467
|
},
|
|
454
468
|
headerTrimmable: () => true,
|
|
455
469
|
},
|
|
470
|
+
paged: pagedCacheCodec,
|
|
456
471
|
mla: {
|
|
457
472
|
matches: (cache) => cacheSignature(cache) === "kv:mla:target",
|
|
458
473
|
snapshot: (cache, context) => snapshotMla("mla", cache, context),
|
|
@@ -515,6 +530,17 @@ export function createCacheCodecProvider(
|
|
|
515
530
|
|
|
516
531
|
export const legacyCacheCodecs = createCacheCodecProvider("mlx-cache-v3", CACHE_CODECS);
|
|
517
532
|
|
|
533
|
+
/** Old TQ files lack the conversion boundary. Keep them reusable at their
|
|
534
|
+
* stored offset, but never infer that an earlier precision state is valid. */
|
|
535
|
+
export function cacheHeaderMinimumReusableOffset(entry: CacheHeaderEntry): number {
|
|
536
|
+
const offset = entry.minimumReusableOffset ?? (entry.kind === "turboquant" ? entry.offset : 0);
|
|
537
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || offset > entry.offset)
|
|
538
|
+
throw new Error("invalid cache minimum reusable offset");
|
|
539
|
+
return offset;
|
|
540
|
+
}
|
|
541
|
+
export const cacheHeadersMinimumReusableOffset = (caches: readonly CacheHeaderEntry[]): number =>
|
|
542
|
+
Math.max(0, ...caches.map(cacheHeaderMinimumReusableOffset));
|
|
543
|
+
|
|
518
544
|
export const cacheHeadersTrimmable = (
|
|
519
545
|
caches: readonly CacheHeaderEntry[], codecs: CacheCodecProvider = legacyCacheCodecs,
|
|
520
546
|
): boolean => caches.every((entry) => codecs.forHeader(entry).headerTrimmable(entry));
|
|
@@ -547,6 +573,7 @@ function snapshotCache(c: Cache, dataOffset: number, codecs: CacheCodecProvider)
|
|
|
547
573
|
|
|
548
574
|
try {
|
|
549
575
|
const entry = codecs.forCache(c).snapshot(c, { slots, push, liveSlice, liveMlaSlice, pushTriple });
|
|
576
|
+
if (c.minimumReusableOffset !== undefined) entry.minimumReusableOffset = c.minimumReusableOffset;
|
|
550
577
|
return { entry, sources, next: cursor };
|
|
551
578
|
} catch (error) {
|
|
552
579
|
for (const source of sources) if (source.disposeAfter) source.arr.dispose();
|
|
@@ -588,7 +615,9 @@ export function cloneKvCaches(caches: Cache[], codecs: CacheCodecProvider = lega
|
|
|
588
615
|
const out: Cache[] = [];
|
|
589
616
|
try {
|
|
590
617
|
for (const c of caches) {
|
|
591
|
-
|
|
618
|
+
const clone = codecs.forCache(c).clone(c, { view, liveView, mlaView, tripleView });
|
|
619
|
+
if (c.minimumReusableOffset !== undefined) clone.minimumReusableOffset = c.minimumReusableOffset;
|
|
620
|
+
out.push(clone);
|
|
592
621
|
pending.length = 0; // transferred to the returned cache
|
|
593
622
|
}
|
|
594
623
|
} catch (err) {
|
|
@@ -597,12 +626,8 @@ export function cloneKvCaches(caches: Cache[], codecs: CacheCodecProvider = lega
|
|
|
597
626
|
return out;
|
|
598
627
|
}
|
|
599
628
|
|
|
600
|
-
/** Shared
|
|
601
|
-
|
|
602
|
-
* between yields so the EVENT LOOP KEEPS SERVING while a multi-hundred-MB
|
|
603
|
-
* entry flushes (the write-behind persistence contract: durability never
|
|
604
|
-
* blocks a request). try/finally still runs on early generator close. */
|
|
605
|
-
function* saveKvCacheSteps(path: string, tokens: number[], caches: Cache[], meta: KvSaveMeta, codecs: CacheCodecProvider): Generator<void, void, void> {
|
|
629
|
+
/** Shared file layout and codec planning for synchronous and worker writes. */
|
|
630
|
+
function planKvCache(tokens: number[], caches: Cache[], meta: KvSaveMeta, codecs: CacheCodecProvider): { header: KvFileHeader; sources: TensorSource[] } {
|
|
606
631
|
// Plan pass: header entries + lazy tensor sources, NO bytes materialized.
|
|
607
632
|
const entries: CacheHeaderEntry[] = [];
|
|
608
633
|
const sources: TensorSource[] = [];
|
|
@@ -615,11 +640,36 @@ function* saveKvCacheSteps(path: string, tokens: number[], caches: Cache[], meta
|
|
|
615
640
|
dataOffset = s.next;
|
|
616
641
|
}
|
|
617
642
|
|
|
643
|
+
const { attachments: liveAttachments, ...identity } = meta;
|
|
644
|
+
const attachments = liveAttachments?.map((attachment) => ({
|
|
645
|
+
schema: attachment.schema,
|
|
646
|
+
metadata: { ...attachment.metadata },
|
|
647
|
+
tensors: attachment.tensors.map((arr) => {
|
|
648
|
+
const slot: TensorSlot = { off: dataOffset, bytes: arr.nbytes,
|
|
649
|
+
shape: [...arr.shape], dtype: arr.dtype, hash: "0000000000000000" };
|
|
650
|
+
dataOffset = alignUp(dataOffset + slot.bytes);
|
|
651
|
+
sources.push({ arr, disposeAfter: false });
|
|
652
|
+
return slot;
|
|
653
|
+
}),
|
|
654
|
+
}));
|
|
655
|
+
|
|
618
656
|
// Header size is final NOW: hashes are fixed-width placeholders that get
|
|
619
657
|
// patched in place (same byte length) after the data pass computes them.
|
|
620
658
|
const header: KvFileHeader = {
|
|
621
|
-
formatVersion: 3, createdAt: Date.now(), ...
|
|
659
|
+
formatVersion: attachments?.length ? 4 : 3, createdAt: Date.now(), ...identity,
|
|
660
|
+
...(attachments?.length ? { attachments } : {}), codecProvider: codecs.id, tokens, caches: entries,
|
|
622
661
|
};
|
|
662
|
+
return { header, sources };
|
|
663
|
+
} catch (error) {
|
|
664
|
+
for (const source of sources) if (source.disposeAfter) source.arr.dispose();
|
|
665
|
+
throw error;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function* saveKvCacheSteps(path: string, tokens: number[], caches: Cache[], meta: KvSaveMeta, codecs: CacheCodecProvider): Generator<void, void, void> {
|
|
670
|
+
const { header, sources } = planKvCache(tokens, caches, meta, codecs);
|
|
671
|
+
const entries = header.caches, attachments = header.attachments;
|
|
672
|
+
try {
|
|
623
673
|
const headerLen = new TextEncoder().encode(JSON.stringify(header)).length;
|
|
624
674
|
const dataStart = alignUp(PREFIX_LEN + headerLen);
|
|
625
675
|
|
|
@@ -639,7 +689,7 @@ function* saveKvCacheSteps(path: string, tokens: number[], caches: Cache[], meta
|
|
|
639
689
|
// pages, which makes them VISIBLE to ps RSS — accounting, not an
|
|
640
690
|
// allocation (see bench-serve.ts' per-leg RSS note).
|
|
641
691
|
let srcIdx = 0;
|
|
642
|
-
for (const e of entries) {
|
|
692
|
+
for (const e of [...entries, ...(attachments ?? [])]) {
|
|
643
693
|
for (const slot of e.tensors) {
|
|
644
694
|
const src = sources[srcIdx++]!;
|
|
645
695
|
const c = ops.contiguous(src.arr);
|
|
@@ -687,58 +737,38 @@ export function saveKvCache(path: string, tokens: number[], caches: Cache[], met
|
|
|
687
737
|
for (const _ of saveKvCacheSteps(path, tokens, caches, meta, codecs)) { /* drain */ }
|
|
688
738
|
}
|
|
689
739
|
|
|
690
|
-
/**
|
|
691
|
-
*
|
|
692
|
-
*
|
|
693
|
-
*
|
|
694
|
-
* `runStep` (optional) runs each blocking tensor step inside the caller's
|
|
695
|
-
* exclusion domain. Waiting for an idle observation is not sufficient: a
|
|
696
|
-
* request can acquire the engine between that check and `steps.next()`,
|
|
697
|
-
* putting rawBytesView's GPU sync on the decode stream during prefill. */
|
|
740
|
+
/** Prepare immutable native storage on the owner thread, then pack, hash and
|
|
741
|
+
* write it on the CPU worker. The optional runner covers preparation only.
|
|
742
|
+
* Callers retain cache and attachment ownership until this promise settles. */
|
|
698
743
|
export async function saveKvCacheAsync(
|
|
699
744
|
path: string, tokens: number[], caches: Cache[], meta: KvSaveMeta = {},
|
|
700
745
|
runStep?: <T>(step: () => T) => Promise<T>, codecs: CacheCodecProvider = legacyCacheCodecs,
|
|
746
|
+
storage: Pick<KvWriteRequest, "layout" | "blockBytes" | "segmented"> = {},
|
|
701
747
|
): Promise<void> {
|
|
702
|
-
const
|
|
748
|
+
const { header, sources } = planKvCache(tokens, caches, meta, codecs);
|
|
703
749
|
try {
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
// open fd. Throw through it so its existing failure cleanup runs.
|
|
712
|
-
steps.throw(error);
|
|
713
|
-
throw error;
|
|
750
|
+
// Snapshot preparation belongs to the MLX owner. After publication the
|
|
751
|
+
// worker sees only immutable bytes and never evaluates or calls MLX.
|
|
752
|
+
const prepare = () => sources.map(source => source.arr.storageView());
|
|
753
|
+
const tensors = runStep ? await runStep(prepare) : prepare();
|
|
754
|
+
await kvWriter.write({ path, header, tensors, ...storage });
|
|
755
|
+
} finally {
|
|
756
|
+
for (const source of sources) if (source.disposeAfter) source.arr.dispose();
|
|
714
757
|
}
|
|
715
758
|
}
|
|
716
759
|
|
|
717
|
-
/**
|
|
718
|
-
* zero-copy cache clones (the queue disposes them on every exit path). */
|
|
760
|
+
/** Queued immutable snapshot; the queue releases its shared views on settle. */
|
|
719
761
|
export interface SpillItem {
|
|
762
|
+
attachments?: CheckpointAttachment[];
|
|
720
763
|
tokens: number[];
|
|
721
764
|
caches: Cache[];
|
|
722
765
|
ns: string;
|
|
723
766
|
}
|
|
724
767
|
|
|
725
|
-
/**
|
|
726
|
-
*
|
|
727
|
-
*
|
|
728
|
-
*
|
|
729
|
-
* WITHOUT BOUND, so under sustained traffic (gate starved, evictions
|
|
730
|
-
* ongoing) resident memory = prompt-cache cap + every queued clone —
|
|
731
|
-
* allocator pressure exactly under the load that caused the evictions.
|
|
732
|
-
*
|
|
733
|
-
* Policy: queued bytes are capped. Over cap, the OLDEST not-in-flight
|
|
734
|
-
* item is dropped and its clones disposed immediately — cache semantics
|
|
735
|
-
* (a dropped spill is a future cache miss, never a wrong result; the
|
|
736
|
-
* contention-free alternative to letting the flush cut into decode).
|
|
737
|
-
* The item being enqueued is never its own victim (soft cap: one
|
|
738
|
-
* oversized entry may exceed the cap alone rather than never spilling).
|
|
739
|
-
* Items run strictly serially in enqueue order via an internal chain;
|
|
740
|
-
* store failures are swallowed (cold tier is best-effort) but clones
|
|
741
|
-
* are disposed on every settle path. */
|
|
768
|
+
/** Serial snapshot queue with optional capacity shedding. TieredPromptCache
|
|
769
|
+
* disables shedding and owns residency itself. Interrupted-generation
|
|
770
|
+
* persistence uses the cap for supersedable intervals. Queue ownership ends
|
|
771
|
+
* after each write settles; the cache can retain independent shared views. */
|
|
742
772
|
type SpillRec = SpillItem & {
|
|
743
773
|
bytes: number;
|
|
744
774
|
dropped: boolean;
|
|
@@ -757,10 +787,12 @@ export class SpillQueue {
|
|
|
757
787
|
readonly capBytes: number,
|
|
758
788
|
/** Byte size of a clone set (prompt-cache's cacheBytes). */
|
|
759
789
|
readonly bytesOf: (caches: Cache[]) => number,
|
|
760
|
-
/** The
|
|
790
|
+
/** The asynchronous storage operation. */
|
|
761
791
|
readonly store: (item: SpillItem) => Promise<unknown>,
|
|
762
792
|
/** Clone disposal — frees the pinned GPU memory. */
|
|
763
793
|
readonly disposeClones: (caches: Cache[]) => void,
|
|
794
|
+
/** Zero-byte control barriers may need to survive capacity shedding. */
|
|
795
|
+
readonly canDrop: (item: SpillItem) => boolean = () => true,
|
|
764
796
|
) {}
|
|
765
797
|
|
|
766
798
|
/** Bytes pinned by queued (not yet flushed) clones. */
|
|
@@ -776,7 +808,7 @@ export class SpillQueue {
|
|
|
776
808
|
const result = new Promise<boolean>((resolve) => { settle = resolve; });
|
|
777
809
|
const rec: SpillRec = {
|
|
778
810
|
...item,
|
|
779
|
-
bytes: this.bytesOf(item.caches),
|
|
811
|
+
bytes: this.bytesOf(item.caches) + attachmentBytes(item.attachments),
|
|
780
812
|
dropped: false,
|
|
781
813
|
settle,
|
|
782
814
|
};
|
|
@@ -784,7 +816,7 @@ export class SpillQueue {
|
|
|
784
816
|
this.#bytes += rec.bytes;
|
|
785
817
|
while (this.#bytes > this.capBytes) {
|
|
786
818
|
const victim = this.#queue.find(
|
|
787
|
-
(p) => !p.dropped && p !== this.#inFlight && p !== rec,
|
|
819
|
+
(p) => !p.dropped && p !== this.#inFlight && p !== rec && this.canDrop(p),
|
|
788
820
|
);
|
|
789
821
|
if (!victim) break; // only the new item (or in-flight) left — soft cap
|
|
790
822
|
this.#drop(victim);
|
|
@@ -803,12 +835,19 @@ export class SpillQueue {
|
|
|
803
835
|
} finally {
|
|
804
836
|
this.#inFlight = null;
|
|
805
837
|
this.#remove(rec);
|
|
806
|
-
this.disposeClones(rec.caches)
|
|
838
|
+
disposeResources([{ dispose: () => this.disposeClones(rec.caches) },
|
|
839
|
+
{ dispose: () => disposeAttachments(rec.attachments) }]);
|
|
807
840
|
}
|
|
808
841
|
});
|
|
809
842
|
return result;
|
|
810
843
|
}
|
|
811
844
|
|
|
845
|
+
/** Cancel queued ownership, excluding the write already in flight. */
|
|
846
|
+
cancelWhere(matches: (item: SpillItem) => boolean): void {
|
|
847
|
+
for (const rec of [...this.#queue])
|
|
848
|
+
if (!rec.dropped && rec !== this.#inFlight && matches(rec)) this.#drop(rec);
|
|
849
|
+
}
|
|
850
|
+
|
|
812
851
|
/** Settles when everything enqueued so far has flushed or dropped
|
|
813
852
|
* (tests and graceful shutdown). */
|
|
814
853
|
drain(): Promise<void> { return this.#chain; }
|
|
@@ -818,7 +857,8 @@ export class SpillQueue {
|
|
|
818
857
|
this.#dropped++;
|
|
819
858
|
rec.settle(false);
|
|
820
859
|
this.#remove(rec);
|
|
821
|
-
this.disposeClones(rec.caches)
|
|
860
|
+
disposeResources([{ dispose: () => this.disposeClones(rec.caches) },
|
|
861
|
+
{ dispose: () => disposeAttachments(rec.attachments) }]);
|
|
822
862
|
}
|
|
823
863
|
|
|
824
864
|
#remove(rec: SpillRec): void {
|
|
@@ -848,7 +888,7 @@ export function readKvHeader(path: string): KvFileHeader & { dataStart: number }
|
|
|
848
888
|
if (BigInt(Bun.hash(body)) !== expectHash)
|
|
849
889
|
throw new Error(`${path}: header hash mismatch (truncated or corrupt)`);
|
|
850
890
|
const header = JSON.parse(new TextDecoder().decode(body)) as KvFileHeader;
|
|
851
|
-
if (header.formatVersion !== 3)
|
|
891
|
+
if (header.formatVersion !== 3 && header.formatVersion !== 4 && header.formatVersion !== 5)
|
|
852
892
|
throw new Error(`${path}: unsupported formatVersion ${header.formatVersion}`);
|
|
853
893
|
return { ...header, dataStart };
|
|
854
894
|
} finally {
|
|
@@ -857,6 +897,7 @@ export function readKvHeader(path: string): KvFileHeader & { dataStart: number }
|
|
|
857
897
|
}
|
|
858
898
|
|
|
859
899
|
export interface LoadedKvCache {
|
|
900
|
+
attachments?: CheckpointAttachment[];
|
|
860
901
|
tokens: number[];
|
|
861
902
|
header: KvFileHeader;
|
|
862
903
|
caches: Cache[];
|
|
@@ -953,19 +994,8 @@ function withStepCapacity(a: MlxArray, offset: number): MlxArray {
|
|
|
953
994
|
}
|
|
954
995
|
}
|
|
955
996
|
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
* per-tensor, and unmapped before returning — the caches own their bytes
|
|
959
|
-
* outright (no dtor contract, no pinned mapping, nothing to retain).
|
|
960
|
-
* `model` is anything with makeCache() — the entry count is validated
|
|
961
|
-
* against the DONOR cache list (model.layers.length was wrong for
|
|
962
|
-
* KV-shared models like e4b, whose makeCache() returns donors only). */
|
|
963
|
-
export function loadKvCache(
|
|
964
|
-
path: string,
|
|
965
|
-
model: { makeCache(): Cache[] },
|
|
966
|
-
expect: KvLoadExpect = {}, codecs: CacheCodecProvider = legacyCacheCodecs,
|
|
967
|
-
): LoadedKvCache {
|
|
968
|
-
const header = readKvHeader(path);
|
|
997
|
+
function validateKvHeader(path: string, header: KvFileHeader, model: { makeCache(): Cache[] },
|
|
998
|
+
expect: KvLoadExpect, codecs: CacheCodecProvider): void {
|
|
969
999
|
if ((header.codecProvider ?? legacyCacheCodecs.id) !== codecs.id)
|
|
970
1000
|
throw new Error(`${path}: cache codec provider mismatch`);
|
|
971
1001
|
for (const entry of header.caches) codecs.forHeader(entry);
|
|
@@ -992,8 +1022,11 @@ export function loadKvCache(
|
|
|
992
1022
|
for (const c of proto) c.dispose();
|
|
993
1023
|
}
|
|
994
1024
|
|
|
995
|
-
|
|
996
|
-
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/** Decode cache layouts independently of the transport supplying arrays. */
|
|
1028
|
+
function restoreKvArrays(path: string, header: KvFileHeader, codecs: CacheCodecProvider,
|
|
1029
|
+
readArray: (slot: TensorSlot) => MlxArray): LoadedKvCache {
|
|
997
1030
|
// Every tensor materialized for the CURRENT entry, drained on any throw
|
|
998
1031
|
// (2026-07-07 review: a hash mismatch under --ssd-cache-verify — or any
|
|
999
1032
|
// per-tensor failure — orphaned the entry's already-built tensors: the
|
|
@@ -1002,19 +1035,7 @@ export function loadKvCache(
|
|
|
1002
1035
|
// is safe (dispose is idempotent).
|
|
1003
1036
|
const pending: MlxArray[] = [];
|
|
1004
1037
|
const arr = (slot: TensorSlot): MlxArray => {
|
|
1005
|
-
const
|
|
1006
|
-
if (expect.verify && hash64(view) !== slot.hash)
|
|
1007
|
-
throw new Error(`${path}: tensor hash mismatch at offset ${slot.off}`);
|
|
1008
|
-
// mlx_array_new_data COPIES synchronously — the view only has to
|
|
1009
|
-
// outlive this call, so the mapping can be advised/unmapped freely.
|
|
1010
|
-
const a = MlxArray.fromBytesCopy(view, slot.shape, slot.dtype as Dtype);
|
|
1011
|
-
pending.push(a);
|
|
1012
|
-
// Drop the just-read clean pages so the load's transient stays at
|
|
1013
|
-
// live entry + one tensor (ALIGN = 16 KiB == the arm64 page size, so
|
|
1014
|
-
// every tensor offset is page-aligned by construction).
|
|
1015
|
-
const len = Math.min(alignUp(slot.bytes), mmap.size - (dataStart + slot.off));
|
|
1016
|
-
mmap.advise(dataStart + slot.off, len, MADV_DONTNEED);
|
|
1017
|
-
return a;
|
|
1038
|
+
const a = readArray(slot); pending.push(a); return a;
|
|
1018
1039
|
};
|
|
1019
1040
|
/** withStepCapacity whose (possibly fresh) result is pending-tracked. */
|
|
1020
1041
|
const grownArr = (slot: TensorSlot, offset: number): MlxArray => {
|
|
@@ -1026,20 +1047,89 @@ export function loadKvCache(
|
|
|
1026
1047
|
({ packed: arr(slots[at]!), scales: arr(slots[at + 1]!), biases: arr(slots[at + 2]!) });
|
|
1027
1048
|
|
|
1028
1049
|
const caches: Cache[] = [];
|
|
1050
|
+
const attachments: CheckpointAttachment[] = [];
|
|
1029
1051
|
try {
|
|
1030
1052
|
for (const e of header.caches) {
|
|
1031
1053
|
const codec = codecs.forHeader(e);
|
|
1032
|
-
|
|
1054
|
+
const minimumOffset = cacheHeaderMinimumReusableOffset(e);
|
|
1055
|
+
const cache = codec.load(e, { path, arr, grownArr, triple });
|
|
1056
|
+
cache.minimumReusableOffset = minimumOffset;
|
|
1057
|
+
caches.push(cache);
|
|
1033
1058
|
// This entry's tensors are now owned by its cache — stop tracking them
|
|
1034
1059
|
// (the catch must not double-free through both pending AND caches).
|
|
1035
1060
|
pending.length = 0;
|
|
1036
1061
|
}
|
|
1062
|
+
for (const attachment of header.attachments ?? []) {
|
|
1063
|
+
attachments.push({ schema: attachment.schema, metadata: { ...attachment.metadata },
|
|
1064
|
+
tensors: attachment.tensors.map(arr) });
|
|
1065
|
+
pending.length = 0;
|
|
1066
|
+
}
|
|
1037
1067
|
} catch (err) {
|
|
1068
|
+
disposeAttachments(attachments);
|
|
1038
1069
|
for (const a of pending) a.dispose(); // the mid-entry orphans
|
|
1039
1070
|
for (const c of caches) c.dispose();
|
|
1040
1071
|
throw err;
|
|
1041
|
-
} finally {
|
|
1042
|
-
mmap.unmap(); // every tensor was copied out — nothing aliases the file
|
|
1043
1072
|
}
|
|
1044
|
-
return { tokens: header.tokens, header, caches };
|
|
1073
|
+
return { tokens: header.tokens, header, caches, ...(attachments.length ? { attachments } : {}) };
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/** CPU-only asynchronous reads into unpublished allocations; no tensor copy
|
|
1077
|
+
* crosses the event loop. Native array construction uses the shared codecs. */
|
|
1078
|
+
export async function loadKvCacheAsync(path: string, model: { makeCache(): Cache[] },
|
|
1079
|
+
expect: KvLoadExpect = {}, codecs: CacheCodecProvider = legacyCacheCodecs): Promise<LoadedKvCache> {
|
|
1080
|
+
const header = readKvHeader(path);
|
|
1081
|
+
validateKvHeader(path, header, model, expect, codecs);
|
|
1082
|
+
const slots = [...header.caches, ...(header.attachments ?? [])].flatMap(e => e.tensors);
|
|
1083
|
+
const buffers = new Map<TensorSlot, HostBuffer>();
|
|
1084
|
+
try {
|
|
1085
|
+
for (const slot of slots) buffers.set(slot, new HostBuffer(slot.bytes));
|
|
1086
|
+
await kvReader.read({ path, header, pointers: slots.map(slot => buffers.get(slot)!.pointer), verify: expect.verify });
|
|
1087
|
+
return restoreKvArrays(path, header, codecs, slot =>
|
|
1088
|
+
MlxArray.adoptHostBuffer(buffers.get(slot)!, slot.shape, slot.dtype as Dtype));
|
|
1089
|
+
} finally { for (const buffer of buffers.values()) buffer.dispose(); }
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/** Reload by STREAMED COPY (see the header note): every tensor is copied
|
|
1093
|
+
* into an mlx-owned leaf; the mapping is read-only, its pages dropped
|
|
1094
|
+
* per-tensor, and unmapped before returning — the caches own their bytes
|
|
1095
|
+
* outright (no dtor contract, no pinned mapping, nothing to retain).
|
|
1096
|
+
* `model` is anything with makeCache() — the entry count is validated
|
|
1097
|
+
* against the DONOR cache list (model.layers.length was wrong for
|
|
1098
|
+
* KV-shared models like e4b, whose makeCache() returns donors only). */
|
|
1099
|
+
export function loadKvCache(
|
|
1100
|
+
path: string,
|
|
1101
|
+
model: { makeCache(): Cache[] },
|
|
1102
|
+
expect: KvLoadExpect = {}, codecs: CacheCodecProvider = legacyCacheCodecs,
|
|
1103
|
+
): LoadedKvCache {
|
|
1104
|
+
const header = readKvHeader(path);
|
|
1105
|
+
validateKvHeader(path, header, model, expect, codecs);
|
|
1106
|
+
if (header.formatVersion === 5) {
|
|
1107
|
+
return restoreKvArrays(path, header, codecs, slot => {
|
|
1108
|
+
const bytes = new Uint8Array(slot.bytes);
|
|
1109
|
+
let offset = 0;
|
|
1110
|
+
for (const block of slot.blocks ?? []) {
|
|
1111
|
+
if (!/^[a-f0-9]{64}$/.test(block.hash) || block.bytes <= 0 || offset + block.bytes > bytes.length)
|
|
1112
|
+
throw new Error(`${path}: invalid KV block reference`);
|
|
1113
|
+
const part = readFileSync(join(dirname(path), "blocks", block.hash));
|
|
1114
|
+
if (part.length !== block.bytes || (expect.verify && createHash("sha256").update(part).digest("hex") !== block.hash))
|
|
1115
|
+
throw new Error(`${path}: KV block mismatch`);
|
|
1116
|
+
bytes.set(part, offset); offset += part.length;
|
|
1117
|
+
}
|
|
1118
|
+
if (offset !== bytes.length)
|
|
1119
|
+
throw new Error(`${path}: KV tensor mismatch`);
|
|
1120
|
+
return MlxArray.fromBytesCopy(bytes, slot.shape, slot.dtype as Dtype);
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
const mmap = MmapFile.open(path, "ro"), dataStart = header.dataStart;
|
|
1124
|
+
try {
|
|
1125
|
+
return restoreKvArrays(path, header, codecs, slot => {
|
|
1126
|
+
const view = mmap.view(dataStart + slot.off, slot.bytes);
|
|
1127
|
+
if (expect.verify && hash64(view) !== slot.hash)
|
|
1128
|
+
throw new Error(`${path}: tensor hash mismatch at offset ${slot.off}`);
|
|
1129
|
+
const array = MlxArray.fromBytesCopy(view, slot.shape, slot.dtype as Dtype);
|
|
1130
|
+
const len = Math.min(alignUp(slot.bytes), mmap.size - (dataStart + slot.off));
|
|
1131
|
+
mmap.advise(dataStart + slot.off, len, MADV_DONTNEED);
|
|
1132
|
+
return array;
|
|
1133
|
+
});
|
|
1134
|
+
} finally { mmap.unmap(); }
|
|
1045
1135
|
}
|
package/src/lab/README.md
CHANGED
|
@@ -14,5 +14,9 @@ fate (PLAN.md "Maintainability program", D6 darlings). Rules, gate-enforced by
|
|
|
14
14
|
| module | flag / entry | oracle |
|
|
15
15
|
|---|---|---|
|
|
16
16
|
| `curve/` (curve sampler + designer page) | `--curve-*`, `/curves` | none (Lab) |
|
|
17
|
-
| `paged-kv/` | `--paged-kv`
|
|
17
|
+
| `paged-kv/` | `--paged-kv`; direct read via `MLX_BUN_PAGED_ATTN` | gathered storage is bit-exact; direct Metal is tolerance-tested, with paired M1/M4 measurements in benchmarks.md |
|
|
18
18
|
| `expert-trace/` | `MLX_BUN_EXPERT_TRACE` hook in gemma4 | closed study (expert-offload) |
|
|
19
|
+
|
|
20
|
+
The optional paged layout has a separate backend persistence codec. Its
|
|
21
|
+
allowlisted import keeps `kv-store` independent of row-execution modules and
|
|
22
|
+
preserves RAM/SSD reuse without promoting direct-attention numerics to L1.
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { MlxArray } from "../../mlx/array";
|
|
2
|
+
import { Dtype } from "../../mlx/ffi";
|
|
3
|
+
import { MetalKernel } from "../../mlx/metal-kernel";
|
|
4
|
+
import * as ops from "../../mlx/ops";
|
|
5
|
+
import type { KvAttentionView, Mask } from "../../model/gemma4-base";
|
|
6
|
+
import { disposeResources } from "../../engine/resources";
|
|
7
|
+
import type { BlockPool } from "./paged-kv";
|
|
8
|
+
|
|
9
|
+
// Each SIMD group reduces one query/head over a bounded sequence partition.
|
|
10
|
+
// Only partial output vectors and normalizers materialize, never gathered KV.
|
|
11
|
+
const partialSpec = { name: "paged_attention_partial", ensureRowContiguous: true,
|
|
12
|
+
inputNames: ["q", "keys", "values", "table", "scale"], outputNames: ["out", "normal"], source: `
|
|
13
|
+
uint lane = thread_position_in_threadgroup.x;
|
|
14
|
+
uint hq = threadgroup_position_in_grid.y % HQ;
|
|
15
|
+
uint query = threadgroup_position_in_grid.y / HQ;
|
|
16
|
+
uint part = threadgroup_position_in_grid.z;
|
|
17
|
+
uint hk = hq / (HQ / HK);
|
|
18
|
+
float accum[(DV + 31) / 32];
|
|
19
|
+
for (uint j = 0; j < (DV + 31) / 32; ++j) accum[j] = 0;
|
|
20
|
+
float maximum = -INFINITY, total = 0;
|
|
21
|
+
uint length = uint(scale[1]);
|
|
22
|
+
uint count = (length + PART - 1) / PART;
|
|
23
|
+
uint end = min(uint(length - N + query + 1), (part + 1) * PART);
|
|
24
|
+
for (uint token = part * PART; token < end; ++token) {
|
|
25
|
+
uint physical = table[token / BS];
|
|
26
|
+
uint keyBase = ((physical * HK + hk) * BS + token % BS) * DK;
|
|
27
|
+
float score = 0;
|
|
28
|
+
for (uint d = lane; d < DK; d += 32)
|
|
29
|
+
score += float(q[(hq * N + query) * DK + d]) * float(keys[keyBase + d]);
|
|
30
|
+
score = simd_sum(score) * scale[0];
|
|
31
|
+
float next = max(maximum, score);
|
|
32
|
+
float previous = exp(maximum - next), weight = exp(score - next);
|
|
33
|
+
uint valueBase = ((physical * HK + hk) * BS + token % BS) * DV;
|
|
34
|
+
for (uint j = 0; j < (DV + 31) / 32; ++j) {
|
|
35
|
+
uint d = j * 32 + lane;
|
|
36
|
+
if (d < DV) accum[j] = accum[j] * previous + weight * float(values[valueBase + d]);
|
|
37
|
+
}
|
|
38
|
+
total = total * previous + weight; maximum = next;
|
|
39
|
+
}
|
|
40
|
+
uint base = ((hq * N + query) * count + part);
|
|
41
|
+
for (uint j = 0; j < (DV + 31) / 32; ++j) {
|
|
42
|
+
uint d = j * 32 + lane;
|
|
43
|
+
if (d < DV) out[base * DV + d] = accum[j];
|
|
44
|
+
}
|
|
45
|
+
if (lane == 0) { normal[base * 2] = maximum; normal[base * 2 + 1] = total; }
|
|
46
|
+
` };
|
|
47
|
+
const partial = new MetalKernel(partialSpec);
|
|
48
|
+
const quantizedPartial = new MetalKernel({ ...partialSpec, name: "paged_attention_affine_partial",
|
|
49
|
+
inputNames: [...partialSpec.inputNames, "ks", "kb", "vs", "vb"],
|
|
50
|
+
source: partialSpec.source
|
|
51
|
+
.replace("float(keys[keyBase + d])", "float(InT(float(ks[(keyBase+d)/GROUP]) * float((keys[(keyBase+d)/PACK] >> (((keyBase+d)%PACK)*BITS)) & ((1u<<BITS)-1u)) + float(kb[(keyBase+d)/GROUP])))")
|
|
52
|
+
.replace("float(values[valueBase + d])", "float(InT(float(vs[(valueBase+d)/GROUP]) * float((values[(valueBase+d)/PACK] >> (((valueBase+d)%PACK)*BITS)) & ((1u<<BITS)-1u)) + float(vb[(valueBase+d)/GROUP])))"),
|
|
53
|
+
});
|
|
54
|
+
const merge = new MetalKernel({ name: "paged_attention_merge", inputNames: ["parts", "normal"], outputNames: ["out"], source: `
|
|
55
|
+
uint d = thread_position_in_grid.x;
|
|
56
|
+
uint row = thread_position_in_grid.y;
|
|
57
|
+
if (d >= DV) return;
|
|
58
|
+
uint count = normal_shape[2];
|
|
59
|
+
float maximum = -INFINITY;
|
|
60
|
+
for (uint part = 0; part < count; ++part) maximum = max(maximum, normal[(row * count + part) * 2]);
|
|
61
|
+
float sum = 0, value = 0;
|
|
62
|
+
for (uint part = 0; part < count; ++part) {
|
|
63
|
+
uint base = row * count + part;
|
|
64
|
+
float weight = exp(normal[base * 2] - maximum);
|
|
65
|
+
sum += normal[base * 2 + 1] * weight;
|
|
66
|
+
value += parts[base * DV + d] * weight;
|
|
67
|
+
}
|
|
68
|
+
out[row * DV + d] = OutT(value / sum);
|
|
69
|
+
` });
|
|
70
|
+
|
|
71
|
+
/** Independent immutable view: later appends or row retirement cannot alter it. */
|
|
72
|
+
export function pagedAttentionView(pool: BlockPool, blockTable: number[], length: number,
|
|
73
|
+
direct: boolean): KvAttentionView {
|
|
74
|
+
const keys = ops.contiguous(pool.keys), values = ops.contiguous(pool.values);
|
|
75
|
+
const table = MlxArray.fromInt32(Int32Array.from(blockTable), [blockTable.length]);
|
|
76
|
+
const bs = pool.blockSize, quantization = pool.quantization;
|
|
77
|
+
const extra = quantization ? [pool.keyScales!, pool.keyBiases!, pool.valueScales!, pool.valueBiases!].map(a => ops.contiguous(a)) : [];
|
|
78
|
+
const dkStored = pool.headDim, dvStored = pool.vHeadDim;
|
|
79
|
+
const dtype = pool.dtype;
|
|
80
|
+
return {
|
|
81
|
+
attend(q: MlxArray, scale: number, mask: Mask): MlxArray {
|
|
82
|
+
const [, hq, n, dk] = q.shape as [number, number, number, number];
|
|
83
|
+
const hk = keys.shape[1]!, dv = dvStored;
|
|
84
|
+
// Long prefill and explicit masks use the established attention oracle.
|
|
85
|
+
// This is shape dispatch within one attention interface, not scheduling.
|
|
86
|
+
if (!direct || n > 8 || mask.mode === "array") {
|
|
87
|
+
const gather = (tensor: MlxArray) => {
|
|
88
|
+
using picked = ops.takeAxis(tensor, table, 0);
|
|
89
|
+
using transposed = ops.transposeAxes(picked, [1, 0, 2, 3]);
|
|
90
|
+
using flat = ops.reshape(transposed, [1, hk, blockTable.length * bs, tensor.shape[3]!]);
|
|
91
|
+
return flat.slice([0, 0, 0, 0], [1, hk, length, tensor.shape[3]!]);
|
|
92
|
+
};
|
|
93
|
+
using k = gather(keys), v = gather(values);
|
|
94
|
+
if (!quantization) return ops.sdpa(q, k, v, scale, mask.mode, mask.arr);
|
|
95
|
+
using ks = gather(extra[0]!), kb = gather(extra[1]!), vs = gather(extra[2]!), vb = gather(extra[3]!);
|
|
96
|
+
using kd = ops.dequantize(k, ks, kb, { ...quantization, mode: "affine" });
|
|
97
|
+
using vd = ops.dequantize(v, vs, vb, { ...quantization, mode: "affine" });
|
|
98
|
+
return ops.sdpa(q, kd, vd, scale, mask.mode, mask.arr);
|
|
99
|
+
}
|
|
100
|
+
const part = 64, parts = Math.ceil(length / part);
|
|
101
|
+
using multiplier = MlxArray.fromFloat32(new Float32Array([scale, length]), [2]);
|
|
102
|
+
const [out, normal] = (quantization ? quantizedPartial : partial).apply([q, keys, values, table, multiplier, ...extra], {
|
|
103
|
+
outputs: [{ shape: [hq, n, parts, dv], dtype: Dtype.float32 }, { shape: [hq, n, parts, 2], dtype: Dtype.float32 }],
|
|
104
|
+
grid: [32, hq * n, parts], threadGroup: [32, 1, 1],
|
|
105
|
+
templateDtypes: quantization ? { InT: dtype } : {},
|
|
106
|
+
templateInts: { HQ: hq, HK: hk, N: n, DK: dkStored, DV: dv, BS: bs, PART: part,
|
|
107
|
+
...(quantization ? { BITS: quantization.bits, GROUP: quantization.groupSize, PACK: 32 / quantization.bits } : {}) },
|
|
108
|
+
});
|
|
109
|
+
try {
|
|
110
|
+
return merge.apply([out!, normal!], { outputs: [{ shape: [1, hq, n, dv], dtype: q.dtype }],
|
|
111
|
+
grid: [Math.ceil(dv / 32) * 32, hq * n, 1], threadGroup: [32, 1, 1],
|
|
112
|
+
templateDtypes: { OutT: q.dtype }, templateInts: { DV: dv } })[0]!;
|
|
113
|
+
} finally { disposeResources([out!, normal!]); }
|
|
114
|
+
},
|
|
115
|
+
dispose() { disposeResources([keys, values, table, ...extra]); },
|
|
116
|
+
};
|
|
117
|
+
}
|