mlx-bun 0.0.9 → 0.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -21
- package/THIRD_PARTY_LICENSES.md +1 -0
- package/docs/archive/mlx-bun-lab-report.html +275 -0
- package/docs/archive/wwdc-mlx-bun.html +848 -0
- package/docs/design/audio-input-plan.md +356 -0
- package/docs/design/batching-perf-path.md +255 -0
- package/docs/design/batching-v2-plan.md +23 -9
- package/docs/design/bucketing-stage.md +2 -1
- package/docs/design/compat-cli-surface-design.md +4 -3
- package/docs/design/decode-speed-program.md +172 -0
- package/docs/design/docs-reorg-plan.md +1 -1
- package/docs/design/dspark-serving-program.md +371 -0
- package/docs/design/dspark-speculative-decoding.md +167 -5
- package/docs/design/faithful-l1-consolidation.md +310 -0
- package/docs/design/generic-model-support.md +8 -5
- package/docs/design/grammar-spec-batching-integration.md +301 -0
- package/docs/design/minicpm5-decode-megakernel.md +16 -11
- package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
- package/docs/design/omlx-adoption-map.md +98 -0
- package/docs/design/orpo-training.md +34 -30
- package/docs/design/paged-kv-cache.md +214 -0
- package/docs/design/parallel-slots.md +82 -49
- package/docs/design/parity-tier-dag.md +43 -33
- package/docs/design/repo-cleanup-plan.md +189 -0
- package/docs/design/runtime-isolation.md +19 -3
- package/docs/design/segmented-backward-training.md +18 -2
- package/docs/design/ssd-kv-cold-tier.md +288 -0
- package/docs/design/structured-output.md +484 -0
- package/docs/design/the-dreaming-master-plan.md +2 -3
- package/docs/design/turboquant-kv.md +235 -0
- package/docs/design/unified-engine-frontier-plan.md +774 -0
- package/docs/design/web-chat-beat-matrix.md +315 -0
- package/docs/design/web-chat-redesign.md +1112 -0
- package/docs/investigations/decode-roofline-lookagain.md +92 -6
- package/docs/investigations/dspark-handoff.md +182 -16
- package/docs/investigations/kernel-perf-review-2026-07.md +105 -26
- package/docs/investigations/lab-build-journal.md +2 -2
- package/docs/investigations/openwiki-evaluation.md +249 -0
- package/docs/planning/IDEAS.md +14 -0
- package/docs/planning/PRODUCT_ROADMAP.md +2 -3
- package/docs/planning/chat-ui-vision-memory-first.md +199 -0
- package/docs/planning/chat-ui-vision.md +50 -0
- package/docs/planning/memory-docs-and-dag-plan.md +15 -6
- package/docs/planning/release-notes-v0.0.10.md +57 -0
- package/docs/planning/release-notes-v0.0.11.md +239 -0
- package/docs/reference/cli.md +35 -16
- package/docs/reference/distribution.md +13 -6
- package/docs/reference/embedding.md +35 -14
- package/docs/reference/features-matrix.md +97 -0
- package/docs/reference/library-api.md +30 -9
- package/docs/reference/memory.md +26 -6
- package/docs/reference/models.md +11 -1
- package/docs/reference/orpo-quickstart.md +3 -4
- package/docs/reference/server-api.md +634 -22
- package/docs/reference/server-config.md +251 -119
- package/docs/reference/training.md +20 -16
- package/package.json +5 -3
- package/src/anthropic.ts +13 -0
- package/src/audio/conformer.ts +647 -0
- package/src/audio/decode.ts +133 -0
- package/src/audio/features.ts +202 -0
- package/src/audio/transcode.ts +67 -0
- package/src/cli.ts +402 -107
- package/src/config.ts +49 -2
- package/src/download.ts +40 -0
- package/src/eval/README.md +7 -6
- package/src/eval/kl.ts +59 -3
- package/src/eval/quality-db.ts +2 -2
- package/src/eval/runner.ts +3 -3
- package/src/expert-offload.ts +15 -2
- package/src/flags.ts +13 -0
- package/src/generate.ts +330 -16
- package/src/grammar.ts +585 -0
- package/src/hub-rest.ts +242 -0
- package/src/kv-store.ts +618 -122
- package/src/lora.ts +8 -0
- package/src/md-modules.d.ts +42 -0
- package/src/media-fetch.ts +284 -0
- package/src/memory/rest.ts +245 -0
- package/src/memory/vault.ts +74 -0
- package/src/mlx/array.ts +127 -19
- package/src/mlx/autograd.ts +2 -2
- package/src/mlx/ffi.ts +27 -3
- package/src/mlx/metal-kernel.ts +7 -4
- package/src/mlx/ops.ts +179 -23
- package/src/mlx/turboquant-ops.ts +831 -0
- package/src/mlx/turboquant-tables.ts +237 -0
- package/src/model/batched-mask.ts +62 -1
- package/src/model/batched-quant.ts +206 -0
- package/src/model/batched-rotating-quant.ts +346 -0
- package/src/model/batched-rotating.ts +46 -1
- package/src/model/compiled-decode.ts +4 -15
- package/src/model/factory.ts +10 -4
- package/src/model/gemma4-base.ts +391 -34
- package/src/model/gemma4.ts +122 -33
- package/src/model/generated/gemma4-12b.ts +9 -13
- package/src/model/generated/gemma4-26b.ts +10 -5
- package/src/model/generated/gemma4-e4b.ts +14 -9
- package/src/model/minicpm5.ts +58 -102
- package/src/model/paged-kv.ts +253 -0
- package/src/model/qwen3-delta.ts +123 -15
- package/src/model/qwen3-moe.ts +404 -0
- package/src/model/qwen3.ts +9 -8
- package/src/model/qwen3_5.ts +99 -25
- package/src/model/support.ts +12 -1
- package/src/model/universal/dense.ts +40 -5
- package/src/model/universal/rope.ts +36 -21
- package/src/pi-web.ts +1299 -82
- package/src/preflight.ts +27 -8
- package/src/prompt-cache.ts +338 -32
- package/src/quantize/quantizer.ts +16 -1
- package/src/registry.ts +61 -3
- package/src/serve/batch-scheduler.ts +1001 -76
- package/src/serve/generation-gateway.ts +292 -30
- package/src/serve/isolate.ts +417 -0
- package/src/serve/lane-registry.ts +44 -0
- package/src/serve/session-search.ts +326 -0
- package/src/server.ts +1460 -105
- package/src/spec/assistant-source.ts +150 -0
- package/src/spec/deepspec-source.ts +143 -0
- package/src/spec/dflash-source.ts +124 -0
- package/src/spec/dspark/ab-stats.ts +136 -0
- package/src/spec/dspark/calibration.ts +112 -0
- package/src/spec/dspark/deepspec-module.ts +801 -0
- package/src/spec/dspark/generate-dflash.ts +72 -20
- package/src/spec/dspark/loader.ts +23 -0
- package/src/spec/dspark/module-dflash.ts +278 -36
- package/src/spec/dspark/quantize-drafter.ts +91 -0
- package/src/spec/generate.ts +59 -25
- package/src/spec/ngram-source.ts +132 -0
- package/src/spec/serve-loop.ts +536 -0
- package/src/spec/source.ts +93 -0
- package/src/spec/two-model.ts +171 -0
- package/src/ssd-cache.ts +285 -0
- package/src/tokenizer.ts +288 -2
- package/src/tool-approvals.ts +131 -0
- package/src/tool-call.ts +408 -20
- package/src/train/flash-cce.ts +29 -26
- package/src/train/job.ts +0 -2
- package/src/train/loss.ts +53 -11
- package/src/train/prefix-shared.ts +30 -7
- package/src/train/segmented.ts +150 -46
- package/src/train/trainer.ts +22 -15
- package/src/vision/prompt.ts +217 -48
- package/src/web/app.html +1184 -1789
- package/src/web/app.js +5667 -0
- package/src/web/icon.svg +13 -0
- package/src/web/manifest.webmanifest +24 -0
- package/src/web/src/adapters-panel.ts +288 -0
- package/src/web/src/api.ts +57 -0
- package/src/web/src/assistant.ts +485 -0
- package/src/web/src/chat.ts +1050 -0
- package/src/web/src/composer.ts +959 -0
- package/src/web/src/dataset.ts +132 -0
- package/src/web/src/finetune.ts +267 -0
- package/src/web/src/hub.ts +428 -0
- package/src/web/src/main.ts +98 -0
- package/src/web/src/markdown.ts +0 -0
- package/src/web/src/memory-panel.ts +572 -0
- package/src/web/src/model-picker.ts +159 -0
- package/src/web/src/palette.ts +346 -0
- package/src/web/src/protocol.ts +62 -0
- package/src/web/src/quantize.ts +167 -0
- package/src/web/src/rag.ts +274 -0
- package/src/web/src/sessions.ts +469 -0
- package/src/web/src/shell.ts +804 -0
- package/src/web/src/status.ts +252 -0
- package/src/web/src/ui-catalog.ts +92 -0
- package/src/web/sw.js +71 -0
- package/src/web/vendor/README.md +61 -0
- package/src/web/vendor/hljs-LICENSE +29 -0
- package/src/web/vendor/hljs-theme.css +59 -0
- package/src/web/vendor/hljs.js +26 -0
- package/src/model/fused-decode-kernel.ts +0 -312
- package/src/model/fused-geglu-kernel.ts +0 -198
- package/src/model/fused-mlp-kernel.ts +0 -169
- package/src/model/fused-swiglu-kernel.ts +0 -240
- package/src/model/steel-linear-kernel.ts +0 -96
- package/src/status-page.html +0 -497
package/src/generate.ts
CHANGED
|
@@ -15,20 +15,34 @@ import {
|
|
|
15
15
|
} from "./mlx/ffi";
|
|
16
16
|
import * as ops from "./mlx/ops";
|
|
17
17
|
import { CompiledDecode } from "./model/compiled-decode";
|
|
18
|
-
import { Gemma4Model, KVCache, RotatingKVCache, type Cache } from "./model/gemma4";
|
|
18
|
+
import { Gemma4Model, KVCache, RotatingKVCache, TurboQuantKVCache, type Cache } from "./model/gemma4";
|
|
19
|
+
import { PagedKVCache } from "./model/paged-kv";
|
|
19
20
|
import { DiffusionGemmaModel } from "./model/diffusion-gemma";
|
|
20
21
|
import { diffusionGenerate } from "./diffusion/diffusion-generate";
|
|
21
22
|
import type { RuntimeModel } from "./model/factory";
|
|
22
|
-
import type { KvQuantSpec } from "./config";
|
|
23
|
+
import type { KvQuantSpec, TurboQuantScheme } from "./config";
|
|
24
|
+
import { flagOn } from "./flags";
|
|
23
25
|
import {
|
|
24
26
|
makeLogitsProcessors, makeSampler,
|
|
25
27
|
type LogitsProcessorOptions, type SamplerOptions, toLogprobs,
|
|
26
28
|
} from "./sampler";
|
|
29
|
+
import type { GrammarController } from "./grammar";
|
|
27
30
|
|
|
28
31
|
export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions {
|
|
29
32
|
maxTokens?: number;
|
|
30
33
|
eosTokenIds?: number[];
|
|
31
34
|
prefillChunkSize?: number;
|
|
35
|
+
/** Fired once during prefill, at `snapshotAt` tokens (default: the full
|
|
36
|
+
* prompt) and BEFORE any further KV is written — the one moment the
|
|
37
|
+
* caches hold exactly that prefix (post-wrap rings can't be rewound
|
|
38
|
+
* later). The server's prompt-boundary cache snapshot hangs here. */
|
|
39
|
+
onPrefillDone?: () => void;
|
|
40
|
+
/** Token count at which onPrefillDone fires (the STABLE cache boundary —
|
|
41
|
+
* chat prompts end in a generation primer, e.g. 12B's thought-channel
|
|
42
|
+
* tokens, that the NEXT turn's re-render does not contain; snapshotting
|
|
43
|
+
* there would make the entry untrimmable-and-divergent). Prefill is
|
|
44
|
+
* split at this index when it falls mid-prompt. */
|
|
45
|
+
snapshotAt?: number;
|
|
32
46
|
/** Pre-warmed KV caches (e.g. from the prompt cache). cache[0].offset
|
|
33
47
|
* prompt tokens are treated as already prefilled; only the suffix is
|
|
34
48
|
* forwarded. Caller keeps ownership — generate() will not dispose. */
|
|
@@ -37,8 +51,16 @@ export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions
|
|
|
37
51
|
* covering the whole prompt; prefilled in one shot (no chunking).
|
|
38
52
|
* Caller keeps ownership. */
|
|
39
53
|
promptEmbeddings?: MlxArray;
|
|
40
|
-
/** bool [L] marking image tokens (bidirectional attention among them).
|
|
54
|
+
/** bool [L] marking image tokens (bidirectional attention among them).
|
|
55
|
+
* MUST be unset when the prompt contains any audio — audio prompts run
|
|
56
|
+
* fully causal (audio-input-plan.md §3.3 Q1). */
|
|
41
57
|
imageMask?: MlxArray;
|
|
58
|
+
/** bool [L] marking ALL multimodal soft tokens (image | audio) for
|
|
59
|
+
* per-layer-input id zeroing (e2b/e4b), decoupled from imageMask so
|
|
60
|
+
* audio-only prompts (no bidirectional mask) still zero their positions.
|
|
61
|
+
* When unset, zeroing falls back to imageMask (the legacy vision shape).
|
|
62
|
+
* Caller keeps ownership. */
|
|
63
|
+
multimodalMask?: MlxArray;
|
|
42
64
|
/** Quantize full-attention KV caches to this many bits (4 or 8).
|
|
43
65
|
* Rotating (sliding-window) caches stay bf16 — they're window-capped
|
|
44
66
|
* and upstream rotating-cache quantization is NYI. */
|
|
@@ -52,6 +74,22 @@ export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions
|
|
|
52
74
|
/** Convert once a cache's offset reaches this (uniform-kvBits default
|
|
53
75
|
* 5000 = mlx-lm; kvConfig default 0 = optiq serve). */
|
|
54
76
|
quantizedKvStart?: number;
|
|
77
|
+
/** TurboQuant scheme (docs/design/turboquant-kv.md): rotation-based KV
|
|
78
|
+
* quantization, a CLI-only runtime lever in the same class as uniform
|
|
79
|
+
* kvBits (mutually exclusive with kvBits/kvConfig — maybeQuantizeKv
|
|
80
|
+
* checks kvBits/kvConfig first, so set at most one). Full-attention
|
|
81
|
+
* KVCache layers convert via TurboQuantKVCache.fromKVCache;
|
|
82
|
+
* RotatingKVCache (sliding-window) layers stay bf16 in v1 — a one-time
|
|
83
|
+
* warning names the limitation, never a throw. */
|
|
84
|
+
turboQuant?: TurboQuantScheme;
|
|
85
|
+
/** OPTIONAL paged KV storage (docs/design/paged-kv-cache.md): fresh
|
|
86
|
+
* full-attention KVCache layers are replaced with PagedKVCache (block
|
|
87
|
+
* pool + gather-to-contiguous) before prefill. v1 scope: serial batch=1
|
|
88
|
+
* Gemma4-family, bf16 — mutually exclusive with kvBits/kvConfig/
|
|
89
|
+
* turboQuant/draft/compiled decode (callers refuse the combos; the
|
|
90
|
+
* cache swap itself only ever touches plain empty KVCache entries).
|
|
91
|
+
* Values are gated bit-exact vs the plain path (tests/paged-kv*). */
|
|
92
|
+
pagedKv?: { blockSize?: number };
|
|
55
93
|
/** Mounted LoRA adapter ids to apply (resolved/validated by
|
|
56
94
|
* AdapterManager.resolveSpec). Residuals sum in order. Set on the
|
|
57
95
|
* model's LoraState for exactly the duration of this generation —
|
|
@@ -70,6 +108,13 @@ export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions
|
|
|
70
108
|
/** Capture the top-k (token id, logprob) pairs per emitted token from the
|
|
71
109
|
* same distribution (mlx_lm.server `top_logprobs`). 0/unset = off. */
|
|
72
110
|
topLogprobs?: number;
|
|
111
|
+
/** Grammar-constrained decoding (src/grammar.ts): a compiled GrammarController
|
|
112
|
+
* that masks invalid tokens to -inf each step. L2-class (oMLX oracle). When
|
|
113
|
+
* set, the decode loop takes a slightly different shape: it eager-reads the
|
|
114
|
+
* token id (acceptToken needs a JS number, which the pipelined loop defers),
|
|
115
|
+
* advances the matcher, and awaits the async mask precompute (which overlaps
|
|
116
|
+
* the GPU forward). Non-grammar requests keep the fast pipelined loop. */
|
|
117
|
+
grammar?: GrammarController;
|
|
73
118
|
}
|
|
74
119
|
|
|
75
120
|
/** Per-token logprob info (only present when GenerateOptions requested it). */
|
|
@@ -82,14 +127,35 @@ export interface TokenLogprobs {
|
|
|
82
127
|
top?: { id: number; logprob: number }[];
|
|
83
128
|
}
|
|
84
129
|
|
|
130
|
+
/** Materialize every cache's state at a prefill chunk boundary. Unlike
|
|
131
|
+
* KVCache/QuantizedKVCache/RotatingKVCache, TurboQuantKVCache.state()
|
|
132
|
+
* allocates FRESH trimmed slice views on every call (required by its
|
|
133
|
+
* snapshotCache/cloneKvCaches callers in kv-store.ts, which already
|
|
134
|
+
* dispose them) rather than returning its own live-owned arrays — so
|
|
135
|
+
* this chokepoint must dispose that cache kind's state() output itself,
|
|
136
|
+
* or the views leak (unreferenced past this call, only reclaimed by the
|
|
137
|
+
* FinalizationRegistry backstop on GC of the tiny JS wrapper). */
|
|
138
|
+
export function evalCacheState(cache: Cache[]): void {
|
|
139
|
+
const turboState = cache.flatMap((c) => (c instanceof TurboQuantKVCache ? c.state() : []));
|
|
140
|
+
try {
|
|
141
|
+
const liveState = cache.flatMap((c) => (c instanceof TurboQuantKVCache ? [] : c.state()));
|
|
142
|
+
ops.evalAll([...turboState, ...liveState]);
|
|
143
|
+
} finally {
|
|
144
|
+
// Disposed even when evalAll throws (Metal OOM / deferred graph error) —
|
|
145
|
+
// the whole reason this chokepoint exists is that these views are owned.
|
|
146
|
+
for (const a of turboState) a.dispose();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
85
150
|
/** Port of mlx-lm maybe_quantize_kv_cache + BOTH halves of optiq serve's
|
|
86
151
|
* per-layer patched variant (incl. patch_rotating_to_quantized: rotating
|
|
87
152
|
* caches convert too — Phase 9):
|
|
88
153
|
* - per-layer bits/group_size selection (kvConfig overrides kvBits,
|
|
89
154
|
* matching optiq's --kv-config precedence; shipped kv_config.json
|
|
90
|
-
* files
|
|
91
|
-
*
|
|
92
|
-
*
|
|
155
|
+
* files cover EVERY cache-owning layer, sliding ones included —
|
|
156
|
+
* verified 12B 48/48, 26B 30/30, e4b 24/24 distinct caches — so
|
|
157
|
+
* rotating quantization engages straight from the config; uniform
|
|
158
|
+
* kvBits — like optiq --kv-bits — reaches them too), and
|
|
93
159
|
* - STREAMING conversion (optiq streaming_kv_quant / serve.py
|
|
94
160
|
* patched_maybe_quantize): eval each layer's quantized triples and
|
|
95
161
|
* clear the buffer pool before building the next layer's conversion.
|
|
@@ -99,7 +165,11 @@ export interface TokenLogprobs {
|
|
|
99
165
|
* 24 GB Mac). Numerics untouched: same quantize math, only the eval
|
|
100
166
|
* ordering is forced (tests/kv-quant.test.ts, tests/rotating-kvq.test.ts). */
|
|
101
167
|
export function maybeQuantizeKv(cache: Cache[], options: GenerateOptions): void {
|
|
102
|
-
const { kvBits, kvConfig } = options;
|
|
168
|
+
const { kvBits, kvConfig, turboQuant } = options;
|
|
169
|
+
if (turboQuant) {
|
|
170
|
+
maybeTurboQuantizeKv(cache, turboQuant, options.quantizedKvStart ?? 0);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
103
173
|
if (!kvBits && !kvConfig?.length) return;
|
|
104
174
|
const start = options.quantizedKvStart ?? (kvConfig?.length ? 0 : 5000);
|
|
105
175
|
const byLayer = kvConfig?.length
|
|
@@ -128,6 +198,70 @@ export function maybeQuantizeKv(cache: Cache[], options: GenerateOptions): void
|
|
|
128
198
|
}
|
|
129
199
|
}
|
|
130
200
|
|
|
201
|
+
/** Paged-KV conversion (docs/design/paged-kv-cache.md): swap each FRESH
|
|
202
|
+
* plain full-attention KVCache for a PagedKVCache sized to hold
|
|
203
|
+
* `capacityTokens` (prompt + maxTokens — known exactly at generate()
|
|
204
|
+
* setup, so pool exhaustion is unreachable absent an accounting bug).
|
|
205
|
+
* Same in-place-mutation shape as maybeQuantizeKv, but runs ONCE before
|
|
206
|
+
* prefill: paging changes storage layout, not arithmetic, so there is no
|
|
207
|
+
* "convert when populated" trigger. Sliding-window (RotatingKVCache)
|
|
208
|
+
* layers keep today's scheme — mixed paged-full + rotating-sliding is
|
|
209
|
+
* the supported v1 shape. Pre-warmed caches (offset > 0) skip conversion
|
|
210
|
+
* entirely: the serve lane bypasses prompt-cache reuse for paged
|
|
211
|
+
* requests, so this only guards library callers. */
|
|
212
|
+
export function maybePageKv(
|
|
213
|
+
cache: Cache[], options: GenerateOptions, capacityTokens: number,
|
|
214
|
+
): void {
|
|
215
|
+
if (!options.pagedKv) return;
|
|
216
|
+
if (cache.some((c) => c.offset > 0)) return;
|
|
217
|
+
const blockSize = options.pagedKv.blockSize ?? PagedKVCache.DEFAULT_BLOCK_SIZE;
|
|
218
|
+
for (let i = 0; i < cache.length; i++) {
|
|
219
|
+
if (cache[i] instanceof KVCache) {
|
|
220
|
+
cache[i]!.dispose(); // fresh (offset 0) — nothing stored yet
|
|
221
|
+
cache[i] = new PagedKVCache(capacityTokens, blockSize);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Emitted once per process: RotatingKVCache (sliding-window) layers are a
|
|
227
|
+
* documented v1 non-goal (docs/design/turboquant-kv.md) — they stay bf16
|
|
228
|
+
* rather than throwing, so mixed full-attention/sliding-window models (e.g.
|
|
229
|
+
* Gemma) still serve correctly under --kv-quant turbo. */
|
|
230
|
+
let warnedTurboRotating = false;
|
|
231
|
+
|
|
232
|
+
/** TurboQuant conversion chokepoint (mirrors the uniform/config branch
|
|
233
|
+
* above): only plain full-attention KVCache instances convert, via
|
|
234
|
+
* TurboQuantKVCache.fromKVCache — RotatingKVCache stays bf16 (warn once,
|
|
235
|
+
* never throw). Same offset===0 skip-empty-cache rule as the affine path. */
|
|
236
|
+
function maybeTurboQuantizeKv(cache: Cache[], scheme: TurboQuantScheme, start: number): void {
|
|
237
|
+
for (let i = 0; i < cache.length; i++) {
|
|
238
|
+
const c = cache[i]!;
|
|
239
|
+
if (c instanceof RotatingKVCache) {
|
|
240
|
+
if (!warnedTurboRotating) {
|
|
241
|
+
warnedTurboRotating = true;
|
|
242
|
+
console.warn(
|
|
243
|
+
"[turbo-quant] sliding-window (RotatingKVCache) layers stay bf16 in v1 " +
|
|
244
|
+
"(full-attention only) — docs/design/turboquant-kv.md.",
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (!(c instanceof KVCache) || c.offset < start || c.offset === 0) continue;
|
|
250
|
+
const tq = TurboQuantKVCache.fromKVCache(c, scheme.kBits, scheme.vBits);
|
|
251
|
+
cache[i] = tq;
|
|
252
|
+
// state() allocates fresh trimmed slice views for this cache kind
|
|
253
|
+
// (see evalCacheState) — dispose after materializing (throw included),
|
|
254
|
+
// or they leak.
|
|
255
|
+
const state = tq.state();
|
|
256
|
+
try {
|
|
257
|
+
ops.evalAll(state);
|
|
258
|
+
} finally {
|
|
259
|
+
for (const a of state) a.dispose();
|
|
260
|
+
}
|
|
261
|
+
clearCache();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
131
265
|
export interface GenerateStats {
|
|
132
266
|
promptTokens: number;
|
|
133
267
|
/** Prompt tokens skipped via a pre-warmed cache. */
|
|
@@ -141,6 +275,14 @@ export interface GenerateStats {
|
|
|
141
275
|
* decoded token that was forwarded, including a trailing EOS the
|
|
142
276
|
* pipeline forwarded before reading it). For PromptCache.put(). */
|
|
143
277
|
cacheTokens: number[];
|
|
278
|
+
/** Speculative-decoding telemetry (serve --draft-model path only).
|
|
279
|
+
* draftedByPos/acceptedByPos: per-draft-position round counts (index =
|
|
280
|
+
* position within a round's block) — the Phase-1c per-position
|
|
281
|
+
* acceptance signal. */
|
|
282
|
+
spec?: {
|
|
283
|
+
drafted: number; accepted: number; targetCalls: number;
|
|
284
|
+
draftedByPos?: number[]; acceptedByPos?: number[];
|
|
285
|
+
};
|
|
144
286
|
}
|
|
145
287
|
|
|
146
288
|
export interface GeneratedToken {
|
|
@@ -324,6 +466,11 @@ async function* generateInner(
|
|
|
324
466
|
throw new Error(
|
|
325
467
|
`pre-warmed cache (${cachedTokens} tokens) must be a strict prefix of the prompt (${promptTokens.length})`,
|
|
326
468
|
);
|
|
469
|
+
// Paged KV (default off): swap fresh full-attention caches for paged
|
|
470
|
+
// storage BEFORE prefill. Capacity is exact — the deepest write lands at
|
|
471
|
+
// prompt + maxTokens − 1 (step maxTokens−1's forward), so this bound
|
|
472
|
+
// makes pool exhaustion an accounting-bug tripwire, not a request limit.
|
|
473
|
+
maybePageKv(cache, options, promptTokens.length + maxTokens);
|
|
327
474
|
/** device-side token history (only maintained when processors need it) */
|
|
328
475
|
let history: MlxArray | null = null;
|
|
329
476
|
|
|
@@ -367,6 +514,10 @@ async function* generateInner(
|
|
|
367
514
|
const V = logits3d.shape[2]!;
|
|
368
515
|
let logits = ops.reshape(logits3d, [1, V]);
|
|
369
516
|
for (const p of processors) logits = disposing(logits, p(history, logits));
|
|
517
|
+
// Grammar mask (src/grammar.ts): apply AFTER the standard processors so the
|
|
518
|
+
// grammar has the final say on validity (a -inf'd token stays -inf through
|
|
519
|
+
// log-softmax). Off (no grammar) = zero cost — the branch is unset.
|
|
520
|
+
if (options.grammar) logits = disposing(logits, options.grammar.applyMask(logits));
|
|
370
521
|
const lp = toLogprobs(logits);
|
|
371
522
|
logits.dispose();
|
|
372
523
|
const tok = sampler(lp, step);
|
|
@@ -444,21 +595,65 @@ async function* generateInner(
|
|
|
444
595
|
if (needsTokenHistory)
|
|
445
596
|
history = ops.fromInt32(promptTokens, [promptTokens.length]);
|
|
446
597
|
// e2b/e4b need the spliced token ids to build per-layer inputs
|
|
447
|
-
// (
|
|
598
|
+
// (multimodal soft-token positions zeroed inside forwardEmbeddings).
|
|
448
599
|
const embedIds = ops.fromInt32(promptTokens, [1, promptTokens.length]);
|
|
449
600
|
h0 = model.forwardEmbeddings(
|
|
450
601
|
options.promptEmbeddings, cache, options.imageMask ?? null, embedIds,
|
|
602
|
+
options.multimodalMask ?? null,
|
|
451
603
|
);
|
|
452
604
|
embedIds.dispose();
|
|
453
605
|
} else {
|
|
454
606
|
let pos = cachedTokens;
|
|
455
|
-
|
|
456
|
-
|
|
607
|
+
// Stable-boundary snapshot: prefill up to snapshotAt first (plain
|
|
608
|
+
// chunks, no logits), fire the hook while the caches hold exactly
|
|
609
|
+
// that prefix, then continue with the remainder below. Splitting here
|
|
610
|
+
// changes chunk shapes only for requests that opted in — the same
|
|
611
|
+
// numerics class as a prompt-cache-hit continuation prefill.
|
|
612
|
+
const snapAt = options.onPrefillDone && options.snapshotAt !== undefined
|
|
613
|
+
? Math.min(Math.max(options.snapshotAt, cachedTokens), promptTokens.length)
|
|
614
|
+
: promptTokens.length;
|
|
615
|
+
if (snapAt > pos && snapAt < promptTokens.length) {
|
|
616
|
+
while (pos < snapAt) {
|
|
617
|
+
const chunk = promptTokens.slice(pos, Math.min(pos + prefillChunkSize, snapAt));
|
|
618
|
+
const ids = ops.fromInt32(chunk, [1, chunk.length]);
|
|
619
|
+
const h = model.forwardHidden(ids, cache);
|
|
620
|
+
ids.dispose();
|
|
621
|
+
h.dispose();
|
|
622
|
+
evalCacheState(cache);
|
|
623
|
+
maybeQuantizeKv(cache, options);
|
|
624
|
+
clearCache();
|
|
625
|
+
pos += chunk.length;
|
|
626
|
+
// Macrotask yield between chunks (runtime-isolation.md Phase 1):
|
|
627
|
+
// each chunk is a synchronous multi-hundred-ms FFI eval; without
|
|
628
|
+
// this the event loop serves no I/O for the WHOLE prefill.
|
|
629
|
+
await new Promise<void>((r) => setImmediate(r));
|
|
630
|
+
}
|
|
631
|
+
options.onPrefillDone?.();
|
|
632
|
+
}
|
|
633
|
+
// Oracle prefill convention (mlx-lm 0.31.3 generate_step,
|
|
634
|
+
// generate.py:430-453): drain the prompt only to len-1 — chunks of
|
|
635
|
+
// min(prefillChunkSize, remaining-1) — then compute step-0 logits from
|
|
636
|
+
// a SEPARATE L=1 forward of the LAST prompt token (its server's
|
|
637
|
+
// batched engine shares the convention: insert_segments' forced final
|
|
638
|
+
// 1-token segment + GenerationBatch._step, generate.py:1645/1327).
|
|
639
|
+
// Forwarding the last token as the tail of an L=n GEMM instead is
|
|
640
|
+
// ulp-different in bf16 (qmm-vs-qmv + L-dependent SDPA dispatch) in
|
|
641
|
+
// BOTH the step-0 logits and that token's stored KV — near-tie greedy
|
|
642
|
+
// streams flip (2026-07-07 12B completion-probe ✗: first token flip at
|
|
643
|
+
// step 24). MLX_BUN_PREFILL_TAIL_SPLIT=0 restores the old
|
|
644
|
+
// full-final-chunk convention (A/B lever + kill switch).
|
|
645
|
+
const tailSplit = flagOn("MLX_BUN_PREFILL_TAIL_SPLIT", true);
|
|
646
|
+
const drainGate = tailSplit ? 1 : prefillChunkSize;
|
|
647
|
+
while (promptTokens.length - pos > drainGate) {
|
|
648
|
+
const n = tailSplit
|
|
649
|
+
? Math.min(prefillChunkSize, promptTokens.length - pos - 1)
|
|
650
|
+
: prefillChunkSize;
|
|
651
|
+
const chunk = promptTokens.slice(pos, pos + n);
|
|
457
652
|
const ids = ops.fromInt32(chunk, [1, chunk.length]);
|
|
458
653
|
const h = model.forwardHidden(ids, cache);
|
|
459
654
|
ids.dispose();
|
|
460
655
|
h.dispose(); // logits never computed for non-final chunks
|
|
461
|
-
|
|
656
|
+
evalCacheState(cache);
|
|
462
657
|
maybeQuantizeKv(cache, options);
|
|
463
658
|
// mlx-lm _prefill clears the allocator cache after every chunk;
|
|
464
659
|
// without this, prefill transients pile up in the buffer cache
|
|
@@ -467,16 +662,26 @@ async function* generateInner(
|
|
|
467
662
|
// measured, scripts/decode-split.ts; the context-scaling decode
|
|
468
663
|
// gap's main term).
|
|
469
664
|
clearCache();
|
|
470
|
-
pos +=
|
|
665
|
+
pos += n;
|
|
666
|
+
// Macrotask yield between chunks (runtime-isolation.md Phase 1).
|
|
667
|
+
await new Promise<void>((r) => setImmediate(r));
|
|
471
668
|
}
|
|
472
669
|
if (needsTokenHistory) {
|
|
473
670
|
history = ops.fromInt32(promptTokens, [promptTokens.length]);
|
|
474
671
|
}
|
|
672
|
+
// Under tailSplit this is exactly [promptTokens[len-1]] — the L=1
|
|
673
|
+
// step-0 forward (uncompiled L=1 is bit-exact with compiled decode,
|
|
674
|
+
// tests/compiled-decode.test.ts). The last prompt token's KV enters
|
|
675
|
+
// the cache HERE, so downstream bookkeeping (forwarded[], cacheTokens,
|
|
676
|
+
// PromptCache.put alignment) is unchanged: after step 0 the caches
|
|
677
|
+
// cover exactly the prompt, as before.
|
|
475
678
|
const lastChunk = promptTokens.slice(pos);
|
|
476
679
|
const ids0 = ops.fromInt32(lastChunk, [1, lastChunk.length]);
|
|
477
680
|
h0 = model.forwardHidden(ids0, cache);
|
|
478
681
|
ids0.dispose();
|
|
479
682
|
}
|
|
683
|
+
if (options.snapshotAt === undefined || options.snapshotAt >= promptTokens.length)
|
|
684
|
+
options.onPrefillDone?.();
|
|
480
685
|
const [, L0, H] = h0.shape as [number, number, number];
|
|
481
686
|
const hLast = h0.slice([0, L0 - 1, 0], [1, L0, H]);
|
|
482
687
|
h0.dispose();
|
|
@@ -509,21 +714,110 @@ async function* generateInner(
|
|
|
509
714
|
// growing attention windows change shape (= every step). Remove this
|
|
510
715
|
// when upstream implements GatherQMM::output_shapes.
|
|
511
716
|
let compiled =
|
|
512
|
-
|
|
717
|
+
flagOn("MLX_BUN_COMPILED_DECODE", true) &&
|
|
513
718
|
!options.adapters?.length &&
|
|
719
|
+
// Paged caches can't compile (data-dependent block-list length —
|
|
720
|
+
// the shapeless-replay hazard; CompiledDecode.supports() also
|
|
721
|
+
// excludes them per step, this just skips the setup).
|
|
722
|
+
!options.pagedKv &&
|
|
514
723
|
model.config.modelType.startsWith("gemma4") &&
|
|
515
724
|
!model.config.text.enableMoeBlock
|
|
516
725
|
? CompiledDecode.for(model as Gemma4Model)
|
|
517
726
|
: null;
|
|
518
727
|
let stop = false;
|
|
728
|
+
/** Token id read eagerly at the top of the loop for grammar (reused for
|
|
729
|
+
* the yield, avoiding a second readback). -1 when grammar is off — the
|
|
730
|
+
* pipelined path keeps its deferred itemUint32 below. */
|
|
731
|
+
let grammarTok = -1;
|
|
732
|
+
// Jump-forward decoding (opt-in, MLX_BUN_GRAMMAR_JUMP=1; serial lane
|
|
733
|
+
// only — the batch lane's #stepGrammar doesn't jump yet): when the
|
|
734
|
+
// grammar forces a unique continuation, emit its retokenized ids without
|
|
735
|
+
// per-token forwards — ONE multi-token forward carries them into the KV
|
|
736
|
+
// (see GrammarController.jumpForward for the contract + the fidelity
|
|
737
|
+
// note on why this is opt-in). Excluded when logprobs are requested
|
|
738
|
+
// (jumped tokens are never sampled, so they'd have no logprobs rows).
|
|
739
|
+
const grammarJump =
|
|
740
|
+
options.grammar !== undefined &&
|
|
741
|
+
flagOn("MLX_BUN_GRAMMAR_JUMP", false) &&
|
|
742
|
+
!options.logprobs;
|
|
519
743
|
while (!stop) {
|
|
520
744
|
const cur = pending!;
|
|
521
745
|
const curExtras = pendingExtras;
|
|
522
746
|
pendingExtras = null;
|
|
747
|
+
// Grammar advance (src/grammar.ts): acceptToken needs the token id as a
|
|
748
|
+
// JS number, which the pipelined loop defers (it operates on device
|
|
749
|
+
// arrays). So grammar requests eager-read cur here, advance the matcher,
|
|
750
|
+
// and await the async mask precompute — which overlaps the GPU forward
|
|
751
|
+
// dispatched just below. This trades the readback/forward overlap for
|
|
752
|
+
// correctness (the mask for step n+1 must reflect token n). Non-grammar
|
|
753
|
+
// requests keep the fast pipelined loop untouched.
|
|
754
|
+
//
|
|
755
|
+
// F1 fix (batched-lane plan): always eager-read `grammarTok` when
|
|
756
|
+
// grammar is on, even at the max_tokens boundary — the emitted token
|
|
757
|
+
// reuses it unconditionally. The OLD code gated the READ on
|
|
758
|
+
// generated+1 < maxTokens but emitted grammarTok regardless, so the LAST
|
|
759
|
+
// iteration skipped the refresh and emitted a stale/garbage token (the
|
|
760
|
+
// previous step's token, or -1 when max_tokens=1) → truncated JSON ended
|
|
761
|
+
// on a corrupted token + cacheTokens recorded the wrong id. Now only
|
|
762
|
+
// accept()/ready() (which prepare the NEXT step's mask) are gated.
|
|
763
|
+
/** Forced ids to emit after cur this iteration (jump-forward), else null. */
|
|
764
|
+
let jumpEmit: number[] | null = null;
|
|
765
|
+
if (options.grammar) {
|
|
766
|
+
grammarTok = ops.itemUint32(cur);
|
|
767
|
+
if (generated + 1 < maxTokens) {
|
|
768
|
+
options.grammar.accept(grammarTok);
|
|
769
|
+
await options.grammar.ready();
|
|
770
|
+
if (grammarJump && !options.grammar.isTerminated) {
|
|
771
|
+
jumpEmit = options.grammar.jumpForward(maxTokens - (generated + 1));
|
|
772
|
+
// jumpForward advanced the matcher and fired the post-jump mask
|
|
773
|
+
// fill; it must be ready before this iteration's sampleStep.
|
|
774
|
+
if (jumpEmit) await options.grammar.ready();
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
523
778
|
// build step n+1's graph from the *unread* pending token
|
|
524
779
|
nextPending = null;
|
|
525
780
|
nextExtras = null;
|
|
526
|
-
|
|
781
|
+
// When the grammar has terminated (a complete valid JSON/schema
|
|
782
|
+
// accepted), there are no valid tokens left — skip building the next
|
|
783
|
+
// step so the sampler never sees an all--inf distribution.
|
|
784
|
+
if (jumpEmit) {
|
|
785
|
+
// JUMP iteration: one [1, 1+m] forward carries cur AND the forced ids
|
|
786
|
+
// into the KV (they are all committed content — jumpForward's
|
|
787
|
+
// contract); the next sampled token, if the budget and grammar allow
|
|
788
|
+
// one, comes from its last position. Compiled decode resumes on the
|
|
789
|
+
// following iteration (supports() re-checks the grown caches).
|
|
790
|
+
maybeQuantizeKv(cache, options);
|
|
791
|
+
pushHistory(cur);
|
|
792
|
+
if (needsTokenHistory) {
|
|
793
|
+
const jt = ops.fromInt32(jumpEmit, [jumpEmit.length]);
|
|
794
|
+
const prev = history!;
|
|
795
|
+
history = ops.concatAxis([prev, jt], 0);
|
|
796
|
+
prev.dispose();
|
|
797
|
+
jt.dispose();
|
|
798
|
+
}
|
|
799
|
+
const chunk = [grammarTok, ...jumpEmit];
|
|
800
|
+
const ids = ops.fromInt32(chunk, [1, chunk.length]);
|
|
801
|
+
const h = model.forwardHidden(ids, cache);
|
|
802
|
+
ids.dispose();
|
|
803
|
+
// Every chunk token's KV is in the cache regardless of what follows.
|
|
804
|
+
forwarded.push(...chunk);
|
|
805
|
+
const willGen = generated + 1 + jumpEmit.length;
|
|
806
|
+
if (willGen < maxTokens && !options.grammar!.isTerminated) {
|
|
807
|
+
const [, Lj, Hj] = h.shape as [number, number, number];
|
|
808
|
+
const hLast = h.slice([0, Lj - 1, 0], [1, Lj, Hj]);
|
|
809
|
+
h.dispose();
|
|
810
|
+
const logits = model.logitsFromHidden(hLast);
|
|
811
|
+
hLast.dispose();
|
|
812
|
+
const sn = sampleStep(logits, willGen);
|
|
813
|
+
nextPending = sn.tok;
|
|
814
|
+
nextExtras = sn.extras;
|
|
815
|
+
logits.dispose();
|
|
816
|
+
ops.asyncEvalAll([nextPending, ...extrasArrays(nextExtras)]);
|
|
817
|
+
} else {
|
|
818
|
+
h.dispose(); // burst ends the generation (max_tokens or grammar done)
|
|
819
|
+
}
|
|
820
|
+
} else if (generated + 1 < maxTokens && !options.grammar?.isTerminated) {
|
|
527
821
|
maybeQuantizeKv(cache, options);
|
|
528
822
|
pushHistory(cur);
|
|
529
823
|
let logits: MlxArray | null = null;
|
|
@@ -558,7 +852,9 @@ async function* generateInner(
|
|
|
558
852
|
}
|
|
559
853
|
|
|
560
854
|
// sync-read step n's token while n+1 computes
|
|
561
|
-
|
|
855
|
+
// sync-read step n's token while n+1 computes (grammar already read it
|
|
856
|
+
// eagerly above — reuse to avoid a second GPU sync)
|
|
857
|
+
const token = options.grammar ? grammarTok : ops.itemUint32(cur);
|
|
562
858
|
if (generated === 0) {
|
|
563
859
|
// first token arrived: prompt clock stops, decode clock starts
|
|
564
860
|
// (mlx-lm stream_generate's n==0 clock swap; the first token is
|
|
@@ -570,7 +866,8 @@ async function* generateInner(
|
|
|
570
866
|
pending = null;
|
|
571
867
|
generated++;
|
|
572
868
|
// if a next-step graph was built, this token's KV entered the cache
|
|
573
|
-
|
|
869
|
+
// (jump iterations pushed the whole chunk already)
|
|
870
|
+
if (nextPending !== null && !jumpEmit) forwarded.push(token);
|
|
574
871
|
|
|
575
872
|
if (eosTokenIds.includes(token)) {
|
|
576
873
|
disposeExtras(curExtras);
|
|
@@ -587,6 +884,18 @@ async function* generateInner(
|
|
|
587
884
|
// mlx-lm generate_step: clear_cache after token 0 (drops the
|
|
588
885
|
// remaining prefill transients) and every 256 tokens after
|
|
589
886
|
if ((generated - 1) % 256 === 0) clearCache();
|
|
887
|
+
// Jump-forward burst: the forced ids follow cur, one yield each (the
|
|
888
|
+
// consumer's stop-sequence matcher and detokenizer see the same
|
|
889
|
+
// one-at-a-time stream shape as always). Their KV is already in the
|
|
890
|
+
// cache (the chunk forward above); a consumer break mid-burst is
|
|
891
|
+
// safe — `forwarded` already reflects the cache exactly.
|
|
892
|
+
if (jumpEmit) {
|
|
893
|
+
for (const jt of jumpEmit) {
|
|
894
|
+
generated++;
|
|
895
|
+
yield { token: jt, index: generated - 1 };
|
|
896
|
+
if ((generated - 1) % 256 === 0) clearCache();
|
|
897
|
+
}
|
|
898
|
+
}
|
|
590
899
|
if (nextPending === null) {
|
|
591
900
|
stop = true;
|
|
592
901
|
} else {
|
|
@@ -611,6 +920,11 @@ async function* generateInner(
|
|
|
611
920
|
disposeExtras(nextExtras);
|
|
612
921
|
}
|
|
613
922
|
if (ownsCache) for (const c of cache) c.dispose();
|
|
923
|
+
// The grammar controller owns native WASM state (GrammarMatcher +
|
|
924
|
+
// TokenizerInfo cache) — dispose on every exit path (normal, early break,
|
|
925
|
+
// throw). TokenizerInfo itself is process-cached (vocab-structural), so
|
|
926
|
+
// only the per-request matcher/compiled are freed here.
|
|
927
|
+
options.grammar?.dispose();
|
|
614
928
|
history?.dispose();
|
|
615
929
|
if (!finished && !threw) {
|
|
616
930
|
// forced early return (consumer break at a yield): still report
|