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/server.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { ContinuationPersistence } from "./backends/mlx/continuation-persistence";
|
|
1
2
|
import { modelServingBinding } from "./backends/mlx/model-serving";
|
|
3
|
+
import { handleAudioRoute } from "./serve/audio-routes";
|
|
4
|
+
import { TranscriptionService, defaultWhisperRecord } from "./serve/transcription-service";
|
|
2
5
|
import type { ServingContext } from "./serve/model-host";
|
|
3
6
|
export { generationCheckpointKey } from "./serve/checkpoint-identity";
|
|
4
7
|
// OpenAI-compatible HTTP server: /v1/chat/completions (+ SSE streaming)
|
|
@@ -25,7 +28,7 @@ import {
|
|
|
25
28
|
type GenerateOptions,
|
|
26
29
|
} from "./generate";
|
|
27
30
|
import { createPreparationExecutor } from "./serve/preparation";
|
|
28
|
-
import { cloneKvCaches, legacyCacheCodecs
|
|
31
|
+
import { cloneKvCaches, legacyCacheCodecs } from "./kv-store";
|
|
29
32
|
import type { Cache } from "./model/gemma4";
|
|
30
33
|
import { resolveKvScheme } from "./kv-scheme";
|
|
31
34
|
import { runtimeValue } from "./runtime-config";
|
|
@@ -48,10 +51,11 @@ import { handleLabRoute } from "./serve/lab-routes";
|
|
|
48
51
|
import { handleModelAdminRoute } from "./serve/model-admin-routes";
|
|
49
52
|
import { handleStaticRoute } from "./serve/static-routes";
|
|
50
53
|
|
|
51
|
-
import { PromptCache
|
|
54
|
+
import { PromptCache } from "./prompt-cache";
|
|
55
|
+
import { CostSizeRetention } from "./storage/retention-policy";
|
|
56
|
+
import { TieredPromptCache } from "./tiered-prompt-cache";
|
|
52
57
|
import { SsdCacheStore } from "./ssd-cache";
|
|
53
58
|
import {
|
|
54
|
-
SsdDurabilityCoordinator,
|
|
55
59
|
type DurabilityFlushResult,
|
|
56
60
|
type DurabilitySnapshotStats,
|
|
57
61
|
} from "./ssd-durability";
|
|
@@ -66,7 +70,7 @@ import {
|
|
|
66
70
|
type ResponsesRequest,
|
|
67
71
|
} from "./responses";
|
|
68
72
|
import { fit } from "./fit";
|
|
69
|
-
import { setMemoryLimit } from "./mlx/ffi";
|
|
73
|
+
import { activeMemory, maxRecommendedWorkingSetSize, setMemoryLimit } from "./mlx/ffi";
|
|
70
74
|
import { makePiWsHandler, type PiWsData } from "./pi-web";
|
|
71
75
|
import { ChatStage } from "./serve/chat-stage";
|
|
72
76
|
import { TextCompletionStage } from "./serve/text-completion-stage";
|
|
@@ -76,6 +80,7 @@ import {
|
|
|
76
80
|
chatCompletionJson, chatCompletionStream, textCompletionJson, textCompletionStream,
|
|
77
81
|
} from "./serve/openai-wire";
|
|
78
82
|
import { createRequestPrep } from "./serve/request-prep";
|
|
83
|
+
import { GeneratedTokenHistory } from "./serve/generated-token-history";
|
|
79
84
|
import {
|
|
80
85
|
detectDraftKind,
|
|
81
86
|
loadContext,
|
|
@@ -109,13 +114,15 @@ export interface ServerOptions {
|
|
|
109
114
|
* (`--kv-budget`, batching-perf-path P3). Joiners whose projected KV
|
|
110
115
|
* (prompt + max_tokens, window-capped) would exceed it QUEUE until rows
|
|
111
116
|
* evict; a request over the budget alone is rejected. Unset = no
|
|
112
|
-
* aggregate cap (
|
|
117
|
+
* aggregate cap (an explicit memoryBudget also limits each request). */
|
|
113
118
|
kvBudgetBytes?: number;
|
|
114
119
|
/** KV quantization override. Unset/"off" is bf16. "config" applies the
|
|
115
120
|
* model's declared mixed-precision kv_config; supported per-layer schemes
|
|
116
121
|
* compose with continuous scheduling. A number forces uniform bits
|
|
117
122
|
* (group size 64, start 0) and uses the preserved serial executor. */
|
|
118
123
|
kvQuant?: "off" | "config" | number;
|
|
124
|
+
/** Optional absolute row offset for KV conversion; omitted preserves start-zero server policy. */
|
|
125
|
+
quantizedKvStart?: number;
|
|
119
126
|
/** TurboQuant scheme (docs/design/turboquant.md): a separate axis from
|
|
120
127
|
* kvQuant above, mutually exclusive with it (`--kv-quant turbo[:k<bits>v
|
|
121
128
|
* <bits>]` sets this instead of kvQuant). Solo-only in v1 — see
|
|
@@ -124,19 +131,13 @@ export interface ServerOptions {
|
|
|
124
131
|
/** OPTIONAL paged KV cache (`--paged-kv`, docs/design/kv-cache.md):
|
|
125
132
|
* vLLM-style block-pool storage for full-attention layers,
|
|
126
133
|
* gather-to-contiguous before the stock SDPA. Default off (unset = the
|
|
127
|
-
* plain KVCache path, byte-identical).
|
|
128
|
-
*
|
|
134
|
+
* plain KVCache path, byte-identical). Gemma4-family, bf16, B1/B>1;
|
|
135
|
+
* startup refuses `--kv-quant`,
|
|
129
136
|
* turbo, and `--draft-model` combinations; paged requests bypass the
|
|
130
137
|
* prompt cache and run uncompiled. Gated bit-exact vs the plain path. */
|
|
131
138
|
pagedKv?: { blockSize?: number };
|
|
132
|
-
/**
|
|
133
|
-
*
|
|
134
|
-
* context are rejected with 400 instead of crashing the GPU: the OOM
|
|
135
|
-
* crash class is UNCATCHABLE (Phase 6 — mlx throws from a Metal
|
|
136
|
-
* completion handler ⇒ std::terminate; optiq serve died exactly this
|
|
137
|
-
* way loading the 26B). Also caps the mlx allocator
|
|
138
|
-
* (mlx_set_memory_limit) as defense in depth. Default: machine RAM ×
|
|
139
|
-
* WIRED_FRACTION, admission check only, allocator untouched. */
|
|
139
|
+
/** Explicit opt-in process budget. Enforces the fit estimate's context
|
|
140
|
+
* limit and sets the MLX allocator limit. Unset keeps estimates advisory. */
|
|
140
141
|
memoryBudgetBytes?: number;
|
|
141
142
|
/** Who owns this server's lifetime: "serve" (persistent, mlx-bun
|
|
142
143
|
* serve) or "pi-session" (dies with the pi session that started
|
|
@@ -166,6 +167,16 @@ export interface ServerOptions {
|
|
|
166
167
|
defaultTemperature?: number;
|
|
167
168
|
defaultTopP?: number;
|
|
168
169
|
defaultTopK?: number;
|
|
170
|
+
/** Whisper checkpoint directory for /v1/audio/* (`--whisper-model`).
|
|
171
|
+
* Unset ⇒ the first downloaded `whisper` checkpoint is used on demand. */
|
|
172
|
+
whisperModelDir?: string;
|
|
173
|
+
/** Display id for the Whisper checkpoint. */
|
|
174
|
+
whisperModelId?: string;
|
|
175
|
+
/** Seconds idle before the Whisper weights are disposed
|
|
176
|
+
* (`--whisper-idle-unload`, default 300; 0 keeps them resident). */
|
|
177
|
+
whisperIdleUnloadSec?: number;
|
|
178
|
+
/** Keep the Whisper weights resident (`--whisper-resident`). */
|
|
179
|
+
whisperResident?: boolean;
|
|
169
180
|
/** Completion cap when the request omits max_tokens (`--max-tokens`).
|
|
170
181
|
* mlx_lm.server's flag; its default there is 512 — ours stays 65,536 so
|
|
171
182
|
* thinking traces never truncate. `--max-tokens 512` = mlx-lm behavior. */
|
|
@@ -287,16 +298,8 @@ export interface ContextAdmissionDecision {
|
|
|
287
298
|
clamped: boolean;
|
|
288
299
|
}
|
|
289
300
|
|
|
290
|
-
/** Resolve a
|
|
291
|
-
*
|
|
292
|
-
* `max_tokens` is a ceiling, not a promise that every token will be emitted:
|
|
293
|
-
* when a prompt fits but a broad client-wide `max_tokens` would push the
|
|
294
|
-
* reservation past the safe context, the bound is CLAMPED to the remaining
|
|
295
|
-
* room instead of rejecting an otherwise valid request (the pre-v0.0.13
|
|
296
|
-
* behavior 400'd a prompt with thousands of tokens of generation room over a
|
|
297
|
-
* 17-token overshoot). This never weakens the OOM guard — generation stops at
|
|
298
|
-
* the clamped ceiling, inside the admitted context. Only a prompt that leaves
|
|
299
|
-
* no generation slot at all is rejected. */
|
|
301
|
+
/** Resolve a completion cap against an explicitly enforced context limit.
|
|
302
|
+
* Serving keeps fit estimates advisory unless the operator chooses a budget. */
|
|
300
303
|
export function admitRequestContext(
|
|
301
304
|
promptTokens: number,
|
|
302
305
|
requestedMaxTokens: number,
|
|
@@ -333,8 +336,7 @@ export function createServer(
|
|
|
333
336
|
// The scheduler chooses its B=1 fast path or B=N step from active rows.
|
|
334
337
|
// Both full-attention (CPM) and
|
|
335
338
|
// sliding-window (Gemma) models batch — the scheduler assembles each layer's
|
|
336
|
-
// cache by attention type.
|
|
337
|
-
// user seed / unsupported explicit kv-quant) drain to the serial executor
|
|
339
|
+
// cache by attention type. Unsupported model/method combinations drain to the serial executor
|
|
338
340
|
// (see GenerationGateway.place). No inference setting is rewritten.
|
|
339
341
|
// DEFAULT 8 (flipped 2026-07-05, Josh's call, after GATE-B1-SPEED): a
|
|
340
342
|
// lone request through the batch lane IS the serial engine (adopted
|
|
@@ -352,8 +354,7 @@ export function createServer(
|
|
|
352
354
|
throw new Error("--generation-checkpoint expects a positive integer token interval");
|
|
353
355
|
if (!serverOptions.ssdCacheDir)
|
|
354
356
|
throw new Error("--generation-checkpoint requires --ssd-cache <dir>");
|
|
355
|
-
|
|
356
|
-
throw new Error("--generation-checkpoint requires --batch 1 (in-flight resume is serial-only)");
|
|
357
|
+
|
|
357
358
|
}
|
|
358
359
|
const defaultGeneratedTokens =
|
|
359
360
|
serverOptions.defaultMaxTokens ?? ctx.glmMemoryPlan?.maxGenerationTokens;
|
|
@@ -376,32 +377,11 @@ export function createServer(
|
|
|
376
377
|
const resolvedKvScheme = resolveKvScheme({
|
|
377
378
|
override: serverOptions.kvQuant,
|
|
378
379
|
turboQuant: serverOptions.turboQuant,
|
|
380
|
+
quantizedKvStart: serverOptions.quantizedKvStart,
|
|
379
381
|
config: ctx.kvConfig,
|
|
380
382
|
});
|
|
381
383
|
const kvScheme = resolvedKvScheme.generationOptions;
|
|
382
|
-
//
|
|
383
|
-
// scheduler applies the mixed scheme per row); uniform kvBits and configs
|
|
384
|
-
// touching rotating layers still route those requests serial. The warning
|
|
385
|
-
// only fires for the still-serial compositions.
|
|
386
|
-
if (batch > 1 && kvScheme.kvBits)
|
|
387
|
-
console.warn(
|
|
388
|
-
`[batch] --batch ${batch} with uniform --kv-quant ${kvScheme.kvBits}: uniform ` +
|
|
389
|
-
`quantized KV is serial-only (it touches rotating layers) — those requests ` +
|
|
390
|
-
`won't batch. Use --kv-quant config for batched mixed-precision KV, or omit ` +
|
|
391
|
-
`--kv-quant to batch in bf16. (docs/design/unified-engine-frontier-plan.md)`,
|
|
392
|
-
);
|
|
393
|
-
// TurboQuant is solo-only in v1 (novel cache class, not batchable by
|
|
394
|
-
// construction — see GenerationGateway placement's cache-capability gate).
|
|
395
|
-
if (batch > 1 && kvScheme.turboQuant)
|
|
396
|
-
console.warn(
|
|
397
|
-
`[batch] --batch ${batch} with --kv-quant turbo: TurboQuant is serial-only in v1 ` +
|
|
398
|
-
`— those requests won't batch. Omit --kv-quant to batch in bf16. ` +
|
|
399
|
-
`(docs/design/turboquant.md)`,
|
|
400
|
-
);
|
|
401
|
-
// Quantized KV (any axis) excludes the spec lane: drafted requests fall to
|
|
402
|
-
// the normal serial path WITH the KV scheme applied rather than losing it
|
|
403
|
-
// silently (mirrors the affine kvBits/kvConfig exclusion; the spec loop is
|
|
404
|
-
// bf16-KV-only in v1).
|
|
384
|
+
// The model execution plan qualifies the method while retaining KV policy.
|
|
405
385
|
if (ctx.draft && (kvScheme.turboQuant || kvScheme.kvBits || kvScheme.kvConfig?.length))
|
|
406
386
|
console.warn(
|
|
407
387
|
`[spec] --draft-model with quantized KV (--kv-quant ${serverOptions.turboQuant ? "turbo" : String(serverOptions.kvQuant)}): ` +
|
|
@@ -427,19 +407,12 @@ export function createServer(
|
|
|
427
407
|
}
|
|
428
408
|
// Paged KV v1 (docs/design/kv-cache.md): explicit refusals, not
|
|
429
409
|
// silent downgrades — the incompatible combos would otherwise degrade
|
|
430
|
-
// quietly (
|
|
431
|
-
//
|
|
432
|
-
// exists to prevent; draft: the spec lane assumes serial-class caches).
|
|
410
|
+
// quietly (kv-quant: the swap would drop the scheme; speculative
|
|
411
|
+
// provider composition with paged storage is not implemented).
|
|
433
412
|
if (serverOptions.pagedKv) {
|
|
434
|
-
if (
|
|
413
|
+
if (kvScheme.kvConfig?.length || kvScheme.turboQuant)
|
|
435
414
|
throw new Error(
|
|
436
|
-
`--paged-kv
|
|
437
|
-
`Batched paging is the follow-up PR (docs/design/kv-cache.md).`,
|
|
438
|
-
);
|
|
439
|
-
if (kvScheme.kvBits || kvScheme.kvConfig?.length || kvScheme.turboQuant)
|
|
440
|
-
throw new Error(
|
|
441
|
-
`--paged-kv is bf16-only in v1 — omit --kv-quant (quantized paged ` +
|
|
442
|
-
`blocks are a documented follow-up, docs/design/kv-cache.md).`,
|
|
415
|
+
`--paged-kv supports bf16 and uniform affine KV4/KV8; per-layer and TurboQuant pages are not implemented.`,
|
|
443
416
|
);
|
|
444
417
|
if (ctx.draft)
|
|
445
418
|
throw new Error(
|
|
@@ -454,11 +427,7 @@ export function createServer(
|
|
|
454
427
|
const bs = serverOptions.pagedKv.blockSize;
|
|
455
428
|
if (bs !== undefined && (!Number.isInteger(bs) || bs <= 0))
|
|
456
429
|
throw new Error(`--paged-kv-block-size must be a positive integer (got ${bs})`);
|
|
457
|
-
|
|
458
|
-
console.warn(
|
|
459
|
-
"[paged-kv] --ssd-cache has no effect: paged requests bypass the prompt " +
|
|
460
|
-
"cache (v1 non-goal), so nothing reaches the SSD tier.",
|
|
461
|
-
);
|
|
430
|
+
|
|
462
431
|
}
|
|
463
432
|
|
|
464
433
|
// SSD cold tier (docs/design/kv-cache.md): prefix KV survives RAM
|
|
@@ -493,6 +462,8 @@ export function createServer(
|
|
|
493
462
|
tokenizerHash: Bun.hash(tokJson).toString(16),
|
|
494
463
|
modelId: ctx.modelId,
|
|
495
464
|
verify: serverOptions.ssdCacheVerify,
|
|
465
|
+
storage: { layout: runtimeValue("MLX_BUN_SSD_LAYOUT") === "blocks" ? "blocks" : "whole",
|
|
466
|
+
segmented: runtimeValue("MLX_BUN_SSD_SEGMENTED") !== "0" },
|
|
496
467
|
});
|
|
497
468
|
const recovered = ssdStore.scan();
|
|
498
469
|
const capacity = Number.isFinite(ssdStore.maxBytes)
|
|
@@ -514,6 +485,15 @@ export function createServer(
|
|
|
514
485
|
const h = ssdStore!.find(prompt, ns);
|
|
515
486
|
return h ? { prefixLen: h.prefixLen, handle: h.entry } : null;
|
|
516
487
|
},
|
|
488
|
+
findExact: (tokens: number[], ns: string) => {
|
|
489
|
+
const hit = ssdStore!.findExact(tokens, ns);
|
|
490
|
+
return hit ? { prefixLen: hit.prefixLen, handle: hit.entry } : null;
|
|
491
|
+
},
|
|
492
|
+
restoreObjectAsync: async (handle: unknown) => {
|
|
493
|
+
const loaded = await ssdStore!.restoreAsync(handle as import("./ssd-cache").SsdIndexEntry,
|
|
494
|
+
{ makeCache: () => [] });
|
|
495
|
+
return loaded ? { ...loaded, retain: () => {} } : null;
|
|
496
|
+
},
|
|
517
497
|
restore: (handle: unknown) => {
|
|
518
498
|
const loaded = serving.restore(ssdStore!, handle as import("./ssd-cache").SsdIndexEntry);
|
|
519
499
|
if (!loaded) return null;
|
|
@@ -521,219 +501,138 @@ export function createServer(
|
|
|
521
501
|
// own their bytes and no mapping outlives loadKvCache — nothing
|
|
522
502
|
// to pin, nothing to unmap. retain stays in the entry contract
|
|
523
503
|
// as a no-op so callers' dispose ordering is unchanged.
|
|
524
|
-
return { tokens: loaded.tokens, caches: loaded.caches, retain: () => {} };
|
|
504
|
+
return { tokens: loaded.tokens, caches: loaded.caches, attachments: loaded.attachments, retain: () => {} };
|
|
525
505
|
},
|
|
526
|
-
|
|
506
|
+
...(serving.restoreAsync && runtimeValue("MLX_BUN_SSD_PREFETCH") !== "0" ? {
|
|
507
|
+
restoreAsync: async (handle: unknown) => {
|
|
508
|
+
const loaded = await serving.restoreAsync!(ssdStore!, handle as import("./ssd-cache").SsdIndexEntry);
|
|
509
|
+
return loaded ? { ...loaded, retain: () => {} } : null;
|
|
510
|
+
},
|
|
511
|
+
} : {}),
|
|
512
|
+
store: (tokens: number[], caches: import("./model/gemma4").Cache[], ns: string,
|
|
513
|
+
attachments?: import("./backends/mlx/checkpoint-state").CheckpointAttachment[]) => {
|
|
527
514
|
if (ssdStore!.hasDurablePrefix(tokens, ns)) return true;
|
|
528
|
-
return ssdStore!.store(tokens, caches, ns);
|
|
515
|
+
return ssdStore!.store(tokens, caches, ns, attachments);
|
|
529
516
|
},
|
|
530
517
|
}
|
|
531
518
|
: null;
|
|
532
|
-
const promptCache =
|
|
533
|
-
promptCacheCap,
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
// SpillQueue -> storeAsync (generation-locked per tensor, see below) ->
|
|
540
|
-
// clone disposal on every settle/drop path (that dispose is what
|
|
541
|
-
// actually frees the demoted GPU memory; the queue's byte cap keeps
|
|
542
|
-
// starved-gate retention bounded — 2026-07-07 review fix). spillQueue
|
|
543
|
-
// is declared with the snapshot scheduler below; safe to close over
|
|
544
|
-
// here because spills only fire at put()/demoteIdle time, long after
|
|
545
|
-
// init.
|
|
546
|
-
ssdStore
|
|
547
|
-
? {
|
|
548
|
-
spillOwned: (entry) =>
|
|
549
|
-
spillQueue!.enqueue({ tokens: entry.tokens, caches: entry.caches, ns: entry.ns }),
|
|
550
|
-
}
|
|
551
|
-
: null,
|
|
552
|
-
coldTier,
|
|
553
|
-
cloneState,
|
|
554
|
-
);
|
|
519
|
+
const promptCache = ssdStore && coldTier
|
|
520
|
+
? new TieredPromptCache(promptCacheCap, ssdStore, coldTier, cloneState,
|
|
521
|
+
runtimeValue("MLX_BUN_SSD_WRITEBEHIND") !== "0")
|
|
522
|
+
: new PromptCache(promptCacheCap, null, null, cloneState);
|
|
523
|
+
if (runtimeValue("MLX_BUN_CACHE_RETENTION") === "cost-size") promptCache.retention = new CostSizeRetention();
|
|
524
|
+
const spillQueue = promptCache instanceof TieredPromptCache ? promptCache.spillQueue : null;
|
|
525
|
+
const durability = promptCache instanceof TieredPromptCache ? promptCache.durability : null;
|
|
555
526
|
// Responses-API store for previous_response_id resumption (Phase 11):
|
|
556
527
|
// TTL + byte-capped LRU, port of optiq/response_store.py. Pairs with
|
|
557
528
|
// the prompt cache: a resumed conversation re-renders the same prefix,
|
|
558
529
|
// so its KV prefill is already cached.
|
|
559
530
|
const responseStore = new ResponseStore();
|
|
560
531
|
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
// transient. The active kv-quant scheme (uniform kvBits / per-layer
|
|
564
|
-
// kvConfig) is billed at its true bytes/element so a quantized cache
|
|
565
|
-
// advertises and admits the larger window it actually enables; only
|
|
566
|
-
// TurboQuant still bills bf16 (conservative — no projector for its
|
|
567
|
-
// layout yet, and it is solo-only in v1).
|
|
532
|
+
// Memory estimates remain available in diagnostics. Only an explicit
|
|
533
|
+
// operator budget or context cap constrains request planning.
|
|
568
534
|
const memoryAdmission = ctx.glmMemoryPlan ?? fit(
|
|
569
535
|
ctx.model.config, ctx.model.weightsBytes, 1,
|
|
570
536
|
undefined, undefined, 0, serverOptions.memoryBudgetBytes,
|
|
571
537
|
resolvedKvScheme.fitOptions,
|
|
572
538
|
);
|
|
573
|
-
// Isolated benchmark profile cap. It may narrow physical admission, never
|
|
574
|
-
// enlarge it. The normal request planner still clamps prompt + output.
|
|
575
539
|
const profileContext = runtimeValue("MLX_BUN_RD_CONTEXT_LIMIT");
|
|
576
|
-
const
|
|
577
|
-
if (
|
|
540
|
+
const profileLimit = profileContext === undefined ? null : Number(profileContext);
|
|
541
|
+
if (profileLimit !== null && (!Number.isSafeInteger(profileLimit) || profileLimit < 1))
|
|
578
542
|
throw new Error("MLX_BUN_RD_CONTEXT_LIMIT must be a positive integer");
|
|
579
|
-
const
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
// still fires, with this same ceiling) or be a false positive from the
|
|
585
|
-
// fit model itself — it can never save anything the request gate
|
|
586
|
-
// doesn't. Serve until physically incapable.
|
|
587
|
-
if (admission.maxSafeContext < 1)
|
|
588
|
-
console.warn(
|
|
589
|
-
`[admission] memory budget ${(admission.usableBytes / 1e9).toFixed(2)} GB leaves no ` +
|
|
590
|
-
`safe context for ${ctx.modelId} (weights ${(ctx.model.weightsBytes / 1e9).toFixed(2)} GB) ` +
|
|
591
|
-
`— serving anyway; generation requests will be refused until the budget is raised`,
|
|
592
|
-
);
|
|
543
|
+
const budgetLimit = serverOptions.memoryBudgetBytes !== undefined
|
|
544
|
+
? memoryAdmission.maxSafeContext : ctx.glmMemoryPlan?.contextTokens ?? null;
|
|
545
|
+
const contextLimit = profileLimit === null ? budgetLimit
|
|
546
|
+
: Math.min(profileLimit, budgetLimit ?? Infinity);
|
|
547
|
+
const admission = memoryAdmission;
|
|
593
548
|
const allocatorLimit =
|
|
594
549
|
admission.allocatorLimitBytes ?? serverOptions.memoryBudgetBytes;
|
|
595
550
|
if (allocatorLimit) setMemoryLimit(allocatorLimit);
|
|
596
551
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
promptCache,
|
|
601
|
-
};
|
|
602
|
-
const runGeneration = serving.createSerial({
|
|
552
|
+
let checkpointPersistence: ContinuationPersistence | undefined;
|
|
553
|
+
const executionServices: import("./backends/mlx/serial-executor").MlxSerialServices = {
|
|
554
|
+
get checkpointPersistence() { return checkpointPersistence; },
|
|
603
555
|
promptCache, checkpoints: ssdStore,
|
|
604
|
-
memoryBudget,
|
|
605
556
|
checkpointEveryTokens: serverOptions.generationCheckpointTokens,
|
|
606
557
|
identity: { artifact: ctx.profile.artifact, implementation: ctx.profile.profile.execution,
|
|
607
558
|
stateAbi: "legacy-cache-array-v1", codecs: stateCodecs.id },
|
|
608
559
|
adapterNamespace: (adapters) => ctx.adapters.cacheNamespace(adapters),
|
|
609
560
|
cloneState,
|
|
610
|
-
}
|
|
561
|
+
};
|
|
562
|
+
serving.gateway.configureContinuation?.(executionServices);
|
|
563
|
+
const runGeneration = serving.createSerial(executionServices);
|
|
611
564
|
|
|
612
|
-
//
|
|
613
|
-
//
|
|
614
|
-
// clean exit): after a request completes, snapshot its still-RAM-resident
|
|
615
|
-
// entry to SSD off the request path. Debounced + coalesced per namespace —
|
|
616
|
-
// an agent hammering one conversation persists the settled state once
|
|
617
|
-
// things go quiet, not every turn. Runs under the gateway lock (byte
|
|
618
|
-
// extraction must not race a generation mutating the entry); if the entry
|
|
619
|
-
// was evicted (already spilled) or extended (a newer schedule pending)
|
|
620
|
-
// meanwhile, findExact misses and nothing is written.
|
|
621
|
-
// Keyed by namespace and exact tokens: a sub-second final [prompt+gen] put
|
|
622
|
-
// must not cancel the pending boundary snapshot, and unrelated prompts of
|
|
623
|
-
// equal length must not cancel each other. Exact reschedules still
|
|
624
|
-
// coalesce; stale keys self-clean when findExact misses.
|
|
625
|
-
//
|
|
626
|
-
// NON-BLOCKING (2026-07-06, the write-behind persistence contract): the
|
|
627
|
-
// gateway lock is held only for a zero-copy SNAPSHOT (findExact +
|
|
628
|
-
// cloneKvCaches — microseconds; entries are immutable so the clones are
|
|
629
|
-
// consistent forever). The flush itself runs OFF the lock via
|
|
630
|
-
// storeAsync, and writes chain serially so two multi-hundred-MB flushes
|
|
631
|
-
// never overlap.
|
|
632
|
-
//
|
|
633
|
-
// IDLE-GATED (2026-07-07, the decode@ctx fix): "off the lock" was not
|
|
634
|
-
// enough — every per-tensor flush step is a blocking GPU sync on the
|
|
635
|
-
// SAME stream decode uses (ops.contiguous enqueues a kernel, rawBytesView
|
|
636
|
-
// evals) plus a synchronous multi-MB writeSync, and the setImmediate
|
|
637
|
-
// pacing interleaved those slices exactly between decode tokens. A ~16k
|
|
638
|
-
// entry's flush overlapping the bench's ctx repeats depressed decode@ctx
|
|
639
|
-
// ~9% on e4b (mlx-lm runs no equivalent background work). Now every step
|
|
640
|
-
// — including the first — runs through gateway.runExclusive(), so the
|
|
641
|
-
// flush only progresses while NOTHING is generating and pauses when a
|
|
642
|
-
// request arrives mid-flush. Tradeoffs, accepted: durability waits for a
|
|
643
|
-
// quiet moment (single-user serving quiesces constantly; sustained
|
|
644
|
-
// hammering defers the flush AND the spill clones' GPU-memory release —
|
|
645
|
-
// bounded by the chain). MLX_BUN_SSD_WRITEBEHIND=0
|
|
646
|
-
// disables write-behind snapshots entirely — the paired-A/B lever + kill
|
|
647
|
-
// switch (restart survival then degrades to spill-on-evict only).
|
|
648
|
-
const writeBehindOn = runtimeValue("MLX_BUN_SSD_WRITEBEHIND") !== "0";
|
|
649
|
-
const ssdFlushStep = <T>(step: () => T): Promise<T> =>
|
|
650
|
-
gateway.runExclusive(async () => step());
|
|
651
|
-
// Bounded write-behind queue (2026-07-07 review fix — see SpillQueue in
|
|
652
|
-
// kv-store.ts): pending clones pin their entries' GPU buffers while the
|
|
653
|
-
// idle gate starves under sustained traffic, so QUEUED bytes are capped —
|
|
654
|
-
// over cap the oldest queued spill drops (clone disposed immediately, a
|
|
655
|
-
// future cache miss, never a wrong result) instead of accumulating past
|
|
656
|
-
// the prompt-cache cap. Default 2 GB (a quarter of the 8 GB RAM-cache
|
|
657
|
-
// default); MLX_BUN_SSD_SPILL_QUEUE_GB overrides. The durability
|
|
658
|
-
// coordinator retains a dirty record until the queue confirms the atomic
|
|
659
|
-
// store. Explicit flush and graceful shutdown can therefore retry a drop.
|
|
660
|
-
// `|| 2` would coerce an explicit "0" back to 2 GB — parse so 0 works
|
|
661
|
-
// (cap 0 = keep only the newest + in-flight clone pinned; the soft cap
|
|
662
|
-
// never drops the item just enqueued).
|
|
663
|
-
// The gateway exists before the spill queue because the SSD writer uses
|
|
664
|
-
// its idle boundary to avoid competing with generation.
|
|
565
|
+
// Cache policy owns RAM residency and SSD persistence independently of
|
|
566
|
+
// request scheduling. Published state is immutable.
|
|
665
567
|
const gateway = new GenerationGateway(serving.gateway, batch, runGeneration, {
|
|
666
568
|
kvBudgetBytes: serverOptions.kvBudgetBytes,
|
|
667
|
-
memoryBudget,
|
|
668
569
|
checkpoints: !!(serverOptions.generationCheckpointTokens && ssdStore),
|
|
669
570
|
stateCodecs,
|
|
670
571
|
kvScheme: resolvedKvScheme,
|
|
671
572
|
promptCache,
|
|
672
|
-
|
|
573
|
+
adapterNamespace: (adapters) => ctx.adapters.cacheNamespace(adapters),
|
|
673
574
|
});
|
|
674
575
|
const spillQueueGbRaw = Number(runtimeValue("MLX_BUN_SSD_SPILL_QUEUE_GB"));
|
|
675
576
|
const spillQueueCapBytes =
|
|
676
577
|
(Number.isFinite(spillQueueGbRaw) && spillQueueGbRaw >= 0 ? spillQueueGbRaw : 2) * 1024 ** 3;
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
const
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
const durabilityStats = (): DurabilitySnapshotStats =>
|
|
695
|
-
durability?.stats ?? {
|
|
578
|
+
if (ssdStore && serverOptions.generationCheckpointTokens)
|
|
579
|
+
checkpointPersistence = new ContinuationPersistence(ssdStore, {
|
|
580
|
+
maxBytes: spillQueueCapBytes,
|
|
581
|
+
});
|
|
582
|
+
// Keep allocator headroom for the next forward. Only optional cache
|
|
583
|
+
// snapshots are reclaimed; context, batch size and sampling stay intact.
|
|
584
|
+
// Do not count MLX's reusable pool as live state or await an SSD write.
|
|
585
|
+
const cacheWorkingSet = Math.min(maxRecommendedWorkingSetSize(), allocatorLimit ?? Infinity);
|
|
586
|
+
const cacheOverBudget = () => Math.max(activeMemory(),
|
|
587
|
+
ctx.model.weightsBytes + Math.max(promptCache.totalBytes, spillQueue?.pendingBytes ?? 0)) > cacheWorkingSet * 0.85;
|
|
588
|
+
promptCache.pressure = {
|
|
589
|
+
// File-backed weights may not yet appear in MLX's active allocations
|
|
590
|
+
// before the first forward. Account for their known footprint as well.
|
|
591
|
+
overBudget: cacheOverBudget,
|
|
592
|
+
};
|
|
593
|
+
const durabilityStats = (): DurabilitySnapshotStats => {
|
|
594
|
+
const stats = durability?.stats ?? {
|
|
696
595
|
pendingSnapshots: 0,
|
|
697
596
|
pendingSpills: spillQueue?.pendingCount ?? 0,
|
|
698
597
|
pendingSpillBytes: spillQueue?.pendingBytes ?? 0,
|
|
699
598
|
droppedSpills: spillQueue?.droppedCount ?? 0,
|
|
700
599
|
failedSpills: spillQueue?.failedCount ?? 0,
|
|
701
600
|
};
|
|
601
|
+
const checkpoints = checkpointPersistence?.stats;
|
|
602
|
+
return { ...stats, pendingSpills: stats.pendingSpills + (checkpoints?.pendingCount ?? 0),
|
|
603
|
+
pendingSpillBytes: stats.pendingSpillBytes + (checkpoints?.pendingBytes ?? 0),
|
|
604
|
+
droppedSpills: stats.droppedSpills + (checkpoints?.dropped ?? 0),
|
|
605
|
+
failedSpills: stats.failedSpills + (checkpoints?.failed ?? 0) };
|
|
606
|
+
};
|
|
702
607
|
const flushDurability = async (): Promise<DurabilityFlushResult> => {
|
|
703
|
-
if (durability) return durability.flush();
|
|
704
608
|
const started = performance.now();
|
|
705
|
-
await
|
|
609
|
+
const checkpoints = await checkpointPersistence?.flush();
|
|
610
|
+
const result = durability ? await durability.flush() : null;
|
|
611
|
+
if (!result) await spillQueue?.drain();
|
|
706
612
|
const stats = durabilityStats();
|
|
707
613
|
return {
|
|
708
614
|
...stats,
|
|
709
|
-
durable: stats.pendingSpills === 0,
|
|
710
|
-
flushedSnapshots: 0,
|
|
711
|
-
missingSnapshots: 0,
|
|
615
|
+
durable: (result?.durable ?? stats.pendingSpills === 0) && (checkpoints?.durable ?? true) && stats.pendingSpills === 0,
|
|
616
|
+
flushedSnapshots: result?.flushedSnapshots ?? 0,
|
|
617
|
+
missingSnapshots: result?.missingSnapshots ?? 0,
|
|
712
618
|
elapsedMs: performance.now() - started,
|
|
713
619
|
};
|
|
714
620
|
};
|
|
715
|
-
//
|
|
716
|
-
|
|
717
|
-
|
|
621
|
+
// Remember exact generated IDs independently of cache persistence.
|
|
622
|
+
const tokenHistory = new GeneratedTokenHistory(ctx.tokenizer);
|
|
623
|
+
if (ssdStore) for (const tokens of ssdStore.tokenPrefixes()) tokenHistory.remember(tokens);
|
|
624
|
+
promptCache.onPut = (tokens) => {
|
|
625
|
+
tokenHistory.remember(tokens);
|
|
626
|
+
};
|
|
718
627
|
|
|
719
|
-
//
|
|
720
|
-
//
|
|
721
|
-
// bursts while every prefix stays reachable (take() restores via
|
|
722
|
-
// zero-copy mmap). The exclusive section below covers only the sweep's
|
|
723
|
-
// zero-copy clone + dispose (spillOwned above); the SSD write itself
|
|
724
|
-
// runs off-lock on ssdWriteChain. Swept only when the engine is TRULY idle: the
|
|
725
|
-
// runExclusive below registers as a serial waiter, which would DRAIN a
|
|
726
|
-
// running batch, so the activity check guards it (a momentary exclusive
|
|
727
|
-
// grab of an idle engine is free). 0 disables.
|
|
628
|
+
// Cache age, not scheduler activity, decides idle demotion. The cache
|
|
629
|
+
// queues any missing SSD copy and retains RAM until that copy commits.
|
|
728
630
|
const demoteIdleMs = (serverOptions.ssdDemoteIdleSec ?? (ssdStore ? 300 : 0)) * 1000;
|
|
729
631
|
let demoteTimer: ReturnType<typeof setInterval> | null = null;
|
|
730
632
|
if (ssdStore && demoteIdleMs > 0) {
|
|
731
633
|
demoteTimer = setInterval(() => {
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
const n = promptCache.demoteIdle(demoteIdleMs);
|
|
735
|
-
if (n > 0) console.log(`[ssd-cache] demoted ${n} idle entr${n === 1 ? "y" : "ies"} to disk`);
|
|
736
|
-
}).catch(() => {});
|
|
634
|
+
const n = promptCache.demoteIdle(demoteIdleMs);
|
|
635
|
+
if (n > 0) console.log(`[ssd-cache] demoted ${n} idle entr${n === 1 ? "y" : "ies"} to disk`);
|
|
737
636
|
}, Math.max(30_000, demoteIdleMs / 4));
|
|
738
637
|
demoteTimer.unref?.();
|
|
739
638
|
}
|
|
@@ -743,7 +642,33 @@ export function createServer(
|
|
|
743
642
|
|
|
744
643
|
// /library response cache (30 s) — registry + config reads only.
|
|
745
644
|
const startedAt = Date.now();
|
|
746
|
-
|
|
645
|
+
// Speech-to-text companion (docs/reference/server-api.md "Audio
|
|
646
|
+
// transcription"): a Whisper checkpoint served beside the chat model,
|
|
647
|
+
// loaded on the first /v1/audio request and paged out after
|
|
648
|
+
// --whisper-idle-unload seconds. Explicit --whisper-model wins; otherwise
|
|
649
|
+
// the first downloaded `whisper` checkpoint is resolved lazily.
|
|
650
|
+
let transcription: TranscriptionService | null = null;
|
|
651
|
+
let transcriptionResolved = false;
|
|
652
|
+
const transcriptionService = async (): Promise<TranscriptionService | null> => {
|
|
653
|
+
if (transcription || transcriptionResolved) return transcription;
|
|
654
|
+
transcriptionResolved = true;
|
|
655
|
+
const rec = serverOptions.whisperModelDir
|
|
656
|
+
? { path: serverOptions.whisperModelDir, repoId: serverOptions.whisperModelId ?? serverOptions.whisperModelDir }
|
|
657
|
+
: await defaultWhisperRecord();
|
|
658
|
+
if (!rec) return null;
|
|
659
|
+
transcription = new TranscriptionService({
|
|
660
|
+
modelDir: rec.path, modelId: rec.repoId,
|
|
661
|
+
idleUnloadSec: serverOptions.whisperIdleUnloadSec,
|
|
662
|
+
resident: serverOptions.whisperResident,
|
|
663
|
+
exclusive: (fn, signal) => gateway.runExclusive(fn, undefined, signal),
|
|
664
|
+
});
|
|
665
|
+
return transcription;
|
|
666
|
+
};
|
|
667
|
+
const transcriptionInfo = async () => {
|
|
668
|
+
const svc = await transcriptionService();
|
|
669
|
+
return svc ? { id: svc.modelId, resident: svc.resident } : null;
|
|
670
|
+
};
|
|
671
|
+
const discoveryRoutes = createDiscoveryRoutes(ctx, gateway, startedAt, transcriptionInfo);
|
|
747
672
|
|
|
748
673
|
// Captured so the WebSocket handler can resolve the bound (possibly
|
|
749
674
|
// ephemeral) port lazily for the loopback pi provider.
|
|
@@ -773,16 +698,26 @@ export function createServer(
|
|
|
773
698
|
// → inferenceStage.admit → inferenceStage.run → InferenceResult → wire
|
|
774
699
|
// (/v1/completions substitutes textStage; Anthropic and Responses reuse
|
|
775
700
|
// chatStage with their own wire formats.)
|
|
776
|
-
const prep = createRequestPrep({ ctx, serverOptions, kvScheme, defaultGeneratedTokens });
|
|
701
|
+
const prep = createRequestPrep({ ctx, serverOptions, kvScheme, defaultGeneratedTokens, tokenHistory });
|
|
777
702
|
const { templateOptionsFor } = prep;
|
|
778
|
-
const preparation = createPreparationExecutor((work, signal) => gateway.
|
|
703
|
+
const preparation = createPreparationExecutor((work, signal) => gateway.runPreparation(work, signal),
|
|
704
|
+
batch, gateway.mediaBatchingEnabled ? batch : 1);
|
|
779
705
|
const chatStage = new ChatStage(
|
|
780
|
-
ctx, prep, promptCache,
|
|
706
|
+
ctx, prep, promptCache, contextLimit, serverOptions.defaultAdapter,
|
|
781
707
|
preparation, serving.buildPrompt);
|
|
782
708
|
const textStage = new TextCompletionStage(
|
|
783
|
-
ctx, prep,
|
|
709
|
+
ctx, prep, contextLimit, defaultGeneratedTokens, serverOptions.defaultAdapter);
|
|
784
710
|
const inferenceStage = new InferenceStage(completionExecutor);
|
|
785
711
|
const openAiMeta = (id: string) => ({ id, created: Math.floor(Date.now() / 1000), model: ctx.modelId });
|
|
712
|
+
/** Carry application session affinity into the shared cache request fields. */
|
|
713
|
+
const applyCacheSession = (body: ChatRequestParams, request: Request, original: unknown = body) => {
|
|
714
|
+
const fields = original as { session_id?: unknown; prompt_cache_key?: unknown };
|
|
715
|
+
const session = typeof fields.session_id === "string" ? fields.session_id :
|
|
716
|
+
typeof fields.prompt_cache_key === "string" ? fields.prompt_cache_key :
|
|
717
|
+
request.headers.get("x-session-affinity") ?? request.headers.get("session_id") ?? undefined;
|
|
718
|
+
if (runtimeValue("MLX_BUN_SESSION_CACHE") !== "0") body.session_id = session;
|
|
719
|
+
else { delete body.session_id; delete body.prompt_cache_key; }
|
|
720
|
+
};
|
|
786
721
|
/** Parse the JSON body under the request's trace; a bad body is a 400. */
|
|
787
722
|
const parseBody = async <T,>(request: Request, trace: PromptResponseTrace | undefined): Promise<T | Response> => {
|
|
788
723
|
const closeBodyParse = trace?.begin("request.body_parse");
|
|
@@ -821,6 +756,7 @@ export function createServer(
|
|
|
821
756
|
// capability flags — true if a tower is loaded or loadable (lazy)
|
|
822
757
|
vision: !!(ctx.vision || ctx.loadVision),
|
|
823
758
|
audio: !!(ctx.audio || ctx.loadAudio),
|
|
759
|
+
transcription: async () => (await transcriptionService()) !== null,
|
|
824
760
|
thinking: ctx.template.supportsThinking,
|
|
825
761
|
// Model-author sampling defaults (generation_config.json, server-CLI
|
|
826
762
|
// overrides applied) for the sampling popover's per-model recommended
|
|
@@ -837,6 +773,12 @@ export function createServer(
|
|
|
837
773
|
async fetch(request, server) {
|
|
838
774
|
const url = new URL(request.url);
|
|
839
775
|
|
|
776
|
+
if (url.pathname === "/admin/cache/session/close" && request.method === "POST") {
|
|
777
|
+
const body = await parseBody<{ session_id?: string }>(request, undefined);
|
|
778
|
+
if (body instanceof Response) return body;
|
|
779
|
+
if (typeof body.session_id === "string") promptCache.closeSession(body.session_id);
|
|
780
|
+
return Response.json({ closed: typeof body.session_id === "string" });
|
|
781
|
+
}
|
|
840
782
|
if (url.pathname === "/admin/cache/flush" && request.method === "POST") {
|
|
841
783
|
const result = await flushDurability();
|
|
842
784
|
return Response.json(
|
|
@@ -868,6 +810,9 @@ export function createServer(
|
|
|
868
810
|
const modelAdminResponse = await handleModelAdminRoute(url, request, ctx, gateway);
|
|
869
811
|
if (modelAdminResponse) return modelAdminResponse;
|
|
870
812
|
|
|
813
|
+
const audioResponse = await handleAudioRoute(url, request, { service: transcriptionService });
|
|
814
|
+
if (audioResponse) return audioResponse;
|
|
815
|
+
|
|
871
816
|
if (url.pathname === "/fit" && request.method === "GET") {
|
|
872
817
|
// Fit assessment for the status page: this-machine report at the
|
|
873
818
|
// admission ceiling + the Apple SKU matrix at a fixed 32k.
|
|
@@ -978,30 +923,6 @@ export function createServer(
|
|
|
978
923
|
}
|
|
979
924
|
|
|
980
925
|
if (url.pathname === "/stats" && request.method === "GET") {
|
|
981
|
-
// Active KV scheme across ALL layers. Since Phase 9 rotating
|
|
982
|
-
// (sliding-window) caches quantize too, so every layer the
|
|
983
|
-
// scheme names counts — the old display filtered to
|
|
984
|
-
// full_attention and silently undercounted (e.g. 26B showed
|
|
985
|
-
// 5/30 quantized when its kv_config.json covers all 30).
|
|
986
|
-
const layerTypes = ctx.model.config.text.layerTypes;
|
|
987
|
-
const kvLayers: Record<string, number> = {};
|
|
988
|
-
let kvMode = "bf16";
|
|
989
|
-
if (kvScheme.turboQuant) {
|
|
990
|
-
// v1: full-attention layers only (sliding-window stays bf16 —
|
|
991
|
-
// docs/design/turboquant.md non-goal).
|
|
992
|
-
const fullAttn = layerTypes.filter((l) => l !== "sliding_attention").length;
|
|
993
|
-
kvMode = `turbo k${kvScheme.turboQuant.kBits}v${kvScheme.turboQuant.vBits}`;
|
|
994
|
-
kvLayers[`turbo-k${kvScheme.turboQuant.kBits}v${kvScheme.turboQuant.vBits}`] = fullAttn;
|
|
995
|
-
} else if (kvScheme.kvBits) {
|
|
996
|
-
kvMode = `uniform-kv${kvScheme.kvBits}`;
|
|
997
|
-
kvLayers[`kv${kvScheme.kvBits}`] = layerTypes.length;
|
|
998
|
-
} else if (kvScheme.kvConfig) {
|
|
999
|
-
kvMode = "mixed (kv_config.json)";
|
|
1000
|
-
for (const e of kvScheme.kvConfig)
|
|
1001
|
-
kvLayers[`kv${e.bits}`] = (kvLayers[`kv${e.bits}`] ?? 0) + 1;
|
|
1002
|
-
}
|
|
1003
|
-
const bf16Layers = layerTypes.length - Object.values(kvLayers).reduce((a, b) => a + b, 0);
|
|
1004
|
-
const slidingLayers = layerTypes.filter((l) => l === "sliding_attention").length;
|
|
1005
926
|
return Response.json({
|
|
1006
927
|
server: {
|
|
1007
928
|
owner: serverOptions.owner ?? "embedded",
|
|
@@ -1014,6 +935,12 @@ export function createServer(
|
|
|
1014
935
|
max_bytes: promptCache.maxBytes,
|
|
1015
936
|
hits: promptCache.hits,
|
|
1016
937
|
misses: promptCache.misses,
|
|
938
|
+
session_hits: promptCache.sessionHits,
|
|
939
|
+
session_misses: promptCache.sessionMisses,
|
|
940
|
+
prefix_scans: promptCache.prefixScans,
|
|
941
|
+
object_hits: promptCache.objectHits,
|
|
942
|
+
object_misses: promptCache.objectMisses,
|
|
943
|
+
object_restores: promptCache.objectRestores,
|
|
1017
944
|
},
|
|
1018
945
|
...(ssdStore ? {
|
|
1019
946
|
ssd_cache: {
|
|
@@ -1039,16 +966,10 @@ export function createServer(
|
|
|
1039
966
|
max_bytes: responseStore.maxBytes,
|
|
1040
967
|
ttl_ms: responseStore.ttlMs,
|
|
1041
968
|
},
|
|
1042
|
-
kv_quant:
|
|
1043
|
-
mode: kvMode,
|
|
1044
|
-
layers: { ...kvLayers, ...(bf16Layers > 0 ? { bf16: bf16Layers } : {}) },
|
|
1045
|
-
attention: {
|
|
1046
|
-
global: layerTypes.length - slidingLayers,
|
|
1047
|
-
sliding_window: slidingLayers,
|
|
1048
|
-
},
|
|
1049
|
-
},
|
|
969
|
+
kv_quant: resolvedKvScheme.describe(ctx.model.config),
|
|
1050
970
|
admission: {
|
|
1051
971
|
max_safe_context: admission.maxSafeContext,
|
|
972
|
+
enforced_context_tokens: contextLimit,
|
|
1052
973
|
memory_budget_bytes:
|
|
1053
974
|
ctx.glmMemoryPlan?.processLimitBytes ??
|
|
1054
975
|
serverOptions.memoryBudgetBytes ?? null,
|
|
@@ -1074,7 +995,6 @@ export function createServer(
|
|
|
1074
995
|
// A parent-managed GPU job holds this response open. The connection owns
|
|
1075
996
|
// the native lease, so parent death cannot strand the worker lock.
|
|
1076
997
|
if (serverOptions.unixSocket && url.pathname === "/admin/lease" && request.method === "POST") {
|
|
1077
|
-
await flushDurability();
|
|
1078
998
|
const lease = await gateway.acquireExecutionLease(request.signal);
|
|
1079
999
|
let released = false;
|
|
1080
1000
|
const release = () => {
|
|
@@ -1190,6 +1110,7 @@ export function createServer(
|
|
|
1190
1110
|
});
|
|
1191
1111
|
const body = await parseBody<ChatRequestParams>(request, trace);
|
|
1192
1112
|
if (body instanceof Response) return body;
|
|
1113
|
+
applyCacheSession(body as ChatRequestParams, request);
|
|
1193
1114
|
const meta = openAiMeta(id);
|
|
1194
1115
|
const a = await admit(
|
|
1195
1116
|
inferenceStage, () => chatStage.run(new ChatRequest(body), id, request.signal), trace, "chat request");
|
|
@@ -1210,6 +1131,7 @@ export function createServer(
|
|
|
1210
1131
|
});
|
|
1211
1132
|
const body = await parseBody<TextCompletionParams>(request, trace);
|
|
1212
1133
|
if (body instanceof Response) return body;
|
|
1134
|
+
applyCacheSession(body as ChatRequestParams, request);
|
|
1213
1135
|
const meta = openAiMeta(id);
|
|
1214
1136
|
const a = await admit(
|
|
1215
1137
|
inferenceStage, () => textStage.run(new TextCompletionRequest(body), id), trace, "text completion");
|
|
@@ -1239,6 +1161,7 @@ export function createServer(
|
|
|
1239
1161
|
let chatBody: ChatRequestParams;
|
|
1240
1162
|
try {
|
|
1241
1163
|
chatBody = anthropicToChatBody(anthropicBody) as unknown as ChatRequestParams;
|
|
1164
|
+
applyCacheSession(chatBody, request, anthropicBody);
|
|
1242
1165
|
} catch (e) {
|
|
1243
1166
|
return anthropicError(400, (e as Error).message, {});
|
|
1244
1167
|
}
|
|
@@ -1294,6 +1217,7 @@ export function createServer(
|
|
|
1294
1217
|
let chatBody: ChatRequestParams;
|
|
1295
1218
|
try {
|
|
1296
1219
|
chatBody = responsesToChatBody(responsesBody) as unknown as ChatRequestParams;
|
|
1220
|
+
applyCacheSession(chatBody, request, responsesBody);
|
|
1297
1221
|
} catch (e) {
|
|
1298
1222
|
return responsesError(400, (e as Error).message, {});
|
|
1299
1223
|
}
|