mlx-bun 0.0.9 → 0.0.11
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 +66 -21
- package/THIRD_PARTY_LICENSES.md +1 -0
- package/docs/archive/mlx-bun-lab-report.html +275 -0
- package/docs/archive/wwdc-mlx-bun.html +848 -0
- package/docs/design/audio-input-plan.md +356 -0
- package/docs/design/batching-perf-path.md +255 -0
- package/docs/design/batching-v2-plan.md +23 -9
- package/docs/design/bucketing-stage.md +2 -1
- package/docs/design/compat-cli-surface-design.md +4 -3
- package/docs/design/decode-speed-program.md +172 -0
- package/docs/design/docs-reorg-plan.md +1 -1
- package/docs/design/dspark-serving-program.md +371 -0
- package/docs/design/dspark-speculative-decoding.md +167 -5
- package/docs/design/faithful-l1-consolidation.md +310 -0
- package/docs/design/generic-model-support.md +8 -5
- package/docs/design/grammar-spec-batching-integration.md +301 -0
- package/docs/design/minicpm5-decode-megakernel.md +16 -11
- package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
- package/docs/design/omlx-adoption-map.md +98 -0
- package/docs/design/orpo-training.md +34 -30
- package/docs/design/paged-kv-cache.md +214 -0
- package/docs/design/parallel-slots.md +82 -49
- package/docs/design/parity-tier-dag.md +43 -33
- package/docs/design/repo-cleanup-plan.md +189 -0
- package/docs/design/runtime-isolation.md +19 -3
- package/docs/design/segmented-backward-training.md +18 -2
- package/docs/design/ssd-kv-cold-tier.md +288 -0
- package/docs/design/structured-output.md +484 -0
- package/docs/design/the-dreaming-master-plan.md +2 -3
- package/docs/design/turboquant-kv.md +235 -0
- package/docs/design/unified-engine-frontier-plan.md +774 -0
- package/docs/design/web-chat-beat-matrix.md +315 -0
- package/docs/design/web-chat-redesign.md +1112 -0
- package/docs/investigations/decode-roofline-lookagain.md +92 -6
- package/docs/investigations/dspark-handoff.md +182 -16
- package/docs/investigations/kernel-perf-review-2026-07.md +105 -26
- package/docs/investigations/lab-build-journal.md +2 -2
- package/docs/investigations/openwiki-evaluation.md +249 -0
- package/docs/planning/IDEAS.md +14 -0
- package/docs/planning/PRODUCT_ROADMAP.md +2 -3
- package/docs/planning/chat-ui-vision-memory-first.md +199 -0
- package/docs/planning/chat-ui-vision.md +50 -0
- package/docs/planning/memory-docs-and-dag-plan.md +15 -6
- package/docs/planning/release-notes-v0.0.10.md +57 -0
- package/docs/planning/release-notes-v0.0.11.md +239 -0
- package/docs/reference/cli.md +35 -16
- package/docs/reference/distribution.md +13 -6
- package/docs/reference/embedding.md +35 -14
- package/docs/reference/features-matrix.md +97 -0
- package/docs/reference/library-api.md +30 -9
- package/docs/reference/memory.md +26 -6
- package/docs/reference/models.md +11 -1
- package/docs/reference/orpo-quickstart.md +3 -4
- package/docs/reference/server-api.md +634 -22
- package/docs/reference/server-config.md +251 -119
- package/docs/reference/training.md +20 -16
- package/package.json +5 -3
- package/src/anthropic.ts +13 -0
- package/src/audio/conformer.ts +647 -0
- package/src/audio/decode.ts +133 -0
- package/src/audio/features.ts +202 -0
- package/src/audio/transcode.ts +67 -0
- package/src/cli.ts +402 -107
- package/src/config.ts +49 -2
- package/src/download.ts +40 -0
- package/src/eval/README.md +7 -6
- package/src/eval/kl.ts +59 -3
- package/src/eval/quality-db.ts +2 -2
- package/src/eval/runner.ts +3 -3
- package/src/expert-offload.ts +15 -2
- package/src/flags.ts +13 -0
- package/src/generate.ts +330 -16
- package/src/grammar.ts +585 -0
- package/src/hub-rest.ts +242 -0
- package/src/kv-store.ts +618 -122
- package/src/lora.ts +8 -0
- package/src/md-modules.d.ts +42 -0
- package/src/media-fetch.ts +284 -0
- package/src/memory/rest.ts +245 -0
- package/src/memory/vault.ts +74 -0
- package/src/mlx/array.ts +127 -19
- package/src/mlx/autograd.ts +2 -2
- package/src/mlx/ffi.ts +27 -3
- package/src/mlx/metal-kernel.ts +7 -4
- package/src/mlx/ops.ts +179 -23
- package/src/mlx/turboquant-ops.ts +831 -0
- package/src/mlx/turboquant-tables.ts +237 -0
- package/src/model/batched-mask.ts +62 -1
- package/src/model/batched-quant.ts +206 -0
- package/src/model/batched-rotating-quant.ts +346 -0
- package/src/model/batched-rotating.ts +46 -1
- package/src/model/compiled-decode.ts +4 -15
- package/src/model/factory.ts +10 -4
- package/src/model/gemma4-base.ts +391 -34
- package/src/model/gemma4.ts +122 -33
- package/src/model/generated/gemma4-12b.ts +9 -13
- package/src/model/generated/gemma4-26b.ts +10 -5
- package/src/model/generated/gemma4-e4b.ts +14 -9
- package/src/model/minicpm5.ts +58 -102
- package/src/model/paged-kv.ts +253 -0
- package/src/model/qwen3-delta.ts +123 -15
- package/src/model/qwen3-moe.ts +404 -0
- package/src/model/qwen3.ts +9 -8
- package/src/model/qwen3_5.ts +99 -25
- package/src/model/support.ts +12 -1
- package/src/model/universal/dense.ts +40 -5
- package/src/model/universal/rope.ts +36 -21
- package/src/pi-web.ts +1299 -82
- package/src/preflight.ts +27 -8
- package/src/prompt-cache.ts +338 -32
- package/src/quantize/quantizer.ts +16 -1
- package/src/registry.ts +61 -3
- package/src/serve/batch-scheduler.ts +1001 -76
- package/src/serve/generation-gateway.ts +292 -30
- package/src/serve/isolate.ts +417 -0
- package/src/serve/lane-registry.ts +44 -0
- package/src/serve/session-search.ts +326 -0
- package/src/server.ts +1460 -105
- package/src/spec/assistant-source.ts +150 -0
- package/src/spec/deepspec-source.ts +143 -0
- package/src/spec/dflash-source.ts +124 -0
- package/src/spec/dspark/ab-stats.ts +136 -0
- package/src/spec/dspark/calibration.ts +112 -0
- package/src/spec/dspark/deepspec-module.ts +801 -0
- package/src/spec/dspark/generate-dflash.ts +72 -20
- package/src/spec/dspark/loader.ts +23 -0
- package/src/spec/dspark/module-dflash.ts +278 -36
- package/src/spec/dspark/quantize-drafter.ts +91 -0
- package/src/spec/generate.ts +59 -25
- package/src/spec/ngram-source.ts +132 -0
- package/src/spec/serve-loop.ts +536 -0
- package/src/spec/source.ts +93 -0
- package/src/spec/two-model.ts +171 -0
- package/src/ssd-cache.ts +285 -0
- package/src/tokenizer.ts +288 -2
- package/src/tool-approvals.ts +131 -0
- package/src/tool-call.ts +408 -20
- package/src/train/flash-cce.ts +29 -26
- package/src/train/job.ts +0 -2
- package/src/train/loss.ts +53 -11
- package/src/train/prefix-shared.ts +30 -7
- package/src/train/segmented.ts +150 -46
- package/src/train/trainer.ts +22 -15
- package/src/vision/prompt.ts +217 -48
- package/src/web/app.html +1184 -1789
- package/src/web/app.js +5667 -0
- package/src/web/icon.svg +13 -0
- package/src/web/manifest.webmanifest +24 -0
- package/src/web/src/adapters-panel.ts +288 -0
- package/src/web/src/api.ts +57 -0
- package/src/web/src/assistant.ts +485 -0
- package/src/web/src/chat.ts +1050 -0
- package/src/web/src/composer.ts +959 -0
- package/src/web/src/dataset.ts +132 -0
- package/src/web/src/finetune.ts +267 -0
- package/src/web/src/hub.ts +428 -0
- package/src/web/src/main.ts +98 -0
- package/src/web/src/markdown.ts +0 -0
- package/src/web/src/memory-panel.ts +572 -0
- package/src/web/src/model-picker.ts +159 -0
- package/src/web/src/palette.ts +346 -0
- package/src/web/src/protocol.ts +62 -0
- package/src/web/src/quantize.ts +167 -0
- package/src/web/src/rag.ts +274 -0
- package/src/web/src/sessions.ts +469 -0
- package/src/web/src/shell.ts +804 -0
- package/src/web/src/status.ts +252 -0
- package/src/web/src/ui-catalog.ts +92 -0
- package/src/web/sw.js +71 -0
- package/src/web/vendor/README.md +61 -0
- package/src/web/vendor/hljs-LICENSE +29 -0
- package/src/web/vendor/hljs-theme.css +59 -0
- package/src/web/vendor/hljs.js +26 -0
- package/src/model/fused-decode-kernel.ts +0 -312
- package/src/model/fused-geglu-kernel.ts +0 -198
- package/src/model/fused-mlp-kernel.ts +0 -169
- package/src/model/fused-swiglu-kernel.ts +0 -240
- package/src/model/steel-linear-kernel.ts +0 -96
- package/src/status-page.html +0 -497
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Two-model speculative drafting — mlx-lm parity (`mlx_lm.server
|
|
2
|
+
// --draft-model`). A full second model drafts autoregressively; the serve
|
|
3
|
+
// loop (src/spec/serve-loop.ts) verifies with the target. L1 oracle:
|
|
4
|
+
// mlx_lm.server with the same target/draft pair, greedy, token-for-token
|
|
5
|
+
// (spec-vs-spec — both batch the verify lm-head, so neither is bit-exact to
|
|
6
|
+
// stock decode at knife-edges; see src/spec/generate.ts header).
|
|
7
|
+
//
|
|
8
|
+
// Faithfulness notes (read from the oracle venv's generate.py):
|
|
9
|
+
// - drafts are sampled with the REQUEST sampler (generate.py:593-601) —
|
|
10
|
+
// greedy drafting under a temperature>0 request is not parity;
|
|
11
|
+
// - draft-cache rewind is max(n - kAccept - 1, 0) (generate.py:589-591),
|
|
12
|
+
// with the all-accept re-feed of the last draft handled by the serve
|
|
13
|
+
// loop's `feed` (generate.py:645-648);
|
|
14
|
+
// - logits processors do NOT run on draft steps (they're target-side).
|
|
15
|
+
|
|
16
|
+
import { MlxArray } from "../mlx/array";
|
|
17
|
+
import * as ops from "../mlx/ops";
|
|
18
|
+
import { clearCache } from "../mlx/ffi";
|
|
19
|
+
import { flagOn } from "../flags";
|
|
20
|
+
import { loadModelConfig } from "../config";
|
|
21
|
+
import { Weights } from "../weights";
|
|
22
|
+
import { createModel, type RuntimeModel } from "../model/factory";
|
|
23
|
+
import type { Cache } from "../model/gemma4";
|
|
24
|
+
import { toLogprobs } from "../sampler";
|
|
25
|
+
import type { DraftProvider, DraftSource } from "./source";
|
|
26
|
+
|
|
27
|
+
/** mlx-lm's prefill_step_size — the draft drain chunks at the same stride
|
|
28
|
+
* as the target's (serve-loop.ts); chunking is numerically exact for
|
|
29
|
+
* causal attention. */
|
|
30
|
+
const PREFILL_CHUNK = 2048;
|
|
31
|
+
|
|
32
|
+
export class TwoModelProvider implements DraftProvider {
|
|
33
|
+
readonly id: string;
|
|
34
|
+
readonly weightsBytes: number;
|
|
35
|
+
|
|
36
|
+
private constructor(
|
|
37
|
+
readonly model: RuntimeModel,
|
|
38
|
+
private readonly weights: Weights,
|
|
39
|
+
id: string,
|
|
40
|
+
) {
|
|
41
|
+
this.id = id;
|
|
42
|
+
this.weightsBytes = [...weights.shards.files.values()].reduce(
|
|
43
|
+
(a, f) => a + f.mmap.size,
|
|
44
|
+
0,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Load the draft model from a snapshot dir. Vocab-size mismatch with the
|
|
49
|
+
* target is a WARNING (mlx-lm parity, server.py:363-368); the tokenizer-
|
|
50
|
+
* family hard check lives at the server (it owns both tokenizers). */
|
|
51
|
+
static async load(modelDir: string, targetVocabSize?: number): Promise<TwoModelProvider> {
|
|
52
|
+
const config = await loadModelConfig(modelDir);
|
|
53
|
+
const weights = await Weights.open(modelDir);
|
|
54
|
+
const model = createModel(weights, config);
|
|
55
|
+
if (
|
|
56
|
+
targetVocabSize !== undefined &&
|
|
57
|
+
config.text.vocabSize !== targetVocabSize
|
|
58
|
+
)
|
|
59
|
+
console.warn(
|
|
60
|
+
`draft model vocab size ${config.text.vocabSize} != target ${targetVocabSize} — ` +
|
|
61
|
+
`speculation will accept nothing useful (mlx-lm warns identically)`,
|
|
62
|
+
);
|
|
63
|
+
return new TwoModelProvider(model, weights, modelDir.split("/").filter(Boolean).at(-1)!);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
open(opts: Parameters<DraftProvider["open"]>[0]): DraftSource {
|
|
67
|
+
return new TwoModelSource(this.model, opts.sampler);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
dispose(): void {
|
|
71
|
+
this.weights.dispose();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
class TwoModelSource implements DraftSource {
|
|
76
|
+
readonly weightsBytes = 0; // provider-owned weights; per-request adds caches only
|
|
77
|
+
private caches: Cache[];
|
|
78
|
+
|
|
79
|
+
constructor(
|
|
80
|
+
private readonly model: RuntimeModel,
|
|
81
|
+
private readonly sampler: (lp: MlxArray, step: number) => MlxArray,
|
|
82
|
+
) {
|
|
83
|
+
this.caches = this.model.makeCache();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
prefill(promptIds: number[]): void {
|
|
87
|
+
// Oracle convention (mlx-lm speculative_generate_step._prefill, re-anchored
|
|
88
|
+
// 2026-07-07): the DRAFT model also drains only to len-1 (`while y.size >
|
|
89
|
+
// 1`) — the last prompt token stays unprocessed and is consumed by the
|
|
90
|
+
// FIRST draft() round's feed ([pending] = that same token under the serve
|
|
91
|
+
// loop's oracle shape), mirroring _draft_generate's first _step. Draft-side
|
|
92
|
+
// full-prefill was the residual knife-edge flipper in the 2026-07-07 live
|
|
93
|
+
// oracle gate (γ=2 haiku cell). MLX_BUN_PREFILL_TAIL_SPLIT=0 reverts.
|
|
94
|
+
// (mlx-lm chunks its drain at prefill_step_size=2048; chunking is
|
|
95
|
+
// numerically exact for causal attention — same KV, same positions —
|
|
96
|
+
// so we chunk too. Pre-2026-07-07 this was a SINGLE forward of the
|
|
97
|
+
// whole head: nothing enforced the "<2048 serve regime" the old
|
|
98
|
+
// comment assumed, and a 32k prompt through the spec lane ran one
|
|
99
|
+
// 32k-position draft forward — a large activation/mask transient on a
|
|
100
|
+
// 24 GB box, and untested numerics vs the chunked oracle.)
|
|
101
|
+
const tailSplit = flagOn("MLX_BUN_PREFILL_TAIL_SPLIT", true);
|
|
102
|
+
const upTo = tailSplit && promptIds.length > 1 ? promptIds.length - 1 : promptIds.length;
|
|
103
|
+
for (let at = 0; at < upTo; at += PREFILL_CHUNK) {
|
|
104
|
+
const end = Math.min(at + PREFILL_CHUNK, upTo);
|
|
105
|
+
const ids = ops.fromInt32(promptIds.slice(at, end), [1, end - at]);
|
|
106
|
+
const h = this.model.forwardHidden(ids, this.caches);
|
|
107
|
+
ids.dispose();
|
|
108
|
+
// The head's last-position logits are not needed: the serve loop's
|
|
109
|
+
// first `feed` is consumed at the top of draft().
|
|
110
|
+
h.dispose();
|
|
111
|
+
clearCache();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** One forward over `feed`, sample the first draft from its last position,
|
|
116
|
+
* then n-1 single-token steps — mirrors _draft_generate's autoregressive
|
|
117
|
+
* chain (with async_eval pipelining upstream; eager here, the draft is
|
|
118
|
+
* small and the serve loop overlaps nothing with it in v1). */
|
|
119
|
+
draft(feed: number[], n: number, stepBase: number): number[] {
|
|
120
|
+
const drafts: number[] = [];
|
|
121
|
+
let input = feed;
|
|
122
|
+
for (let k = 0; k < n; k++) {
|
|
123
|
+
const ids = ops.fromInt32(input, [1, input.length]);
|
|
124
|
+
const h = this.model.forwardHidden(ids, this.caches);
|
|
125
|
+
ids.dispose();
|
|
126
|
+
const L = h.shape[1]!;
|
|
127
|
+
const H = h.shape[2]!;
|
|
128
|
+
const hLast = h.slice([0, L - 1, 0], [1, L, H]);
|
|
129
|
+
h.dispose();
|
|
130
|
+
const logits = this.model.logitsFromHidden(hLast);
|
|
131
|
+
hLast.dispose();
|
|
132
|
+
const V = logits.shape[logits.shape.length - 1]!;
|
|
133
|
+
const flat = ops.reshape(logits, [1, V]);
|
|
134
|
+
logits.dispose();
|
|
135
|
+
const lp = toLogprobs(flat);
|
|
136
|
+
flat.dispose();
|
|
137
|
+
const tokArr = this.sampler(lp, stepBase + k);
|
|
138
|
+
lp.dispose();
|
|
139
|
+
const tok = ops.itemUint32(tokArr);
|
|
140
|
+
tokArr.dispose();
|
|
141
|
+
drafts.push(tok);
|
|
142
|
+
input = [tok];
|
|
143
|
+
}
|
|
144
|
+
return drafts;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
commit(n: number, kAccept: number): void {
|
|
148
|
+
const rewind = Math.max(n - kAccept - 1, 0);
|
|
149
|
+
if (rewind > 0) {
|
|
150
|
+
for (const c of this.caches) {
|
|
151
|
+
if (!c.isTrimmable()) {
|
|
152
|
+
// The serve loop stops speculating on the TARGET-side trim check
|
|
153
|
+
// before this can matter; a wrapped draft ring here just means the
|
|
154
|
+
// source is done being useful. Recreate is the safe recovery.
|
|
155
|
+
this.resetCaches();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
c.trim(rewind);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private resetCaches(): void {
|
|
164
|
+
for (const c of this.caches) c.dispose();
|
|
165
|
+
this.caches = this.model.makeCache();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
dispose(): void {
|
|
169
|
+
for (const c of this.caches) c.dispose();
|
|
170
|
+
}
|
|
171
|
+
}
|
package/src/ssd-cache.ts
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// SSD cold tier for the prompt/KV cache (docs/design/ssd-kv-cold-tier.md).
|
|
2
|
+
//
|
|
3
|
+
// Files ARE the database: no sidecar index. Layout
|
|
4
|
+
// <dir>/<configFingerprint>/<nsHash>/<uuid>.mlxkv
|
|
5
|
+
// so two models (or two kv-quant schemes of one model) share a directory
|
|
6
|
+
// without ever colliding, and an incompatible fingerprint dir is simply
|
|
7
|
+
// ignored (never deleted — it may belong to the user's other model).
|
|
8
|
+
// Startup recovery = a header-only scan of OUR fingerprint dir: header hash
|
|
9
|
+
// verified (cheap), corrupt or metadata-mismatched files unlinked, `.tmp`
|
|
10
|
+
// write orphans reaped. LRU state = file mtime (bumped on hit); the byte
|
|
11
|
+
// cap is enforced at write time by oldest-mtime eviction. A cache is always
|
|
12
|
+
// droppable: every failure path degrades to "no hit" or "not stored" — the
|
|
13
|
+
// tier must never take serving down (oMLX paged_ssd_cache lesson, Apache-2.0
|
|
14
|
+
// idea port; ours spills whole prefix entries, not content-hashed blocks —
|
|
15
|
+
// see the design doc's D1 for why).
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
existsSync, mkdirSync, readdirSync, rmSync, statSync, utimesSync,
|
|
19
|
+
} from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { randomUUID } from "node:crypto";
|
|
22
|
+
import { commonPrefixLength } from "./prompt-cache";
|
|
23
|
+
import {
|
|
24
|
+
saveKvCache, saveKvCacheAsync, loadKvCache, readKvHeader,
|
|
25
|
+
type KvSaveMeta, type KvLoadExpect, type LoadedKvCache,
|
|
26
|
+
} from "./kv-store";
|
|
27
|
+
import type { Cache } from "./model/gemma4-base";
|
|
28
|
+
|
|
29
|
+
export interface SsdIndexEntry {
|
|
30
|
+
path: string;
|
|
31
|
+
ns: string;
|
|
32
|
+
tokens: number[];
|
|
33
|
+
bytes: number;
|
|
34
|
+
mtimeMs: number;
|
|
35
|
+
/** Whether every serialized cache can trim (derived from kinds+state at
|
|
36
|
+
* store/scan time). An untrimmable entry (wrapped ring, SSM) can only
|
|
37
|
+
* seed prompts it matches WITHOUT a trim — find() must not prefer it
|
|
38
|
+
* over a shorter usable one (the 2026-07-06 restart-0 defect: the
|
|
39
|
+
* [prompt+gen] file always won, always got rejected after restore). */
|
|
40
|
+
trimmable: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Trimmability from a file header alone (mirror of the live caches'
|
|
44
|
+
* isTrimmable(): kv/qkv/turboquant always trim; rotating kinds only pre-wrap
|
|
45
|
+
* (offset < maxSize); ssm never). Keep in sync with gemma4-base/qwen3-delta. */
|
|
46
|
+
const headerTrimmable = (caches: { kind: string; offset: number; maxSize?: number }[]): boolean =>
|
|
47
|
+
caches.every((c) =>
|
|
48
|
+
c.kind === "kv" || c.kind === "qkv" || c.kind === "turboquant"
|
|
49
|
+
? true
|
|
50
|
+
: c.kind === "rotating" || c.kind === "rotating-qkv"
|
|
51
|
+
? c.offset < (c.maxSize ?? 0)
|
|
52
|
+
: false);
|
|
53
|
+
|
|
54
|
+
export interface SsdStoreOptions {
|
|
55
|
+
dir: string;
|
|
56
|
+
maxBytes: number;
|
|
57
|
+
/** Server identity — written into every file, enforced on scan/load. */
|
|
58
|
+
configFingerprint: string;
|
|
59
|
+
tokenizerHash: string;
|
|
60
|
+
modelId: string;
|
|
61
|
+
/** Verify every tensor hash on load (--ssd-cache-verify). */
|
|
62
|
+
verify?: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const nsHash = (ns: string): string =>
|
|
66
|
+
ns === "" ? "base" : Bun.hash(ns).toString(16);
|
|
67
|
+
|
|
68
|
+
export class SsdCacheStore {
|
|
69
|
+
readonly #opts: SsdStoreOptions;
|
|
70
|
+
readonly #root: string; // <dir>/<configFingerprint>
|
|
71
|
+
#index: SsdIndexEntry[] = [];
|
|
72
|
+
#warnedWriteFailure = false;
|
|
73
|
+
stats = { restores: 0, spills: 0, restoreMsLast: 0 };
|
|
74
|
+
|
|
75
|
+
constructor(opts: SsdStoreOptions) {
|
|
76
|
+
this.#opts = opts;
|
|
77
|
+
this.#root = join(opts.dir, opts.configFingerprint);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
get entries(): number {
|
|
81
|
+
return this.#index.length;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
get totalBytes(): number {
|
|
85
|
+
return this.#index.reduce((a, e) => a + e.bytes, 0);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
get maxBytes(): number {
|
|
89
|
+
return this.#opts.maxBytes;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Startup recovery: rebuild the in-memory index from disk. Corrupt
|
|
93
|
+
* headers and metadata mismatches are unlinked; `.tmp` orphans reaped.
|
|
94
|
+
* Only OUR fingerprint dir is touched. Returns entries indexed. */
|
|
95
|
+
scan(): number {
|
|
96
|
+
this.#index = [];
|
|
97
|
+
if (!existsSync(this.#root)) return 0;
|
|
98
|
+
for (const nsDir of readdirSync(this.#root)) {
|
|
99
|
+
const nsPath = join(this.#root, nsDir);
|
|
100
|
+
let files: string[];
|
|
101
|
+
try { files = readdirSync(nsPath); } catch { continue; }
|
|
102
|
+
for (const f of files) {
|
|
103
|
+
const path = join(nsPath, f);
|
|
104
|
+
if (f.endsWith(".tmp")) {
|
|
105
|
+
// A crash mid-write: the rename never happened. Ours by
|
|
106
|
+
// construction (uuid names), safe to reap.
|
|
107
|
+
try { rmSync(path, { force: true }); } catch {}
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (!f.endsWith(".mlxkv")) continue;
|
|
111
|
+
try {
|
|
112
|
+
const h = readKvHeader(path); // verifies the header hash
|
|
113
|
+
if (
|
|
114
|
+
h.configFingerprint !== this.#opts.configFingerprint ||
|
|
115
|
+
h.tokenizerHash !== this.#opts.tokenizerHash
|
|
116
|
+
)
|
|
117
|
+
throw new Error("metadata mismatch");
|
|
118
|
+
const st = statSync(path);
|
|
119
|
+
this.#index.push({
|
|
120
|
+
path, ns: h.ns ?? "", tokens: h.tokens, bytes: st.size, mtimeMs: st.mtimeMs,
|
|
121
|
+
trimmable: headerTrimmable(h.caches),
|
|
122
|
+
});
|
|
123
|
+
} catch {
|
|
124
|
+
try { rmSync(path, { force: true }); } catch {}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return this.#index.length;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Best USABLE stored entry for `prompt` in `ns`: the longest common
|
|
132
|
+
* prefix (capped at prompt.length−1 — at least one token must forward).
|
|
133
|
+
* An entry LONGER than the matched prefix is returned only when it can
|
|
134
|
+
* actually seed the prompt: the divergent tail must be trimmable after
|
|
135
|
+
* restore. Untrimmable entries (ring post-wrap, SSM) that would need a
|
|
136
|
+
* trim are SKIPPED here — before this gate, the big [prompt+gen] file
|
|
137
|
+
* always outranked the usable boundary snapshot, got restored, and was
|
|
138
|
+
* thrown away by take()'s backstop (the 2026-07-06 restart-0 + wasted
|
|
139
|
+
* 84 s restore-then-re-prefill path). Pure index lookup; no I/O. */
|
|
140
|
+
find(prompt: number[], ns = ""): { entry: SsdIndexEntry; prefixLen: number } | null {
|
|
141
|
+
let best: SsdIndexEntry | null = null;
|
|
142
|
+
let bestLen = 0;
|
|
143
|
+
for (const e of this.#index) {
|
|
144
|
+
if (e.ns !== ns) continue;
|
|
145
|
+
const p = Math.min(commonPrefixLength(e.tokens, prompt), prompt.length - 1);
|
|
146
|
+
if (p === 0 || p <= bestLen) continue;
|
|
147
|
+
if (e.tokens.length - p > 0 && !e.trimmable) continue;
|
|
148
|
+
bestLen = p;
|
|
149
|
+
best = e;
|
|
150
|
+
}
|
|
151
|
+
return best ? { entry: best, prefixLen: bestLen } : null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Restore an entry via kv-store's STREAMED COPY (bounded host
|
|
155
|
+
* transient — live entry + one tensor; no mapping survives the call,
|
|
156
|
+
* the caches own their bytes). Bumps LRU mtime. On ANY failure the
|
|
157
|
+
* file is dropped and null returned. */
|
|
158
|
+
restore(entry: SsdIndexEntry, model: { makeCache(): Cache[] }): LoadedKvCache | null {
|
|
159
|
+
const t0 = performance.now();
|
|
160
|
+
try {
|
|
161
|
+
const expect: KvLoadExpect = {
|
|
162
|
+
configFingerprint: this.#opts.configFingerprint,
|
|
163
|
+
tokenizerHash: this.#opts.tokenizerHash,
|
|
164
|
+
ns: entry.ns,
|
|
165
|
+
verify: this.#opts.verify,
|
|
166
|
+
};
|
|
167
|
+
const loaded = loadKvCache(entry.path, model, expect);
|
|
168
|
+
const now = new Date();
|
|
169
|
+
try { utimesSync(entry.path, now, now); entry.mtimeMs = now.getTime(); } catch {}
|
|
170
|
+
this.stats.restores++;
|
|
171
|
+
this.stats.restoreMsLast = performance.now() - t0;
|
|
172
|
+
return loaded;
|
|
173
|
+
} catch (err) {
|
|
174
|
+
// Loud: a failed restore silently degraded to a full re-prefill for
|
|
175
|
+
// a month before the 2026-07-06 bench made it observable.
|
|
176
|
+
console.warn(`[ssd-cache] restore failed, dropping ${entry.path}: ${(err as Error).message}`);
|
|
177
|
+
this.remove(entry.path);
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Persist an entry. Synchronous (the tier calls it on the idle serial
|
|
183
|
+
* lane between requests); atomic via kv-store's tmp+fsync+rename. An
|
|
184
|
+
* entry bigger than the cap is refused; disk/write failure is a warn-once
|
|
185
|
+
* soft-fail — serving never stalls on the cold tier. Existing entries in
|
|
186
|
+
* the same ns whose tokens are a PREFIX of the new entry are superseded
|
|
187
|
+
* (the agent-conversation pattern: one growing entry, not N generations
|
|
188
|
+
* of it). Returns true if stored. */
|
|
189
|
+
store(tokens: number[], caches: Cache[], ns = ""): boolean {
|
|
190
|
+
const dir = join(this.#root, nsHash(ns));
|
|
191
|
+
const path = join(dir, `${randomUUID()}.mlxkv`);
|
|
192
|
+
try {
|
|
193
|
+
mkdirSync(dir, { recursive: true });
|
|
194
|
+
saveKvCache(path, tokens, caches, this.#meta(ns));
|
|
195
|
+
return this.#indexStored(path, tokens, caches, ns);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
return this.#storeFailed(path, err);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Non-blocking store (the write-behind persistence path): same file,
|
|
202
|
+
* index, and supersede semantics as store(), but the tensor flush
|
|
203
|
+
* yields the event loop between tensors so serving interleaves.
|
|
204
|
+
* Caller passes zero-copy CLONES it owns (a consistent snapshot no
|
|
205
|
+
* matter what the live entry does meanwhile) and disposes them after.
|
|
206
|
+
* `waitTurn` gates every per-tensor step (see saveKvCacheAsync) — the
|
|
207
|
+
* server passes the gateway's onIdle so the flush only progresses while
|
|
208
|
+
* the engine is idle, never mid-decode. */
|
|
209
|
+
async storeAsync(tokens: number[], caches: Cache[], ns = "", waitTurn?: () => Promise<void>): Promise<boolean> {
|
|
210
|
+
const dir = join(this.#root, nsHash(ns));
|
|
211
|
+
const path = join(dir, `${randomUUID()}.mlxkv`);
|
|
212
|
+
try {
|
|
213
|
+
mkdirSync(dir, { recursive: true });
|
|
214
|
+
await saveKvCacheAsync(path, tokens, caches, this.#meta(ns), waitTurn);
|
|
215
|
+
return this.#indexStored(path, tokens, caches, ns);
|
|
216
|
+
} catch (err) {
|
|
217
|
+
return this.#storeFailed(path, err);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
#meta(ns: string): KvSaveMeta {
|
|
222
|
+
return {
|
|
223
|
+
modelId: this.#opts.modelId,
|
|
224
|
+
configFingerprint: this.#opts.configFingerprint,
|
|
225
|
+
tokenizerHash: this.#opts.tokenizerHash,
|
|
226
|
+
ns,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
#indexStored(path: string, tokens: number[], caches: Cache[], ns: string): boolean {
|
|
231
|
+
const st = statSync(path);
|
|
232
|
+
if (st.size > this.#opts.maxBytes) {
|
|
233
|
+
rmSync(path, { force: true });
|
|
234
|
+
console.warn(`[ssd-cache] entry not stored: ${st.size} bytes exceeds the ${this.#opts.maxBytes}-byte cap`);
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
const trimmable = caches.every((c) => c.isTrimmable());
|
|
238
|
+
// Exact duplicates (same tokens) are replaced regardless of
|
|
239
|
+
// trimmability — the new file serves exactly the old one's matches.
|
|
240
|
+
for (const e of [...this.#index]) {
|
|
241
|
+
if (e.ns !== ns || e.tokens.length !== tokens.length) continue;
|
|
242
|
+
if (commonPrefixLength(e.tokens, tokens) === tokens.length) this.remove(e.path);
|
|
243
|
+
}
|
|
244
|
+
// Supersede shadowed ancestors of this prefix (same ns) — ONLY when
|
|
245
|
+
// the new entry can serve their prefixes (all caches trimmable; the
|
|
246
|
+
// same guard as PromptCache.put()). An untrimmable [prompt+gen]
|
|
247
|
+
// entry deleting the boundary snapshot was the gemma restart-0
|
|
248
|
+
// defect (2026-07-06): the only restorable file died the moment the
|
|
249
|
+
// unrestorable one landed.
|
|
250
|
+
if (trimmable) {
|
|
251
|
+
for (const e of [...this.#index]) {
|
|
252
|
+
if (e.ns !== ns || e.tokens.length >= tokens.length) continue;
|
|
253
|
+
if (commonPrefixLength(e.tokens, tokens) === e.tokens.length) this.remove(e.path);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
this.#index.push({ path, ns, tokens, bytes: st.size, mtimeMs: Date.now(), trimmable });
|
|
257
|
+
this.stats.spills++;
|
|
258
|
+
this.evictToCap();
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
#storeFailed(path: string, err: unknown): boolean {
|
|
263
|
+
try { rmSync(path, { force: true }); rmSync(`${path}.tmp`, { force: true }); } catch {}
|
|
264
|
+
if (!this.#warnedWriteFailure) {
|
|
265
|
+
this.#warnedWriteFailure = true;
|
|
266
|
+
console.warn(`[ssd-cache] store failed (disk full or unwritable?) — cold tier disabled for this entry: ${(err as Error).message}`);
|
|
267
|
+
}
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Enforce the byte cap: unlink oldest-mtime entries until under it. */
|
|
272
|
+
evictToCap(): void {
|
|
273
|
+
while (this.totalBytes > this.#opts.maxBytes && this.#index.length > 0) {
|
|
274
|
+
let oldest = 0;
|
|
275
|
+
for (let i = 1; i < this.#index.length; i++)
|
|
276
|
+
if (this.#index[i]!.mtimeMs < this.#index[oldest]!.mtimeMs) oldest = i;
|
|
277
|
+
this.remove(this.#index[oldest]!.path);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
remove(path: string): void {
|
|
282
|
+
try { rmSync(path, { force: true }); } catch {}
|
|
283
|
+
this.#index = this.#index.filter((e) => e.path !== path);
|
|
284
|
+
}
|
|
285
|
+
}
|