mlx-bun 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -32
- package/docs/README.md +55 -42
- package/docs/archive/investigations/benchmark-ledger-through-v0.4.0.md +3617 -0
- package/docs/archive/planning/release-notes-v0.5.0.md +37 -0
- package/docs/design/batching.md +220 -7
- package/docs/design/decode-speed-program.md +121 -66
- package/docs/design/generic-model-support.md +116 -0
- package/docs/design/kv-cache.md +380 -211
- package/docs/design/speculative-decoding.md +165 -26
- package/docs/design/turboquant.md +20 -7
- package/docs/design/unified-engine-frontier-plan.md +120 -13
- package/docs/planning/PRODUCT_ROADMAP.md +5 -6
- package/docs/planning/public-readiness-handoff.md +244 -0
- package/docs/planning/public-readiness.md +149 -0
- package/docs/reference/benchmarks.md +3463 -3489
- package/docs/reference/cli.md +62 -1
- package/docs/reference/distribution.md +29 -4
- package/docs/reference/environment.md +104 -8
- package/docs/reference/library-api.md +18 -4
- package/docs/reference/memory.md +1 -1
- package/docs/reference/models.md +17 -5
- package/docs/reference/server-api.md +181 -11
- package/docs/reference/server-config.md +232 -105
- package/package.json +2 -2
- package/src/audio/audiotoolbox.ts +112 -0
- package/src/audio/conformer.ts +8 -0
- package/src/audio/mic-capture.ts +98 -0
- package/src/audio/silero-vad.ts +379 -0
- package/src/audio/whisper-decode.ts +774 -0
- package/src/audio/whisper-fast.ts +732 -0
- package/src/audio/whisper-format.ts +73 -0
- package/src/audio/whisper-mel.ts +185 -0
- package/src/audio/whisper-timing.ts +274 -0
- package/src/audio/whisper-tokenizer.ts +239 -0
- package/src/audio/whisper-transcribe.ts +410 -0
- package/src/backends/mlx/autoregressive.ts +22 -1
- package/src/backends/mlx/batch-group.ts +111 -27
- package/src/backends/mlx/cache-layout.ts +3 -3
- package/src/backends/mlx/continuation-persistence.ts +5 -4
- package/src/backends/mlx/encoder-cache.ts +25 -0
- package/src/backends/mlx/fill-append.ts +21 -8
- package/src/backends/mlx/fill-group.ts +497 -0
- package/src/backends/mlx/gateway-binding.ts +56 -12
- package/src/backends/mlx/kv-maintenance.ts +30 -2
- package/src/backends/mlx/mixed-iteration.ts +62 -0
- package/src/backends/mlx/model-prompt.ts +46 -32
- package/src/backends/mlx/model-serving.ts +4 -3
- package/src/backends/mlx/paged-cache-codec.ts +27 -0
- package/src/backends/mlx/prefill-cohort.ts +15 -4
- package/src/backends/mlx/prefill-policy.ts +26 -0
- package/src/backends/mlx/prefill-rows.ts +57 -13
- package/src/backends/mlx/prompt-input.ts +29 -0
- package/src/backends/mlx/qwen-prompt-input.ts +40 -0
- package/src/backends/mlx/request-state-policy.ts +18 -5
- package/src/backends/mlx/rollback.ts +6 -3
- package/src/backends/mlx/serial-executor.ts +16 -7
- package/src/backends/mlx/speculative-group.ts +105 -26
- package/src/backends/mlx/speculative.ts +24 -7
- package/src/backends/mlx/target-layout-capability.ts +1 -1
- package/src/backends/mlx/transitioning-kv-rows.ts +8 -0
- package/src/cli.ts +322 -0
- package/src/contracts/execution.ts +10 -0
- package/src/contracts/object-cache.ts +8 -0
- package/src/contracts/pi-provider.ts +1 -1
- package/src/contracts/pi.ts +3 -1
- package/src/contracts/prefix-cache.ts +13 -3
- package/src/contracts/scheduling.ts +9 -0
- package/src/engine/execution-plan.ts +23 -12
- package/src/engine/execution-tasks.ts +44 -0
- package/src/engine/namespaced-cache.ts +13 -0
- package/src/engine/scheduler.ts +22 -2
- package/src/fill/fill-session.ts +14 -3
- package/src/generate.ts +28 -22
- package/src/grammar.ts +13 -0
- package/src/inference/prefill.ts +6 -0
- package/src/kv-scheme.ts +22 -0
- package/src/kv-store.ts +115 -79
- package/src/lab/README.md +5 -1
- package/src/lab/paged-kv/paged-attention.ts +117 -0
- package/src/lab/paged-kv/paged-kv-rows.ts +33 -5
- package/src/lab/paged-kv/paged-kv.ts +108 -42
- package/src/md-modules.d.ts +5 -0
- package/src/mlx/array.ts +37 -0
- package/src/mlx/ffi.ts +19 -0
- package/src/mlx/normalized-argmax.ts +116 -0
- package/src/mlx/ops.ts +97 -10
- package/src/model/batched-turboquant-kv.ts +6 -5
- package/src/model/delayed-quantized-kv.ts +3 -3
- package/src/model/delayed-rotating-quantized-kv.ts +1 -1
- package/src/model/delayed-turboquant-kv.ts +6 -4
- package/src/model/factory.ts +26 -0
- package/src/model/fingerprint.ts +12 -0
- package/src/model/full-prefill-row.ts +1 -1
- package/src/model/gemma4-base.ts +25 -6
- package/src/model/gemma4.ts +82 -2
- package/src/model/kv-attention-view.ts +4 -1
- package/src/model/profile.ts +29 -4
- package/src/model/quantized-append-attention.ts +31 -0
- package/src/model/qwen3-mrope.ts +7 -9
- package/src/model/qwen3_5.ts +99 -37
- package/src/model/support.ts +8 -0
- package/src/model/token-groups.ts +66 -0
- package/src/model/trellis-linear.ts +2 -2
- package/src/model/turboquant-codec.ts +6 -0
- package/src/model/whisper.ts +435 -0
- package/src/native/mic_capture.swift +92 -0
- package/src/native-pack.ts +6 -5
- package/src/pi-web.ts +9 -1
- package/src/prompt-cache.ts +265 -43
- package/src/runtime-config.ts +1 -5
- package/src/safetensors.ts +9 -2
- package/src/sampler.ts +5 -4
- package/src/serve/audio-routes.ts +323 -0
- package/src/serve/chat-request.ts +2 -0
- package/src/serve/chat-stage.ts +17 -12
- package/src/serve/completion-executor.ts +9 -1
- package/src/serve/discovery-routes.ts +17 -1
- package/src/serve/generated-token-history.ts +57 -0
- package/src/serve/generation-gateway.ts +52 -11
- package/src/serve/model-binding.ts +7 -1
- package/src/serve/preparation.ts +3 -3
- package/src/serve/prompt-response-trace.ts +14 -0
- package/src/serve/request-plan.ts +2 -0
- package/src/serve/request-prep.ts +38 -21
- package/src/serve/transcription-server.ts +64 -0
- package/src/serve/transcription-service.ts +502 -0
- package/src/server.ts +132 -164
- package/src/spec/assistant-rows.ts +1 -0
- package/src/spec/deepspec-rows.ts +1 -1
- package/src/spec/dflash-source.ts +1 -1
- package/src/spec/glm52-mtp-source.ts +1 -0
- package/src/spec/ngram-source.ts +27 -5
- package/src/spec/projected-draft-rows.ts +2 -2
- package/src/spec/qwen-mtp-rows.ts +11 -0
- package/src/spec/qwen-mtp-source.ts +3 -0
- package/src/spec/serve-loop.ts +5 -3
- package/src/spec/source.ts +14 -1
- package/src/spec/two-model-rows.ts +1 -0
- package/src/spec/two-model.ts +2 -2
- package/src/ssd-cache.ts +76 -17
- package/src/ssd-durability.ts +20 -22
- package/src/storage/host-buffer.ts +24 -0
- package/src/storage/kv-writer.ts +83 -0
- package/src/storage/kv-writer.worker.js +264 -0
- package/src/storage/retention-policy.ts +37 -0
- package/src/tiered-prompt-cache.ts +45 -0
- package/src/vision/embedder.ts +8 -0
- package/src/vision/prompt.ts +101 -65
- package/src/vision/qwen3vl-prompt.ts +101 -64
- package/src/vision/qwen3vl-tower.ts +11 -0
- package/src/vision/siglip.ts +8 -0
- package/src/web/app.html +12 -0
- package/src/web/app.js +160 -0
- package/src/web/src/chat.ts +4 -1
- package/src/web/src/composer.ts +3 -0
- package/src/web/src/voice.ts +141 -0
- package/src/web/sw.js +1 -1
- /package/docs/{planning → archive/planning}/release-notes-v0.4.0.md +0 -0
package/src/prompt-cache.ts
CHANGED
|
@@ -17,16 +17,16 @@ import { withResource, cleanupFailure, disposeResources, ownResource } from "./e
|
|
|
17
17
|
//
|
|
18
18
|
// TIERING (Layer 0, unified-engine plan): when a ColdTier is attached,
|
|
19
19
|
// take() itself runs the two-tier dance — RAM peek vs cold find, restore
|
|
20
|
-
// (
|
|
20
|
+
// (streamed copy) + trim when the cold tier holds a strictly longer
|
|
21
21
|
// prefix — so EVERY consumer (the serial lane, the batch scheduler, future
|
|
22
22
|
// prefix sharing) gets SSD restores through the same take()/put() it
|
|
23
23
|
// already calls. Eviction spills to the tier (the #spill hook); idle
|
|
24
|
-
// entries
|
|
25
|
-
//
|
|
26
|
-
// snapshot for both lanes.
|
|
24
|
+
// entries demote to it when policy permits. TieredPromptCache owns queued
|
|
25
|
+
// persistence and promotes restored prefixes into RAM.
|
|
27
26
|
|
|
28
27
|
import type { Cache } from "./model/gemma4";
|
|
29
28
|
import type { PrefixCache, PrefixCacheHit } from "./contracts/prefix-cache";
|
|
29
|
+
import { LruRetention, type RetentionPolicy, type RetentionCandidate } from "./storage/retention-policy";
|
|
30
30
|
import { cloneKvCaches } from "./kv-store";
|
|
31
31
|
|
|
32
32
|
/** Reference-counted release: wraps an entry's `retain` (e.g. an mmap
|
|
@@ -64,10 +64,14 @@ export interface ColdTier {
|
|
|
64
64
|
/** Longest stored usable prefix for prompt/ns — index-only, no I/O.
|
|
65
65
|
* `handle` is the tier's opaque entry token, passed back to restore. */
|
|
66
66
|
find(prompt: number[], ns: string): { prefixLen: number; handle: unknown } | null;
|
|
67
|
-
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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
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"]>>;
|
|
71
75
|
/** Borrow live caches and finish persistence before returning. A false
|
|
72
76
|
* result prevents a required demotion. */
|
|
73
77
|
store(tokens: number[], caches: Cache[], ns: string, attachments?: CheckpointAttachment[]): boolean | void;
|
|
@@ -121,7 +125,7 @@ export function commonPrefixLength(a: number[], b: number[]): number {
|
|
|
121
125
|
return i;
|
|
122
126
|
}
|
|
123
127
|
|
|
124
|
-
interface EntryRecord {
|
|
128
|
+
interface EntryRecord extends RetentionCandidate {
|
|
125
129
|
entry: PromptCacheEntry;
|
|
126
130
|
bytes: number;
|
|
127
131
|
lastUsed: number;
|
|
@@ -129,12 +133,98 @@ interface EntryRecord {
|
|
|
129
133
|
/** Ref-counted retain: the donor holds one share (entry.retain); every
|
|
130
134
|
* clone handed out by take() holds another. */
|
|
131
135
|
share: { acquire(): () => void };
|
|
136
|
+
resident: boolean;
|
|
137
|
+
sessions: number;
|
|
132
138
|
}
|
|
133
139
|
|
|
134
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
|
+
|
|
135
188
|
readonly maxBytes: number;
|
|
136
189
|
#entries: EntryRecord[] = [];
|
|
137
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
|
+
|
|
138
228
|
hits = 0;
|
|
139
229
|
misses = 0;
|
|
140
230
|
demotions = 0;
|
|
@@ -145,9 +235,98 @@ export class PromptCache implements PrefixCache<Cache[], CheckpointAttachment[]>
|
|
|
145
235
|
readonly #spillSync: ((entry: PromptCacheEntry) => void) | null;
|
|
146
236
|
/** Optional cold tier: take() tiers over it, demoteIdle() spills into it. */
|
|
147
237
|
readonly #cold: ColdTier | null;
|
|
148
|
-
/**
|
|
149
|
-
* write-behind SSD snapshot here so BOTH lanes' entries persist. */
|
|
238
|
+
/** Observer after publication, independent of persistence policy. */
|
|
150
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
|
+
}
|
|
151
330
|
|
|
152
331
|
/** Zero-copy view cloner — injectable so model-free tests can stub it. */
|
|
153
332
|
readonly #clone: (caches: Cache[]) => Cache[];
|
|
@@ -168,6 +347,8 @@ export class PromptCache implements PrefixCache<Cache[], CheckpointAttachment[]>
|
|
|
168
347
|
}
|
|
169
348
|
|
|
170
349
|
#disposeEntry(entry: PromptCacheEntry, spillFirst: boolean): void {
|
|
350
|
+
const record = this.#records.get(entry);
|
|
351
|
+
if (record) record.resident = false;
|
|
171
352
|
if (spillFirst) {
|
|
172
353
|
if (this.#spillOwned) {
|
|
173
354
|
// NON-BLOCKING spill: clone BEFORE disposing (clones are zero-copy
|
|
@@ -221,16 +402,17 @@ export class PromptCache implements PrefixCache<Cache[], CheckpointAttachment[]>
|
|
|
221
402
|
relievePressure(overBudget: () => boolean): number {
|
|
222
403
|
let count = 0;
|
|
223
404
|
while (this.#entries.length && overBudget()) {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const { entry } =
|
|
405
|
+
const victim = this.#victim();
|
|
406
|
+
if (!victim) break;
|
|
407
|
+
const oldest = this.#entries.indexOf(victim);
|
|
408
|
+
const { entry } = victim;
|
|
228
409
|
// Keep the entry resident if persistence throws. A failed SSD write
|
|
229
410
|
// must not be presented as a successful demotion.
|
|
230
411
|
if (this.#cold) {
|
|
231
412
|
if (this.#cold.store(entry.tokens, entry.caches, entry.ns, entry.attachments) === false) break;
|
|
232
413
|
}
|
|
233
414
|
else this.#spillSync?.(entry);
|
|
415
|
+
this.retention.evicted(victim);
|
|
234
416
|
this.#entries.splice(oldest, 1);
|
|
235
417
|
this.#disposeEntry(entry, false);
|
|
236
418
|
this.demotions++;
|
|
@@ -251,12 +433,30 @@ export class PromptCache implements PrefixCache<Cache[], CheckpointAttachment[]>
|
|
|
251
433
|
* wrapped — those entries only match in full.
|
|
252
434
|
*
|
|
253
435
|
* Tier order: the cold tier wins only with a STRICTLY longer usable
|
|
254
|
-
* prefix (
|
|
436
|
+
* prefix (restore reads storage and materializes native arrays). A
|
|
255
437
|
* cold entry with an untrimmable divergent tail (ring post-wrap, SSM)
|
|
256
438
|
* is dropped and the RAM candidate (or a fresh prefill) serves.
|
|
257
439
|
* hits/misses count RAM candidacy only (cold restores are counted by
|
|
258
440
|
* the tier itself), preserving the /stats meaning. */
|
|
259
|
-
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++;
|
|
260
460
|
let bestIdx = -1;
|
|
261
461
|
let bestLen = 0;
|
|
262
462
|
for (let i = 0; i < this.#entries.length; i++) {
|
|
@@ -276,7 +476,7 @@ export class PromptCache implements PrefixCache<Cache[], CheckpointAttachment[]>
|
|
|
276
476
|
// candidate stays put UNTOUCHED (the old serial-lane flow trimmed it
|
|
277
477
|
// before comparing — a needless entry degradation, gone now).
|
|
278
478
|
if (this.#cold) {
|
|
279
|
-
const hit = this.#
|
|
479
|
+
const hit = this.#coldHit(prompt, ns, sessionId);
|
|
280
480
|
if (hit && hit.prefixLen > bestLen) {
|
|
281
481
|
const loaded = this.#cold.restore(hit.handle);
|
|
282
482
|
if (loaded) {
|
|
@@ -296,7 +496,27 @@ export class PromptCache implements PrefixCache<Cache[], CheckpointAttachment[]>
|
|
|
296
496
|
const tokens = entry.tokens.slice(0, hit.prefixLen);
|
|
297
497
|
return { ...owner.transfer(), tokens, ns };
|
|
298
498
|
});
|
|
299
|
-
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
|
+
}
|
|
300
520
|
}
|
|
301
521
|
}
|
|
302
522
|
}
|
|
@@ -312,8 +532,14 @@ export class PromptCache implements PrefixCache<Cache[], CheckpointAttachment[]>
|
|
|
312
532
|
// consume-and-trim semantics CANNIBALIZED donors: agent B borrowing
|
|
313
533
|
// agent A's 2k system prompt destroyed A's 10k entry to do it.
|
|
314
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 {
|
|
315
540
|
rec.lastUsed = ++this.#clock;
|
|
316
541
|
rec.lastUsedMs = Date.now();
|
|
542
|
+
rec.uses++; rec.cost = bestLen; this.retention.accessed(rec);
|
|
317
543
|
// Lend immutable views for every backend state. RAM retention and SSD
|
|
318
544
|
// durability remain cache responsibilities while the caller extends its
|
|
319
545
|
// own views. This keeps synchronous persistence out of a RAM hit.
|
|
@@ -350,9 +576,7 @@ export class PromptCache implements PrefixCache<Cache[], CheckpointAttachment[]>
|
|
|
350
576
|
return best;
|
|
351
577
|
}
|
|
352
578
|
|
|
353
|
-
/** Read-only
|
|
354
|
-
* entry stays owned by the cache; the caller only reads array state,
|
|
355
|
-
* under the gateway lock so no generation is mutating it). */
|
|
579
|
+
/** Read-only lookup of an immutable, cache-owned checkpoint. */
|
|
356
580
|
findExact(tokens: number[], ns = ""): PromptCacheEntry | null {
|
|
357
581
|
for (const { entry: e } of this.#entries) {
|
|
358
582
|
if (e.ns !== ns || e.tokens.length !== tokens.length) continue;
|
|
@@ -364,7 +588,7 @@ export class PromptCache implements PrefixCache<Cache[], CheckpointAttachment[]>
|
|
|
364
588
|
/** Insert (or reinsert) an entry; evicts LRU entries over the byte cap
|
|
365
589
|
* (spilling each to the cold tier first, when one is attached). If the
|
|
366
590
|
* entry itself exceeds the cap it is spilled + disposed, not stored. */
|
|
367
|
-
put(tokens: number[], caches: Cache[], ns = "", retain?: () => void, attachments?: CheckpointAttachment[]): void {
|
|
591
|
+
put(tokens: number[], caches: Cache[], ns = "", retain?: () => void, attachments?: CheckpointAttachment[], sessionId?: string): void {
|
|
368
592
|
// All cache-provided validation runs before ownership is adopted.
|
|
369
593
|
const bytes = cacheBytes(caches) + attachmentBytes(attachments);
|
|
370
594
|
const trimmable = !attachments?.length && caches.every((cache) => cache.isTrimmable());
|
|
@@ -379,7 +603,7 @@ export class PromptCache implements PrefixCache<Cache[], CheckpointAttachment[]>
|
|
|
379
603
|
}
|
|
380
604
|
};
|
|
381
605
|
const entry: PromptCacheEntry = { tokens, caches, ns, retain, attachments };
|
|
382
|
-
if (bytes > this.maxBytes) {
|
|
606
|
+
if (bytes > this.maxBytes && !this.canEvict) {
|
|
383
607
|
discard(entry, true);
|
|
384
608
|
return;
|
|
385
609
|
}
|
|
@@ -387,8 +611,11 @@ export class PromptCache implements PrefixCache<Cache[], CheckpointAttachment[]>
|
|
|
387
611
|
// shares; the entry's own retain is the donor's share.
|
|
388
612
|
const share = makeSharedRetain(retain);
|
|
389
613
|
entry.retain = share.acquire();
|
|
390
|
-
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);
|
|
391
616
|
this.#entries.push(rec);
|
|
617
|
+
this.#records.set(entry, rec);
|
|
618
|
+
this.#associate(sessionId, ns, rec);
|
|
392
619
|
// Exact duplicates (equal tokens) are redundant REGARDLESS of
|
|
393
620
|
// trimmability — the new entry serves exactly the matches the old one
|
|
394
621
|
// did (mlx-lm's trie replaces in place). Without this, every gemma ctx
|
|
@@ -398,6 +625,12 @@ export class PromptCache implements PrefixCache<Cache[], CheckpointAttachment[]>
|
|
|
398
625
|
if (old === rec || old.entry.ns !== ns) continue;
|
|
399
626
|
if (old.entry.tokens.length !== tokens.length) continue;
|
|
400
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
|
+
}
|
|
401
634
|
this.#entries = this.#entries.filter((r) => r !== old);
|
|
402
635
|
discard(old.entry, false);
|
|
403
636
|
}
|
|
@@ -419,33 +652,20 @@ export class PromptCache implements PrefixCache<Cache[], CheckpointAttachment[]>
|
|
|
419
652
|
discard(old.entry, false);
|
|
420
653
|
}
|
|
421
654
|
}
|
|
422
|
-
|
|
423
|
-
let lruIdx = 0;
|
|
424
|
-
for (let i = 1; i < this.#entries.length; i++)
|
|
425
|
-
if (this.#entries[i]!.lastUsed < this.#entries[lruIdx]!.lastUsed) lruIdx = i;
|
|
426
|
-
const [evicted] = this.#entries.splice(lruIdx, 1);
|
|
427
|
-
discard(evicted!.entry, true);
|
|
428
|
-
}
|
|
655
|
+
this.evictToBudget();
|
|
429
656
|
try { this.onPut?.(tokens, ns); } catch {}
|
|
430
657
|
}
|
|
431
658
|
|
|
432
|
-
/**
|
|
433
|
-
*
|
|
434
|
-
* next take() restores it via zero-copy mmap (~0.25 s for a 13.7k-token
|
|
435
|
-
* entry, vs a 12 s re-prefill) — but between bursts the RAM tier drains
|
|
436
|
-
* toward empty, returning unified memory to the system. Caller must
|
|
437
|
-
* hold the generation lock (entries' arrays are disposed here) — with
|
|
438
|
-
* a spillOwned sink the lock covers only the zero-copy clone; the
|
|
439
|
-
* write runs off-lock and the buffers free when it settles. No-op
|
|
440
|
-
* without a cold tier — demotion without a place to demote TO would
|
|
441
|
-
* 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. */
|
|
442
661
|
demoteIdle(idleMs: number, now = Date.now()): number {
|
|
443
662
|
if (!this.#cold) return 0;
|
|
444
663
|
let n = 0;
|
|
445
664
|
for (const rec of [...this.#entries]) {
|
|
446
|
-
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;
|
|
447
667
|
this.#entries = this.#entries.filter((r) => r !== rec);
|
|
448
|
-
this.#disposeEntry(rec.entry,
|
|
668
|
+
this.#disposeEntry(rec.entry, !this.canEvict); // persisted entries need no duplicate write
|
|
449
669
|
this.demotions++;
|
|
450
670
|
n++;
|
|
451
671
|
}
|
|
@@ -453,6 +673,8 @@ export class PromptCache implements PrefixCache<Cache[], CheckpointAttachment[]>
|
|
|
453
673
|
}
|
|
454
674
|
|
|
455
675
|
clear(): void {
|
|
676
|
+
this.#sessions.clear();
|
|
677
|
+
this.#epoch++;
|
|
456
678
|
const entries = this.#entries;
|
|
457
679
|
this.#entries = [];
|
|
458
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;
|
package/src/sampler.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// and the XTC-off path is unchanged.)
|
|
10
10
|
|
|
11
11
|
import { Dtype } from "./mlx/ffi";
|
|
12
|
+
import { normalizedArgmax } from "./mlx/normalized-argmax";
|
|
12
13
|
import { MlxArray } from "./mlx/array";
|
|
13
14
|
import * as ops from "./mlx/ops";
|
|
14
15
|
import type { TokenLogprobs } from "./contracts/generation";
|
|
@@ -826,6 +827,7 @@ export function makeStepSampler(
|
|
|
826
827
|
const captureSelected = config.captureSelectedLogprob === true;
|
|
827
828
|
const captureTop = Math.max(0, config.captureTopLogprobs ?? 0);
|
|
828
829
|
const capture = captureSelected || captureTop > 0;
|
|
830
|
+
const greedyWithoutMetadata = (options.temperature ?? 0) === 0 && !options.curve && !capture && !config.sampler;
|
|
829
831
|
let history: MlxArray | null = null;
|
|
830
832
|
|
|
831
833
|
const seedHistory = (tokens: readonly number[]): void => {
|
|
@@ -888,6 +890,8 @@ export function makeStepSampler(
|
|
|
888
890
|
current = next;
|
|
889
891
|
ownsCurrent = current !== input;
|
|
890
892
|
}
|
|
893
|
+
if (greedyWithoutMetadata)
|
|
894
|
+
return { token: normalizedArgmax(current), extras: null };
|
|
891
895
|
logprobs = toLogprobs(current);
|
|
892
896
|
if (ownsCurrent) current.dispose();
|
|
893
897
|
current = input;
|
|
@@ -994,10 +998,7 @@ export function makeStepSampler(
|
|
|
994
998
|
/** Stateless sampling across rows or verification positions. Preserve the
|
|
995
999
|
* normalized-score argmax, including rounding-created ties. */
|
|
996
1000
|
export const independentGreedySampling = Object.freeze({
|
|
997
|
-
sample
|
|
998
|
-
using logprobs = toLogprobs(logits);
|
|
999
|
-
return ops.argmaxAxis(logprobs, -1);
|
|
1000
|
-
},
|
|
1001
|
+
sample: normalizedArgmax,
|
|
1001
1002
|
});
|
|
1002
1003
|
|
|
1003
1004
|
/** logits [..., V] → logprobs [..., V] (logits - logsumexp). */
|