mlx-bun 0.0.10 → 0.0.12
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 +107 -32
- package/THIRD_PARTY_LICENSES.md +10 -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 +58 -35
- package/docs/design/batching-v2-plan.md +23 -9
- package/docs/design/bucketing-stage.md +2 -1
- package/docs/design/colibri-glm52-port.md +1479 -0
- 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 +14 -6
- 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/pre-colibri-stabilization.md +329 -0
- package/docs/design/repo-cleanup-plan.md +189 -0
- package/docs/design/runtime-isolation.md +19 -3
- package/docs/design/segmented-backward-training.md +3 -2
- package/docs/design/ssd-kv-cold-tier.md +130 -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/colibri-oracle-pin.md +1284 -0
- package/docs/investigations/dspark-handoff.md +182 -16
- package/docs/investigations/indexshare-performance-spike.md +357 -0
- 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.11.md +239 -0
- package/docs/planning/release-notes-v0.0.12.md +80 -0
- package/docs/reference/cli.md +63 -18
- package/docs/reference/distribution.md +37 -6
- package/docs/reference/embedding.md +40 -16
- package/docs/reference/features-matrix.md +99 -0
- package/docs/reference/library-api.md +69 -20
- package/docs/reference/memory.md +26 -6
- package/docs/reference/models.md +60 -3
- package/docs/reference/orpo-quickstart.md +3 -4
- package/docs/reference/server-api.md +717 -24
- package/docs/reference/server-config.md +265 -129
- package/docs/reference/training.md +24 -16
- package/package.json +5 -3
- package/src/anthropic.ts +67 -44
- 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/chat-template.ts +155 -10
- package/src/cli.ts +483 -113
- package/src/config.ts +116 -24
- package/src/diffusion/diffusion-generate.ts +14 -8
- package/src/download.ts +143 -12
- package/src/eval/README.md +7 -6
- package/src/eval/ifeval.ts +63 -120
- package/src/eval/kl.ts +59 -3
- package/src/eval/quality-db.ts +2 -2
- package/src/eval/runner.ts +17 -8
- package/src/eval/tasks/ifeval.ts +167 -31
- package/src/expert-io.ts +391 -0
- package/src/expert-offload.ts +15 -2
- package/src/expert-residency.ts +1134 -0
- package/src/expert-usage.ts +415 -0
- package/src/flags.ts +13 -0
- package/src/generate.ts +438 -35
- package/src/grammar.ts +585 -0
- package/src/hf-push.ts +1 -1
- package/src/hub-rest.ts +242 -0
- package/src/index.ts +14 -2
- package/src/jobs/runner.ts +37 -18
- package/src/kv-store.ts +586 -93
- package/src/lora.ts +85 -14
- package/src/md-modules.d.ts +42 -0
- package/src/media-fetch.ts +426 -0
- package/src/memory/rest.ts +245 -0
- package/src/memory/vault.ts +74 -0
- package/src/mlx/array.ts +149 -19
- package/src/mlx/autograd.ts +53 -16
- package/src/mlx/checkpoint.ts +46 -13
- package/src/mlx/custom-vjp.ts +83 -35
- package/src/mlx/ffi.ts +27 -3
- package/src/mlx/metal-kernel.ts +7 -4
- package/src/mlx/ops.ts +153 -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 +103 -6
- package/src/model/gemma4-base.ts +395 -39
- 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/glm52-atlas.ts +511 -0
- package/src/model/glm52-cache.ts +706 -0
- package/src/model/glm52-config.ts +262 -0
- package/src/model/glm52-container.ts +305 -0
- package/src/model/glm52-coupling.ts +388 -0
- package/src/model/glm52-dsa.ts +602 -0
- package/src/model/glm52-expert-layout.ts +253 -0
- package/src/model/glm52-layout.ts +152 -0
- package/src/model/glm52-memory.ts +473 -0
- package/src/model/glm52-mla.ts +786 -0
- package/src/model/glm52-moe.ts +223 -0
- package/src/model/glm52-pilot.ts +537 -0
- package/src/model/glm52-reference.ts +445 -0
- package/src/model/glm52-residency.ts +557 -0
- package/src/model/glm52-resident-weights.ts +135 -0
- package/src/model/glm52-streamed-experts.ts +453 -0
- package/src/model/glm52-streamed-metal.ts +610 -0
- package/src/model/glm52-weights.ts +503 -0
- package/src/model/glm52.ts +1153 -0
- package/src/model/minicpm5.ts +58 -102
- package/src/model/paged-kv.ts +253 -0
- package/src/model/qwen3-delta.ts +76 -15
- package/src/model/qwen3-moe.ts +404 -0
- package/src/model/qwen3.ts +9 -8
- package/src/model/qwen3_5.ts +88 -23
- package/src/model/support.ts +19 -1
- package/src/model/universal/dense.ts +40 -5
- package/src/model/universal/rope.ts +36 -21
- package/src/native/expert_io.c +523 -0
- package/src/native-pack.ts +6 -4
- package/src/pi-web.ts +1299 -82
- package/src/preflight.ts +27 -8
- package/src/prompt-cache.ts +293 -33
- package/src/quantize/quantizer.ts +16 -1
- package/src/registry.ts +57 -2
- package/src/responses.ts +62 -49
- package/src/serve/batch-scheduler.ts +1050 -78
- package/src/serve/generation-gateway.ts +252 -24
- 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 +1701 -175
- 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/glm52-mtp-source.ts +392 -0
- package/src/spec/ngram-source.ts +132 -0
- package/src/spec/serve-loop.ts +617 -0
- package/src/spec/source.ts +118 -0
- package/src/spec/two-model.ts +171 -0
- package/src/ssd-cache.ts +111 -35
- package/src/tokenizer.ts +288 -2
- package/src/tool-approvals.ts +131 -0
- package/src/tool-call.ts +438 -22
- package/src/train/flash-cce.ts +2 -2
- package/src/train/job.ts +0 -12
- package/src/train/lora-params.ts +17 -6
- package/src/train/trainer.ts +60 -30
- package/src/vision/prompt.ts +217 -48
- package/src/web/app.html +1197 -1791
- package/src/web/app.js +5694 -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 +300 -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/web-tools.ts +54 -2
- package/src/weights.ts +82 -19
- 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,22 @@ 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;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function shouldUseGrammarJump(
|
|
121
|
+
options: Pick<GenerateOptions, "grammar" | "logprobs" | "topLogprobs">,
|
|
122
|
+
): boolean {
|
|
123
|
+
return options.grammar !== undefined &&
|
|
124
|
+
flagOn("MLX_BUN_GRAMMAR_JUMP", false) &&
|
|
125
|
+
!options.logprobs &&
|
|
126
|
+
!(options.topLogprobs && options.topLogprobs > 0);
|
|
73
127
|
}
|
|
74
128
|
|
|
75
129
|
/** Per-token logprob info (only present when GenerateOptions requested it). */
|
|
@@ -82,14 +136,35 @@ export interface TokenLogprobs {
|
|
|
82
136
|
top?: { id: number; logprob: number }[];
|
|
83
137
|
}
|
|
84
138
|
|
|
139
|
+
/** Materialize every cache's state at a prefill chunk boundary. Unlike
|
|
140
|
+
* KVCache/QuantizedKVCache/RotatingKVCache, TurboQuantKVCache.state()
|
|
141
|
+
* allocates FRESH trimmed slice views on every call (required by its
|
|
142
|
+
* snapshotCache/cloneKvCaches callers in kv-store.ts, which already
|
|
143
|
+
* dispose them) rather than returning its own live-owned arrays — so
|
|
144
|
+
* this chokepoint must dispose that cache kind's state() output itself,
|
|
145
|
+
* or the views leak (unreferenced past this call, only reclaimed by the
|
|
146
|
+
* FinalizationRegistry backstop on GC of the tiny JS wrapper). */
|
|
147
|
+
export function evalCacheState(cache: Cache[]): void {
|
|
148
|
+
const turboState = cache.flatMap((c) => (c instanceof TurboQuantKVCache ? c.state() : []));
|
|
149
|
+
try {
|
|
150
|
+
const liveState = cache.flatMap((c) => (c instanceof TurboQuantKVCache ? [] : c.state()));
|
|
151
|
+
ops.evalAll([...turboState, ...liveState]);
|
|
152
|
+
} finally {
|
|
153
|
+
// Disposed even when evalAll throws (Metal OOM / deferred graph error) —
|
|
154
|
+
// the whole reason this chokepoint exists is that these views are owned.
|
|
155
|
+
for (const a of turboState) a.dispose();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
85
159
|
/** Port of mlx-lm maybe_quantize_kv_cache + BOTH halves of optiq serve's
|
|
86
160
|
* per-layer patched variant (incl. patch_rotating_to_quantized: rotating
|
|
87
161
|
* caches convert too — Phase 9):
|
|
88
162
|
* - per-layer bits/group_size selection (kvConfig overrides kvBits,
|
|
89
163
|
* matching optiq's --kv-config precedence; shipped kv_config.json
|
|
90
|
-
* files
|
|
91
|
-
*
|
|
92
|
-
*
|
|
164
|
+
* files cover EVERY cache-owning layer, sliding ones included —
|
|
165
|
+
* verified 12B 48/48, 26B 30/30, e4b 24/24 distinct caches — so
|
|
166
|
+
* rotating quantization engages straight from the config; uniform
|
|
167
|
+
* kvBits — like optiq --kv-bits — reaches them too), and
|
|
93
168
|
* - STREAMING conversion (optiq streaming_kv_quant / serve.py
|
|
94
169
|
* patched_maybe_quantize): eval each layer's quantized triples and
|
|
95
170
|
* clear the buffer pool before building the next layer's conversion.
|
|
@@ -99,7 +174,11 @@ export interface TokenLogprobs {
|
|
|
99
174
|
* 24 GB Mac). Numerics untouched: same quantize math, only the eval
|
|
100
175
|
* ordering is forced (tests/kv-quant.test.ts, tests/rotating-kvq.test.ts). */
|
|
101
176
|
export function maybeQuantizeKv(cache: Cache[], options: GenerateOptions): void {
|
|
102
|
-
const { kvBits, kvConfig } = options;
|
|
177
|
+
const { kvBits, kvConfig, turboQuant } = options;
|
|
178
|
+
if (turboQuant) {
|
|
179
|
+
maybeTurboQuantizeKv(cache, turboQuant, options.quantizedKvStart ?? 0);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
103
182
|
if (!kvBits && !kvConfig?.length) return;
|
|
104
183
|
const start = options.quantizedKvStart ?? (kvConfig?.length ? 0 : 5000);
|
|
105
184
|
const byLayer = kvConfig?.length
|
|
@@ -128,6 +207,70 @@ export function maybeQuantizeKv(cache: Cache[], options: GenerateOptions): void
|
|
|
128
207
|
}
|
|
129
208
|
}
|
|
130
209
|
|
|
210
|
+
/** Paged-KV conversion (docs/design/paged-kv-cache.md): swap each FRESH
|
|
211
|
+
* plain full-attention KVCache for a PagedKVCache sized to hold
|
|
212
|
+
* `capacityTokens` (prompt + maxTokens — known exactly at generate()
|
|
213
|
+
* setup, so pool exhaustion is unreachable absent an accounting bug).
|
|
214
|
+
* Same in-place-mutation shape as maybeQuantizeKv, but runs ONCE before
|
|
215
|
+
* prefill: paging changes storage layout, not arithmetic, so there is no
|
|
216
|
+
* "convert when populated" trigger. Sliding-window (RotatingKVCache)
|
|
217
|
+
* layers keep today's scheme — mixed paged-full + rotating-sliding is
|
|
218
|
+
* the supported v1 shape. Pre-warmed caches (offset > 0) skip conversion
|
|
219
|
+
* entirely: the serve lane bypasses prompt-cache reuse for paged
|
|
220
|
+
* requests, so this only guards library callers. */
|
|
221
|
+
export function maybePageKv(
|
|
222
|
+
cache: Cache[], options: GenerateOptions, capacityTokens: number,
|
|
223
|
+
): void {
|
|
224
|
+
if (!options.pagedKv) return;
|
|
225
|
+
if (cache.some((c) => c.offset > 0)) return;
|
|
226
|
+
const blockSize = options.pagedKv.blockSize ?? PagedKVCache.DEFAULT_BLOCK_SIZE;
|
|
227
|
+
for (let i = 0; i < cache.length; i++) {
|
|
228
|
+
if (cache[i] instanceof KVCache) {
|
|
229
|
+
cache[i]!.dispose(); // fresh (offset 0) — nothing stored yet
|
|
230
|
+
cache[i] = new PagedKVCache(capacityTokens, blockSize);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Emitted once per process: RotatingKVCache (sliding-window) layers are a
|
|
236
|
+
* documented v1 non-goal (docs/design/turboquant-kv.md) — they stay bf16
|
|
237
|
+
* rather than throwing, so mixed full-attention/sliding-window models (e.g.
|
|
238
|
+
* Gemma) still serve correctly under --kv-quant turbo. */
|
|
239
|
+
let warnedTurboRotating = false;
|
|
240
|
+
|
|
241
|
+
/** TurboQuant conversion chokepoint (mirrors the uniform/config branch
|
|
242
|
+
* above): only plain full-attention KVCache instances convert, via
|
|
243
|
+
* TurboQuantKVCache.fromKVCache — RotatingKVCache stays bf16 (warn once,
|
|
244
|
+
* never throw). Same offset===0 skip-empty-cache rule as the affine path. */
|
|
245
|
+
function maybeTurboQuantizeKv(cache: Cache[], scheme: TurboQuantScheme, start: number): void {
|
|
246
|
+
for (let i = 0; i < cache.length; i++) {
|
|
247
|
+
const c = cache[i]!;
|
|
248
|
+
if (c instanceof RotatingKVCache) {
|
|
249
|
+
if (!warnedTurboRotating) {
|
|
250
|
+
warnedTurboRotating = true;
|
|
251
|
+
console.warn(
|
|
252
|
+
"[turbo-quant] sliding-window (RotatingKVCache) layers stay bf16 in v1 " +
|
|
253
|
+
"(full-attention only) — docs/design/turboquant-kv.md.",
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (!(c instanceof KVCache) || c.offset < start || c.offset === 0) continue;
|
|
259
|
+
const tq = TurboQuantKVCache.fromKVCache(c, scheme.kBits, scheme.vBits);
|
|
260
|
+
cache[i] = tq;
|
|
261
|
+
// state() allocates fresh trimmed slice views for this cache kind
|
|
262
|
+
// (see evalCacheState) — dispose after materializing (throw included),
|
|
263
|
+
// or they leak.
|
|
264
|
+
const state = tq.state();
|
|
265
|
+
try {
|
|
266
|
+
ops.evalAll(state);
|
|
267
|
+
} finally {
|
|
268
|
+
for (const a of state) a.dispose();
|
|
269
|
+
}
|
|
270
|
+
clearCache();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
131
274
|
export interface GenerateStats {
|
|
132
275
|
promptTokens: number;
|
|
133
276
|
/** Prompt tokens skipped via a pre-warmed cache. */
|
|
@@ -141,6 +284,16 @@ export interface GenerateStats {
|
|
|
141
284
|
* decoded token that was forwarded, including a trailing EOS the
|
|
142
285
|
* pipeline forwarded before reading it). For PromptCache.put(). */
|
|
143
286
|
cacheTokens: number[];
|
|
287
|
+
/** Speculative-decoding telemetry (serve --draft-model path only).
|
|
288
|
+
* draftedByPos/acceptedByPos: per-draft-position round counts (index =
|
|
289
|
+
* position within a round's block) — the Phase-1c per-position
|
|
290
|
+
* acceptance signal. */
|
|
291
|
+
spec?: {
|
|
292
|
+
drafted: number; accepted: number; targetCalls: number;
|
|
293
|
+
draftedByPos?: number[]; acceptedByPos?: number[];
|
|
294
|
+
rejected?: number; rounds?: number; acceptanceLengths?: number[];
|
|
295
|
+
tokensPerForward?: number; forwardsSaved?: number;
|
|
296
|
+
};
|
|
144
297
|
}
|
|
145
298
|
|
|
146
299
|
export interface GeneratedToken {
|
|
@@ -196,6 +349,33 @@ export class Generation implements AsyncIterable<GeneratedToken> {
|
|
|
196
349
|
const WIRE_THRESHOLD = 0.75;
|
|
197
350
|
let wiredScopeDepth = 0;
|
|
198
351
|
let wiredOldLimit = 0;
|
|
352
|
+
|
|
353
|
+
type WiredModelMemory = {
|
|
354
|
+
readonly weightsBytes: number;
|
|
355
|
+
readonly expertRuntime?: {
|
|
356
|
+
readonly plan: { readonly plannedBytes: number };
|
|
357
|
+
flushUsage?: () => void;
|
|
358
|
+
finishUsage?: () => Promise<void>;
|
|
359
|
+
} | null;
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
/** Bytes whose MLX graph must stay resident while a model executes. */
|
|
363
|
+
export function wiredWorkingSetBytes(model: WiredModelMemory): number {
|
|
364
|
+
const planned = model.expertRuntime?.plan.plannedBytes;
|
|
365
|
+
return typeof planned === "number" && Number.isFinite(planned) && planned > 0
|
|
366
|
+
? Math.max(model.weightsBytes, planned)
|
|
367
|
+
: model.weightsBytes;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export function modelNeedsWiredLimit(
|
|
371
|
+
model: WiredModelMemory,
|
|
372
|
+
recommendedBytes = maxRecommendedWorkingSetSize(),
|
|
373
|
+
force = process.env.MLX_BUN_FORCE_WIRE === "1",
|
|
374
|
+
): boolean {
|
|
375
|
+
return force ||
|
|
376
|
+
wiredWorkingSetBytes(model) > WIRE_THRESHOLD * recommendedBytes;
|
|
377
|
+
}
|
|
378
|
+
|
|
199
379
|
function enterWiredScope(): void {
|
|
200
380
|
if (wiredScopeDepth++ === 0)
|
|
201
381
|
wiredOldLimit = setWiredLimit(maxRecommendedWorkingSetSize());
|
|
@@ -220,9 +400,10 @@ export function generate(
|
|
|
220
400
|
? generateDiffusionInner(model, promptTokens, options)
|
|
221
401
|
: generateInner(model, promptTokens, options);
|
|
222
402
|
if (options.adapters?.length) inner = adapterScoped(model, options.adapters, inner);
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
403
|
+
const memoryModel: WiredModelMemory = model;
|
|
404
|
+
if (memoryModel.expertRuntime?.flushUsage)
|
|
405
|
+
inner = usageScoped(memoryModel, inner);
|
|
406
|
+
const wire = modelNeedsWiredLimit(model);
|
|
226
407
|
return new Generation(wire ? wiredScoped(inner) : inner);
|
|
227
408
|
}
|
|
228
409
|
|
|
@@ -295,6 +476,68 @@ async function* wiredScoped(
|
|
|
295
476
|
}
|
|
296
477
|
}
|
|
297
478
|
|
|
479
|
+
/** Publish the streamed model's route ledger on every generator exit path. */
|
|
480
|
+
async function* usageScoped(
|
|
481
|
+
model: WiredModelMemory,
|
|
482
|
+
inner: AsyncGenerator<GeneratedToken, GenerateStats>,
|
|
483
|
+
): AsyncGenerator<GeneratedToken, GenerateStats> {
|
|
484
|
+
try {
|
|
485
|
+
return yield* inner;
|
|
486
|
+
} finally {
|
|
487
|
+
if (model.expertRuntime?.finishUsage)
|
|
488
|
+
await model.expertRuntime.finishUsage();
|
|
489
|
+
else
|
|
490
|
+
model.expertRuntime?.flushUsage?.();
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Apply generate()'s scoped wiring policy to non-generator execution paths. */
|
|
495
|
+
export async function withModelWiredLimit<T>(
|
|
496
|
+
model: WiredModelMemory,
|
|
497
|
+
run: () => Promise<T>,
|
|
498
|
+
): Promise<T> {
|
|
499
|
+
if (!modelNeedsWiredLimit(model)) return run();
|
|
500
|
+
enterWiredScope();
|
|
501
|
+
try {
|
|
502
|
+
return await run();
|
|
503
|
+
} finally {
|
|
504
|
+
exitWiredScope();
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** Apply generate()'s usage safe-point to direct/non-generator paths. */
|
|
509
|
+
export async function withModelUsageFlush<T>(
|
|
510
|
+
model: WiredModelMemory,
|
|
511
|
+
run: () => Promise<T>,
|
|
512
|
+
): Promise<T> {
|
|
513
|
+
try {
|
|
514
|
+
return await run();
|
|
515
|
+
} finally {
|
|
516
|
+
if (model.expertRuntime?.finishUsage)
|
|
517
|
+
await model.expertRuntime.finishUsage();
|
|
518
|
+
else
|
|
519
|
+
model.expertRuntime?.flushUsage?.();
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Forward through either the ordinary synchronous model surface or a
|
|
524
|
+
* streamed-expert model whose layer execution must cross async I/O. Keeping
|
|
525
|
+
* this choice inside the shared generator preserves the complete generation
|
|
526
|
+
* contract (sampling, grammar, logprobs, prompt cache, and cancellation) for
|
|
527
|
+
* both execution styles. */
|
|
528
|
+
async function forwardHiddenForGeneration(
|
|
529
|
+
model: RuntimeModel,
|
|
530
|
+
ids: MlxArray,
|
|
531
|
+
cache: Cache[],
|
|
532
|
+
): Promise<MlxArray> {
|
|
533
|
+
const asyncModel = model as RuntimeModel & {
|
|
534
|
+
forwardHiddenAsync?: (ids: MlxArray, cache: Cache[]) => Promise<MlxArray>;
|
|
535
|
+
};
|
|
536
|
+
return typeof asyncModel.forwardHiddenAsync === "function"
|
|
537
|
+
? await asyncModel.forwardHiddenAsync(ids, cache)
|
|
538
|
+
: model.forwardHidden(ids, cache);
|
|
539
|
+
}
|
|
540
|
+
|
|
298
541
|
async function* generateInner(
|
|
299
542
|
model: RuntimeModel,
|
|
300
543
|
promptTokens: number[],
|
|
@@ -324,6 +567,11 @@ async function* generateInner(
|
|
|
324
567
|
throw new Error(
|
|
325
568
|
`pre-warmed cache (${cachedTokens} tokens) must be a strict prefix of the prompt (${promptTokens.length})`,
|
|
326
569
|
);
|
|
570
|
+
// Paged KV (default off): swap fresh full-attention caches for paged
|
|
571
|
+
// storage BEFORE prefill. Capacity is exact — the deepest write lands at
|
|
572
|
+
// prompt + maxTokens − 1 (step maxTokens−1's forward), so this bound
|
|
573
|
+
// makes pool exhaustion an accounting-bug tripwire, not a request limit.
|
|
574
|
+
maybePageKv(cache, options, promptTokens.length + maxTokens);
|
|
327
575
|
/** device-side token history (only maintained when processors need it) */
|
|
328
576
|
let history: MlxArray | null = null;
|
|
329
577
|
|
|
@@ -341,15 +589,14 @@ async function* generateInner(
|
|
|
341
589
|
if (e) for (const a of [e.sel, e.topIdx, e.topVals]) a?.dispose();
|
|
342
590
|
};
|
|
343
591
|
/** Read extras back to JS (forces eval — they were async-dispatched with the
|
|
344
|
-
* token
|
|
592
|
+
* token) without appending casts behind the next decode step. */
|
|
345
593
|
const readExtras = (e: StepExtras | null): TokenLogprobs | undefined => {
|
|
346
594
|
if (!e) return undefined;
|
|
347
595
|
const out: TokenLogprobs = {};
|
|
348
|
-
if (e.sel) out.logprob = e.sel.
|
|
596
|
+
if (e.sel) out.logprob = e.sel.toFloat32Host()[0]!;
|
|
349
597
|
if (e.topIdx && e.topVals) {
|
|
350
|
-
|
|
351
|
-
const
|
|
352
|
-
const vals = e.topVals.toFloat32();
|
|
598
|
+
const ids = e.topIdx.toIntTokens();
|
|
599
|
+
const vals = e.topVals.toFloat32Host();
|
|
353
600
|
out.top = Array.from(ids, (id, i) => ({ id, logprob: vals[i]! })).sort(
|
|
354
601
|
(a, b) => b.logprob - a.logprob,
|
|
355
602
|
);
|
|
@@ -367,6 +614,10 @@ async function* generateInner(
|
|
|
367
614
|
const V = logits3d.shape[2]!;
|
|
368
615
|
let logits = ops.reshape(logits3d, [1, V]);
|
|
369
616
|
for (const p of processors) logits = disposing(logits, p(history, logits));
|
|
617
|
+
// Grammar mask (src/grammar.ts): apply AFTER the standard processors so the
|
|
618
|
+
// grammar has the final say on validity (a -inf'd token stays -inf through
|
|
619
|
+
// log-softmax). Off (no grammar) = zero cost — the branch is unset.
|
|
620
|
+
if (options.grammar) logits = disposing(logits, options.grammar.applyMask(logits));
|
|
370
621
|
const lp = toLogprobs(logits);
|
|
371
622
|
logits.dispose();
|
|
372
623
|
const tok = sampler(lp, step);
|
|
@@ -444,21 +695,65 @@ async function* generateInner(
|
|
|
444
695
|
if (needsTokenHistory)
|
|
445
696
|
history = ops.fromInt32(promptTokens, [promptTokens.length]);
|
|
446
697
|
// e2b/e4b need the spliced token ids to build per-layer inputs
|
|
447
|
-
// (
|
|
698
|
+
// (multimodal soft-token positions zeroed inside forwardEmbeddings).
|
|
448
699
|
const embedIds = ops.fromInt32(promptTokens, [1, promptTokens.length]);
|
|
449
700
|
h0 = model.forwardEmbeddings(
|
|
450
701
|
options.promptEmbeddings, cache, options.imageMask ?? null, embedIds,
|
|
702
|
+
options.multimodalMask ?? null,
|
|
451
703
|
);
|
|
452
704
|
embedIds.dispose();
|
|
453
705
|
} else {
|
|
454
706
|
let pos = cachedTokens;
|
|
455
|
-
|
|
456
|
-
|
|
707
|
+
// Stable-boundary snapshot: prefill up to snapshotAt first (plain
|
|
708
|
+
// chunks, no logits), fire the hook while the caches hold exactly
|
|
709
|
+
// that prefix, then continue with the remainder below. Splitting here
|
|
710
|
+
// changes chunk shapes only for requests that opted in — the same
|
|
711
|
+
// numerics class as a prompt-cache-hit continuation prefill.
|
|
712
|
+
const snapAt = options.onPrefillDone && options.snapshotAt !== undefined
|
|
713
|
+
? Math.min(Math.max(options.snapshotAt, cachedTokens), promptTokens.length)
|
|
714
|
+
: promptTokens.length;
|
|
715
|
+
if (snapAt > pos && snapAt < promptTokens.length) {
|
|
716
|
+
while (pos < snapAt) {
|
|
717
|
+
const chunk = promptTokens.slice(pos, Math.min(pos + prefillChunkSize, snapAt));
|
|
718
|
+
const ids = ops.fromInt32(chunk, [1, chunk.length]);
|
|
719
|
+
const h = await forwardHiddenForGeneration(model, ids, cache);
|
|
720
|
+
ids.dispose();
|
|
721
|
+
h.dispose();
|
|
722
|
+
evalCacheState(cache);
|
|
723
|
+
maybeQuantizeKv(cache, options);
|
|
724
|
+
clearCache();
|
|
725
|
+
pos += chunk.length;
|
|
726
|
+
// Macrotask yield between chunks (runtime-isolation.md Phase 1):
|
|
727
|
+
// each chunk is a synchronous multi-hundred-ms FFI eval; without
|
|
728
|
+
// this the event loop serves no I/O for the WHOLE prefill.
|
|
729
|
+
await new Promise<void>((r) => setImmediate(r));
|
|
730
|
+
}
|
|
731
|
+
options.onPrefillDone?.();
|
|
732
|
+
}
|
|
733
|
+
// Oracle prefill convention (mlx-lm 0.31.3 generate_step,
|
|
734
|
+
// generate.py:430-453): drain the prompt only to len-1 — chunks of
|
|
735
|
+
// min(prefillChunkSize, remaining-1) — then compute step-0 logits from
|
|
736
|
+
// a SEPARATE L=1 forward of the LAST prompt token (its server's
|
|
737
|
+
// batched engine shares the convention: insert_segments' forced final
|
|
738
|
+
// 1-token segment + GenerationBatch._step, generate.py:1645/1327).
|
|
739
|
+
// Forwarding the last token as the tail of an L=n GEMM instead is
|
|
740
|
+
// ulp-different in bf16 (qmm-vs-qmv + L-dependent SDPA dispatch) in
|
|
741
|
+
// BOTH the step-0 logits and that token's stored KV — near-tie greedy
|
|
742
|
+
// streams flip (2026-07-07 12B completion-probe ✗: first token flip at
|
|
743
|
+
// step 24). MLX_BUN_PREFILL_TAIL_SPLIT=0 restores the old
|
|
744
|
+
// full-final-chunk convention (A/B lever + kill switch).
|
|
745
|
+
const tailSplit = flagOn("MLX_BUN_PREFILL_TAIL_SPLIT", true);
|
|
746
|
+
const drainGate = tailSplit ? 1 : prefillChunkSize;
|
|
747
|
+
while (promptTokens.length - pos > drainGate) {
|
|
748
|
+
const n = tailSplit
|
|
749
|
+
? Math.min(prefillChunkSize, promptTokens.length - pos - 1)
|
|
750
|
+
: prefillChunkSize;
|
|
751
|
+
const chunk = promptTokens.slice(pos, pos + n);
|
|
457
752
|
const ids = ops.fromInt32(chunk, [1, chunk.length]);
|
|
458
|
-
const h = model
|
|
753
|
+
const h = await forwardHiddenForGeneration(model, ids, cache);
|
|
459
754
|
ids.dispose();
|
|
460
755
|
h.dispose(); // logits never computed for non-final chunks
|
|
461
|
-
|
|
756
|
+
evalCacheState(cache);
|
|
462
757
|
maybeQuantizeKv(cache, options);
|
|
463
758
|
// mlx-lm _prefill clears the allocator cache after every chunk;
|
|
464
759
|
// without this, prefill transients pile up in the buffer cache
|
|
@@ -467,16 +762,26 @@ async function* generateInner(
|
|
|
467
762
|
// measured, scripts/decode-split.ts; the context-scaling decode
|
|
468
763
|
// gap's main term).
|
|
469
764
|
clearCache();
|
|
470
|
-
pos +=
|
|
765
|
+
pos += n;
|
|
766
|
+
// Macrotask yield between chunks (runtime-isolation.md Phase 1).
|
|
767
|
+
await new Promise<void>((r) => setImmediate(r));
|
|
471
768
|
}
|
|
472
769
|
if (needsTokenHistory) {
|
|
473
770
|
history = ops.fromInt32(promptTokens, [promptTokens.length]);
|
|
474
771
|
}
|
|
772
|
+
// Under tailSplit this is exactly [promptTokens[len-1]] — the L=1
|
|
773
|
+
// step-0 forward (uncompiled L=1 is bit-exact with compiled decode,
|
|
774
|
+
// tests/compiled-decode.test.ts). The last prompt token's KV enters
|
|
775
|
+
// the cache HERE, so downstream bookkeeping (forwarded[], cacheTokens,
|
|
776
|
+
// PromptCache.put alignment) is unchanged: after step 0 the caches
|
|
777
|
+
// cover exactly the prompt, as before.
|
|
475
778
|
const lastChunk = promptTokens.slice(pos);
|
|
476
779
|
const ids0 = ops.fromInt32(lastChunk, [1, lastChunk.length]);
|
|
477
|
-
h0 = model
|
|
780
|
+
h0 = await forwardHiddenForGeneration(model, ids0, cache);
|
|
478
781
|
ids0.dispose();
|
|
479
782
|
}
|
|
783
|
+
if (options.snapshotAt === undefined || options.snapshotAt >= promptTokens.length)
|
|
784
|
+
options.onPrefillDone?.();
|
|
480
785
|
const [, L0, H] = h0.shape as [number, number, number];
|
|
481
786
|
const hLast = h0.slice([0, L0 - 1, 0], [1, L0, H]);
|
|
482
787
|
h0.dispose();
|
|
@@ -508,30 +813,108 @@ async function* generateInner(
|
|
|
508
813
|
// 0.6.0, and shapeless replay re-infers the whole tape whenever the
|
|
509
814
|
// growing attention windows change shape (= every step). Remove this
|
|
510
815
|
// when upstream implements GatherQMM::output_shapes.
|
|
511
|
-
// MLX_BUN_FUSED_DECODE=1 (the N-tiled fused decode experiment) cannot
|
|
512
|
-
// compose with compiled decode: the tile loop's bounds bake at trace-time
|
|
513
|
-
// N, so growing quantized caches silently stop attending their newest
|
|
514
|
-
// rows under shapeless replay (kernel-perf-review 2026-07; reproduced on
|
|
515
|
-
// e4b). The explicit opt-in wins over the default optimization — same
|
|
516
|
-
// policy as LoRA and MoE below. quantizedSdpa also throws on the combo
|
|
517
|
-
// as a backstop.
|
|
518
816
|
let compiled =
|
|
519
|
-
|
|
520
|
-
process.env.MLX_BUN_FUSED_DECODE !== "1" &&
|
|
817
|
+
flagOn("MLX_BUN_COMPILED_DECODE", true) &&
|
|
521
818
|
!options.adapters?.length &&
|
|
819
|
+
// Paged caches can't compile (data-dependent block-list length —
|
|
820
|
+
// the shapeless-replay hazard; CompiledDecode.supports() also
|
|
821
|
+
// excludes them per step, this just skips the setup).
|
|
822
|
+
!options.pagedKv &&
|
|
522
823
|
model.config.modelType.startsWith("gemma4") &&
|
|
523
824
|
!model.config.text.enableMoeBlock
|
|
524
825
|
? CompiledDecode.for(model as Gemma4Model)
|
|
525
826
|
: null;
|
|
526
827
|
let stop = false;
|
|
828
|
+
/** Token id read eagerly at the top of the loop for grammar (reused for
|
|
829
|
+
* the yield, avoiding a second readback). -1 when grammar is off — the
|
|
830
|
+
* pipelined path keeps its deferred itemUint32 below. */
|
|
831
|
+
let grammarTok = -1;
|
|
832
|
+
// Jump-forward decoding (opt-in, MLX_BUN_GRAMMAR_JUMP=1; serial lane
|
|
833
|
+
// only — the batch lane's #stepGrammar doesn't jump yet): when the
|
|
834
|
+
// grammar forces a unique continuation, emit its retokenized ids without
|
|
835
|
+
// per-token forwards — ONE multi-token forward carries them into the KV
|
|
836
|
+
// (see GrammarController.jumpForward for the contract + the fidelity
|
|
837
|
+
// note on why this is opt-in). Excluded when logprobs are requested
|
|
838
|
+
// (jumped tokens are never sampled, so they'd have no logprobs rows).
|
|
839
|
+
const grammarJump = shouldUseGrammarJump(options);
|
|
527
840
|
while (!stop) {
|
|
528
841
|
const cur = pending!;
|
|
529
842
|
const curExtras = pendingExtras;
|
|
530
843
|
pendingExtras = null;
|
|
844
|
+
// Grammar advance (src/grammar.ts): acceptToken needs the token id as a
|
|
845
|
+
// JS number, which the pipelined loop defers (it operates on device
|
|
846
|
+
// arrays). So grammar requests eager-read cur here, advance the matcher,
|
|
847
|
+
// and await the async mask precompute — which overlaps the GPU forward
|
|
848
|
+
// dispatched just below. This trades the readback/forward overlap for
|
|
849
|
+
// correctness (the mask for step n+1 must reflect token n). Non-grammar
|
|
850
|
+
// requests keep the fast pipelined loop untouched.
|
|
851
|
+
//
|
|
852
|
+
// F1 fix (batched-lane plan): always eager-read `grammarTok` when
|
|
853
|
+
// grammar is on, even at the max_tokens boundary — the emitted token
|
|
854
|
+
// reuses it unconditionally. The OLD code gated the READ on
|
|
855
|
+
// generated+1 < maxTokens but emitted grammarTok regardless, so the LAST
|
|
856
|
+
// iteration skipped the refresh and emitted a stale/garbage token (the
|
|
857
|
+
// previous step's token, or -1 when max_tokens=1) → truncated JSON ended
|
|
858
|
+
// on a corrupted token + cacheTokens recorded the wrong id. Now only
|
|
859
|
+
// accept()/ready() (which prepare the NEXT step's mask) are gated.
|
|
860
|
+
/** Forced ids to emit after cur this iteration (jump-forward), else null. */
|
|
861
|
+
let jumpEmit: number[] | null = null;
|
|
862
|
+
if (options.grammar) {
|
|
863
|
+
grammarTok = ops.itemUint32(cur);
|
|
864
|
+
if (generated + 1 < maxTokens) {
|
|
865
|
+
options.grammar.accept(grammarTok);
|
|
866
|
+
await options.grammar.ready();
|
|
867
|
+
if (grammarJump && !options.grammar.isTerminated) {
|
|
868
|
+
jumpEmit = options.grammar.jumpForward(maxTokens - (generated + 1));
|
|
869
|
+
// jumpForward advanced the matcher and fired the post-jump mask
|
|
870
|
+
// fill; it must be ready before this iteration's sampleStep.
|
|
871
|
+
if (jumpEmit) await options.grammar.ready();
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|
|
531
875
|
// build step n+1's graph from the *unread* pending token
|
|
532
876
|
nextPending = null;
|
|
533
877
|
nextExtras = null;
|
|
534
|
-
|
|
878
|
+
// When the grammar has terminated (a complete valid JSON/schema
|
|
879
|
+
// accepted), there are no valid tokens left — skip building the next
|
|
880
|
+
// step so the sampler never sees an all--inf distribution.
|
|
881
|
+
if (jumpEmit) {
|
|
882
|
+
// JUMP iteration: one [1, 1+m] forward carries cur AND the forced ids
|
|
883
|
+
// into the KV (they are all committed content — jumpForward's
|
|
884
|
+
// contract); the next sampled token, if the budget and grammar allow
|
|
885
|
+
// one, comes from its last position. Compiled decode resumes on the
|
|
886
|
+
// following iteration (supports() re-checks the grown caches).
|
|
887
|
+
maybeQuantizeKv(cache, options);
|
|
888
|
+
pushHistory(cur);
|
|
889
|
+
if (needsTokenHistory) {
|
|
890
|
+
const jt = ops.fromInt32(jumpEmit, [jumpEmit.length]);
|
|
891
|
+
const prev = history!;
|
|
892
|
+
history = ops.concatAxis([prev, jt], 0);
|
|
893
|
+
prev.dispose();
|
|
894
|
+
jt.dispose();
|
|
895
|
+
}
|
|
896
|
+
const chunk = [grammarTok, ...jumpEmit];
|
|
897
|
+
const ids = ops.fromInt32(chunk, [1, chunk.length]);
|
|
898
|
+
const h = await forwardHiddenForGeneration(model, ids, cache);
|
|
899
|
+
ids.dispose();
|
|
900
|
+
// Every chunk token's KV is in the cache regardless of what follows.
|
|
901
|
+
forwarded.push(...chunk);
|
|
902
|
+
const willGen = generated + 1 + jumpEmit.length;
|
|
903
|
+
if (willGen < maxTokens && !options.grammar!.isTerminated) {
|
|
904
|
+
const [, Lj, Hj] = h.shape as [number, number, number];
|
|
905
|
+
const hLast = h.slice([0, Lj - 1, 0], [1, Lj, Hj]);
|
|
906
|
+
h.dispose();
|
|
907
|
+
const logits = model.logitsFromHidden(hLast);
|
|
908
|
+
hLast.dispose();
|
|
909
|
+
const sn = sampleStep(logits, willGen);
|
|
910
|
+
nextPending = sn.tok;
|
|
911
|
+
nextExtras = sn.extras;
|
|
912
|
+
logits.dispose();
|
|
913
|
+
ops.asyncEvalAll([nextPending, ...extrasArrays(nextExtras)]);
|
|
914
|
+
} else {
|
|
915
|
+
h.dispose(); // burst ends the generation (max_tokens or grammar done)
|
|
916
|
+
}
|
|
917
|
+
} else if (generated + 1 < maxTokens && !options.grammar?.isTerminated) {
|
|
535
918
|
maybeQuantizeKv(cache, options);
|
|
536
919
|
pushHistory(cur);
|
|
537
920
|
let logits: MlxArray | null = null;
|
|
@@ -553,7 +936,7 @@ async function* generateInner(
|
|
|
553
936
|
}
|
|
554
937
|
if (!logits) {
|
|
555
938
|
const ids = ops.reshape(cur, [1, 1]);
|
|
556
|
-
const h = model
|
|
939
|
+
const h = await forwardHiddenForGeneration(model, ids, cache);
|
|
557
940
|
ids.dispose();
|
|
558
941
|
logits = model.logitsFromHidden(h);
|
|
559
942
|
h.dispose();
|
|
@@ -566,7 +949,9 @@ async function* generateInner(
|
|
|
566
949
|
}
|
|
567
950
|
|
|
568
951
|
// sync-read step n's token while n+1 computes
|
|
569
|
-
|
|
952
|
+
// sync-read step n's token while n+1 computes (grammar already read it
|
|
953
|
+
// eagerly above — reuse to avoid a second GPU sync)
|
|
954
|
+
const token = options.grammar ? grammarTok : ops.itemUint32(cur);
|
|
570
955
|
if (generated === 0) {
|
|
571
956
|
// first token arrived: prompt clock stops, decode clock starts
|
|
572
957
|
// (mlx-lm stream_generate's n==0 clock swap; the first token is
|
|
@@ -578,7 +963,8 @@ async function* generateInner(
|
|
|
578
963
|
pending = null;
|
|
579
964
|
generated++;
|
|
580
965
|
// if a next-step graph was built, this token's KV entered the cache
|
|
581
|
-
|
|
966
|
+
// (jump iterations pushed the whole chunk already)
|
|
967
|
+
if (nextPending !== null && !jumpEmit) forwarded.push(token);
|
|
582
968
|
|
|
583
969
|
if (eosTokenIds.includes(token)) {
|
|
584
970
|
disposeExtras(curExtras);
|
|
@@ -595,6 +981,18 @@ async function* generateInner(
|
|
|
595
981
|
// mlx-lm generate_step: clear_cache after token 0 (drops the
|
|
596
982
|
// remaining prefill transients) and every 256 tokens after
|
|
597
983
|
if ((generated - 1) % 256 === 0) clearCache();
|
|
984
|
+
// Jump-forward burst: the forced ids follow cur, one yield each (the
|
|
985
|
+
// consumer's stop-sequence matcher and detokenizer see the same
|
|
986
|
+
// one-at-a-time stream shape as always). Their KV is already in the
|
|
987
|
+
// cache (the chunk forward above); a consumer break mid-burst is
|
|
988
|
+
// safe — `forwarded` already reflects the cache exactly.
|
|
989
|
+
if (jumpEmit) {
|
|
990
|
+
for (const jt of jumpEmit) {
|
|
991
|
+
generated++;
|
|
992
|
+
yield { token: jt, index: generated - 1 };
|
|
993
|
+
if ((generated - 1) % 256 === 0) clearCache();
|
|
994
|
+
}
|
|
995
|
+
}
|
|
598
996
|
if (nextPending === null) {
|
|
599
997
|
stop = true;
|
|
600
998
|
} else {
|
|
@@ -619,6 +1017,11 @@ async function* generateInner(
|
|
|
619
1017
|
disposeExtras(nextExtras);
|
|
620
1018
|
}
|
|
621
1019
|
if (ownsCache) for (const c of cache) c.dispose();
|
|
1020
|
+
// The grammar controller owns native WASM state (GrammarMatcher +
|
|
1021
|
+
// TokenizerInfo cache) — dispose on every exit path (normal, early break,
|
|
1022
|
+
// throw). TokenizerInfo itself is process-cached (vocab-structural), so
|
|
1023
|
+
// only the per-request matcher/compiled are freed here.
|
|
1024
|
+
options.grammar?.dispose();
|
|
622
1025
|
history?.dispose();
|
|
623
1026
|
if (!finished && !threw) {
|
|
624
1027
|
// forced early return (consumer break at a yield): still report
|