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/prompt-cache.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type CheckpointAttachment, materializeCheckpoint, attachmentBytes, cloneAttachments, disposeAttachments } from "./backends/mlx/checkpoint-state";
|
|
2
|
+
import { leaseCacheState, minimumReusableOffset } from "./backends/mlx/state-views";
|
|
2
3
|
import { withResource, cleanupFailure, disposeResources, ownResource } from "./engine/resources";
|
|
3
4
|
// Byte-capped LRU prompt cache — the RAM tier of the Layer-0 KV store.
|
|
4
5
|
//
|
|
@@ -10,24 +11,22 @@ import { withResource, cleanupFailure, disposeResources, ownResource } from "./e
|
|
|
10
11
|
// const hit = cache.take(promptIds); // longest-prefix match
|
|
11
12
|
// generate(model, promptIds, { cache: hit?.caches ?? fresh })
|
|
12
13
|
// cache.put([...promptIds, ...generated], caches, ns, hit?.retain);
|
|
13
|
-
// take()
|
|
14
|
-
// and
|
|
15
|
-
//
|
|
16
|
-
// an exact hit, avoiding an extra resident recurrent snapshot. put() supersedes
|
|
17
|
-
// same-ns prefix-ancestors (when the new entry is trimmable) so a conversation
|
|
18
|
-
// stays one entry, not one per turn.
|
|
14
|
+
// take() lends zero-copy views and leaves every RAM donor in place, including
|
|
15
|
+
// recurrent and method state. SSD persistence runs independently of RAM reuse.
|
|
16
|
+
// put() supersedes same-ns prefix-ancestors when the new entry is trimmable.
|
|
19
17
|
//
|
|
20
18
|
// TIERING (Layer 0, unified-engine plan): when a ColdTier is attached,
|
|
21
19
|
// take() itself runs the two-tier dance — RAM peek vs cold find, restore
|
|
22
|
-
// (
|
|
20
|
+
// (streamed copy) + trim when the cold tier holds a strictly longer
|
|
23
21
|
// prefix — so EVERY consumer (the serial lane, the batch scheduler, future
|
|
24
22
|
// prefix sharing) gets SSD restores through the same take()/put() it
|
|
25
23
|
// already calls. Eviction spills to the tier (the #spill hook); idle
|
|
26
|
-
// entries
|
|
27
|
-
//
|
|
28
|
-
// snapshot for both lanes.
|
|
24
|
+
// entries demote to it when policy permits. TieredPromptCache owns queued
|
|
25
|
+
// persistence and promotes restored prefixes into RAM.
|
|
29
26
|
|
|
30
27
|
import type { Cache } from "./model/gemma4";
|
|
28
|
+
import type { PrefixCache, PrefixCacheHit } from "./contracts/prefix-cache";
|
|
29
|
+
import { LruRetention, type RetentionPolicy, type RetentionCandidate } from "./storage/retention-policy";
|
|
31
30
|
import { cloneKvCaches } from "./kv-store";
|
|
32
31
|
|
|
33
32
|
/** Reference-counted release: wraps an entry's `retain` (e.g. an mmap
|
|
@@ -65,13 +64,17 @@ export interface ColdTier {
|
|
|
65
64
|
/** Longest stored usable prefix for prompt/ns — index-only, no I/O.
|
|
66
65
|
* `handle` is the tier's opaque entry token, passed back to restore. */
|
|
67
66
|
find(prompt: number[], ns: string): { prefixLen: number; handle: unknown } | null;
|
|
68
|
-
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
restore(handle: unknown): { tokens: number[]; caches: Cache[]; retain: () => void } | null;
|
|
67
|
+
/** Restore GPU-visible state. The default SSD backend copies into owned
|
|
68
|
+
* arrays. An optional backing release runs after consumers dispose their
|
|
69
|
+
* arrays. Null on any failure. */
|
|
70
|
+
restore(handle: unknown): { tokens: number[]; caches: Cache[]; attachments?: CheckpointAttachment[]; retain: () => void } | null;
|
|
71
|
+
findExact?(tokens: number[], ns: string): { prefixLen: number; handle: unknown } | null;
|
|
72
|
+
restoreAsync?(handle: unknown): Promise<ReturnType<ColdTier["restore"]>>;
|
|
73
|
+
/** Tensor objects have no target layers; restore with their own schema. */
|
|
74
|
+
restoreObjectAsync?(handle: unknown): Promise<ReturnType<ColdTier["restore"]>>;
|
|
72
75
|
/** Borrow live caches and finish persistence before returning. A false
|
|
73
|
-
* result prevents a required demotion
|
|
74
|
-
store(tokens: number[], caches: Cache[], ns: string): boolean | void;
|
|
76
|
+
* result prevents a required demotion. */
|
|
77
|
+
store(tokens: number[], caches: Cache[], ns: string, attachments?: CheckpointAttachment[]): boolean | void;
|
|
75
78
|
}
|
|
76
79
|
|
|
77
80
|
/** Spill sink for eviction/demotion (write-behind, 2026-07-06).
|
|
@@ -95,9 +98,7 @@ export interface SpillSink {
|
|
|
95
98
|
spillSync?: (entry: PromptCacheEntry) => void;
|
|
96
99
|
}
|
|
97
100
|
|
|
98
|
-
export interface PromptCacheEntry {
|
|
99
|
-
tokens: number[];
|
|
100
|
-
caches: Cache[];
|
|
101
|
+
export interface PromptCacheEntry extends PrefixCacheHit<Cache[], CheckpointAttachment[]> {
|
|
101
102
|
/** Namespace key — adapter spec for LoRA requests ("" = base model).
|
|
102
103
|
* KV computed under one adapter must never seed another's prefill. */
|
|
103
104
|
ns: string;
|
|
@@ -124,7 +125,7 @@ export function commonPrefixLength(a: number[], b: number[]): number {
|
|
|
124
125
|
return i;
|
|
125
126
|
}
|
|
126
127
|
|
|
127
|
-
interface EntryRecord {
|
|
128
|
+
interface EntryRecord extends RetentionCandidate {
|
|
128
129
|
entry: PromptCacheEntry;
|
|
129
130
|
bytes: number;
|
|
130
131
|
lastUsed: number;
|
|
@@ -132,12 +133,98 @@ interface EntryRecord {
|
|
|
132
133
|
/** Ref-counted retain: the donor holds one share (entry.retain); every
|
|
133
134
|
* clone handed out by take() holds another. */
|
|
134
135
|
share: { acquire(): () => void };
|
|
136
|
+
resident: boolean;
|
|
137
|
+
sessions: number;
|
|
135
138
|
}
|
|
136
139
|
|
|
137
|
-
export class PromptCache {
|
|
140
|
+
export class PromptCache implements PrefixCache<Cache[], CheckpointAttachment[]> {
|
|
141
|
+
/** Exact tensor objects share this store's byte budget, eviction and writer.
|
|
142
|
+
* Empty token history denotes no conversation; the private namespace is the
|
|
143
|
+
* complete object identity. No fabricated language tokens enter the index. */
|
|
144
|
+
readonly #objects: import("./contracts/object-cache").ObjectCache<CheckpointAttachment[]> = {
|
|
145
|
+
take: key => this.#takeObject(`object:${key}`),
|
|
146
|
+
put: (key, value) => this.put([], [], `object:${key}`, undefined, value),
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
get objects(): import("./contracts/object-cache").ObjectCache<CheckpointAttachment[]> | undefined {
|
|
150
|
+
return this.maxBytes > 0 || this.#cold ? this.#objects : undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async #takeObject(ns: string): Promise<{ value: CheckpointAttachment[]; dispose(): void } | null> {
|
|
154
|
+
let entry = this.findExact([], ns);
|
|
155
|
+
if (entry) this.objectHits++;
|
|
156
|
+
else this.objectMisses++;
|
|
157
|
+
if (!entry) {
|
|
158
|
+
const cold = this.#cold;
|
|
159
|
+
const hit = cold?.findExact?.([], ns);
|
|
160
|
+
if (!hit || !cold?.restoreObjectAsync) return null;
|
|
161
|
+
const epoch = this.#epoch;
|
|
162
|
+
const loaded = await cold.restoreObjectAsync(hit.handle);
|
|
163
|
+
if (!loaded) return null;
|
|
164
|
+
this.objectRestores++;
|
|
165
|
+
if (epoch !== this.#epoch) { this.#disposeEntry({ ...loaded, ns }, false); return null; }
|
|
166
|
+
// Another request may have published this immutable object during IO.
|
|
167
|
+
entry = this.findExact([], ns);
|
|
168
|
+
if (entry) this.#disposeEntry({ ...loaded, ns }, false);
|
|
169
|
+
else {
|
|
170
|
+
this.#publishingPrefetch = true;
|
|
171
|
+
try { this.put([], loaded.caches, ns, loaded.retain, loaded.attachments); }
|
|
172
|
+
catch (error) { this.#disposeEntry({ ...loaded, ns }, false); throw error; }
|
|
173
|
+
finally { this.#publishingPrefetch = false; }
|
|
174
|
+
entry = this.findExact([], ns);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (!entry) return null;
|
|
178
|
+
const borrowed = this.#borrow(this.#records.get(entry)!, 0, ns);
|
|
179
|
+
try { this.evictToBudget(); this.reclaim(); }
|
|
180
|
+
catch (error) { this.#disposeEntry(borrowed, false); throw error; }
|
|
181
|
+
let released = false;
|
|
182
|
+
return { value: borrowed.attachments ?? [], dispose: () => {
|
|
183
|
+
if (released) return; released = true;
|
|
184
|
+
this.#disposeEntry(borrowed, false);
|
|
185
|
+
} };
|
|
186
|
+
}
|
|
187
|
+
|
|
138
188
|
readonly maxBytes: number;
|
|
139
189
|
#entries: EntryRecord[] = [];
|
|
140
190
|
#clock = 0;
|
|
191
|
+
#sessions = new Map<string, Map<string, EntryRecord>>();
|
|
192
|
+
#records = new WeakMap<PromptCacheEntry, EntryRecord>();
|
|
193
|
+
sessionHits = 0;
|
|
194
|
+
sessionMisses = 0;
|
|
195
|
+
prefixScans = 0;
|
|
196
|
+
objectHits = 0;
|
|
197
|
+
objectMisses = 0;
|
|
198
|
+
objectRestores = 0;
|
|
199
|
+
|
|
200
|
+
closeSession(sessionId: string): void {
|
|
201
|
+
for (const record of this.#sessions.get(sessionId)?.values() ?? []) record.sessions--;
|
|
202
|
+
this.#sessions.delete(sessionId);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
#associate(sessionId: string | undefined, ns: string, record: EntryRecord): void {
|
|
206
|
+
if (!sessionId) return;
|
|
207
|
+
let entries = this.#sessions.get(sessionId);
|
|
208
|
+
if (!entries) this.#sessions.set(sessionId, entries = new Map());
|
|
209
|
+
const old = entries.get(ns);
|
|
210
|
+
if (old === record) return;
|
|
211
|
+
if (old) old.sessions--;
|
|
212
|
+
entries.set(ns, record); record.sessions++;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
#sessionRecord(prompt: number[], ns: string, sessionId?: string): EntryRecord | undefined {
|
|
216
|
+
const record = sessionId ? this.#sessions.get(sessionId)?.get(ns) : undefined;
|
|
217
|
+
// A session labels affinity. Full-history callers may edit or branch;
|
|
218
|
+
// incompatible history uses ordinary lookup, never a request refusal.
|
|
219
|
+
return record && record.entry.tokens.length < prompt.length &&
|
|
220
|
+
commonPrefixLength(record.entry.tokens, prompt) === record.entry.tokens.length ? record : undefined;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
#coldHit(prompt: number[], ns: string, sessionId?: string) {
|
|
224
|
+
const known = this.#sessionRecord(prompt, ns, sessionId);
|
|
225
|
+
return (known && this.#cold?.findExact?.(known.entry.tokens, ns)) || this.#cold?.find(prompt, ns) || null;
|
|
226
|
+
}
|
|
227
|
+
|
|
141
228
|
hits = 0;
|
|
142
229
|
misses = 0;
|
|
143
230
|
demotions = 0;
|
|
@@ -148,9 +235,98 @@ export class PromptCache {
|
|
|
148
235
|
readonly #spillSync: ((entry: PromptCacheEntry) => void) | null;
|
|
149
236
|
/** Optional cold tier: take() tiers over it, demoteIdle() spills into it. */
|
|
150
237
|
readonly #cold: ColdTier | null;
|
|
151
|
-
/**
|
|
152
|
-
* write-behind SSD snapshot here so BOTH lanes' entries persist. */
|
|
238
|
+
/** Observer after publication, independent of persistence policy. */
|
|
153
239
|
onPut: ((tokens: number[], ns: string) => void) | null = null;
|
|
240
|
+
/** The backend measures pressure; this store chooses which optional
|
|
241
|
+
* snapshots to release. No storage I/O runs at a reclaim boundary. */
|
|
242
|
+
pressure: { overBudget(): boolean; releasePending?(): void } | null = null;
|
|
243
|
+
/** Tier policy: a pending write is not a lower-tier copy. This controls
|
|
244
|
+
* eviction only; persistence completion does not itself remove an entry. */
|
|
245
|
+
canEvict: ((entry: PromptCacheEntry) => boolean) | null = null;
|
|
246
|
+
protected promoteRestores = false;
|
|
247
|
+
retention: RetentionPolicy = new LruRetention();
|
|
248
|
+
#prefetches = new Map<unknown, Promise<PromptCacheEntry | null>>();
|
|
249
|
+
#pins = new Map<PromptCacheEntry, number>();
|
|
250
|
+
#publishingPrefetch = false;
|
|
251
|
+
#epoch = 0;
|
|
252
|
+
|
|
253
|
+
/** Start cold reads during request preparation, coalescing identical IO.
|
|
254
|
+
* Active request interest keeps a just-restored donor resident until the
|
|
255
|
+
* caller has consumed it. Cancellation releases interest through the lease. */
|
|
256
|
+
async prefetch(prompt: number[], ns = "", sessionId?: string): Promise<() => void> {
|
|
257
|
+
const known = this.#sessionRecord(prompt, ns, sessionId);
|
|
258
|
+
if (known?.resident) return this.#pin(known.entry);
|
|
259
|
+
const cold = this.#cold;
|
|
260
|
+
const hit = cold?.restoreAsync ? this.#coldHit(prompt, ns, sessionId) : null;
|
|
261
|
+
if (!cold?.restoreAsync || !hit || (!known && hit.prefixLen <= this.peekPrefixLen(prompt, ns))) return () => {};
|
|
262
|
+
let pending = this.#prefetches.get(hit.handle);
|
|
263
|
+
if (!pending) {
|
|
264
|
+
const epoch = this.#epoch;
|
|
265
|
+
pending = (async () => {
|
|
266
|
+
const loaded = await cold.restoreAsync!(hit.handle);
|
|
267
|
+
if (!loaded) return null;
|
|
268
|
+
if (epoch !== this.#epoch) { this.#disposeEntry({ ...loaded, ns }, false); return null; }
|
|
269
|
+
this.#publishingPrefetch = true;
|
|
270
|
+
try { this.put(loaded.tokens, loaded.caches, ns, loaded.retain, loaded.attachments); }
|
|
271
|
+
catch (error) { this.#disposeEntry({ ...loaded, ns }, false); throw error; }
|
|
272
|
+
finally { this.#publishingPrefetch = false; }
|
|
273
|
+
return this.findExact(loaded.tokens, ns);
|
|
274
|
+
})();
|
|
275
|
+
this.#prefetches.set(hit.handle, pending);
|
|
276
|
+
}
|
|
277
|
+
let entry: PromptCacheEntry | null;
|
|
278
|
+
try { entry = await pending; }
|
|
279
|
+
finally { if (this.#prefetches.get(hit.handle) === pending) this.#prefetches.delete(hit.handle); }
|
|
280
|
+
if (!entry) return () => {};
|
|
281
|
+
this.#associate(sessionId, ns, this.#records.get(entry)!);
|
|
282
|
+
return this.#pin(entry);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
#pin(entry: PromptCacheEntry): () => void {
|
|
286
|
+
this.#pins.set(entry, (this.#pins.get(entry) ?? 0) + 1);
|
|
287
|
+
let released = false;
|
|
288
|
+
return () => {
|
|
289
|
+
if (released) return; released = true;
|
|
290
|
+
const count = this.#pins.get(entry!) ?? 1;
|
|
291
|
+
if (count === 1) this.#pins.delete(entry!); else this.#pins.set(entry!, count - 1);
|
|
292
|
+
this.evictToBudget(); this.reclaim();
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
#victim(): EntryRecord | undefined {
|
|
297
|
+
const candidates = this.#entries.filter(record => !this.#pins.has(record.entry));
|
|
298
|
+
const unrelated = candidates.filter(record => record.sessions === 0);
|
|
299
|
+
return candidates.length ? this.retention.victim(unrelated.length ? unrelated : candidates) : undefined;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
evictToBudget(): void {
|
|
303
|
+
if (this.#publishingPrefetch) return;
|
|
304
|
+
while (this.totalBytes > this.maxBytes) {
|
|
305
|
+
if (!this.#entries.length) break;
|
|
306
|
+
const oldest = this.#victim();
|
|
307
|
+
if (!oldest) break;
|
|
308
|
+
if (this.canEvict && !this.canEvict(oldest.entry)) break;
|
|
309
|
+
this.retention.evicted(oldest);
|
|
310
|
+
this.#entries = this.#entries.filter(r => r !== oldest);
|
|
311
|
+
try { this.#disposeEntry(oldest.entry, !this.canEvict); }
|
|
312
|
+
catch (error) { console.warn(`[prompt-cache] entry cleanup failed: ${error}`); }
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
reclaim(): void {
|
|
317
|
+
const pressure = this.pressure;
|
|
318
|
+
if (!pressure?.overBudget()) return;
|
|
319
|
+
pressure.releasePending?.();
|
|
320
|
+
while (this.#entries.length && pressure.overBudget()) {
|
|
321
|
+
const oldest = this.#victim();
|
|
322
|
+
if (!oldest) break;
|
|
323
|
+
if (this.canEvict && !this.canEvict(oldest.entry)) break;
|
|
324
|
+
this.retention.evicted(oldest);
|
|
325
|
+
this.#entries = this.#entries.filter(r => r !== oldest);
|
|
326
|
+
this.#disposeEntry(oldest.entry, false);
|
|
327
|
+
this.demotions++;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
154
330
|
|
|
155
331
|
/** Zero-copy view cloner — injectable so model-free tests can stub it. */
|
|
156
332
|
readonly #clone: (caches: Cache[]) => Cache[];
|
|
@@ -161,6 +337,7 @@ export class PromptCache {
|
|
|
161
337
|
spill: SpillSink | ((entry: PromptCacheEntry) => void) | null = null,
|
|
162
338
|
cold: ColdTier | null = null,
|
|
163
339
|
clone: (caches: Cache[]) => Cache[] = cloneKvCaches,
|
|
340
|
+
private readonly materialize: (caches: Cache[], attachments?: CheckpointAttachment[]) => void = materializeCheckpoint,
|
|
164
341
|
) {
|
|
165
342
|
this.maxBytes = maxBytes;
|
|
166
343
|
this.#spillOwned = typeof spill === "function" ? null : spill?.spillOwned ?? null;
|
|
@@ -170,6 +347,8 @@ export class PromptCache {
|
|
|
170
347
|
}
|
|
171
348
|
|
|
172
349
|
#disposeEntry(entry: PromptCacheEntry, spillFirst: boolean): void {
|
|
350
|
+
const record = this.#records.get(entry);
|
|
351
|
+
if (record) record.resident = false;
|
|
173
352
|
if (spillFirst) {
|
|
174
353
|
if (this.#spillOwned) {
|
|
175
354
|
// NON-BLOCKING spill: clone BEFORE disposing (clones are zero-copy
|
|
@@ -185,23 +364,26 @@ export class PromptCache {
|
|
|
185
364
|
// no backing mmap for retain to unmap, so nothing the clones
|
|
186
365
|
// could alias goes away.
|
|
187
366
|
let clones: Cache[] | null = null;
|
|
367
|
+
let attachments: CheckpointAttachment[] | undefined;
|
|
188
368
|
try {
|
|
189
369
|
clones = this.#clone(entry.caches);
|
|
190
|
-
|
|
370
|
+
attachments = cloneAttachments(entry.attachments);
|
|
371
|
+
this.#spillOwned({ tokens: [...entry.tokens], caches: clones, ns: entry.ns, attachments });
|
|
191
372
|
} catch {
|
|
192
373
|
// Failure degrades to no-spill; eviction/demotion never unwinds.
|
|
193
374
|
if (clones) for (const c of clones) try { c.dispose(); } catch {}
|
|
375
|
+
try { disposeAttachments(attachments); } catch {}
|
|
194
376
|
}
|
|
195
377
|
} else {
|
|
196
378
|
const spill =
|
|
197
379
|
this.#spillSync ??
|
|
198
380
|
(this.#cold
|
|
199
|
-
? (e: PromptCacheEntry) => this.#cold!.store(e.tokens, e.caches, e.ns)
|
|
381
|
+
? (e: PromptCacheEntry) => this.#cold!.store(e.tokens, e.caches, e.ns, e.attachments)
|
|
200
382
|
: null);
|
|
201
383
|
if (spill) try { spill(entry); } catch {}
|
|
202
384
|
}
|
|
203
385
|
}
|
|
204
|
-
disposeResources(entry.caches);
|
|
386
|
+
disposeResources([...entry.caches, { dispose: () => disposeAttachments(entry.attachments) }]);
|
|
205
387
|
entry.retain?.(); // backing release requires successful cache disposal
|
|
206
388
|
}
|
|
207
389
|
|
|
@@ -220,16 +402,17 @@ export class PromptCache {
|
|
|
220
402
|
relievePressure(overBudget: () => boolean): number {
|
|
221
403
|
let count = 0;
|
|
222
404
|
while (this.#entries.length && overBudget()) {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const { entry } =
|
|
405
|
+
const victim = this.#victim();
|
|
406
|
+
if (!victim) break;
|
|
407
|
+
const oldest = this.#entries.indexOf(victim);
|
|
408
|
+
const { entry } = victim;
|
|
227
409
|
// Keep the entry resident if persistence throws. A failed SSD write
|
|
228
410
|
// must not be presented as a successful demotion.
|
|
229
411
|
if (this.#cold) {
|
|
230
|
-
if (this.#cold.store(entry.tokens, entry.caches, entry.ns) === false) break;
|
|
412
|
+
if (this.#cold.store(entry.tokens, entry.caches, entry.ns, entry.attachments) === false) break;
|
|
231
413
|
}
|
|
232
414
|
else this.#spillSync?.(entry);
|
|
415
|
+
this.retention.evicted(victim);
|
|
233
416
|
this.#entries.splice(oldest, 1);
|
|
234
417
|
this.#disposeEntry(entry, false);
|
|
235
418
|
this.demotions++;
|
|
@@ -239,9 +422,9 @@ export class PromptCache {
|
|
|
239
422
|
}
|
|
240
423
|
|
|
241
424
|
/** Find the entry with the longest usable common prefix of `prompt`
|
|
242
|
-
* across BOTH tiers.
|
|
243
|
-
*
|
|
244
|
-
*
|
|
425
|
+
* across BOTH tiers. Every RAM entry lends zero-copy views and retains its
|
|
426
|
+
* donor, including exact-boundary recurrent state and method attachments.
|
|
427
|
+
* The caller owns the returned views —
|
|
245
428
|
* dispose them or put() an extended entry, honoring `retain` either way.
|
|
246
429
|
*
|
|
247
430
|
* Usable prefix = common prefix capped at prompt.length - 1 (at least
|
|
@@ -250,21 +433,39 @@ export class PromptCache {
|
|
|
250
433
|
* wrapped — those entries only match in full.
|
|
251
434
|
*
|
|
252
435
|
* Tier order: the cold tier wins only with a STRICTLY longer usable
|
|
253
|
-
* prefix (
|
|
436
|
+
* prefix (restore reads storage and materializes native arrays). A
|
|
254
437
|
* cold entry with an untrimmable divergent tail (ring post-wrap, SSM)
|
|
255
438
|
* is dropped and the RAM candidate (or a fresh prefill) serves.
|
|
256
439
|
* hits/misses count RAM candidacy only (cold restores are counted by
|
|
257
440
|
* the tier itself), preserving the /stats meaning. */
|
|
258
|
-
take(prompt: number[], ns = ""): PromptCacheEntry | null {
|
|
441
|
+
take(prompt: number[], ns = "", sessionId?: string): PromptCacheEntry | null {
|
|
442
|
+
const hit = this.#take(prompt, ns, sessionId);
|
|
443
|
+
// Keep the chosen state's owned views before releasing optional donors.
|
|
444
|
+
// This also covers a cold restore materializing its bytes at admission.
|
|
445
|
+
try { this.reclaim(); return hit; }
|
|
446
|
+
catch (error) {
|
|
447
|
+
if (hit) this.#disposeEntry(hit, false);
|
|
448
|
+
throw error;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
#take(prompt: number[], ns: string, sessionId?: string): PromptCacheEntry | null {
|
|
453
|
+
const known = this.#sessionRecord(prompt, ns, sessionId);
|
|
454
|
+
if (known?.resident) {
|
|
455
|
+
this.sessionHits++; this.hits++;
|
|
456
|
+
return this.#borrow(known, known.entry.tokens.length, ns);
|
|
457
|
+
}
|
|
458
|
+
if (sessionId) this.sessionMisses++;
|
|
459
|
+
this.prefixScans++;
|
|
259
460
|
let bestIdx = -1;
|
|
260
461
|
let bestLen = 0;
|
|
261
462
|
for (let i = 0; i < this.#entries.length; i++) {
|
|
262
463
|
const e = this.#entries[i]!.entry;
|
|
263
464
|
if (e.ns !== ns) continue;
|
|
264
465
|
const p = Math.min(commonPrefixLength(e.tokens, prompt), prompt.length - 1);
|
|
265
|
-
if (p <= bestLen) continue;
|
|
466
|
+
if (p <= bestLen || p < minimumReusableOffset(e.caches)) continue;
|
|
266
467
|
const trimNeeded = e.tokens.length - p;
|
|
267
|
-
if (trimNeeded > 0 && !e.caches.every((c) => c.isTrimmable())) continue;
|
|
468
|
+
if (trimNeeded > 0 && (!!e.attachments?.length || !e.caches.every((c) => c.isTrimmable()))) continue;
|
|
268
469
|
bestLen = p;
|
|
269
470
|
bestIdx = i;
|
|
270
471
|
}
|
|
@@ -275,7 +476,7 @@ export class PromptCache {
|
|
|
275
476
|
// candidate stays put UNTOUCHED (the old serial-lane flow trimmed it
|
|
276
477
|
// before comparing — a needless entry degradation, gone now).
|
|
277
478
|
if (this.#cold) {
|
|
278
|
-
const hit = this.#
|
|
479
|
+
const hit = this.#coldHit(prompt, ns, sessionId);
|
|
279
480
|
if (hit && hit.prefixLen > bestLen) {
|
|
280
481
|
const loaded = this.#cold.restore(hit.handle);
|
|
281
482
|
if (loaded) {
|
|
@@ -283,7 +484,8 @@ export class PromptCache {
|
|
|
283
484
|
this.#disposeEntry({ ...entry, ns }, false));
|
|
284
485
|
const result = withResource(owner, (entry) => {
|
|
285
486
|
const trimNeeded = entry.tokens.length - hit.prefixLen;
|
|
286
|
-
if (
|
|
487
|
+
if (hit.prefixLen < minimumReusableOffset(entry.caches)) return null;
|
|
488
|
+
if (trimNeeded > 0 && (!!entry.attachments?.length || !entry.caches.every((cache) => cache.isTrimmable()))) {
|
|
287
489
|
console.warn(
|
|
288
490
|
`[prompt-cache] cold entry unusable: ${entry.tokens.length} tokens, ` +
|
|
289
491
|
`needs trim ${trimNeeded} but untrimmable (wrapped ring/SSM) — re-prefilling`,
|
|
@@ -294,7 +496,27 @@ export class PromptCache {
|
|
|
294
496
|
const tokens = entry.tokens.slice(0, hit.prefixLen);
|
|
295
497
|
return { ...owner.transfer(), tokens, ns };
|
|
296
498
|
});
|
|
297
|
-
if (result)
|
|
499
|
+
if (result) {
|
|
500
|
+
if (this.promoteRestores) {
|
|
501
|
+
// Cache the restored prefix immediately. A second request can
|
|
502
|
+
// reuse RAM even before this borrower finishes its generation.
|
|
503
|
+
const share = makeSharedRetain(result.retain);
|
|
504
|
+
result.retain = share.acquire();
|
|
505
|
+
const retained = share.acquire();
|
|
506
|
+
let clones: Cache[] = [];
|
|
507
|
+
let attachments: CheckpointAttachment[] | undefined;
|
|
508
|
+
try {
|
|
509
|
+
clones = this.#clone(result.caches);
|
|
510
|
+
attachments = cloneAttachments(result.attachments);
|
|
511
|
+
this.put([...result.tokens], clones, ns, retained, attachments, sessionId);
|
|
512
|
+
} catch (error) {
|
|
513
|
+
cleanupFailure(error, () => disposeResources([...clones,
|
|
514
|
+
{ dispose: () => disposeAttachments(attachments) },
|
|
515
|
+
{ dispose: retained }, { dispose: () => this.#disposeEntry(result, false) }]));
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return result;
|
|
519
|
+
}
|
|
298
520
|
}
|
|
299
521
|
}
|
|
300
522
|
}
|
|
@@ -310,30 +532,30 @@ export class PromptCache {
|
|
|
310
532
|
// consume-and-trim semantics CANNIBALIZED donors: agent B borrowing
|
|
311
533
|
// agent A's 2k system prompt destroyed A's 10k entry to do it.
|
|
312
534
|
const rec = this.#entries[bestIdx]!;
|
|
535
|
+
this.#associate(sessionId, ns, rec);
|
|
536
|
+
return this.#borrow(rec, bestLen, ns);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
#borrow(rec: EntryRecord, bestLen: number, ns: string): PromptCacheEntry {
|
|
313
540
|
rec.lastUsed = ++this.#clock;
|
|
314
541
|
rec.lastUsedMs = Date.now();
|
|
315
|
-
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
// cannot be trimmed back, and a delayed writer can no longer find it
|
|
320
|
-
// in RAM after ownership moves. An already durable boundary is cheap.
|
|
321
|
-
if (rec.entry.caches.some((c) => c.signature() === "ssm")) {
|
|
322
|
-
if (this.#cold?.store(rec.entry.tokens, rec.entry.caches, ns) === false)
|
|
323
|
-
throw new Error("prompt-cache: failed to persist recurrent boundary before transfer");
|
|
324
|
-
this.#entries.splice(bestIdx, 1);
|
|
325
|
-
return rec.entry;
|
|
326
|
-
}
|
|
542
|
+
rec.uses++; rec.cost = bestLen; this.retention.accessed(rec);
|
|
543
|
+
// Lend immutable views for every backend state. RAM retention and SSD
|
|
544
|
+
// durability remain cache responsibilities while the caller extends its
|
|
545
|
+
// own views. This keeps synchronous persistence out of a RAM hit.
|
|
327
546
|
const clones = this.#clone(rec.entry.caches);
|
|
547
|
+
let attachments: CheckpointAttachment[] | undefined;
|
|
328
548
|
const trimNeeded = rec.entry.tokens.length - bestLen;
|
|
329
549
|
try {
|
|
550
|
+
attachments = cloneAttachments(rec.entry.attachments);
|
|
330
551
|
if (trimNeeded > 0) for (const c of clones) c.trim(trimNeeded);
|
|
331
552
|
} catch (error) {
|
|
332
|
-
cleanupFailure(error, () => disposeResources(clones));
|
|
553
|
+
cleanupFailure(error, () => disposeResources([...clones, { dispose: () => disposeAttachments(attachments) }]));
|
|
333
554
|
}
|
|
334
555
|
return {
|
|
335
556
|
tokens: rec.entry.tokens.slice(0, bestLen),
|
|
336
557
|
caches: clones,
|
|
558
|
+
attachments,
|
|
337
559
|
ns,
|
|
338
560
|
retain: rec.share.acquire(),
|
|
339
561
|
};
|
|
@@ -346,17 +568,15 @@ export class PromptCache {
|
|
|
346
568
|
for (const { entry: e } of this.#entries) {
|
|
347
569
|
if (e.ns !== ns) continue;
|
|
348
570
|
const p = Math.min(commonPrefixLength(e.tokens, prompt), prompt.length - 1);
|
|
349
|
-
if (p <= best) continue;
|
|
571
|
+
if (p <= best || p < minimumReusableOffset(e.caches)) continue;
|
|
350
572
|
const trimNeeded = e.tokens.length - p; // same usability rule as take()
|
|
351
|
-
if (trimNeeded > 0 && !e.caches.every((c) => c.isTrimmable())) continue;
|
|
573
|
+
if (trimNeeded > 0 && (!!e.attachments?.length || !e.caches.every((c) => c.isTrimmable()))) continue;
|
|
352
574
|
best = p;
|
|
353
575
|
}
|
|
354
576
|
return best;
|
|
355
577
|
}
|
|
356
578
|
|
|
357
|
-
/** Read-only
|
|
358
|
-
* entry stays owned by the cache; the caller only reads array state,
|
|
359
|
-
* under the gateway lock so no generation is mutating it). */
|
|
579
|
+
/** Read-only lookup of an immutable, cache-owned checkpoint. */
|
|
360
580
|
findExact(tokens: number[], ns = ""): PromptCacheEntry | null {
|
|
361
581
|
for (const { entry: e } of this.#entries) {
|
|
362
582
|
if (e.ns !== ns || e.tokens.length !== tokens.length) continue;
|
|
@@ -368,10 +588,12 @@ export class PromptCache {
|
|
|
368
588
|
/** Insert (or reinsert) an entry; evicts LRU entries over the byte cap
|
|
369
589
|
* (spilling each to the cold tier first, when one is attached). If the
|
|
370
590
|
* entry itself exceeds the cap it is spilled + disposed, not stored. */
|
|
371
|
-
put(tokens: number[], caches: Cache[], ns = "", retain?: () => void): void {
|
|
591
|
+
put(tokens: number[], caches: Cache[], ns = "", retain?: () => void, attachments?: CheckpointAttachment[], sessionId?: string): void {
|
|
372
592
|
// All cache-provided validation runs before ownership is adopted.
|
|
373
|
-
const bytes = cacheBytes(caches);
|
|
374
|
-
const trimmable = caches.every((cache) => cache.isTrimmable());
|
|
593
|
+
const bytes = cacheBytes(caches) + attachmentBytes(attachments);
|
|
594
|
+
const trimmable = !attachments?.length && caches.every((cache) => cache.isTrimmable());
|
|
595
|
+
// Storage takes resolved snapshots, independently of their producer.
|
|
596
|
+
this.materialize(caches, attachments);
|
|
375
597
|
const discard = (entry: PromptCacheEntry, spill: boolean) => {
|
|
376
598
|
try { this.#disposeEntry(entry, spill); }
|
|
377
599
|
catch (error) {
|
|
@@ -380,8 +602,8 @@ export class PromptCache {
|
|
|
380
602
|
console.warn(`[prompt-cache] entry cleanup failed: ${error}`);
|
|
381
603
|
}
|
|
382
604
|
};
|
|
383
|
-
const entry: PromptCacheEntry = { tokens, caches, ns, retain };
|
|
384
|
-
if (bytes > this.maxBytes) {
|
|
605
|
+
const entry: PromptCacheEntry = { tokens, caches, ns, retain, attachments };
|
|
606
|
+
if (bytes > this.maxBytes && !this.canEvict) {
|
|
385
607
|
discard(entry, true);
|
|
386
608
|
return;
|
|
387
609
|
}
|
|
@@ -389,8 +611,11 @@ export class PromptCache {
|
|
|
389
611
|
// shares; the entry's own retain is the donor's share.
|
|
390
612
|
const share = makeSharedRetain(retain);
|
|
391
613
|
entry.retain = share.acquire();
|
|
392
|
-
const rec: EntryRecord = { entry, bytes, lastUsed: ++this.#clock, lastUsedMs: Date.now(), share };
|
|
614
|
+
const rec: EntryRecord = { entry, bytes, cost: tokens.length, uses: 1, priority: 0, lastUsed: ++this.#clock, lastUsedMs: Date.now(), share, resident: true, sessions: 0 };
|
|
615
|
+
this.retention.accessed(rec);
|
|
393
616
|
this.#entries.push(rec);
|
|
617
|
+
this.#records.set(entry, rec);
|
|
618
|
+
this.#associate(sessionId, ns, rec);
|
|
394
619
|
// Exact duplicates (equal tokens) are redundant REGARDLESS of
|
|
395
620
|
// trimmability — the new entry serves exactly the matches the old one
|
|
396
621
|
// did (mlx-lm's trie replaces in place). Without this, every gemma ctx
|
|
@@ -400,6 +625,12 @@ export class PromptCache {
|
|
|
400
625
|
if (old === rec || old.entry.ns !== ns) continue;
|
|
401
626
|
if (old.entry.tokens.length !== tokens.length) continue;
|
|
402
627
|
if (commonPrefixLength(old.entry.tokens, tokens) !== tokens.length) continue;
|
|
628
|
+
// Equivalent publication replaces the cache object, not the checkpoint
|
|
629
|
+
// identity. Every session sharing it follows the replacement.
|
|
630
|
+
if (old.sessions) for (const sessions of this.#sessions.values()) {
|
|
631
|
+
if (sessions.get(ns) !== old) continue;
|
|
632
|
+
sessions.set(ns, rec); old.sessions--; rec.sessions++;
|
|
633
|
+
}
|
|
403
634
|
this.#entries = this.#entries.filter((r) => r !== old);
|
|
404
635
|
discard(old.entry, false);
|
|
405
636
|
}
|
|
@@ -415,39 +646,26 @@ export class PromptCache {
|
|
|
415
646
|
if (trimmable) {
|
|
416
647
|
for (const old of [...this.#entries]) {
|
|
417
648
|
if (old === rec || old.entry.ns !== ns) continue;
|
|
418
|
-
if (old.entry.tokens.length >= tokens.length) continue;
|
|
649
|
+
if (old.entry.tokens.length >= tokens.length || old.entry.tokens.length < minimumReusableOffset(caches)) continue;
|
|
419
650
|
if (commonPrefixLength(old.entry.tokens, tokens) !== old.entry.tokens.length) continue;
|
|
420
651
|
this.#entries = this.#entries.filter((r) => r !== old);
|
|
421
652
|
discard(old.entry, false);
|
|
422
653
|
}
|
|
423
654
|
}
|
|
424
|
-
|
|
425
|
-
let lruIdx = 0;
|
|
426
|
-
for (let i = 1; i < this.#entries.length; i++)
|
|
427
|
-
if (this.#entries[i]!.lastUsed < this.#entries[lruIdx]!.lastUsed) lruIdx = i;
|
|
428
|
-
const [evicted] = this.#entries.splice(lruIdx, 1);
|
|
429
|
-
discard(evicted!.entry, true);
|
|
430
|
-
}
|
|
655
|
+
this.evictToBudget();
|
|
431
656
|
try { this.onPut?.(tokens, ns); } catch {}
|
|
432
657
|
}
|
|
433
658
|
|
|
434
|
-
/**
|
|
435
|
-
*
|
|
436
|
-
* next take() restores it via zero-copy mmap (~0.25 s for a 13.7k-token
|
|
437
|
-
* entry, vs a 12 s re-prefill) — but between bursts the RAM tier drains
|
|
438
|
-
* toward empty, returning unified memory to the system. Caller must
|
|
439
|
-
* hold the generation lock (entries' arrays are disposed here) — with
|
|
440
|
-
* a spillOwned sink the lock covers only the zero-copy clone; the
|
|
441
|
-
* write runs off-lock and the buffers free when it settles. No-op
|
|
442
|
-
* without a cold tier — demotion without a place to demote TO would
|
|
443
|
-
* just be data loss. Returns entries demoted. */
|
|
659
|
+
/** Evict entries older than idleMs. Tiered residency policy first ensures
|
|
660
|
+
* SSD coverage. No-op without a cold tier. Returns entries removed from RAM. */
|
|
444
661
|
demoteIdle(idleMs: number, now = Date.now()): number {
|
|
445
662
|
if (!this.#cold) return 0;
|
|
446
663
|
let n = 0;
|
|
447
664
|
for (const rec of [...this.#entries]) {
|
|
448
|
-
if (now - rec.lastUsedMs < idleMs) continue;
|
|
665
|
+
if (now - rec.lastUsedMs < idleMs || this.#pins.has(rec.entry)) continue;
|
|
666
|
+
if (this.canEvict && !this.canEvict(rec.entry)) continue;
|
|
449
667
|
this.#entries = this.#entries.filter((r) => r !== rec);
|
|
450
|
-
this.#disposeEntry(rec.entry,
|
|
668
|
+
this.#disposeEntry(rec.entry, !this.canEvict); // persisted entries need no duplicate write
|
|
451
669
|
this.demotions++;
|
|
452
670
|
n++;
|
|
453
671
|
}
|
|
@@ -455,6 +673,8 @@ export class PromptCache {
|
|
|
455
673
|
}
|
|
456
674
|
|
|
457
675
|
clear(): void {
|
|
676
|
+
this.#sessions.clear();
|
|
677
|
+
this.#epoch++;
|
|
458
678
|
const entries = this.#entries;
|
|
459
679
|
this.#entries = [];
|
|
460
680
|
disposeResources(entries.map(({ entry }) => ({ dispose: () => this.#disposeEntry(entry, false) })));
|
package/src/runtime-config.ts
CHANGED
|
@@ -52,11 +52,7 @@ export function runtimeValue(name: RuntimeKey): string | undefined {
|
|
|
52
52
|
return runtimeConfig().value(name);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
/**
|
|
56
|
-
* (Formerly src/flags.ts `flagOn` — same resolver, one module.) */
|
|
57
|
-
export function flagOn(name: RuntimeKey, defaultOn: boolean): boolean {
|
|
58
|
-
return runtimeFlag(name, defaultOn);
|
|
59
|
-
}
|
|
55
|
+
/** An explicit "1"/"0" wins; other values retain the caller's default. */
|
|
60
56
|
export function runtimeFlag(name: RuntimeKey, defaultOn: boolean): boolean {
|
|
61
57
|
return runtimeConfig().flag(name, defaultOn);
|
|
62
58
|
}
|
package/src/safetensors.ts
CHANGED
|
@@ -127,8 +127,15 @@ export class ShardedSafetensors {
|
|
|
127
127
|
self.tensorToFile.set(tensor, sf);
|
|
128
128
|
}
|
|
129
129
|
} else {
|
|
130
|
-
|
|
131
|
-
|
|
130
|
+
// Single-file artifacts: HF's model.safetensors, or mlx-whisper's
|
|
131
|
+
// weights.safetensors (mlx-community/whisper-*).
|
|
132
|
+
const file = (await Bun.file(`${modelDir}/model.safetensors`).exists())
|
|
133
|
+
? "model.safetensors"
|
|
134
|
+
: (await Bun.file(`${modelDir}/weights.safetensors`).exists())
|
|
135
|
+
? "weights.safetensors"
|
|
136
|
+
: "model.safetensors";
|
|
137
|
+
const sf = SafetensorsFile.open(`${modelDir}/${file}`);
|
|
138
|
+
self.files.set(file, sf);
|
|
132
139
|
for (const name of sf.tensors.keys()) self.tensorToFile.set(name, sf);
|
|
133
140
|
}
|
|
134
141
|
return self;
|