mlx-bun 0.4.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 +55 -42
- package/docs/archive/investigations/benchmark-ledger-through-v0.4.0.md +3617 -0
- package/docs/archive/planning/release-notes-v0.5.0.md +37 -0
- package/docs/design/batching.md +220 -7
- package/docs/design/decode-speed-program.md +121 -66
- package/docs/design/generic-model-support.md +116 -0
- package/docs/design/kv-cache.md +380 -211
- package/docs/design/speculative-decoding.md +165 -26
- package/docs/design/turboquant.md +20 -7
- package/docs/design/unified-engine-frontier-plan.md +120 -13
- package/docs/planning/PRODUCT_ROADMAP.md +5 -6
- package/docs/planning/public-readiness-handoff.md +244 -0
- package/docs/planning/public-readiness.md +149 -0
- package/docs/reference/benchmarks.md +3463 -3489
- package/docs/reference/cli.md +62 -1
- package/docs/reference/distribution.md +29 -4
- package/docs/reference/environment.md +104 -8
- package/docs/reference/library-api.md +18 -4
- package/docs/reference/memory.md +1 -1
- package/docs/reference/models.md +17 -5
- package/docs/reference/server-api.md +181 -11
- package/docs/reference/server-config.md +232 -105
- 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/autoregressive.ts +22 -1
- package/src/backends/mlx/batch-group.ts +111 -27
- package/src/backends/mlx/cache-layout.ts +3 -3
- package/src/backends/mlx/continuation-persistence.ts +5 -4
- 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/gateway-binding.ts +56 -12
- package/src/backends/mlx/kv-maintenance.ts +30 -2
- 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 +4 -3
- package/src/backends/mlx/paged-cache-codec.ts +27 -0
- package/src/backends/mlx/prefill-cohort.ts +15 -4
- package/src/backends/mlx/prefill-policy.ts +26 -0
- package/src/backends/mlx/prefill-rows.ts +57 -13
- 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 +18 -5
- package/src/backends/mlx/rollback.ts +6 -3
- package/src/backends/mlx/serial-executor.ts +16 -7
- package/src/backends/mlx/speculative-group.ts +105 -26
- package/src/backends/mlx/speculative.ts +24 -7
- package/src/backends/mlx/target-layout-capability.ts +1 -1
- package/src/backends/mlx/transitioning-kv-rows.ts +8 -0
- package/src/cli.ts +322 -0
- package/src/contracts/execution.ts +10 -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 +13 -3
- package/src/contracts/scheduling.ts +9 -0
- package/src/engine/execution-plan.ts +23 -12
- package/src/engine/execution-tasks.ts +44 -0
- package/src/engine/namespaced-cache.ts +13 -0
- package/src/engine/scheduler.ts +22 -2
- package/src/fill/fill-session.ts +14 -3
- package/src/generate.ts +28 -22
- package/src/grammar.ts +13 -0
- package/src/inference/prefill.ts +6 -0
- package/src/kv-scheme.ts +22 -0
- package/src/kv-store.ts +115 -79
- 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 +33 -5
- package/src/lab/paged-kv/paged-kv.ts +108 -42
- package/src/md-modules.d.ts +5 -0
- package/src/mlx/array.ts +37 -0
- 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/batched-turboquant-kv.ts +6 -5
- package/src/model/delayed-quantized-kv.ts +3 -3
- package/src/model/delayed-rotating-quantized-kv.ts +1 -1
- package/src/model/delayed-turboquant-kv.ts +6 -4
- package/src/model/factory.ts +26 -0
- package/src/model/fingerprint.ts +12 -0
- package/src/model/full-prefill-row.ts +1 -1
- package/src/model/gemma4-base.ts +25 -6
- package/src/model/gemma4.ts +82 -2
- package/src/model/kv-attention-view.ts +4 -1
- package/src/model/profile.ts +29 -4
- package/src/model/quantized-append-attention.ts +31 -0
- package/src/model/qwen3-mrope.ts +7 -9
- package/src/model/qwen3_5.ts +99 -37
- package/src/model/support.ts +8 -0
- package/src/model/token-groups.ts +66 -0
- package/src/model/trellis-linear.ts +2 -2
- package/src/model/turboquant-codec.ts +6 -0
- 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/prompt-cache.ts +265 -43
- package/src/runtime-config.ts +1 -5
- package/src/safetensors.ts +9 -2
- package/src/sampler.ts +5 -4
- package/src/serve/audio-routes.ts +323 -0
- package/src/serve/chat-request.ts +2 -0
- package/src/serve/chat-stage.ts +17 -12
- 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 +52 -11
- package/src/serve/model-binding.ts +7 -1
- package/src/serve/preparation.ts +3 -3
- package/src/serve/prompt-response-trace.ts +14 -0
- package/src/serve/request-plan.ts +2 -0
- package/src/serve/request-prep.ts +38 -21
- package/src/serve/transcription-server.ts +64 -0
- package/src/serve/transcription-service.ts +502 -0
- package/src/server.ts +132 -164
- package/src/spec/assistant-rows.ts +1 -0
- package/src/spec/deepspec-rows.ts +1 -1
- package/src/spec/dflash-source.ts +1 -1
- package/src/spec/glm52-mtp-source.ts +1 -0
- package/src/spec/ngram-source.ts +27 -5
- package/src/spec/projected-draft-rows.ts +2 -2
- package/src/spec/qwen-mtp-rows.ts +11 -0
- package/src/spec/qwen-mtp-source.ts +3 -0
- package/src/spec/serve-loop.ts +5 -3
- package/src/spec/source.ts +14 -1
- package/src/spec/two-model-rows.ts +1 -0
- package/src/spec/two-model.ts +2 -2
- package/src/ssd-cache.ts +76 -17
- package/src/ssd-durability.ts +20 -22
- 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 +160 -0
- 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/docs/{planning → archive/planning}/release-notes-v0.4.0.md +0 -0
package/src/fill/fill-session.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// sources in priority order, clamps what they return, and counts. It never
|
|
21
21
|
// touches MLX, the tokenizer, or the template. Row COMPILATION is
|
|
22
22
|
// ./schema-rows.ts.
|
|
23
|
-
import {
|
|
23
|
+
import { runtimeFlag, runtimeNumber, runtimeValue } from "../runtime-config";
|
|
24
24
|
import { EchoSource, type EchoConfig } from "./echo-index";
|
|
25
25
|
import type {
|
|
26
26
|
Proposal, ProposalOrigin, ProposalPolicy, ProposalSource, TokenView,
|
|
@@ -153,7 +153,7 @@ export function fillEchoConfig(): FillEchoConfig {
|
|
|
153
153
|
* the engine's fill path. Off by default (the invariant it checks is the
|
|
154
154
|
* easiest bug in the feature, and the check costs a JS compare). */
|
|
155
155
|
export function fillTraceEnabled(): boolean {
|
|
156
|
-
return
|
|
156
|
+
return runtimeFlag("MLX_BUN_FILL_TRACE", false) || fillTracePath() !== null;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
159
|
/** MLX_BUN_FILL_TRACE=<file.jsonl> — additionally append one record per
|
|
@@ -319,12 +319,23 @@ export class FillSession {
|
|
|
319
319
|
/** Feed one SAMPLED token, then ask the sources what follows. `budget` is
|
|
320
320
|
* the caller's remaining allowance (max_tokens − generated). */
|
|
321
321
|
push(tokenId: number, budget: number = Number.POSITIVE_INFINITY): Proposal | null {
|
|
322
|
+
this.observe(tokenId);
|
|
323
|
+
return this.propose(budget);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Record sampled output independently of when the method requests proposals. */
|
|
327
|
+
observe(tokenId: number): void {
|
|
322
328
|
this.stats.decodeSteps++;
|
|
323
329
|
this.#append([tokenId]);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Select a continuation after a committed output boundary. Methods may
|
|
333
|
+
* request only verified proposals while retaining ordinary sampled policy. */
|
|
334
|
+
propose(budget: number = Number.POSITIVE_INFINITY, policy?: ProposalPolicy): Proposal | null {
|
|
324
335
|
this.#budget = budget;
|
|
325
336
|
for (const source of this.#sources) {
|
|
326
337
|
const proposal = source.propose(this);
|
|
327
|
-
if (!proposal) continue;
|
|
338
|
+
if (!proposal || (policy && proposal.policy !== policy)) continue;
|
|
328
339
|
if (proposal.branchStop) this.stats.branchStops++;
|
|
329
340
|
const clamped = this.#clamp(proposal.ids, budget, proposal.origin === "echo");
|
|
330
341
|
if (!clamped) continue;
|
package/src/generate.ts
CHANGED
|
@@ -27,7 +27,7 @@ import { denoiseAsync } from "./diffusion/diffusion-generate";
|
|
|
27
27
|
import { bindLegacyDenoisingModel, type MlxDenoisingBinding } from "./backends/mlx/diffusion";
|
|
28
28
|
import type { RuntimeModel } from "./model/factory";
|
|
29
29
|
import {
|
|
30
|
-
assertMlxAutoregressiveBinding, bindLegacyAutoregressiveModel,
|
|
30
|
+
assertMlxAutoregressiveBinding, bindLegacyAutoregressiveModel, supportsCommittedAppendCache,
|
|
31
31
|
type MlxAutoregressiveBinding, type MlxModelMemory, type MlxDecodeStep, type MlxTokenAppend,
|
|
32
32
|
} from "./backends/mlx/autoregressive";
|
|
33
33
|
import { createKvMaintenance } from "./backends/mlx/kv-maintenance";
|
|
@@ -61,6 +61,8 @@ import {
|
|
|
61
61
|
} from "./fill/fill-session";
|
|
62
62
|
|
|
63
63
|
export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions {
|
|
64
|
+
/** Application session affinity; independent of numerical cache identity. */
|
|
65
|
+
cacheSessionId?: string;
|
|
64
66
|
/** Resolved host policy. Direct compatibility calls resolve from their
|
|
65
67
|
* captured binding when this is absent. */
|
|
66
68
|
decodePolicy?: Readonly<Pick<import("./contracts/execution").ResolvedExecution, "compiledDecode" | "grammarJump">>;
|
|
@@ -201,17 +203,16 @@ export function shouldUseGrammarJump(
|
|
|
201
203
|
}
|
|
202
204
|
|
|
203
205
|
/** Composition gate for token fast-forwarding, enforced inside the engine
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
* tier — an operator who asked for K3c must not get K3b silently.
|
|
206
|
+
* The serving planner owns request-level eligibility, including explicit
|
|
207
|
+
* seeds in serial execution and mounted draft models. Shared strict fill
|
|
208
|
+
* has its own grouped method and uses the same append-format declaration.
|
|
208
209
|
* - grammar: forced-token production is the grammar's job; two producers in
|
|
209
210
|
* one iteration is forbidden (asserted in the loop).
|
|
210
211
|
* - logprobs/top_logprobs: injected tokens are never sampled, so they have
|
|
211
212
|
* no distribution row (same rule as shouldUseGrammarJump).
|
|
212
213
|
* - promptEmbeddings: vision/audio prompts (and mRoPE) — untested shape.
|
|
213
|
-
* -
|
|
214
|
-
*
|
|
214
|
+
* - affine KV: the model's append binding declares its supported formats.
|
|
215
|
+
* Verify-policy quantized appends remain a separate gate. */
|
|
215
216
|
export function shouldUseFill(
|
|
216
217
|
options: Pick<
|
|
217
218
|
GenerateOptions,
|
|
@@ -219,6 +220,7 @@ export function shouldUseFill(
|
|
|
219
220
|
| "kvBits" | "kvConfig" | "turboQuant"
|
|
220
221
|
>,
|
|
221
222
|
runtime: RuntimeConfig = runtimeConfig(),
|
|
223
|
+
append?: Pick<MlxTokenAppend, "affineKvBits" | "turboQuantFormats"> | null,
|
|
222
224
|
): boolean {
|
|
223
225
|
if (!options.fill) return false;
|
|
224
226
|
if (resolveFillMode(runtime.value("MLX_BUN_FILL") ?? "off") === "off") return false;
|
|
@@ -226,9 +228,7 @@ export function shouldUseFill(
|
|
|
226
228
|
!options.logprobs &&
|
|
227
229
|
!(options.topLogprobs && options.topLogprobs > 0) &&
|
|
228
230
|
options.promptEmbeddings === undefined &&
|
|
229
|
-
|
|
230
|
-
!options.kvConfig?.length &&
|
|
231
|
-
options.turboQuant === undefined;
|
|
231
|
+
supportsCommittedAppendCache(append, options);
|
|
232
232
|
}
|
|
233
233
|
|
|
234
234
|
/** Compatibility entry point for callers that perform a single conversion.
|
|
@@ -599,7 +599,7 @@ async function* generateInner(
|
|
|
599
599
|
const {
|
|
600
600
|
maxTokens = 512,
|
|
601
601
|
eosTokenIds = binding.eosTokenIds,
|
|
602
|
-
prefillChunkSize = 2048,
|
|
602
|
+
prefillChunkSize = binding.prefillPolicy?.chunkSize(promptTokens.length) ?? 2048,
|
|
603
603
|
} = options;
|
|
604
604
|
|
|
605
605
|
try {
|
|
@@ -641,6 +641,7 @@ async function* generateInner(
|
|
|
641
641
|
const ownsCache = !options.cache;
|
|
642
642
|
const resuming = options.initialPendingToken !== undefined;
|
|
643
643
|
let cache: Cache[] = options.cache ?? [];
|
|
644
|
+
let appender: MlxTokenAppend | null = null;
|
|
644
645
|
try {
|
|
645
646
|
if (ownsCache) cache = binding.makeCache();
|
|
646
647
|
if (!cache.length) throw new Error("AR binding returned an empty cache");
|
|
@@ -655,6 +656,9 @@ async function* generateInner(
|
|
|
655
656
|
// Replace fresh full-attention caches before any forward. The deepest
|
|
656
657
|
// write is prompt + maxTokens - 1, so capacity covers every decode step.
|
|
657
658
|
maybePageKv(cache, options, promptTokens.length + maxTokens);
|
|
659
|
+
appender = options.fill ? binding.createAppend?.({
|
|
660
|
+
hasAdapters: !!options.adapters?.length, pagedKv: !!options.pagedKv,
|
|
661
|
+
}) ?? null : null;
|
|
658
662
|
} catch (error) {
|
|
659
663
|
if (ownsCache) for (const state of cache) state.dispose();
|
|
660
664
|
stepSampler.dispose();
|
|
@@ -668,7 +672,7 @@ async function* generateInner(
|
|
|
668
672
|
// window) layers append multi-token writes through #updateConcat — O(window)
|
|
669
673
|
// per append rather than the O(L) a plain ring pays — so v1 warns and skips
|
|
670
674
|
// the whole feature for those models rather than paying it silently.
|
|
671
|
-
let fillOn = shouldUseFill(options, runtime);
|
|
675
|
+
let fillOn = shouldUseFill(options, runtime, appender);
|
|
672
676
|
if (fillOn && cache.some((c) => c instanceof RotatingKVCache)) {
|
|
673
677
|
fillOn = false;
|
|
674
678
|
if (!warnedFillRotating) {
|
|
@@ -688,7 +692,7 @@ async function* generateInner(
|
|
|
688
692
|
// restore a pre-round snapshot and bit-exactly replay the accepted prefix.
|
|
689
693
|
// A model whose caches can do neither still gets assert-policy fills; verify
|
|
690
694
|
// proposals are dropped and counted (stats.verifyUnsupported).
|
|
691
|
-
const verifyCapable = fillOn && cache.every(
|
|
695
|
+
const verifyCapable = fillOn && !options.kvBits && !options.kvConfig?.length && !options.turboQuant && cache.every(
|
|
692
696
|
(c) => c.isTrimmable() || typeof rewindable(c).specRoundRollback === "function",
|
|
693
697
|
);
|
|
694
698
|
closeBatchSetup?.();
|
|
@@ -720,7 +724,6 @@ async function* generateInner(
|
|
|
720
724
|
let pendingExtras: StepExtras | null = null;
|
|
721
725
|
let nextExtras: StepExtras | null = null;
|
|
722
726
|
let decoder: MlxDecodeStep | null = null;
|
|
723
|
-
let appender: MlxTokenAppend | null = null;
|
|
724
727
|
let finished = false;
|
|
725
728
|
let threw = false;
|
|
726
729
|
let executionError: unknown;
|
|
@@ -823,8 +826,7 @@ async function* generateInner(
|
|
|
823
826
|
nextPending = null;
|
|
824
827
|
disposeStepExtras(nextExtras);
|
|
825
828
|
nextExtras = null;
|
|
826
|
-
//
|
|
827
|
-
// boundary stays correct if those exclusions ever loosen.
|
|
829
|
+
// Conversion runs at the ordinary committed-token boundary as well.
|
|
828
830
|
maintainKv(cache);
|
|
829
831
|
|
|
830
832
|
let checkpointMs = 0;
|
|
@@ -840,11 +842,18 @@ async function* generateInner(
|
|
|
840
842
|
let logitsAll: MlxArray | null = null;
|
|
841
843
|
try {
|
|
842
844
|
const chunkSize = (state: readonly Cache[]) => {
|
|
843
|
-
const maxChunk = appender?.maxChunkSize(state) ?? 1
|
|
845
|
+
const maxChunk = Math.min(appender?.maxChunkSize(state) ?? 1,
|
|
846
|
+
maintainKv.maxAppendTokens?.(state) ?? Number.POSITIVE_INFINITY);
|
|
844
847
|
return fill.appendChunkSize > 0 ? Math.min(fill.appendChunkSize, maxChunk) : maxChunk;
|
|
845
848
|
};
|
|
849
|
+
const committedGraph = appender ? appender.forwardHidden.bind(appender) : graph.forwardHidden.bind(graph);
|
|
850
|
+
const forwardCommitted = maintainKv.maxAppendTokens ? async (ids: MlxArray, state: Cache[]) => {
|
|
851
|
+
const hidden = await committedGraph(ids, state);
|
|
852
|
+
try { maintainKv(state); return hidden; }
|
|
853
|
+
catch (error) { hidden.dispose(); throw error; }
|
|
854
|
+
} : committedGraph;
|
|
846
855
|
hf = await appendFillHidden(
|
|
847
|
-
|
|
856
|
+
verify ? graph.forwardHidden.bind(graph) : forwardCommitted,
|
|
848
857
|
cache, ids, verify ? () => ids.length : chunkSize,
|
|
849
858
|
);
|
|
850
859
|
const [, Lf, Hf] = hf.shape as [number, number, number];
|
|
@@ -1032,9 +1041,6 @@ async function* generateInner(
|
|
|
1032
1041
|
decoder = options.decodePolicy?.compiledDecode === false ? null : binding.createDecode?.({
|
|
1033
1042
|
hasAdapters: !!options.adapters?.length, pagedKv: !!options.pagedKv,
|
|
1034
1043
|
}) ?? null;
|
|
1035
|
-
appender = fillOn ? binding.createAppend?.({
|
|
1036
|
-
hasAdapters: !!options.adapters?.length, pagedKv: !!options.pagedKv,
|
|
1037
|
-
}) ?? null : null;
|
|
1038
1044
|
let stop = false;
|
|
1039
1045
|
/** Token id read eagerly at the top of the loop for grammar (reused for
|
|
1040
1046
|
* the yield, avoiding a second readback). -1 when grammar is off — the
|
|
@@ -1050,7 +1056,7 @@ async function* generateInner(
|
|
|
1050
1056
|
const grammarJump = options.decodePolicy?.grammarJump ?? shouldUseGrammarJump(options, runtime);
|
|
1051
1057
|
// Yield token zero before its own decode forward. The captured runtime
|
|
1052
1058
|
// keeps this scheduling choice stable across consumer awaits.
|
|
1053
|
-
const earlyFirstToken = runtime.
|
|
1059
|
+
const earlyFirstToken = runtime.flag("MLX_BUN_EARLY_FIRST_TOKEN", true) &&
|
|
1054
1060
|
maxTokens > 1 && !resuming && !fillOn && !options.grammar;
|
|
1055
1061
|
while (!stop) {
|
|
1056
1062
|
options.signal?.throwIfAborted();
|
package/src/grammar.ts
CHANGED
|
@@ -314,6 +314,19 @@ export class GrammarController {
|
|
|
314
314
|
return this.terminated;
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
+
/** Propose the forced string without advancing the matcher. Tokenization
|
|
318
|
+
* can differ at its first boundary; verification and the normal grammar
|
|
319
|
+
* sampler accept or reject the candidates before anything is committed. */
|
|
320
|
+
async proposeTokens(maxTokens: number): Promise<number[]> {
|
|
321
|
+
await this.ready();
|
|
322
|
+
if (this.terminated || !this.encodeRaw) return [];
|
|
323
|
+
return wasmQueue(async () => {
|
|
324
|
+
if (this.disposed) return [];
|
|
325
|
+
const text = this.matcher.findJumpForwardString();
|
|
326
|
+
return text ? this.encodeRaw!(text).slice(0, maxTokens) : [];
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
317
330
|
/** Jump-forward decoding (SGLang's technique via xgrammar's
|
|
318
331
|
* findJumpForwardString; opt-in — MLX_BUN_GRAMMAR_JUMP=1): when the grammar
|
|
319
332
|
* admits exactly one continuation string (JSON keys/punctuation, enum
|
package/src/inference/prefill.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/** Request-level chunk policy. Execution consumes the selected size; model
|
|
2
|
+
* backends can account for their temporary workspace without changing admission. */
|
|
3
|
+
export interface PrefillPolicy {
|
|
4
|
+
chunkSize(promptTokens: number): number;
|
|
5
|
+
}
|
|
6
|
+
|
|
1
7
|
export interface PrefillPosition {
|
|
2
8
|
readonly length: number;
|
|
3
9
|
readonly position: number;
|
package/src/kv-scheme.ts
CHANGED
|
@@ -172,6 +172,28 @@ export class KvScheme {
|
|
|
172
172
|
return "bf16";
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
/** Configured attention storage, not live allocator occupancy. Recurrent
|
|
176
|
+
* layers retain their own state and never acquire an attention KV codec. */
|
|
177
|
+
describe(config: ModelConfig) {
|
|
178
|
+
const layers: Record<string, number> = {};
|
|
179
|
+
const attention = { global: 0, sliding_window: 0 };
|
|
180
|
+
let recurrent_layers = 0;
|
|
181
|
+
const perLayer = new Map(this.options.kvConfig?.map(entry => [entry.layerIdx, entry.bits]));
|
|
182
|
+
for (let layer = 0; layer < config.text.numHiddenLayers; layer++) {
|
|
183
|
+
const type = config.text.layerTypes[layer] ?? "full_attention";
|
|
184
|
+
if (type === "linear_attention") { recurrent_layers++; continue; }
|
|
185
|
+
const sliding = type === "sliding_attention";
|
|
186
|
+
attention[sliding ? "sliding_window" : "global"]++;
|
|
187
|
+
const bits = this.kind === "affine-config" ? perLayer.get(layer)
|
|
188
|
+
: this.kind === "affine-uniform" ? this.options.kvBits : undefined;
|
|
189
|
+
const codec = this.kind === "turbo" && !sliding
|
|
190
|
+
? `turbo-k${this.options.turboQuant!.kBits}v${this.options.turboQuant!.vBits}`
|
|
191
|
+
: bits ? `kv${bits}` : "bf16";
|
|
192
|
+
layers[codec] = (layers[codec] ?? 0) + 1;
|
|
193
|
+
}
|
|
194
|
+
return { mode: this.label, layers, attention, recurrent_layers };
|
|
195
|
+
}
|
|
196
|
+
|
|
175
197
|
bytesAt(config: ModelConfig, tokens: number): number {
|
|
176
198
|
// TurboQuant stays conservatively billed as bf16 until its packed layout
|
|
177
199
|
// exposes a stable projector.
|
package/src/kv-store.ts
CHANGED
|
@@ -43,8 +43,14 @@ import { cleanupFailure, disposeResources } from "./engine/resources";
|
|
|
43
43
|
// whole entry again.
|
|
44
44
|
|
|
45
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";
|
|
46
51
|
import { MmapFile, MADV_DONTNEED } from "./mmap";
|
|
47
52
|
import { MlxArray } from "./mlx/array";
|
|
53
|
+
import { kvWriter, kvReader, type KvWriteRequest } from "./storage/kv-writer";
|
|
48
54
|
import type { Dtype } from "./mlx/ffi";
|
|
49
55
|
import * as ops from "./mlx/ops";
|
|
50
56
|
import {
|
|
@@ -67,6 +73,7 @@ const hash64 = (bytes: Uint8Array): string => Bun.hash(bytes).toString(16).padSt
|
|
|
67
73
|
|
|
68
74
|
export type CacheKind =
|
|
69
75
|
| "kv"
|
|
76
|
+
| "paged"
|
|
70
77
|
| "rotating"
|
|
71
78
|
| "qkv"
|
|
72
79
|
| "rotating-qkv"
|
|
@@ -76,7 +83,8 @@ export type CacheKind =
|
|
|
76
83
|
| "mla-dsa"
|
|
77
84
|
| "mtp-mla";
|
|
78
85
|
|
|
79
|
-
interface TensorSlot {
|
|
86
|
+
export interface TensorSlot {
|
|
87
|
+
blocks?: Array<{ hash: string; bytes: number }>;
|
|
80
88
|
off: number;
|
|
81
89
|
bytes: number;
|
|
82
90
|
shape: number[];
|
|
@@ -85,6 +93,8 @@ interface TensorSlot {
|
|
|
85
93
|
}
|
|
86
94
|
|
|
87
95
|
export interface CacheHeaderEntry {
|
|
96
|
+
paged?: { capacityTokens: number; blockSize: number; blockTable: number[]; numBlocks: number;
|
|
97
|
+
headDim: number; vHeadDim: number; dtype: Dtype; direct: boolean };
|
|
88
98
|
kind: CacheKind;
|
|
89
99
|
offset: number;
|
|
90
100
|
minimumReusableOffset?: number;
|
|
@@ -146,7 +156,7 @@ export interface KvFileHeader extends Omit<KvSaveMeta, "attachments"> {
|
|
|
146
156
|
attachments?: Array<Omit<CheckpointAttachment, "tensors"> & { tensors: TensorSlot[] }>;
|
|
147
157
|
/** Absent in earlier v3 files, which used mlx-cache-v3. */
|
|
148
158
|
codecProvider?: string;
|
|
149
|
-
formatVersion: 3 | 4;
|
|
159
|
+
formatVersion: 3 | 4 | 5;
|
|
150
160
|
createdAt: number;
|
|
151
161
|
tokens: number[];
|
|
152
162
|
caches: CacheHeaderEntry[];
|
|
@@ -430,7 +440,7 @@ const CACHE_CODECS = {
|
|
|
430
440
|
if (state.length === 0) throw new Error("cannot clone an empty cache");
|
|
431
441
|
try {
|
|
432
442
|
const [kIdx, kScales, kZeros, vPacked, vScales] = state;
|
|
433
|
-
const clone = new TurboQuantKVCache(c.kBits, c.vBits);
|
|
443
|
+
const clone = new TurboQuantKVCache(c.kBits, c.vBits, c.fusedDecode);
|
|
434
444
|
clone.restoreState({
|
|
435
445
|
kIdx: context.view(kIdx!),
|
|
436
446
|
kScales: context.view(kScales!),
|
|
@@ -457,6 +467,7 @@ const CACHE_CODECS = {
|
|
|
457
467
|
},
|
|
458
468
|
headerTrimmable: () => true,
|
|
459
469
|
},
|
|
470
|
+
paged: pagedCacheCodec,
|
|
460
471
|
mla: {
|
|
461
472
|
matches: (cache) => cacheSignature(cache) === "kv:mla:target",
|
|
462
473
|
snapshot: (cache, context) => snapshotMla("mla", cache, context),
|
|
@@ -615,12 +626,8 @@ export function cloneKvCaches(caches: Cache[], codecs: CacheCodecProvider = lega
|
|
|
615
626
|
return out;
|
|
616
627
|
}
|
|
617
628
|
|
|
618
|
-
/** Shared
|
|
619
|
-
|
|
620
|
-
* between yields so the EVENT LOOP KEEPS SERVING while a multi-hundred-MB
|
|
621
|
-
* entry flushes (the write-behind persistence contract: durability never
|
|
622
|
-
* blocks a request). try/finally still runs on early generator close. */
|
|
623
|
-
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[] } {
|
|
624
631
|
// Plan pass: header entries + lazy tensor sources, NO bytes materialized.
|
|
625
632
|
const entries: CacheHeaderEntry[] = [];
|
|
626
633
|
const sources: TensorSource[] = [];
|
|
@@ -652,6 +659,17 @@ function* saveKvCacheSteps(path: string, tokens: number[], caches: Cache[], meta
|
|
|
652
659
|
formatVersion: attachments?.length ? 4 : 3, createdAt: Date.now(), ...identity,
|
|
653
660
|
...(attachments?.length ? { attachments } : {}), codecProvider: codecs.id, tokens, caches: entries,
|
|
654
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 {
|
|
655
673
|
const headerLen = new TextEncoder().encode(JSON.stringify(header)).length;
|
|
656
674
|
const dataStart = alignUp(PREFIX_LEN + headerLen);
|
|
657
675
|
|
|
@@ -719,35 +737,27 @@ export function saveKvCache(path: string, tokens: number[], caches: Cache[], met
|
|
|
719
737
|
for (const _ of saveKvCacheSteps(path, tokens, caches, meta, codecs)) { /* drain */ }
|
|
720
738
|
}
|
|
721
739
|
|
|
722
|
-
/**
|
|
723
|
-
*
|
|
724
|
-
*
|
|
725
|
-
*
|
|
726
|
-
* `runStep` (optional) runs each blocking tensor step inside the caller's
|
|
727
|
-
* exclusion domain. Waiting for an idle observation is not sufficient: a
|
|
728
|
-
* request can acquire the engine between that check and `steps.next()`,
|
|
729
|
-
* 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. */
|
|
730
743
|
export async function saveKvCacheAsync(
|
|
731
744
|
path: string, tokens: number[], caches: Cache[], meta: KvSaveMeta = {},
|
|
732
745
|
runStep?: <T>(step: () => T) => Promise<T>, codecs: CacheCodecProvider = legacyCacheCodecs,
|
|
746
|
+
storage: Pick<KvWriteRequest, "layout" | "blockBytes" | "segmented"> = {},
|
|
733
747
|
): Promise<void> {
|
|
734
|
-
const
|
|
748
|
+
const { header, sources } = planKvCache(tokens, caches, meta, codecs);
|
|
735
749
|
try {
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
// open fd. Throw through it so its existing failure cleanup runs.
|
|
744
|
-
steps.throw(error);
|
|
745
|
-
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();
|
|
746
757
|
}
|
|
747
758
|
}
|
|
748
759
|
|
|
749
|
-
/**
|
|
750
|
-
* zero-copy cache clones (the queue disposes them on every exit path). */
|
|
760
|
+
/** Queued immutable snapshot; the queue releases its shared views on settle. */
|
|
751
761
|
export interface SpillItem {
|
|
752
762
|
attachments?: CheckpointAttachment[];
|
|
753
763
|
tokens: number[];
|
|
@@ -755,23 +765,10 @@ export interface SpillItem {
|
|
|
755
765
|
ns: string;
|
|
756
766
|
}
|
|
757
767
|
|
|
758
|
-
/**
|
|
759
|
-
*
|
|
760
|
-
*
|
|
761
|
-
*
|
|
762
|
-
* WITHOUT BOUND, so under sustained traffic (gate starved, evictions
|
|
763
|
-
* ongoing) resident memory = prompt-cache cap + every queued clone —
|
|
764
|
-
* allocator pressure exactly under the load that caused the evictions.
|
|
765
|
-
*
|
|
766
|
-
* Policy: queued bytes are capped. Over cap, the OLDEST not-in-flight
|
|
767
|
-
* item is dropped and its clones disposed immediately — cache semantics
|
|
768
|
-
* (a dropped spill is a future cache miss, never a wrong result; the
|
|
769
|
-
* contention-free alternative to letting the flush cut into decode).
|
|
770
|
-
* The item being enqueued is never its own victim (soft cap: one
|
|
771
|
-
* oversized entry may exceed the cap alone rather than never spilling).
|
|
772
|
-
* Items run strictly serially in enqueue order via an internal chain;
|
|
773
|
-
* store failures are swallowed (cold tier is best-effort) but clones
|
|
774
|
-
* 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. */
|
|
775
772
|
type SpillRec = SpillItem & {
|
|
776
773
|
bytes: number;
|
|
777
774
|
dropped: boolean;
|
|
@@ -790,7 +787,7 @@ export class SpillQueue {
|
|
|
790
787
|
readonly capBytes: number,
|
|
791
788
|
/** Byte size of a clone set (prompt-cache's cacheBytes). */
|
|
792
789
|
readonly bytesOf: (caches: Cache[]) => number,
|
|
793
|
-
/** The
|
|
790
|
+
/** The asynchronous storage operation. */
|
|
794
791
|
readonly store: (item: SpillItem) => Promise<unknown>,
|
|
795
792
|
/** Clone disposal — frees the pinned GPU memory. */
|
|
796
793
|
readonly disposeClones: (caches: Cache[]) => void,
|
|
@@ -891,7 +888,7 @@ export function readKvHeader(path: string): KvFileHeader & { dataStart: number }
|
|
|
891
888
|
if (BigInt(Bun.hash(body)) !== expectHash)
|
|
892
889
|
throw new Error(`${path}: header hash mismatch (truncated or corrupt)`);
|
|
893
890
|
const header = JSON.parse(new TextDecoder().decode(body)) as KvFileHeader;
|
|
894
|
-
if (header.formatVersion !== 3 && header.formatVersion !== 4)
|
|
891
|
+
if (header.formatVersion !== 3 && header.formatVersion !== 4 && header.formatVersion !== 5)
|
|
895
892
|
throw new Error(`${path}: unsupported formatVersion ${header.formatVersion}`);
|
|
896
893
|
return { ...header, dataStart };
|
|
897
894
|
} finally {
|
|
@@ -997,19 +994,8 @@ function withStepCapacity(a: MlxArray, offset: number): MlxArray {
|
|
|
997
994
|
}
|
|
998
995
|
}
|
|
999
996
|
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
* per-tensor, and unmapped before returning — the caches own their bytes
|
|
1003
|
-
* outright (no dtor contract, no pinned mapping, nothing to retain).
|
|
1004
|
-
* `model` is anything with makeCache() — the entry count is validated
|
|
1005
|
-
* against the DONOR cache list (model.layers.length was wrong for
|
|
1006
|
-
* KV-shared models like e4b, whose makeCache() returns donors only). */
|
|
1007
|
-
export function loadKvCache(
|
|
1008
|
-
path: string,
|
|
1009
|
-
model: { makeCache(): Cache[] },
|
|
1010
|
-
expect: KvLoadExpect = {}, codecs: CacheCodecProvider = legacyCacheCodecs,
|
|
1011
|
-
): LoadedKvCache {
|
|
1012
|
-
const header = readKvHeader(path);
|
|
997
|
+
function validateKvHeader(path: string, header: KvFileHeader, model: { makeCache(): Cache[] },
|
|
998
|
+
expect: KvLoadExpect, codecs: CacheCodecProvider): void {
|
|
1013
999
|
if ((header.codecProvider ?? legacyCacheCodecs.id) !== codecs.id)
|
|
1014
1000
|
throw new Error(`${path}: cache codec provider mismatch`);
|
|
1015
1001
|
for (const entry of header.caches) codecs.forHeader(entry);
|
|
@@ -1036,8 +1022,11 @@ export function loadKvCache(
|
|
|
1036
1022
|
for (const c of proto) c.dispose();
|
|
1037
1023
|
}
|
|
1038
1024
|
|
|
1039
|
-
|
|
1040
|
-
|
|
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 {
|
|
1041
1030
|
// Every tensor materialized for the CURRENT entry, drained on any throw
|
|
1042
1031
|
// (2026-07-07 review: a hash mismatch under --ssd-cache-verify — or any
|
|
1043
1032
|
// per-tensor failure — orphaned the entry's already-built tensors: the
|
|
@@ -1046,19 +1035,7 @@ export function loadKvCache(
|
|
|
1046
1035
|
// is safe (dispose is idempotent).
|
|
1047
1036
|
const pending: MlxArray[] = [];
|
|
1048
1037
|
const arr = (slot: TensorSlot): MlxArray => {
|
|
1049
|
-
const
|
|
1050
|
-
if (expect.verify && hash64(view) !== slot.hash)
|
|
1051
|
-
throw new Error(`${path}: tensor hash mismatch at offset ${slot.off}`);
|
|
1052
|
-
// mlx_array_new_data COPIES synchronously — the view only has to
|
|
1053
|
-
// outlive this call, so the mapping can be advised/unmapped freely.
|
|
1054
|
-
const a = MlxArray.fromBytesCopy(view, slot.shape, slot.dtype as Dtype);
|
|
1055
|
-
pending.push(a);
|
|
1056
|
-
// Drop the just-read clean pages so the load's transient stays at
|
|
1057
|
-
// live entry + one tensor (ALIGN = 16 KiB == the arm64 page size, so
|
|
1058
|
-
// every tensor offset is page-aligned by construction).
|
|
1059
|
-
const len = Math.min(alignUp(slot.bytes), mmap.size - (dataStart + slot.off));
|
|
1060
|
-
mmap.advise(dataStart + slot.off, len, MADV_DONTNEED);
|
|
1061
|
-
return a;
|
|
1038
|
+
const a = readArray(slot); pending.push(a); return a;
|
|
1062
1039
|
};
|
|
1063
1040
|
/** withStepCapacity whose (possibly fresh) result is pending-tracked. */
|
|
1064
1041
|
const grownArr = (slot: TensorSlot, offset: number): MlxArray => {
|
|
@@ -1092,8 +1069,67 @@ export function loadKvCache(
|
|
|
1092
1069
|
for (const a of pending) a.dispose(); // the mid-entry orphans
|
|
1093
1070
|
for (const c of caches) c.dispose();
|
|
1094
1071
|
throw err;
|
|
1095
|
-
} finally {
|
|
1096
|
-
mmap.unmap(); // every tensor was copied out — nothing aliases the file
|
|
1097
1072
|
}
|
|
1098
1073
|
return { tokens: header.tokens, header, caches, ...(attachments.length ? { attachments } : {}) };
|
|
1099
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(); }
|
|
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.
|