mlx-bun 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -32
- package/docs/README.md +58 -42
- package/docs/archive/investigations/benchmark-ledger-through-v0.4.0.md +3617 -0
- package/docs/archive/investigations/openjarvis-fit.md +198 -0
- package/docs/archive/investigations/qwen38-27b-campaign-log.md +1092 -0
- package/docs/archive/planning/release-notes-v0.4.0.md +45 -0
- package/docs/archive/planning/release-notes-v0.5.0.md +37 -0
- package/docs/design/batching.md +1535 -50
- package/docs/design/decode-speed-program.md +394 -1171
- package/docs/design/generic-model-support.md +195 -3
- package/docs/design/kv-cache.md +538 -225
- package/docs/design/speculative-decoding.md +552 -50
- package/docs/design/turboquant.md +140 -17
- package/docs/design/unified-engine-frontier-plan.md +259 -150
- package/docs/planning/PRODUCT_ROADMAP.md +21 -6
- package/docs/planning/public-readiness-handoff.md +244 -0
- package/docs/planning/public-readiness.md +149 -0
- package/docs/reference/benchmarks.md +3537 -1112
- package/docs/reference/cli.md +102 -2
- package/docs/reference/distribution.md +45 -13
- package/docs/reference/environment.md +159 -13
- package/docs/reference/library-api.md +18 -4
- package/docs/reference/memory.md +1 -1
- package/docs/reference/models.md +28 -7
- package/docs/reference/server-api.md +257 -33
- package/docs/reference/server-config.md +372 -151
- package/docs/reference/troubleshooting.md +13 -21
- package/package.json +2 -2
- package/src/audio/audiotoolbox.ts +112 -0
- package/src/audio/conformer.ts +8 -0
- package/src/audio/mic-capture.ts +98 -0
- package/src/audio/silero-vad.ts +379 -0
- package/src/audio/whisper-decode.ts +774 -0
- package/src/audio/whisper-fast.ts +732 -0
- package/src/audio/whisper-format.ts +73 -0
- package/src/audio/whisper-mel.ts +185 -0
- package/src/audio/whisper-timing.ts +274 -0
- package/src/audio/whisper-tokenizer.ts +239 -0
- package/src/audio/whisper-transcribe.ts +410 -0
- package/src/backends/mlx/assistant-target.ts +46 -0
- package/src/backends/mlx/autoregressive-draft-rows.ts +120 -0
- package/src/backends/mlx/autoregressive.ts +22 -1
- package/src/backends/mlx/batch-group.ts +464 -236
- package/src/backends/mlx/cache-attachment.ts +67 -0
- package/src/backends/mlx/cache-layout.ts +31 -0
- package/src/backends/mlx/checkpoint-state.ts +53 -0
- package/src/backends/mlx/continuation-persistence.ts +107 -0
- package/src/backends/mlx/continuation-request.ts +43 -0
- package/src/backends/mlx/continuation.ts +77 -0
- package/src/backends/mlx/draft-target.ts +7 -22
- package/src/backends/mlx/encoder-cache.ts +25 -0
- package/src/backends/mlx/fill-append.ts +21 -8
- package/src/backends/mlx/fill-group.ts +497 -0
- package/src/backends/mlx/full-kv-row-append.ts +32 -0
- package/src/backends/mlx/full-kv-row-donor.ts +54 -0
- package/src/backends/mlx/full-transitioning-kv-rows.ts +65 -0
- package/src/backends/mlx/gateway-binding.ts +122 -19
- package/src/backends/mlx/kv-codec.ts +9 -0
- package/src/backends/mlx/kv-maintenance.ts +161 -0
- package/src/backends/mlx/kv-tensor-rows.ts +212 -0
- package/src/backends/mlx/mixed-iteration.ts +62 -0
- package/src/backends/mlx/model-prompt.ts +46 -32
- package/src/backends/mlx/model-serving.ts +5 -4
- package/src/backends/mlx/paged-cache-codec.ts +27 -0
- package/src/backends/mlx/prefill-cohort.ts +100 -0
- package/src/backends/mlx/prefill-policy.ts +26 -0
- package/src/backends/mlx/prefill-rows.ts +244 -0
- package/src/backends/mlx/prefill.ts +1 -1
- package/src/backends/mlx/projected-context-rows.ts +72 -0
- package/src/backends/mlx/prompt-input.ts +29 -0
- package/src/backends/mlx/qwen-prompt-input.ts +40 -0
- package/src/backends/mlx/request-state-policy.ts +54 -0
- package/src/backends/mlx/rollback.ts +45 -4
- package/src/backends/mlx/row-sampling.ts +49 -0
- package/src/backends/mlx/serial-executor.ts +51 -73
- package/src/backends/mlx/speculative-group.ts +438 -0
- package/src/backends/mlx/speculative-round.ts +161 -0
- package/src/backends/mlx/speculative.ts +24 -7
- package/src/backends/mlx/state-rows.ts +104 -0
- package/src/backends/mlx/state-views.ts +21 -1
- package/src/backends/mlx/target-layout-capability.ts +35 -0
- package/src/backends/mlx/transitioning-kv-rows.ts +173 -0
- package/src/cli.ts +333 -36
- package/src/contracts/execution.ts +21 -0
- package/src/contracts/object-cache.ts +8 -0
- package/src/contracts/pi-provider.ts +1 -1
- package/src/contracts/pi.ts +3 -1
- package/src/contracts/prefix-cache.ts +33 -0
- package/src/contracts/resources.ts +7 -0
- package/src/contracts/sampling.ts +23 -0
- package/src/contracts/scheduling.ts +27 -0
- package/src/engine/execution-plan.ts +38 -25
- package/src/engine/execution-tasks.ts +44 -0
- package/src/engine/namespaced-cache.ts +13 -0
- package/src/engine/resources.ts +12 -1
- package/src/engine/scheduler.ts +32 -7
- package/src/fill/fill-session.ts +14 -3
- package/src/fit.ts +29 -15
- package/src/generate.ts +105 -195
- package/src/grammar.ts +13 -0
- package/src/inference/draft-acceptance.ts +41 -0
- package/src/inference/draft-output.ts +49 -0
- package/src/inference/prefill.ts +6 -0
- package/src/inference/rollback.ts +2 -2
- package/src/kv-scheme.ts +92 -12
- package/src/kv-store.ts +183 -93
- package/src/lab/README.md +5 -1
- package/src/lab/paged-kv/paged-attention.ts +117 -0
- package/src/lab/paged-kv/paged-kv-rows.ts +111 -0
- package/src/lab/paged-kv/paged-kv.ts +126 -46
- package/src/md-modules.d.ts +5 -0
- package/src/mlx/array.ts +39 -1
- package/src/mlx/ffi.ts +19 -0
- package/src/mlx/normalized-argmax.ts +116 -0
- package/src/mlx/ops.ts +97 -10
- package/src/model/artifact-identity.ts +73 -0
- package/src/model/batched-kv.ts +235 -0
- package/src/model/batched-mask.ts +12 -4
- package/src/model/batched-quant.ts +8 -2
- package/src/model/batched-quantized-kv.ts +144 -0
- package/src/model/batched-rotating-quant.ts +76 -15
- package/src/model/batched-rotating-state.ts +76 -0
- package/src/model/batched-rotating.ts +64 -22
- package/src/model/batched-row-storage.ts +52 -0
- package/src/model/batched-ssm.ts +52 -0
- package/src/model/batched-turboquant-kv.ts +94 -0
- package/src/model/compiled-decode.ts +7 -1
- package/src/model/decoded-kv-donor.ts +12 -0
- package/src/model/delayed-quantized-kv.ts +72 -0
- package/src/model/delayed-rotating-quantized-kv.ts +93 -0
- package/src/model/delayed-turboquant-kv.ts +57 -0
- package/src/model/factory.ts +26 -0
- package/src/model/fingerprint.ts +12 -0
- package/src/model/full-prefill-padding.ts +56 -0
- package/src/model/full-prefill-row.ts +105 -0
- package/src/model/gemma4-base.ts +183 -82
- package/src/model/gemma4.ts +116 -28
- package/src/model/generated/gemma4-12b.ts +4 -1
- package/src/model/generated/gemma4-26b.ts +4 -1
- package/src/model/generated/gemma4-e4b.ts +4 -1
- package/src/model/glm52-cache.ts +80 -4
- package/src/model/glm52-mla.ts +12 -6
- package/src/model/glm52.ts +19 -40
- package/src/model/kv-attention-view.ts +70 -0
- package/src/model/minicpm5.ts +11 -7
- package/src/model/profile.ts +29 -4
- package/src/model/quantized-append-attention.ts +31 -0
- package/src/model/qwen3-delta-state.ts +74 -0
- package/src/model/qwen3-delta.ts +59 -36
- package/src/model/qwen3-mrope.ts +7 -9
- package/src/model/qwen3_5.ts +177 -75
- package/src/model/rotating-kv-donor.ts +35 -0
- package/src/model/rotating-kv-layout.ts +262 -0
- package/src/model/rotating-row-transaction.ts +35 -0
- package/src/model/speculative-rotating-kv.ts +101 -0
- package/src/model/ssm-prefill-padding.ts +39 -0
- package/src/model/support.ts +8 -0
- package/src/model/token-groups.ts +66 -0
- package/src/model/trellis-linear.ts +4 -4
- package/src/model/turboquant-codec.ts +89 -0
- package/src/model/universal/dense.ts +14 -13
- package/src/model/whisper.ts +435 -0
- package/src/native/mic_capture.swift +92 -0
- package/src/native-pack.ts +6 -5
- package/src/pi-web.ts +9 -1
- package/src/preflight.ts +31 -15
- package/src/prompt-cache.ts +306 -86
- package/src/runtime-config.ts +1 -5
- package/src/safetensors.ts +9 -2
- package/src/sampler.ts +37 -25
- package/src/serve/audio-routes.ts +323 -0
- package/src/serve/chat-request.ts +2 -0
- package/src/serve/chat-stage.ts +19 -14
- package/src/serve/completion-executor.ts +9 -1
- package/src/serve/discovery-routes.ts +17 -1
- package/src/serve/generated-token-history.ts +57 -0
- package/src/serve/generation-gateway.ts +88 -38
- package/src/serve/model-binding.ts +7 -1
- package/src/serve/model-host.ts +3 -4
- package/src/serve/preparation.ts +3 -3
- package/src/serve/prompt-response-trace.ts +14 -0
- package/src/serve/request-plan.ts +6 -3
- package/src/serve/request-prep.ts +40 -25
- package/src/serve/text-completion-stage.ts +2 -2
- package/src/serve/transcription-server.ts +64 -0
- package/src/serve/transcription-service.ts +502 -0
- package/src/server.ts +185 -261
- package/src/spec/assistant-rows.ts +93 -0
- package/src/spec/assistant-source.ts +28 -18
- package/src/spec/cache-identity.ts +22 -0
- package/src/spec/deepspec-rows.ts +29 -0
- package/src/spec/deepspec-source.ts +12 -10
- package/src/spec/dflash-source.ts +28 -4
- package/src/spec/drafter.ts +153 -142
- package/src/spec/dspark/deepspec-module.ts +95 -120
- package/src/spec/dspark/module-dflash.ts +126 -170
- package/src/spec/glm52-mtp-graph.ts +29 -0
- package/src/spec/glm52-mtp-rows.ts +132 -0
- package/src/spec/glm52-mtp-source.ts +54 -136
- package/src/spec/glm52-mtp-state.ts +33 -0
- package/src/spec/ngram-source.ts +115 -21
- package/src/spec/projected-draft-rows.ts +73 -0
- package/src/spec/qwen-mtp-module.ts +171 -0
- package/src/spec/qwen-mtp-rows.ts +230 -0
- package/src/spec/qwen-mtp-source.ts +84 -226
- package/src/spec/qwen-mtp-state.ts +30 -0
- package/src/spec/serve-loop.ts +73 -69
- package/src/spec/source.ts +93 -15
- package/src/spec/two-model-rows.ts +62 -0
- package/src/spec/two-model.ts +45 -103
- package/src/ssd-cache.ts +107 -36
- package/src/ssd-durability.ts +29 -27
- package/src/storage/host-buffer.ts +24 -0
- package/src/storage/kv-writer.ts +83 -0
- package/src/storage/kv-writer.worker.js +264 -0
- package/src/storage/retention-policy.ts +37 -0
- package/src/tiered-prompt-cache.ts +45 -0
- package/src/vision/embedder.ts +8 -0
- package/src/vision/prompt.ts +101 -65
- package/src/vision/qwen3vl-prompt.ts +101 -64
- package/src/vision/qwen3vl-tower.ts +11 -0
- package/src/vision/siglip.ts +8 -0
- package/src/web/app.html +12 -0
- package/src/web/app.js +217 -57
- package/src/web/src/chat.ts +4 -1
- package/src/web/src/composer.ts +3 -0
- package/src/web/src/voice.ts +141 -0
- package/src/web/sw.js +1 -1
- package/src/backends/mlx/memory-guard.ts +0 -40
- package/src/spec/prefix-state.ts +0 -45
- /package/docs/{planning → archive/planning}/release-notes-v0.3.0.md +0 -0
package/src/spec/two-model.ts
CHANGED
|
@@ -16,13 +16,15 @@
|
|
|
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";
|
|
23
|
-
import type {
|
|
24
|
-
import {
|
|
25
|
-
import
|
|
23
|
+
import type { DraftProvider, DraftSource, GroupedDraftProvider } from "./source";
|
|
24
|
+
import { standaloneDraftGroups, openStandaloneDraftRows } from "./two-model-rows";
|
|
25
|
+
import { bindLegacyAutoregressiveModel } from "../backends/mlx/autoregressive";
|
|
26
|
+
import { artifactIdentity } from "../model/artifact-identity";
|
|
27
|
+
import { configFingerprint } from "../model/fingerprint";
|
|
26
28
|
|
|
27
29
|
/** mlx-lm's prefill_step_size — the draft drain chunks at the same stride
|
|
28
30
|
* as the target's (serve-loop.ts); chunking is numerically exact for
|
|
@@ -32,13 +34,16 @@ const PREFILL_CHUNK = 2048;
|
|
|
32
34
|
export class TwoModelProvider implements DraftProvider {
|
|
33
35
|
readonly id: string;
|
|
34
36
|
readonly weightsBytes: number;
|
|
37
|
+
readonly grouped: GroupedDraftProvider;
|
|
35
38
|
|
|
36
39
|
private constructor(
|
|
37
40
|
readonly model: RuntimeModel,
|
|
38
41
|
private readonly weights: Weights,
|
|
39
42
|
id: string,
|
|
43
|
+
private readonly namespace: string,
|
|
40
44
|
) {
|
|
41
45
|
this.id = id;
|
|
46
|
+
this.grouped = standaloneDraftGroups(bindLegacyAutoregressiveModel(model), namespace);
|
|
42
47
|
this.weightsBytes = [...weights.shards.files.values()].reduce(
|
|
43
48
|
(a, f) => a + f.mmap.size,
|
|
44
49
|
0,
|
|
@@ -51,20 +56,25 @@ export class TwoModelProvider implements DraftProvider {
|
|
|
51
56
|
static async load(modelDir: string, targetVocabSize?: number): Promise<TwoModelProvider> {
|
|
52
57
|
const config = await loadModelConfig(modelDir);
|
|
53
58
|
const weights = await Weights.open(modelDir);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
`
|
|
62
|
-
|
|
63
|
-
|
|
59
|
+
try {
|
|
60
|
+
const model = createModel(weights, config);
|
|
61
|
+
if (
|
|
62
|
+
targetVocabSize !== undefined &&
|
|
63
|
+
config.text.vocabSize !== targetVocabSize
|
|
64
|
+
)
|
|
65
|
+
console.warn(
|
|
66
|
+
`draft model vocab size ${config.text.vocabSize} != target ${targetVocabSize} — ` +
|
|
67
|
+
`speculation will accept nothing useful (mlx-lm warns identically)`,
|
|
68
|
+
);
|
|
69
|
+
const identity = await artifactIdentity(configFingerprint(config),
|
|
70
|
+
[...weights.shards.files].map(([name, shard]) => ({ name, path: shard.path })));
|
|
71
|
+
return new TwoModelProvider(model, weights, modelDir.split("/").filter(Boolean).at(-1)!,
|
|
72
|
+
`standalone-draft-v1:${identity}`);
|
|
73
|
+
} catch (error) { weights.dispose(); throw error; }
|
|
64
74
|
}
|
|
65
75
|
|
|
66
76
|
open(opts: Parameters<DraftProvider["open"]>[0]): DraftSource {
|
|
67
|
-
return new TwoModelSource(this.model, opts.sampler);
|
|
77
|
+
return new TwoModelSource(this.model, opts.sampler, this.namespace);
|
|
68
78
|
}
|
|
69
79
|
|
|
70
80
|
dispose(): void {
|
|
@@ -72,100 +82,32 @@ export class TwoModelProvider implements DraftProvider {
|
|
|
72
82
|
}
|
|
73
83
|
}
|
|
74
84
|
|
|
85
|
+
/** Compatibility adapter: the existing request loop also uses the shared
|
|
86
|
+
* draft graph at B=1. Proposal generation and state recovery have one owner. */
|
|
75
87
|
class TwoModelSource implements DraftSource {
|
|
76
|
-
readonly weightsBytes = 0;
|
|
77
|
-
private
|
|
78
|
-
|
|
79
|
-
constructor(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
) {
|
|
83
|
-
this.caches = this.model.makeCache();
|
|
88
|
+
readonly weightsBytes = 0;
|
|
89
|
+
private readonly rows: ReturnType<typeof openStandaloneDraftRows>;
|
|
90
|
+
private processed = 0;
|
|
91
|
+
constructor(model: RuntimeModel, sampler: (lp: MlxArray, step: number) => MlxArray, namespace: string) {
|
|
92
|
+
this.rows = openStandaloneDraftRows(bindLegacyAutoregressiveModel(model), namespace,
|
|
93
|
+
{ sample: (logprobs, steps) => sampler(logprobs, steps[0]!) }, [null]);
|
|
84
94
|
}
|
|
85
|
-
|
|
86
|
-
|
|
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);
|
|
95
|
+
async prefill(promptIds: number[]): Promise<void> {
|
|
96
|
+
const tailSplit = runtimeFlag("MLX_BUN_PREFILL_TAIL_SPLIT", true);
|
|
102
97
|
const upTo = tailSplit && promptIds.length > 1 ? promptIds.length - 1 : promptIds.length;
|
|
103
|
-
for (let at =
|
|
98
|
+
for (let at = this.processed; at < upTo; at += PREFILL_CHUNK) {
|
|
104
99
|
const end = Math.min(at + PREFILL_CHUNK, upTo);
|
|
105
|
-
|
|
106
|
-
|
|
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];
|
|
100
|
+
using ids = ops.fromInt32(promptIds.slice(at, end), [1, end - at]);
|
|
101
|
+
await this.rows.prefill(ids); clearCache();
|
|
143
102
|
}
|
|
144
|
-
|
|
103
|
+
this.processed = upTo;
|
|
145
104
|
}
|
|
146
|
-
|
|
147
|
-
|
|
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();
|
|
105
|
+
async draft(feed: number[], count: number, step: number): Promise<number[]> {
|
|
106
|
+
return (await this.rows.draft([feed.at(-1)!], count, [step]))[0]!;
|
|
166
107
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
108
|
+
async commit(_drafted: number, accepted: number): Promise<void> {
|
|
109
|
+
await this.rows.commit([accepted]);
|
|
110
|
+
this.processed += accepted + 1;
|
|
170
111
|
}
|
|
112
|
+
dispose(): void { this.rows.dispose(); }
|
|
171
113
|
}
|
package/src/ssd-cache.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CheckpointAttachment } from "./backends/mlx/checkpoint-state";
|
|
1
2
|
// SSD cold tier for the prompt/KV cache (docs/design/kv-cache.md).
|
|
2
3
|
//
|
|
3
4
|
// Files ARE the database: no sidecar index. Layout
|
|
@@ -17,17 +18,20 @@
|
|
|
17
18
|
import {
|
|
18
19
|
existsSync, mkdirSync, readdirSync, rmSync, statSync, utimesSync,
|
|
19
20
|
} from "node:fs";
|
|
20
|
-
import {
|
|
21
|
+
import { kvWriter, type KvWriteRequest } from "./storage/kv-writer";
|
|
22
|
+
import { join, dirname } from "node:path";
|
|
21
23
|
import { randomUUID } from "node:crypto";
|
|
22
24
|
import { commonPrefixLength } from "./prompt-cache";
|
|
23
25
|
import {
|
|
24
|
-
saveKvCache, saveKvCacheAsync, loadKvCache, readKvHeader,
|
|
25
|
-
cacheHeadersTrimmable, legacyCacheCodecs, type CacheCodecProvider,
|
|
26
|
+
saveKvCache, saveKvCacheAsync, loadKvCache, loadKvCacheAsync, readKvHeader,
|
|
27
|
+
cacheHeadersTrimmable, cacheHeadersMinimumReusableOffset, legacyCacheCodecs, type CacheCodecProvider,
|
|
26
28
|
type KvSaveMeta, type KvLoadExpect, type LoadedKvCache,
|
|
27
29
|
} from "./kv-store";
|
|
28
30
|
import type { Cache } from "./model/gemma4-base";
|
|
31
|
+
import { minimumReusableOffset } from "./backends/mlx/state-views";
|
|
29
32
|
|
|
30
33
|
export interface SsdIndexEntry {
|
|
34
|
+
blocks?: Array<{ hash: string; bytes: number }>;
|
|
31
35
|
path: string;
|
|
32
36
|
ns: string;
|
|
33
37
|
tokens: number[];
|
|
@@ -39,10 +43,12 @@ export interface SsdIndexEntry {
|
|
|
39
43
|
* over a shorter usable one (the 2026-07-06 restart-0 defect: the
|
|
40
44
|
* [prompt+gen] file always won, always got rejected after restore). */
|
|
41
45
|
trimmable: boolean;
|
|
46
|
+
minimumReusableOffset?: number;
|
|
42
47
|
generationCheckpoint?: NonNullable<KvSaveMeta["generationCheckpoint"]>;
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
export interface SsdStoreOptions {
|
|
51
|
+
storage?: Pick<KvWriteRequest, "layout" | "blockBytes" | "segmented">;
|
|
46
52
|
codecs?: CacheCodecProvider;
|
|
47
53
|
dir: string;
|
|
48
54
|
maxBytes: number;
|
|
@@ -62,6 +68,27 @@ export class SsdCacheStore {
|
|
|
62
68
|
readonly #codecs: CacheCodecProvider;
|
|
63
69
|
readonly #root: string; // <dir>/<configFingerprint>
|
|
64
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
|
+
|
|
65
92
|
#warnedWriteFailure = false;
|
|
66
93
|
stats = { restores: 0, spills: 0, restoreMsLast: 0 };
|
|
67
94
|
|
|
@@ -75,8 +102,16 @@ export class SsdCacheStore {
|
|
|
75
102
|
return this.#index.length;
|
|
76
103
|
}
|
|
77
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
|
+
|
|
78
110
|
get totalBytes(): number {
|
|
79
|
-
|
|
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);
|
|
80
115
|
}
|
|
81
116
|
|
|
82
117
|
get maxBytes(): number {
|
|
@@ -92,7 +127,7 @@ export class SsdCacheStore {
|
|
|
92
127
|
* trimmable descendant is equivalent to an exact boundary snapshot. */
|
|
93
128
|
hasDurablePrefix(tokens: number[], ns = ""): boolean {
|
|
94
129
|
return this.#index.some((entry) => {
|
|
95
|
-
if (entry.ns !== ns || entry.tokens.length < tokens.length) return false;
|
|
130
|
+
if (entry.ns !== ns || entry.tokens.length < tokens.length || tokens.length < (entry.minimumReusableOffset ?? 0)) return false;
|
|
96
131
|
if (commonPrefixLength(entry.tokens, tokens) !== tokens.length) return false;
|
|
97
132
|
return entry.tokens.length === tokens.length || entry.trimmable;
|
|
98
133
|
});
|
|
@@ -102,7 +137,7 @@ export class SsdCacheStore {
|
|
|
102
137
|
* headers and metadata mismatches are unlinked; `.tmp` orphans reaped.
|
|
103
138
|
* Only OUR fingerprint dir is touched. Returns entries indexed. */
|
|
104
139
|
scan(): number {
|
|
105
|
-
this.#index = [];
|
|
140
|
+
this.#index = []; this.#exact.clear();
|
|
106
141
|
if (!existsSync(this.#root)) return 0;
|
|
107
142
|
for (const nsDir of readdirSync(this.#root)) {
|
|
108
143
|
const nsPath = join(this.#root, nsDir);
|
|
@@ -127,9 +162,11 @@ export class SsdCacheStore {
|
|
|
127
162
|
throw new Error("metadata mismatch");
|
|
128
163
|
if ((h.codecProvider ?? legacyCacheCodecs.id) !== this.#codecs.id) continue;
|
|
129
164
|
const st = statSync(path);
|
|
130
|
-
this.#
|
|
165
|
+
this.#addIndex({
|
|
166
|
+
blocks: [...h.caches, ...(h.attachments ?? [])].flatMap(e => e.tensors.flatMap(t => t.blocks ?? [])),
|
|
131
167
|
path, ns: h.ns ?? "", tokens: h.tokens, bytes: st.size, mtimeMs: st.mtimeMs,
|
|
132
|
-
trimmable: cacheHeadersTrimmable(h.caches, this.#codecs),
|
|
168
|
+
trimmable: !h.attachments?.length && cacheHeadersTrimmable(h.caches, this.#codecs),
|
|
169
|
+
minimumReusableOffset: cacheHeadersMinimumReusableOffset(h.caches),
|
|
133
170
|
...(h.generationCheckpoint
|
|
134
171
|
? { generationCheckpoint: h.generationCheckpoint }
|
|
135
172
|
: {}),
|
|
@@ -139,6 +176,7 @@ export class SsdCacheStore {
|
|
|
139
176
|
}
|
|
140
177
|
}
|
|
141
178
|
}
|
|
179
|
+
void kvWriter.collect(this.#root).catch(() => {});
|
|
142
180
|
return this.#index.length;
|
|
143
181
|
}
|
|
144
182
|
|
|
@@ -157,7 +195,7 @@ export class SsdCacheStore {
|
|
|
157
195
|
for (const e of this.#index) {
|
|
158
196
|
if (e.ns !== ns) continue;
|
|
159
197
|
const p = Math.min(commonPrefixLength(e.tokens, prompt), prompt.length - 1);
|
|
160
|
-
if (p === 0 || p <= bestLen) continue;
|
|
198
|
+
if (p === 0 || p <= bestLen || p < (e.minimumReusableOffset ?? 0)) continue;
|
|
161
199
|
if (e.tokens.length - p > 0 && !e.trimmable) continue;
|
|
162
200
|
bestLen = p;
|
|
163
201
|
best = e;
|
|
@@ -194,6 +232,23 @@ export class SsdCacheStore {
|
|
|
194
232
|
}
|
|
195
233
|
}
|
|
196
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
|
+
|
|
197
252
|
/** Persist an entry. Synchronous (the tier calls it on the idle serial
|
|
198
253
|
* lane between requests); atomic via kv-store's tmp+fsync+rename. An
|
|
199
254
|
* entry bigger than the cap is refused; disk/write failure is a warn-once
|
|
@@ -201,13 +256,13 @@ export class SsdCacheStore {
|
|
|
201
256
|
* the same ns whose tokens are a PREFIX of the new entry are superseded
|
|
202
257
|
* (the agent-conversation pattern: one growing entry, not N generations
|
|
203
258
|
* of it). Returns true if stored. */
|
|
204
|
-
store(tokens: number[], caches: Cache[], ns = ""): boolean {
|
|
259
|
+
store(tokens: number[], caches: Cache[], ns = "", attachments?: CheckpointAttachment[]): boolean {
|
|
205
260
|
const dir = join(this.#root, nsHash(ns));
|
|
206
261
|
const path = join(dir, `${randomUUID()}.mlxkv`);
|
|
207
262
|
try {
|
|
208
263
|
mkdirSync(dir, { recursive: true });
|
|
209
|
-
saveKvCache(path, tokens, caches, this.#meta(ns), this.#codecs);
|
|
210
|
-
return this.#indexStored(path, tokens, caches, ns);
|
|
264
|
+
saveKvCache(path, tokens, caches, { ...this.#meta(ns), attachments }, this.#codecs);
|
|
265
|
+
return this.#indexStored(path, tokens, caches, ns, undefined, attachments);
|
|
211
266
|
} catch (err) {
|
|
212
267
|
return this.#storeFailed(path, err);
|
|
213
268
|
}
|
|
@@ -218,19 +273,18 @@ export class SsdCacheStore {
|
|
|
218
273
|
* yields the event loop between tensors so serving interleaves.
|
|
219
274
|
* Caller passes zero-copy CLONES it owns (a consistent snapshot no
|
|
220
275
|
* matter what the live entry does meanwhile) and disposes them after.
|
|
221
|
-
* `runStep`
|
|
222
|
-
*
|
|
223
|
-
* 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. */
|
|
224
278
|
async storeAsync(
|
|
225
279
|
tokens: number[], caches: Cache[], ns = "",
|
|
226
280
|
runStep?: <T>(step: () => T) => Promise<T>,
|
|
281
|
+
attachments?: CheckpointAttachment[],
|
|
227
282
|
): Promise<boolean> {
|
|
228
283
|
const dir = join(this.#root, nsHash(ns));
|
|
229
284
|
const path = join(dir, `${randomUUID()}.mlxkv`);
|
|
230
285
|
try {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
return this.#indexStored(path, tokens, caches, ns);
|
|
286
|
+
await saveKvCacheAsync(path, tokens, caches, { ...this.#meta(ns), attachments }, runStep, this.#codecs, this.#opts.storage);
|
|
287
|
+
return this.#indexStored(path, tokens, caches, ns, undefined, attachments);
|
|
234
288
|
} catch (err) {
|
|
235
289
|
return this.#storeFailed(path, err);
|
|
236
290
|
}
|
|
@@ -260,25 +314,30 @@ export class SsdCacheStore {
|
|
|
260
314
|
* its rename succeeds, so a crash during a write leaves the prior point. */
|
|
261
315
|
async storeGenerationCheckpoint(
|
|
262
316
|
tokens: number[], caches: Cache[], checkpoint: NonNullable<KvSaveMeta["generationCheckpoint"]>,
|
|
317
|
+
runStep?: <T>(step: () => T) => Promise<T>,
|
|
263
318
|
): Promise<boolean> {
|
|
264
319
|
const ns = `__generation_checkpoint__:${checkpoint.key}`;
|
|
265
320
|
const dir = join(this.#root, nsHash(ns));
|
|
266
321
|
const path = join(dir, `${randomUUID()}.mlxkv`);
|
|
267
322
|
try {
|
|
268
|
-
mkdirSync(dir, { recursive: true });
|
|
323
|
+
if (runStep) await runStep(() => mkdirSync(dir, { recursive: true }));
|
|
324
|
+
else mkdirSync(dir, { recursive: true });
|
|
269
325
|
await saveKvCacheAsync(path, tokens, caches, {
|
|
270
326
|
...this.#meta(ns), generationCheckpoint: checkpoint,
|
|
271
|
-
},
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
327
|
+
}, runStep, this.#codecs, this.#opts.storage);
|
|
328
|
+
const index = () => {
|
|
329
|
+
const stored = this.#indexStored(path, tokens, caches, ns, checkpoint);
|
|
330
|
+
if (stored) {
|
|
331
|
+
for (const entry of [...this.#index]) {
|
|
332
|
+
if (entry.path === path) continue;
|
|
333
|
+
if (entry.generationCheckpoint?.key === checkpoint.key) this.remove(entry.path);
|
|
334
|
+
}
|
|
277
335
|
}
|
|
278
|
-
|
|
279
|
-
|
|
336
|
+
return stored;
|
|
337
|
+
};
|
|
338
|
+
return runStep ? await runStep(index) : index();
|
|
280
339
|
} catch (err) {
|
|
281
|
-
return this.#storeFailed(path, err);
|
|
340
|
+
return runStep ? await runStep(() => this.#storeFailed(path, err)) : this.#storeFailed(path, err);
|
|
282
341
|
}
|
|
283
342
|
}
|
|
284
343
|
|
|
@@ -299,14 +358,19 @@ export class SsdCacheStore {
|
|
|
299
358
|
#indexStored(
|
|
300
359
|
path: string, tokens: number[], caches: Cache[], ns: string,
|
|
301
360
|
generationCheckpoint?: NonNullable<KvSaveMeta["generationCheckpoint"]>,
|
|
361
|
+
attachments?: CheckpointAttachment[],
|
|
302
362
|
): boolean {
|
|
303
363
|
const st = statSync(path);
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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`);
|
|
307
371
|
return false;
|
|
308
372
|
}
|
|
309
|
-
const trimmable = caches.every((c) => c.isTrimmable());
|
|
373
|
+
const trimmable = !attachments?.length && caches.every((c) => c.isTrimmable());
|
|
310
374
|
// Exact duplicates (same tokens) are replaced regardless of
|
|
311
375
|
// trimmability — the new file serves exactly the old one's matches.
|
|
312
376
|
for (const e of [...this.#index]) {
|
|
@@ -321,12 +385,13 @@ export class SsdCacheStore {
|
|
|
321
385
|
// unrestorable one landed.
|
|
322
386
|
if (trimmable) {
|
|
323
387
|
for (const e of [...this.#index]) {
|
|
324
|
-
if (e.ns !== ns || e.tokens.length >= tokens.length) continue;
|
|
388
|
+
if (e.ns !== ns || e.tokens.length >= tokens.length || e.tokens.length < minimumReusableOffset(caches)) continue;
|
|
325
389
|
if (commonPrefixLength(e.tokens, tokens) === e.tokens.length) this.remove(e.path);
|
|
326
390
|
}
|
|
327
391
|
}
|
|
328
|
-
this.#
|
|
329
|
-
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,
|
|
394
|
+
minimumReusableOffset: minimumReusableOffset(caches),
|
|
330
395
|
...(generationCheckpoint ? { generationCheckpoint } : {}),
|
|
331
396
|
});
|
|
332
397
|
this.stats.spills++;
|
|
@@ -335,7 +400,8 @@ export class SsdCacheStore {
|
|
|
335
400
|
}
|
|
336
401
|
|
|
337
402
|
#storeFailed(path: string, err: unknown): boolean {
|
|
338
|
-
|
|
403
|
+
this.remove(path);
|
|
404
|
+
try { rmSync(`${path}.tmp`, { force: true }); } catch {}
|
|
339
405
|
if (!this.#warnedWriteFailure) {
|
|
340
406
|
this.#warnedWriteFailure = true;
|
|
341
407
|
console.warn(`[ssd-cache] store failed (disk full or unwritable?) — cold tier disabled for this entry: ${(err as Error).message}`);
|
|
@@ -355,6 +421,11 @@ export class SsdCacheStore {
|
|
|
355
421
|
|
|
356
422
|
remove(path: string): void {
|
|
357
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
|
+
}
|
|
358
428
|
this.#index = this.#index.filter((e) => e.path !== path);
|
|
429
|
+
void kvWriter.collect(this.#root).catch(() => {});
|
|
359
430
|
}
|
|
360
431
|
}
|
package/src/ssd-durability.ts
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
|
+
import { cloneAttachments } from "./backends/mlx/checkpoint-state";
|
|
2
|
+
import { cleanupFailure, disposeResources } from "./engine/resources";
|
|
1
3
|
import type { Cache } from "./model/gemma4";
|
|
2
4
|
import type { PromptCacheEntry } from "./prompt-cache";
|
|
3
5
|
import type { SpillItem, SpillQueue } from "./kv-store";
|
|
4
6
|
|
|
5
|
-
export interface DurabilityGateway {
|
|
6
|
-
readonly busy: boolean;
|
|
7
|
-
runExclusive<T>(fn: () => Promise<T>): Promise<T>;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
7
|
export interface DurabilityPromptCache {
|
|
11
8
|
findExact(tokens: number[], ns?: string): PromptCacheEntry | null;
|
|
12
9
|
}
|
|
@@ -48,21 +45,23 @@ export class SsdDurabilityCoordinator {
|
|
|
48
45
|
#flush: Promise<DurabilityFlushResult> | null = null;
|
|
49
46
|
|
|
50
47
|
constructor(
|
|
51
|
-
readonly gateway: DurabilityGateway,
|
|
52
48
|
readonly promptCache: DurabilityPromptCache,
|
|
53
49
|
readonly spillQueue: SpillQueue,
|
|
54
50
|
readonly cloneCaches: (caches: Cache[]) => Cache[],
|
|
55
51
|
readonly isAlreadyDurable: (tokens: number[], ns: string) => boolean = () => false,
|
|
56
|
-
readonly debounceMs =
|
|
57
|
-
readonly
|
|
52
|
+
readonly debounceMs = 0,
|
|
53
|
+
readonly retryMs = 5_000,
|
|
54
|
+
readonly onStored: () => void = () => {},
|
|
58
55
|
) {}
|
|
59
56
|
|
|
60
|
-
schedule(tokens: number[], ns = ""): void {
|
|
61
|
-
|
|
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;
|
|
62
60
|
// PromptCache can hold unrelated entries with the same namespace and
|
|
63
61
|
// length. Include the tokens so one conversation cannot cancel another
|
|
64
62
|
// conversation's pending durability record.
|
|
65
63
|
const key = `${ns.length}:${ns}:${tokens.join(",")}`;
|
|
64
|
+
if (!replace && this.#dirty.has(key)) return;
|
|
66
65
|
const rec: DirtySnapshot = { key, tokens: [...tokens], ns };
|
|
67
66
|
this.#dirty.set(key, rec);
|
|
68
67
|
this.#arm(key, this.debounceMs);
|
|
@@ -106,10 +105,6 @@ export class SsdDurabilityCoordinator {
|
|
|
106
105
|
|
|
107
106
|
const rec = this.#dirty.get(key);
|
|
108
107
|
if (!rec) return "stored";
|
|
109
|
-
if (!force && this.gateway.busy) {
|
|
110
|
-
this.#arm(key, this.busyRetryMs);
|
|
111
|
-
return "failed";
|
|
112
|
-
}
|
|
113
108
|
|
|
114
109
|
const task = this.#store(rec);
|
|
115
110
|
this.#attempts.set(key, task);
|
|
@@ -118,7 +113,7 @@ export class SsdDurabilityCoordinator {
|
|
|
118
113
|
if (outcome === "stored" && this.#dirty.get(key) === rec)
|
|
119
114
|
this.#dirty.delete(key);
|
|
120
115
|
if (outcome !== "stored" && !force && this.#dirty.get(key) === rec)
|
|
121
|
-
this.#arm(key, this.
|
|
116
|
+
this.#arm(key, this.retryMs);
|
|
122
117
|
return outcome;
|
|
123
118
|
} finally {
|
|
124
119
|
if (this.#attempts.get(key) === task) this.#attempts.delete(key);
|
|
@@ -128,22 +123,28 @@ export class SsdDurabilityCoordinator {
|
|
|
128
123
|
async #store(rec: DirtySnapshot): Promise<StoreOutcome> {
|
|
129
124
|
let snap: SpillItem | null = null;
|
|
130
125
|
try {
|
|
131
|
-
snap =
|
|
126
|
+
snap = (() => {
|
|
132
127
|
if (this.isAlreadyDurable(rec.tokens, rec.ns)) return null;
|
|
133
128
|
const entry = this.promptCache.findExact(rec.tokens, rec.ns);
|
|
134
129
|
if (!entry) return null;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
140
|
-
|
|
130
|
+
const caches = this.cloneCaches(entry.caches);
|
|
131
|
+
try {
|
|
132
|
+
return { tokens: [...entry.tokens], caches, ns: rec.ns,
|
|
133
|
+
attachments: cloneAttachments(entry.attachments) };
|
|
134
|
+
} catch (error) {
|
|
135
|
+
return cleanupFailure(error, () => disposeResources(caches));
|
|
136
|
+
}
|
|
137
|
+
})();
|
|
141
138
|
} catch {
|
|
142
139
|
return "failed";
|
|
143
140
|
}
|
|
144
141
|
if (!snap)
|
|
145
142
|
return this.isAlreadyDurable(rec.tokens, rec.ns) ? "stored" : "missing";
|
|
146
|
-
|
|
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";
|
|
147
148
|
}
|
|
148
149
|
|
|
149
150
|
async #flushInner(started: number): Promise<DurabilityFlushResult> {
|
|
@@ -155,12 +156,13 @@ export class SsdDurabilityCoordinator {
|
|
|
155
156
|
const droppedBefore = this.spillQueue.droppedCount;
|
|
156
157
|
const failedBefore = this.spillQueue.failedCount;
|
|
157
158
|
|
|
158
|
-
if (this.#attempts.size > 0)
|
|
159
|
-
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
|
+
}
|
|
160
163
|
await this.spillQueue.drain();
|
|
161
164
|
|
|
162
|
-
// Flush
|
|
163
|
-
// boundary snapshot while a large final snapshot is already in flight.
|
|
165
|
+
// Flush dirty entries serially without a second queue of snapshots.
|
|
164
166
|
// Attempt each record version once. Missing state stays dirty, so a
|
|
165
167
|
// second flush cannot report success without a real durable snapshot.
|
|
166
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
|
+
}
|