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
|
@@ -47,6 +47,8 @@ const DRAFT_PREFILL_CHUNK = 2048;
|
|
|
47
47
|
|
|
48
48
|
export class QwenMtpProvider implements DraftProvider {
|
|
49
49
|
readonly grouped: GroupedDraftProvider = {
|
|
50
|
+
supportsExternalTokens: true,
|
|
51
|
+
checkpointNamespace: () => this.#checkpointNamespace,
|
|
50
52
|
open: options => this.#openRows(options.target, options.sampling, options.checkpoints),
|
|
51
53
|
openPrefill: options => this.#openRows(options.target, null, options.checkpoints),
|
|
52
54
|
};
|
|
@@ -132,6 +134,7 @@ export class QwenMtpProvider implements DraftProvider {
|
|
|
132
134
|
append, prepareAppend, prefill: (tokens, context) => rows.prefill(tokens, context!), materialize: rows.materialize.bind(rows),
|
|
133
135
|
filterRows: rows.filterRows.bind(rows),
|
|
134
136
|
draft: rows.draft.bind(rows), commit: rows.commit.bind(rows),
|
|
137
|
+
consume: rows.consume.bind(rows),
|
|
135
138
|
capture(row) {
|
|
136
139
|
const state = rows.extractRow(row);
|
|
137
140
|
try { return captureQwenMtpState(state); }
|
package/src/spec/serve-loop.ts
CHANGED
|
@@ -133,7 +133,9 @@ async function specRunInner(
|
|
|
133
133
|
sampler,
|
|
134
134
|
});
|
|
135
135
|
const gamma = Math.max(1, numDraftTokens);
|
|
136
|
+
const trace = runtimeConfig().value("MLX_BUN_SPEC_TRACE") === "1";
|
|
136
137
|
const prefillChunk = options.prefillChunkSize ??
|
|
138
|
+
binding.prefillPolicy?.chunkSize(promptIds.length) ??
|
|
137
139
|
runtimeConfig().number("MLX_BUN_RD_PREFILL_CHUNK", PREFILL_CHUNK);
|
|
138
140
|
|
|
139
141
|
// Allocated INSIDE the try below (2026-07-07 review): provider.open()
|
|
@@ -216,7 +218,7 @@ async function specRunInner(
|
|
|
216
218
|
kvGroupSize: options.kvGroupSize ?? 64, quantizedKvStart: options.quantizedKvStart ?? null,
|
|
217
219
|
turboQuant: options.turboQuant ?? null });
|
|
218
220
|
let cached = 0;
|
|
219
|
-
const hit = prefixCache?.take(promptIds, prefixNamespace);
|
|
221
|
+
const hit = prefixCache?.take(promptIds, prefixNamespace, options.cacheSessionId);
|
|
220
222
|
if (hit) {
|
|
221
223
|
try {
|
|
222
224
|
disposeResources(caches);
|
|
@@ -237,7 +239,7 @@ async function specRunInner(
|
|
|
237
239
|
try {
|
|
238
240
|
state = (services.cloneState ?? cloneKvCaches)(caches);
|
|
239
241
|
attachments.push(checkpoint!.capture(tokens.length));
|
|
240
|
-
prefixCache!.put(tokens, state, prefixNamespace, undefined, attachments);
|
|
242
|
+
prefixCache!.put(tokens, state, prefixNamespace, undefined, attachments, options.cacheSessionId);
|
|
241
243
|
state = []; attachments = [];
|
|
242
244
|
} finally {
|
|
243
245
|
disposeResources([...state, { dispose: () => disposeAttachments(attachments) }]);
|
|
@@ -445,7 +447,7 @@ async function specRunInner(
|
|
|
445
447
|
);
|
|
446
448
|
const d = drafts.length;
|
|
447
449
|
if (d < 0 || d > n) throw new Error(`DraftSource returned ${d} drafts (contract: 0..${n})`);
|
|
448
|
-
if (
|
|
450
|
+
if (trace) {
|
|
449
451
|
console.error(
|
|
450
452
|
`[SPEC_TRACE] round=${extras.rounds + 1} ` +
|
|
451
453
|
`feed=${feed.join(",")} drafts=${drafts.join(",")}`,
|
package/src/spec/source.ts
CHANGED
|
@@ -145,6 +145,12 @@ export interface DraftRowSampling {
|
|
|
145
145
|
sample(logprobs: MlxArray, steps: readonly number[]): MlxArray;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
/** Request-owned constraints can propose known continuations without changing
|
|
149
|
+
* their committed state. The target verifier still decides every output. */
|
|
150
|
+
export interface DraftRowConstraints {
|
|
151
|
+
propose(row: number, maxTokens: number): Promise<number[]>;
|
|
152
|
+
}
|
|
153
|
+
|
|
148
154
|
export interface DraftRowCheckpoint {
|
|
149
155
|
readonly processedTokens: number;
|
|
150
156
|
readonly attachment: CheckpointAttachment;
|
|
@@ -155,6 +161,10 @@ export interface DraftRowCheckpoint {
|
|
|
155
161
|
* Membership changes and capture occur only between committed rounds.
|
|
156
162
|
* The caller retains the provider's residency lease for the group's lifetime. */
|
|
157
163
|
export interface DraftRowGroup extends MlxDraftRows {
|
|
164
|
+
/** Consume externally chosen target inputs at a committed round boundary.
|
|
165
|
+
* Context and tokens are borrowed rectangular windows; lengths retain each
|
|
166
|
+
* row's consumed prefix, excluding right padding and the pending output. */
|
|
167
|
+
consume?(tokens: MlxArray, context: MlxArray, lengths: readonly number[]): void | Promise<void>;
|
|
158
168
|
readonly namespace: string;
|
|
159
169
|
readonly tapLayers: readonly number[];
|
|
160
170
|
readonly rowCount: number;
|
|
@@ -187,12 +197,15 @@ export interface DraftPrefillGroup {
|
|
|
187
197
|
}
|
|
188
198
|
|
|
189
199
|
export interface GroupedDraftProvider {
|
|
200
|
+
readonly supportsExternalTokens?: boolean;
|
|
201
|
+
/** Resolve persistence identity without allocating a draft row. */
|
|
202
|
+
checkpointNamespace?(): string;
|
|
190
203
|
/** Draft state remains valid when target forwards run under a mounted
|
|
191
204
|
* adapter context. Unqualified learned draft graphs leave this absent. */
|
|
192
205
|
readonly supportsTargetAdapters?: boolean;
|
|
193
206
|
openPrefill(options: { target: TargetView;
|
|
194
207
|
checkpoints: readonly (DraftRowCheckpoint | null)[] }): DraftPrefillGroup;
|
|
195
|
-
open(options: { target: TargetView; sampling: DraftRowSampling;
|
|
208
|
+
open(options: { target: TargetView; sampling: DraftRowSampling; constraints?: DraftRowConstraints;
|
|
196
209
|
checkpoints: readonly DraftRowCheckpoint[] }): DraftRowGroup;
|
|
197
210
|
}
|
|
198
211
|
|
|
@@ -55,6 +55,7 @@ export function openStandaloneDraftRows(binding: MlxAutoregressiveBinding, names
|
|
|
55
55
|
|
|
56
56
|
export function standaloneDraftGroups(binding: MlxAutoregressiveBinding, namespace: string): GroupedDraftProvider {
|
|
57
57
|
return {
|
|
58
|
+
checkpointNamespace: () => namespace,
|
|
58
59
|
open: options => openStandaloneDraftRows(binding, namespace, options.sampling, options.checkpoints),
|
|
59
60
|
openPrefill: options => openStandaloneDraftRows(binding, namespace, null, options.checkpoints),
|
|
60
61
|
};
|
package/src/spec/two-model.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import { MlxArray } from "../mlx/array";
|
|
17
17
|
import * as ops from "../mlx/ops";
|
|
18
18
|
import { clearCache } from "../mlx/ffi";
|
|
19
|
-
import {
|
|
19
|
+
import { runtimeFlag } from "../runtime-config";
|
|
20
20
|
import { loadModelConfig } from "../config";
|
|
21
21
|
import { Weights } from "../weights";
|
|
22
22
|
import { createModel, type RuntimeModel } from "../model/factory";
|
|
@@ -93,7 +93,7 @@ class TwoModelSource implements DraftSource {
|
|
|
93
93
|
{ sample: (logprobs, steps) => sampler(logprobs, steps[0]!) }, [null]);
|
|
94
94
|
}
|
|
95
95
|
async prefill(promptIds: number[]): Promise<void> {
|
|
96
|
-
const tailSplit =
|
|
96
|
+
const tailSplit = runtimeFlag("MLX_BUN_PREFILL_TAIL_SPLIT", true);
|
|
97
97
|
const upTo = tailSplit && promptIds.length > 1 ? promptIds.length - 1 : promptIds.length;
|
|
98
98
|
for (let at = this.processed; at < upTo; at += PREFILL_CHUNK) {
|
|
99
99
|
const end = Math.min(at + PREFILL_CHUNK, upTo);
|
package/src/ssd-cache.ts
CHANGED
|
@@ -18,11 +18,12 @@ import type { CheckpointAttachment } from "./backends/mlx/checkpoint-state";
|
|
|
18
18
|
import {
|
|
19
19
|
existsSync, mkdirSync, readdirSync, rmSync, statSync, utimesSync,
|
|
20
20
|
} from "node:fs";
|
|
21
|
-
import {
|
|
21
|
+
import { kvWriter, type KvWriteRequest } from "./storage/kv-writer";
|
|
22
|
+
import { join, dirname } from "node:path";
|
|
22
23
|
import { randomUUID } from "node:crypto";
|
|
23
24
|
import { commonPrefixLength } from "./prompt-cache";
|
|
24
25
|
import {
|
|
25
|
-
saveKvCache, saveKvCacheAsync, loadKvCache, readKvHeader,
|
|
26
|
+
saveKvCache, saveKvCacheAsync, loadKvCache, loadKvCacheAsync, readKvHeader,
|
|
26
27
|
cacheHeadersTrimmable, cacheHeadersMinimumReusableOffset, legacyCacheCodecs, type CacheCodecProvider,
|
|
27
28
|
type KvSaveMeta, type KvLoadExpect, type LoadedKvCache,
|
|
28
29
|
} from "./kv-store";
|
|
@@ -30,6 +31,7 @@ import type { Cache } from "./model/gemma4-base";
|
|
|
30
31
|
import { minimumReusableOffset } from "./backends/mlx/state-views";
|
|
31
32
|
|
|
32
33
|
export interface SsdIndexEntry {
|
|
34
|
+
blocks?: Array<{ hash: string; bytes: number }>;
|
|
33
35
|
path: string;
|
|
34
36
|
ns: string;
|
|
35
37
|
tokens: number[];
|
|
@@ -46,6 +48,7 @@ export interface SsdIndexEntry {
|
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
export interface SsdStoreOptions {
|
|
51
|
+
storage?: Pick<KvWriteRequest, "layout" | "blockBytes" | "segmented">;
|
|
49
52
|
codecs?: CacheCodecProvider;
|
|
50
53
|
dir: string;
|
|
51
54
|
maxBytes: number;
|
|
@@ -65,6 +68,27 @@ export class SsdCacheStore {
|
|
|
65
68
|
readonly #codecs: CacheCodecProvider;
|
|
66
69
|
readonly #root: string; // <dir>/<configFingerprint>
|
|
67
70
|
#index: SsdIndexEntry[] = [];
|
|
71
|
+
#exact = new Map<string, SsdIndexEntry>();
|
|
72
|
+
#tokenKeys = new WeakMap<number[], string>();
|
|
73
|
+
#key(tokens: number[], ns: string): string {
|
|
74
|
+
let hash = this.#tokenKeys.get(tokens);
|
|
75
|
+
if (!hash) {
|
|
76
|
+
hash = Bun.hash(new Uint32Array(tokens)).toString(16);
|
|
77
|
+
this.#tokenKeys.set(tokens, hash);
|
|
78
|
+
}
|
|
79
|
+
return JSON.stringify([ns, tokens.length, hash]);
|
|
80
|
+
}
|
|
81
|
+
#addIndex(entry: SsdIndexEntry): void {
|
|
82
|
+
this.#index.push(entry);
|
|
83
|
+
this.#exact.set(this.#key(entry.tokens, entry.ns), entry);
|
|
84
|
+
}
|
|
85
|
+
/** Exact checkpoint selection for a known session, independent of residency. */
|
|
86
|
+
findExact(tokens: number[], ns = ""): { entry: SsdIndexEntry; prefixLen: number } | null {
|
|
87
|
+
const entry = this.#exact.get(this.#key(tokens, ns));
|
|
88
|
+
return entry && commonPrefixLength(entry.tokens, tokens) === tokens.length
|
|
89
|
+
? { entry, prefixLen: tokens.length } : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
68
92
|
#warnedWriteFailure = false;
|
|
69
93
|
stats = { restores: 0, spills: 0, restoreMsLast: 0 };
|
|
70
94
|
|
|
@@ -78,8 +102,16 @@ export class SsdCacheStore {
|
|
|
78
102
|
return this.#index.length;
|
|
79
103
|
}
|
|
80
104
|
|
|
105
|
+
/** Header-only provenance for the serving tokenizer; no tensor restore. */
|
|
106
|
+
*tokenPrefixes(): Iterable<readonly number[]> {
|
|
107
|
+
for (const entry of this.#index) yield entry.tokens;
|
|
108
|
+
}
|
|
109
|
+
|
|
81
110
|
get totalBytes(): number {
|
|
82
|
-
|
|
111
|
+
const blocks = new Map<string, number>();
|
|
112
|
+
for (const entry of this.#index) for (const block of entry.blocks ?? [])
|
|
113
|
+
blocks.set(`${dirname(entry.path)}/${block.hash}`, block.bytes);
|
|
114
|
+
return this.#index.reduce((a, e) => a + e.bytes, 0) + [...blocks.values()].reduce((a, b) => a + b, 0);
|
|
83
115
|
}
|
|
84
116
|
|
|
85
117
|
get maxBytes(): number {
|
|
@@ -105,7 +137,7 @@ export class SsdCacheStore {
|
|
|
105
137
|
* headers and metadata mismatches are unlinked; `.tmp` orphans reaped.
|
|
106
138
|
* Only OUR fingerprint dir is touched. Returns entries indexed. */
|
|
107
139
|
scan(): number {
|
|
108
|
-
this.#index = [];
|
|
140
|
+
this.#index = []; this.#exact.clear();
|
|
109
141
|
if (!existsSync(this.#root)) return 0;
|
|
110
142
|
for (const nsDir of readdirSync(this.#root)) {
|
|
111
143
|
const nsPath = join(this.#root, nsDir);
|
|
@@ -130,7 +162,8 @@ export class SsdCacheStore {
|
|
|
130
162
|
throw new Error("metadata mismatch");
|
|
131
163
|
if ((h.codecProvider ?? legacyCacheCodecs.id) !== this.#codecs.id) continue;
|
|
132
164
|
const st = statSync(path);
|
|
133
|
-
this.#
|
|
165
|
+
this.#addIndex({
|
|
166
|
+
blocks: [...h.caches, ...(h.attachments ?? [])].flatMap(e => e.tensors.flatMap(t => t.blocks ?? [])),
|
|
134
167
|
path, ns: h.ns ?? "", tokens: h.tokens, bytes: st.size, mtimeMs: st.mtimeMs,
|
|
135
168
|
trimmable: !h.attachments?.length && cacheHeadersTrimmable(h.caches, this.#codecs),
|
|
136
169
|
minimumReusableOffset: cacheHeadersMinimumReusableOffset(h.caches),
|
|
@@ -143,6 +176,7 @@ export class SsdCacheStore {
|
|
|
143
176
|
}
|
|
144
177
|
}
|
|
145
178
|
}
|
|
179
|
+
void kvWriter.collect(this.#root).catch(() => {});
|
|
146
180
|
return this.#index.length;
|
|
147
181
|
}
|
|
148
182
|
|
|
@@ -198,6 +232,23 @@ export class SsdCacheStore {
|
|
|
198
232
|
}
|
|
199
233
|
}
|
|
200
234
|
|
|
235
|
+
/** Read on the dedicated CPU queue, then let the model codec adopt state. */
|
|
236
|
+
async restoreAsync(entry: SsdIndexEntry, model: { makeCache(): Cache[] }): Promise<LoadedKvCache | null> {
|
|
237
|
+
const t0 = performance.now();
|
|
238
|
+
try {
|
|
239
|
+
const loaded = await loadKvCacheAsync(entry.path, model, {
|
|
240
|
+
...this.#meta(entry.ns), verify: this.#opts.verify,
|
|
241
|
+
}, this.#codecs);
|
|
242
|
+
const now = new Date();
|
|
243
|
+
try { utimesSync(entry.path, now, now); entry.mtimeMs = now.getTime(); } catch {}
|
|
244
|
+
this.stats.restores++; this.stats.restoreMsLast = performance.now() - t0;
|
|
245
|
+
return loaded;
|
|
246
|
+
} catch (error) {
|
|
247
|
+
console.warn(`[ssd-cache] restore failed, dropping ${entry.path}: ${error}`);
|
|
248
|
+
this.remove(entry.path); return null;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
201
252
|
/** Persist an entry. Synchronous (the tier calls it on the idle serial
|
|
202
253
|
* lane between requests); atomic via kv-store's tmp+fsync+rename. An
|
|
203
254
|
* entry bigger than the cap is refused; disk/write failure is a warn-once
|
|
@@ -222,9 +273,8 @@ export class SsdCacheStore {
|
|
|
222
273
|
* yields the event loop between tensors so serving interleaves.
|
|
223
274
|
* Caller passes zero-copy CLONES it owns (a consistent snapshot no
|
|
224
275
|
* matter what the live entry does meanwhile) and disposes them after.
|
|
225
|
-
* `runStep`
|
|
226
|
-
*
|
|
227
|
-
* between an idle check and the blocking MLX readback. */
|
|
276
|
+
* `runStep` optionally wraps owner-thread snapshot preparation. Hashing,
|
|
277
|
+
* packing and disk writes execute on the CPU worker. */
|
|
228
278
|
async storeAsync(
|
|
229
279
|
tokens: number[], caches: Cache[], ns = "",
|
|
230
280
|
runStep?: <T>(step: () => T) => Promise<T>,
|
|
@@ -233,8 +283,7 @@ export class SsdCacheStore {
|
|
|
233
283
|
const dir = join(this.#root, nsHash(ns));
|
|
234
284
|
const path = join(dir, `${randomUUID()}.mlxkv`);
|
|
235
285
|
try {
|
|
236
|
-
|
|
237
|
-
await saveKvCacheAsync(path, tokens, caches, { ...this.#meta(ns), attachments }, runStep, this.#codecs);
|
|
286
|
+
await saveKvCacheAsync(path, tokens, caches, { ...this.#meta(ns), attachments }, runStep, this.#codecs, this.#opts.storage);
|
|
238
287
|
return this.#indexStored(path, tokens, caches, ns, undefined, attachments);
|
|
239
288
|
} catch (err) {
|
|
240
289
|
return this.#storeFailed(path, err);
|
|
@@ -275,7 +324,7 @@ export class SsdCacheStore {
|
|
|
275
324
|
else mkdirSync(dir, { recursive: true });
|
|
276
325
|
await saveKvCacheAsync(path, tokens, caches, {
|
|
277
326
|
...this.#meta(ns), generationCheckpoint: checkpoint,
|
|
278
|
-
}, runStep, this.#codecs);
|
|
327
|
+
}, runStep, this.#codecs, this.#opts.storage);
|
|
279
328
|
const index = () => {
|
|
280
329
|
const stored = this.#indexStored(path, tokens, caches, ns, checkpoint);
|
|
281
330
|
if (stored) {
|
|
@@ -312,9 +361,13 @@ export class SsdCacheStore {
|
|
|
312
361
|
attachments?: CheckpointAttachment[],
|
|
313
362
|
): boolean {
|
|
314
363
|
const st = statSync(path);
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
364
|
+
const header = readKvHeader(path);
|
|
365
|
+
const blocks = [...header.caches, ...(header.attachments ?? [])].flatMap(e => e.tensors.flatMap(t => t.blocks ?? []));
|
|
366
|
+
const unique = new Map(blocks.map(block => [block.hash, block.bytes]));
|
|
367
|
+
const entryBytes = st.size + [...unique.values()].reduce((a, b) => a + b, 0);
|
|
368
|
+
if (entryBytes > this.#opts.maxBytes) {
|
|
369
|
+
this.remove(path);
|
|
370
|
+
console.warn(`[ssd-cache] entry not stored: ${entryBytes} bytes exceeds the ${this.#opts.maxBytes}-byte cap`);
|
|
318
371
|
return false;
|
|
319
372
|
}
|
|
320
373
|
const trimmable = !attachments?.length && caches.every((c) => c.isTrimmable());
|
|
@@ -336,8 +389,8 @@ export class SsdCacheStore {
|
|
|
336
389
|
if (commonPrefixLength(e.tokens, tokens) === e.tokens.length) this.remove(e.path);
|
|
337
390
|
}
|
|
338
391
|
}
|
|
339
|
-
this.#
|
|
340
|
-
path, ns, tokens, bytes: st.size, mtimeMs: Date.now(), trimmable,
|
|
392
|
+
this.#addIndex({
|
|
393
|
+
blocks, path, ns, tokens, bytes: st.size, mtimeMs: Date.now(), trimmable,
|
|
341
394
|
minimumReusableOffset: minimumReusableOffset(caches),
|
|
342
395
|
...(generationCheckpoint ? { generationCheckpoint } : {}),
|
|
343
396
|
});
|
|
@@ -347,7 +400,8 @@ export class SsdCacheStore {
|
|
|
347
400
|
}
|
|
348
401
|
|
|
349
402
|
#storeFailed(path: string, err: unknown): boolean {
|
|
350
|
-
|
|
403
|
+
this.remove(path);
|
|
404
|
+
try { rmSync(`${path}.tmp`, { force: true }); } catch {}
|
|
351
405
|
if (!this.#warnedWriteFailure) {
|
|
352
406
|
this.#warnedWriteFailure = true;
|
|
353
407
|
console.warn(`[ssd-cache] store failed (disk full or unwritable?) — cold tier disabled for this entry: ${(err as Error).message}`);
|
|
@@ -367,6 +421,11 @@ export class SsdCacheStore {
|
|
|
367
421
|
|
|
368
422
|
remove(path: string): void {
|
|
369
423
|
try { rmSync(path, { force: true }); } catch {}
|
|
424
|
+
for (const entry of this.#index) if (entry.path === path) {
|
|
425
|
+
const key = this.#key(entry.tokens, entry.ns);
|
|
426
|
+
if (this.#exact.get(key) === entry) this.#exact.delete(key);
|
|
427
|
+
}
|
|
370
428
|
this.#index = this.#index.filter((e) => e.path !== path);
|
|
429
|
+
void kvWriter.collect(this.#root).catch(() => {});
|
|
371
430
|
}
|
|
372
431
|
}
|
package/src/ssd-durability.ts
CHANGED
|
@@ -4,11 +4,6 @@ import type { Cache } from "./model/gemma4";
|
|
|
4
4
|
import type { PromptCacheEntry } from "./prompt-cache";
|
|
5
5
|
import type { SpillItem, SpillQueue } from "./kv-store";
|
|
6
6
|
|
|
7
|
-
export interface DurabilityGateway {
|
|
8
|
-
readonly busy: boolean;
|
|
9
|
-
runWhenIdle<T>(fn: () => Promise<T>): Promise<T>;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
7
|
export interface DurabilityPromptCache {
|
|
13
8
|
findExact(tokens: number[], ns?: string): PromptCacheEntry | null;
|
|
14
9
|
}
|
|
@@ -50,21 +45,23 @@ export class SsdDurabilityCoordinator {
|
|
|
50
45
|
#flush: Promise<DurabilityFlushResult> | null = null;
|
|
51
46
|
|
|
52
47
|
constructor(
|
|
53
|
-
readonly gateway: DurabilityGateway,
|
|
54
48
|
readonly promptCache: DurabilityPromptCache,
|
|
55
49
|
readonly spillQueue: SpillQueue,
|
|
56
50
|
readonly cloneCaches: (caches: Cache[]) => Cache[],
|
|
57
51
|
readonly isAlreadyDurable: (tokens: number[], ns: string) => boolean = () => false,
|
|
58
|
-
readonly debounceMs =
|
|
59
|
-
readonly
|
|
52
|
+
readonly debounceMs = 0,
|
|
53
|
+
readonly retryMs = 5_000,
|
|
54
|
+
readonly onStored: () => void = () => {},
|
|
60
55
|
) {}
|
|
61
56
|
|
|
62
|
-
schedule(tokens: number[], ns = ""): void {
|
|
63
|
-
|
|
57
|
+
schedule(tokens: number[], ns = "", replace = true): void {
|
|
58
|
+
// Exact tensor objects use an empty history and still need persistence.
|
|
59
|
+
if (tokens.length === 0 && !this.promptCache.findExact(tokens, ns)?.attachments?.length) return;
|
|
64
60
|
// PromptCache can hold unrelated entries with the same namespace and
|
|
65
61
|
// length. Include the tokens so one conversation cannot cancel another
|
|
66
62
|
// conversation's pending durability record.
|
|
67
63
|
const key = `${ns.length}:${ns}:${tokens.join(",")}`;
|
|
64
|
+
if (!replace && this.#dirty.has(key)) return;
|
|
68
65
|
const rec: DirtySnapshot = { key, tokens: [...tokens], ns };
|
|
69
66
|
this.#dirty.set(key, rec);
|
|
70
67
|
this.#arm(key, this.debounceMs);
|
|
@@ -108,10 +105,6 @@ export class SsdDurabilityCoordinator {
|
|
|
108
105
|
|
|
109
106
|
const rec = this.#dirty.get(key);
|
|
110
107
|
if (!rec) return "stored";
|
|
111
|
-
if (!force && this.gateway.busy) {
|
|
112
|
-
this.#arm(key, this.busyRetryMs);
|
|
113
|
-
return "failed";
|
|
114
|
-
}
|
|
115
108
|
|
|
116
109
|
const task = this.#store(rec);
|
|
117
110
|
this.#attempts.set(key, task);
|
|
@@ -120,7 +113,7 @@ export class SsdDurabilityCoordinator {
|
|
|
120
113
|
if (outcome === "stored" && this.#dirty.get(key) === rec)
|
|
121
114
|
this.#dirty.delete(key);
|
|
122
115
|
if (outcome !== "stored" && !force && this.#dirty.get(key) === rec)
|
|
123
|
-
this.#arm(key, this.
|
|
116
|
+
this.#arm(key, this.retryMs);
|
|
124
117
|
return outcome;
|
|
125
118
|
} finally {
|
|
126
119
|
if (this.#attempts.get(key) === task) this.#attempts.delete(key);
|
|
@@ -130,7 +123,7 @@ export class SsdDurabilityCoordinator {
|
|
|
130
123
|
async #store(rec: DirtySnapshot): Promise<StoreOutcome> {
|
|
131
124
|
let snap: SpillItem | null = null;
|
|
132
125
|
try {
|
|
133
|
-
snap =
|
|
126
|
+
snap = (() => {
|
|
134
127
|
if (this.isAlreadyDurable(rec.tokens, rec.ns)) return null;
|
|
135
128
|
const entry = this.promptCache.findExact(rec.tokens, rec.ns);
|
|
136
129
|
if (!entry) return null;
|
|
@@ -141,13 +134,17 @@ export class SsdDurabilityCoordinator {
|
|
|
141
134
|
} catch (error) {
|
|
142
135
|
return cleanupFailure(error, () => disposeResources(caches));
|
|
143
136
|
}
|
|
144
|
-
});
|
|
137
|
+
})();
|
|
145
138
|
} catch {
|
|
146
139
|
return "failed";
|
|
147
140
|
}
|
|
148
141
|
if (!snap)
|
|
149
142
|
return this.isAlreadyDurable(rec.tokens, rec.ns) ? "stored" : "missing";
|
|
150
|
-
|
|
143
|
+
const stored = await this.spillQueue.enqueue(snap);
|
|
144
|
+
// The queue releases its views before this continuation runs. Residency
|
|
145
|
+
// policy now sees memory without the completed writer's ownership.
|
|
146
|
+
if (stored) this.onStored();
|
|
147
|
+
return stored ? "stored" : "failed";
|
|
151
148
|
}
|
|
152
149
|
|
|
153
150
|
async #flushInner(started: number): Promise<DurabilityFlushResult> {
|
|
@@ -159,12 +156,13 @@ export class SsdDurabilityCoordinator {
|
|
|
159
156
|
const droppedBefore = this.spillQueue.droppedCount;
|
|
160
157
|
const failedBefore = this.spillQueue.failedCount;
|
|
161
158
|
|
|
162
|
-
if (this.#attempts.size > 0)
|
|
163
|
-
await Promise.allSettled([...this.#attempts.values()]);
|
|
159
|
+
if (this.#attempts.size > 0) {
|
|
160
|
+
const results = await Promise.allSettled([...this.#attempts.values()]);
|
|
161
|
+
flushedSnapshots += results.filter(result => result.status === "fulfilled" && result.value === "stored").length;
|
|
162
|
+
}
|
|
164
163
|
await this.spillQueue.drain();
|
|
165
164
|
|
|
166
|
-
// Flush
|
|
167
|
-
// boundary snapshot while a large final snapshot is already in flight.
|
|
165
|
+
// Flush dirty entries serially without a second queue of snapshots.
|
|
168
166
|
// Attempt each record version once. Missing state stays dirty, so a
|
|
169
167
|
// second flush cannot report success without a real durable snapshot.
|
|
170
168
|
const attempted = new Set<DirtySnapshot>();
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { dlopen, ptr, read, type Pointer } from "bun:ffi";
|
|
2
|
+
|
|
3
|
+
// Page alignment permits Metal to use the restored allocation directly.
|
|
4
|
+
// The destructor is native free, so final release may occur on any thread.
|
|
5
|
+
const libc = dlopen("/usr/lib/libSystem.B.dylib", {
|
|
6
|
+
posix_memalign: { args: ["ptr", "u64", "u64"], returns: "i32" },
|
|
7
|
+
free: { args: ["ptr"], returns: "void" },
|
|
8
|
+
});
|
|
9
|
+
export const hostBufferDestructor = Number(Reflect.get(libc.symbols.free, "ptr"));
|
|
10
|
+
|
|
11
|
+
/** Unpublished CPU storage. A reader fills it before ownership moves to MLX. */
|
|
12
|
+
export class HostBuffer {
|
|
13
|
+
#pointer: number;
|
|
14
|
+
constructor(readonly bytes: number) {
|
|
15
|
+
const slot = new BigUint64Array(1), address = ptr(slot);
|
|
16
|
+
const status = libc.symbols.posix_memalign(address, 16384n, BigInt(Math.max(16384, Math.ceil(bytes / 16384) * 16384)));
|
|
17
|
+
if (status) throw new Error(`KV restore allocation failed (${status})`);
|
|
18
|
+
this.#pointer = Number(read.u64(address));
|
|
19
|
+
}
|
|
20
|
+
get pointer(): number { return this.#pointer; }
|
|
21
|
+
transfer(): number { const p = this.#pointer; this.#pointer = 0; return p; }
|
|
22
|
+
dispose(): void { if (this.#pointer) libc.symbols.free(this.transfer() as Pointer); }
|
|
23
|
+
[Symbol.dispose](): void { this.dispose(); }
|
|
24
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Worker } from "node:worker_threads";
|
|
2
|
+
import workerSource from "./kv-writer.worker.js" with { type: "text" };
|
|
3
|
+
import type { KvFileHeader } from "../kv-store";
|
|
4
|
+
|
|
5
|
+
/** Immutable, evaluated host-visible storage. The owner retains the native
|
|
6
|
+
* buffers until write() settles. No MLX handles cross the worker boundary. */
|
|
7
|
+
export interface StoredTensorView {
|
|
8
|
+
pointer: number;
|
|
9
|
+
shape: number[];
|
|
10
|
+
strides: number[];
|
|
11
|
+
itemSize: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface KvWriteRequest {
|
|
15
|
+
layout?: "whole" | "blocks";
|
|
16
|
+
blockBytes?: number;
|
|
17
|
+
segmented?: boolean;
|
|
18
|
+
path: string;
|
|
19
|
+
header: KvFileHeader;
|
|
20
|
+
tensors: StoredTensorView[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Hashing, layout packing and atomic disk writes run on a dedicated CPU
|
|
24
|
+
* worker. Only metadata and pointers are posted; tensor bytes are not copied
|
|
25
|
+
* through structured clone. The RAM cache remains an independent owner. */
|
|
26
|
+
export class KvWriter {
|
|
27
|
+
#worker: Worker | null = null;
|
|
28
|
+
#next = 0;
|
|
29
|
+
#pending = new Map<number, { resolve(): void; reject(error: Error): void }>();
|
|
30
|
+
|
|
31
|
+
lastMetrics: { elapsedMs: number; writtenBytes?: number; reusedBytes?: number; copiedBytes?: number; scratchPeak?: number } | null = null;
|
|
32
|
+
|
|
33
|
+
write(request: KvWriteRequest): Promise<void> { return this.#send(request); }
|
|
34
|
+
|
|
35
|
+
read(request: { path: string; header: KvFileHeader & { dataStart: number }; pointers: number[]; verify?: boolean }): Promise<void> {
|
|
36
|
+
return this.#send({ ...request, operation: "read" });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
collect(root: string): Promise<void> { return this.#send({ root, operation: "collect" }); }
|
|
40
|
+
|
|
41
|
+
#send(request: object): Promise<void> {
|
|
42
|
+
const worker = this.#worker ?? this.#start();
|
|
43
|
+
worker.ref();
|
|
44
|
+
const id = ++this.#next;
|
|
45
|
+
return new Promise<void>((resolve, reject) => {
|
|
46
|
+
this.#pending.set(id, { resolve, reject });
|
|
47
|
+
try { worker.postMessage({ id, ...request }); }
|
|
48
|
+
catch (error) {
|
|
49
|
+
this.#pending.delete(id);
|
|
50
|
+
if (!this.#pending.size) worker.unref();
|
|
51
|
+
reject(error);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#start(): Worker {
|
|
57
|
+
// Embed the worker in the single-file executable as well as source runs.
|
|
58
|
+
const worker = new Worker(workerSource, { eval: true });
|
|
59
|
+
worker.on("message", (message: { id: number; error?: string; metrics?: KvWriter["lastMetrics"] }) => {
|
|
60
|
+
const result = this.#pending.get(message.id);
|
|
61
|
+
if (!result) return;
|
|
62
|
+
this.#pending.delete(message.id);
|
|
63
|
+
if (message.error) result.reject(new Error(message.error));
|
|
64
|
+
else { this.lastMetrics = message.metrics ?? null; result.resolve(); }
|
|
65
|
+
if (!this.#pending.size) worker.unref();
|
|
66
|
+
});
|
|
67
|
+
// Job failures are acknowledged by the worker. A worker-level failure
|
|
68
|
+
// settles only at exit, after it can no longer read borrowed buffers.
|
|
69
|
+
worker.on("error", () => {});
|
|
70
|
+
worker.on("exit", () => {
|
|
71
|
+
for (const job of this.#pending.values()) job.reject(new Error("KV persistence worker exited"));
|
|
72
|
+
this.#pending.clear();
|
|
73
|
+
this.#worker = null;
|
|
74
|
+
});
|
|
75
|
+
this.#worker = worker;
|
|
76
|
+
return worker;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const kvWriter = new KvWriter();
|
|
81
|
+
|
|
82
|
+
/** Reads have their own queue: a restore never waits behind persistence. */
|
|
83
|
+
export const kvReader = new KvWriter();
|