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
|
@@ -56,17 +56,47 @@
|
|
|
56
56
|
|
|
57
57
|
import { MlxArray } from "../mlx/array";
|
|
58
58
|
import * as ops from "../mlx/ops";
|
|
59
|
-
import { clearCache } from "../mlx/ffi";
|
|
60
|
-
import {
|
|
61
|
-
import {
|
|
59
|
+
import { clearCache, Dtype } from "../mlx/ffi";
|
|
60
|
+
import { flagOn } from "../flags";
|
|
61
|
+
import { CompiledDecode } from "../model/compiled-decode";
|
|
62
|
+
import type { Gemma4Model } from "../model/gemma4";
|
|
63
|
+
import {
|
|
64
|
+
KVCache, QuantizedKVCache, RotatingKVCache, RotatingQuantizedKVCache,
|
|
65
|
+
isBatchableCache, type BatchableCache, type Cache,
|
|
66
|
+
} from "../model/gemma4-base";
|
|
67
|
+
import { BatchedDecodeMaskCache, mergeKVRows, extendKVRows, extractKVRow, filterKVRows } from "../model/batched-mask";
|
|
68
|
+
import {
|
|
69
|
+
BatchedQuantDecodeMaskCache, extendQuantRows, extractQuantRow, filterQuantRows, mergeQuantRows,
|
|
70
|
+
type QuantRow,
|
|
71
|
+
} from "../model/batched-quant";
|
|
72
|
+
import { BatchedRotatingQuantCache } from "../model/batched-rotating-quant";
|
|
73
|
+
import type { KvQuantSpec } from "../config";
|
|
62
74
|
import { BatchedRotatingCache } from "../model/batched-rotating";
|
|
75
|
+
import { cloneKvCaches } from "../kv-store";
|
|
63
76
|
import { SSMCache } from "../model/qwen3-delta";
|
|
64
77
|
import type { RuntimeModel } from "../model/factory";
|
|
78
|
+
import type { GrammarController } from "../grammar";
|
|
79
|
+
import { kvBytesAt } from "../fit";
|
|
80
|
+
import { toLogprobs } from "../sampler";
|
|
65
81
|
|
|
66
82
|
/** Decode-pipeline kill switch (read once at load, like the serial loop's
|
|
67
83
|
* MLX_BUN_COMPILED_DECODE): 1 ⇒ read each step's tokens synchronously. */
|
|
68
84
|
const NO_PIPELINE = process.env.MLX_BUN_BATCH_NO_PIPELINE === "1";
|
|
69
85
|
|
|
86
|
+
/** Per-step phase timing (MLX_BUN_BATCH_STEP_TRACE=1, debug-only): where a
|
|
87
|
+
* decode step's wall time goes — graph BUILD+dispatch (host), the pipelined
|
|
88
|
+
* READ of the previous step's tokens (GPU wait), row EMIT (onToken/SSE), and
|
|
89
|
+
* the GAP between consecutive steps (drive-loop + everything else). Sums
|
|
90
|
+
* print via `stepTraceReport()` (the b1 profile experiment calls it). */
|
|
91
|
+
const STEP_TRACE = process.env.MLX_BUN_BATCH_STEP_TRACE === "1";
|
|
92
|
+
const STEP_T = { t0: 0, lastEnd: 0, build: 0, read: 0, emit: 0, gap: 0, n: 0 };
|
|
93
|
+
export function stepTraceReport(): string {
|
|
94
|
+
const per = (x: number) => (STEP_T.n ? (x / STEP_T.n).toFixed(3) : "0");
|
|
95
|
+
const s = `steps=${STEP_T.n} build=${per(STEP_T.build)}ms read=${per(STEP_T.read)}ms emit=${per(STEP_T.emit)}ms gap=${per(STEP_T.gap)}ms (per step)`;
|
|
96
|
+
STEP_T.t0 = STEP_T.lastEnd = STEP_T.build = STEP_T.read = STEP_T.emit = STEP_T.gap = STEP_T.n = 0;
|
|
97
|
+
return s;
|
|
98
|
+
}
|
|
99
|
+
|
|
70
100
|
// NOTE — oMLX-style "adaptive burst decode" (several steps per event-loop
|
|
71
101
|
// yield, omlx/engine_core.py _step_burst) was ported here 2026-07-02 and
|
|
72
102
|
// REFUTED by measurement: on this runtime it REGRESSED cpm5 B=4 aggregate
|
|
@@ -95,17 +125,59 @@ export interface BatchRequest {
|
|
|
95
125
|
* THIS row only (its submit promise rejects; siblings continue). May be
|
|
96
126
|
* async; keep it cheap — it runs inline in the step loop. */
|
|
97
127
|
onToken: (token: number) => void | boolean | Promise<void | boolean>;
|
|
128
|
+
/** Client/request lifetime. An aborted pending row is removed before
|
|
129
|
+
* admission; a row in prefill or decode is evicted at the next safe
|
|
130
|
+
* scheduler boundary. */
|
|
131
|
+
signal?: AbortSignal;
|
|
132
|
+
/** The row's sampler is PLAIN GREEDY (temperature 0, no curve) with no
|
|
133
|
+
* logits processors and no grammar — set by the gateway when true. Lets
|
|
134
|
+
* the scheduler take the vectorized sampling fast path (ONE
|
|
135
|
+
* log-softmax+argmax over [B,V] instead of B slice/sample/concat graphs)
|
|
136
|
+
* when EVERY live row qualifies. Numerically identical per row
|
|
137
|
+
* (row-independent ops, same per-row shapes); the per-row closure path
|
|
138
|
+
* is the fallback and the MLX_BUN_BATCH_VEC_SAMPLE=0 kill switch. */
|
|
139
|
+
plainGreedy?: boolean;
|
|
140
|
+
/** Grammar controller for this row (B1: per-row matchers). When set, the
|
|
141
|
+
* scheduler drives it: `accept()` after each emitted token (fires the async
|
|
142
|
+
* bitmask fill), `await ready()` before the row's next sample (the sample
|
|
143
|
+
* closure applies `applyMask`). Termination (grammar satisfied) is an
|
|
144
|
+
* additional per-row stop source. The gateway OWNS disposal (finally around
|
|
145
|
+
* submit()); the scheduler uses but never owns. Null on the degrade path. */
|
|
146
|
+
grammar?: GrammarController;
|
|
147
|
+
/** Stable cache boundary from the server's template probe (the serial
|
|
148
|
+
* lane's options.snapshotAt): the prompt prefix the NEXT turn's render
|
|
149
|
+
* preserves. The scheduler snapshots a trim-free prompt-cache entry at
|
|
150
|
+
* min(this, promptIds.length-1) during the solo prefill — the oracle
|
|
151
|
+
* invariant (mlx-lm insert_segments) that gives wrapped-ring models a
|
|
152
|
+
* reuse path. Absent = still capped at promptIds.length-1. */
|
|
153
|
+
snapshotAt?: number;
|
|
98
154
|
}
|
|
99
155
|
|
|
100
156
|
export interface BatchStats {
|
|
101
157
|
promptTokens: number;
|
|
102
158
|
generatedTokens: number;
|
|
103
|
-
/** Prompt tokens served from
|
|
104
|
-
*
|
|
159
|
+
/** Prompt tokens served from the prompt cache (Phase 3.2): a joiner's solo
|
|
160
|
+
* prefill starts from the longest usable cached prefix, exactly like the
|
|
161
|
+
* serial lane's runGeneration take(). 0 on a cold prefill. */
|
|
105
162
|
cachedTokens: number;
|
|
106
163
|
finishReason: "stop" | "length";
|
|
107
164
|
}
|
|
108
165
|
|
|
166
|
+
/** The slice of PromptCache the scheduler drives (structural — the server's
|
|
167
|
+
* PromptCache satisfies it). take() on admission (any joiner: a restored
|
|
168
|
+
* prefix + suffix prefill is byte-safe whether the row later merges or
|
|
169
|
+
* not). put() paths: rows that finish NEVER-MERGED (adopted lone rows,
|
|
170
|
+
* Phase 3.2) hand their pristine serial caches over zero-copy; rows that
|
|
171
|
+
* finish INSIDE a multi-row batch get their KV EXTRACTED per row into
|
|
172
|
+
* fresh serial caches first (mlx-lm server.py:872 extract_cache — the
|
|
173
|
+
* cross-request reuse concurrent agents live on). */
|
|
174
|
+
export interface RowPromptCache {
|
|
175
|
+
take(prompt: number[], ns?: string): {
|
|
176
|
+
tokens: number[]; caches: Cache[]; retain?: () => void;
|
|
177
|
+
} | null;
|
|
178
|
+
put(tokens: number[], caches: Cache[], ns?: string, retain?: () => void): void;
|
|
179
|
+
}
|
|
180
|
+
|
|
109
181
|
interface Row {
|
|
110
182
|
req: BatchRequest;
|
|
111
183
|
resolve: (s: BatchStats) => void;
|
|
@@ -114,6 +186,28 @@ interface Row {
|
|
|
114
186
|
generated: number; // tokens emitted so far (incl. a terminating EOS)
|
|
115
187
|
sampled: number; // sample() calls so far (leads `generated` by 1 in-pipeline)
|
|
116
188
|
promptTokens: number;
|
|
189
|
+
/** Prompt tokens restored from the prompt cache at admission (stats). */
|
|
190
|
+
cachedTokens: number;
|
|
191
|
+
/** Generated tokens whose KV actually entered the cache (fed as a step
|
|
192
|
+
* input) — serial generate()'s `forwarded` list. promptIds+fed is the
|
|
193
|
+
* exact token coverage of the row's caches, the put() entry key.
|
|
194
|
+
* PER-ROW EXACT for every row: pushes are gated on the pending
|
|
195
|
+
* register's per-slot real/placeholder flags (#pendingReal), so a
|
|
196
|
+
* doomed slot's placeholder is never recorded — and if one ever DID
|
|
197
|
+
* feed a forward, `fedTainted` flips instead (see #step's read). */
|
|
198
|
+
fed: number[];
|
|
199
|
+
/** A placeholder slot value fed this row's KV (junk token in the cache):
|
|
200
|
+
* the coverage key is no longer derivable — extraction refuses.
|
|
201
|
+
* Structurally unreachable today (a doomed row always evicts at the
|
|
202
|
+
* emit that follows its placeholder, filtering the slot before the
|
|
203
|
+
* next forward; cold steps never contain doomed rows because a flush
|
|
204
|
+
* emits every in-flight token) — this flag makes `fed` provably exact
|
|
205
|
+
* instead of resting on that timing chain. */
|
|
206
|
+
fedTainted: boolean;
|
|
207
|
+
/** Ever merged into a multi-row batch (or poisoned by a reject): its KV
|
|
208
|
+
* is interleaved in batched buffers — a finish put() must EXTRACT the
|
|
209
|
+
* row (#extractRowCaches) rather than adopt the inners. */
|
|
210
|
+
merged: boolean;
|
|
117
211
|
}
|
|
118
212
|
|
|
119
213
|
/** A joiner mid-prefill: `pos` prompt tokens are already in `solo`. Advanced
|
|
@@ -122,6 +216,13 @@ interface PrefillState {
|
|
|
122
216
|
row: Row;
|
|
123
217
|
solo: Cache[];
|
|
124
218
|
pos: number;
|
|
219
|
+
/** Release hook carried by an SSD-restored prompt-cache entry (must run
|
|
220
|
+
* only after `solo`/the adopted caches are disposed — see PromptCacheEntry). */
|
|
221
|
+
retain?: () => void;
|
|
222
|
+
/** Pending boundary-snapshot position: #prefillChunk splits its chunking
|
|
223
|
+
* exactly here, clones + put()s the trim-free prefix entry, then nulls
|
|
224
|
+
* it. null = no snapshot for this row (short prompt / cached past it). */
|
|
225
|
+
snapAt: number | null;
|
|
125
226
|
}
|
|
126
227
|
|
|
127
228
|
/** Held while the batch is active; the serial fallback acquires the same lock. */
|
|
@@ -139,9 +240,42 @@ export interface BatchSchedulerOptions {
|
|
|
139
240
|
admissionHeld?: () => boolean;
|
|
140
241
|
/** Joiner prefill chunk length (default 2048, the serial loop's constant). */
|
|
141
242
|
prefillChunkSize?: number;
|
|
243
|
+
/** Aggregate KV-byte budget across all running rows (batching-perf-path
|
|
244
|
+
* P3 admission, via `--kv-budget`). A joiner whose PROJECTED KV
|
|
245
|
+
* (kvBytesAt(config, prompt + maxTokens), sliding window already capped)
|
|
246
|
+
* would push the batch's projected total over this ceiling WAITS in the
|
|
247
|
+
* queue (FIFO, head-of-line — no starvation, no reorder) until rows
|
|
248
|
+
* evict. A request that can NEVER fit (over budget alone, empty batch)
|
|
249
|
+
* is rejected instead of deadlocking. Unset = unlimited (v1 behavior). */
|
|
250
|
+
kvBudgetBytes?: number;
|
|
251
|
+
/** Per-layer mixed-precision KV scheme (kv_config.json entries) — Phase
|
|
252
|
+
* 3.1 batched quantized KV. Server-wide constant (one scheme per server
|
|
253
|
+
* lifetime), so it lives here at construction, not on BatchRequest.
|
|
254
|
+
* P1 scope: only layers whose cache is a plain full-attention KVCache
|
|
255
|
+
* convert; the GATEWAY admits batching only when every configured
|
|
256
|
+
* layerIdx is such a layer (rotating/uniform stay serial). Each joiner's
|
|
257
|
+
* solo prefill converts at the SAME chunk boundaries as the serial
|
|
258
|
+
* maybeQuantizeKv, so a row's quantized bytes are bit-exact vs serial
|
|
259
|
+
* `--kv-quant config` by construction (the L2-oracle composition rule). */
|
|
260
|
+
kvConfig?: KvQuantSpec[];
|
|
261
|
+
/** Prompt-cache hook (Phase 3.2): admission take()s the longest usable
|
|
262
|
+
* prefix into the joiner's solo caches (suffix-only prefill — the
|
|
263
|
+
* multi-turn chat TTFT path); rows that finish never-merged put() their
|
|
264
|
+
* caches back. Adapter requests never reach the batch lane, so the
|
|
265
|
+
* namespace is always "" here. Runs under the gateway mutex domain, so
|
|
266
|
+
* take/put never race the serial lane's use of the same cache. */
|
|
267
|
+
promptCache?: RowPromptCache;
|
|
142
268
|
}
|
|
143
269
|
|
|
144
|
-
type LayerInner =
|
|
270
|
+
type LayerInner =
|
|
271
|
+
| KVCache
|
|
272
|
+
| QuantizedKVCache
|
|
273
|
+
| BatchedRotatingCache
|
|
274
|
+
| BatchedRotatingQuantCache // rot layer under a kv_config scheme (milestone 2)
|
|
275
|
+
| RotatingKVCache // adopted lone-row state only (see #mergeJoiner adopt)
|
|
276
|
+
| RotatingQuantizedKVCache // adopted lone-row state, quantized rot layer
|
|
277
|
+
| SSMCache
|
|
278
|
+
| BatchableCache;
|
|
145
279
|
type Row1 = { keys: MlxArray; values: MlxArray };
|
|
146
280
|
|
|
147
281
|
export class BatchScheduler {
|
|
@@ -153,40 +287,164 @@ export class BatchScheduler {
|
|
|
153
287
|
/** Sampled-but-unread token array [B], aligned with #running — the decode
|
|
154
288
|
* pipeline register. Filtered/disposed alongside the batched KV. */
|
|
155
289
|
#pendingToks: MlxArray | null = null;
|
|
290
|
+
/** Per-slot flags for #pendingToks: true = a REAL sampled token of that
|
|
291
|
+
* row's stream; false = a doomed/terminated row's placeholder (fed-token
|
|
292
|
+
* accounting must skip it — see Row.fed/fedTainted). Filtered/cleared in
|
|
293
|
+
* lockstep with the register. */
|
|
294
|
+
#pendingReal: boolean[] | null = null;
|
|
156
295
|
#steps = 0; // decode-step counter (clearCache cadence)
|
|
296
|
+
#lastHop = 0; // last macrotask yield (B=1 fast-path rate limiting)
|
|
157
297
|
#looping = false;
|
|
158
298
|
#wake: (() => void) | null = null;
|
|
159
299
|
readonly #maxBatch: number;
|
|
160
300
|
readonly #lock: ExclusiveLock | undefined;
|
|
161
301
|
readonly #admissionHeld: (() => boolean) | undefined;
|
|
162
302
|
readonly #prefillChunkSize: number;
|
|
163
|
-
readonly #
|
|
303
|
+
readonly #kvBudgetBytes: number | undefined;
|
|
304
|
+
readonly #promptCache: RowPromptCache | undefined;
|
|
305
|
+
/** Retain hook of the currently-ADOPTED row's cache entry (at most one:
|
|
306
|
+
* only a lone adopted row holds un-copied entry caches). Runs after the
|
|
307
|
+
* adopted caches are disposed, or transfers back on put(). */
|
|
308
|
+
#adoptedRetain: (() => void) | null = null;
|
|
309
|
+
readonly #kinds: ("full" | "rot" | "ssm" | "owned-batch")[];
|
|
164
310
|
readonly #rotMaxSize: number[]; // per-layer sliding window (rot layers only)
|
|
311
|
+
readonly #compressedProjectors: Array<(tokens: number) => number> | null;
|
|
312
|
+
readonly #batchCacheMaxTokens: number | null;
|
|
313
|
+
/** layerIdx → mixed-precision spec (Phase 3.1); null = bf16 batch (v1). */
|
|
314
|
+
readonly #kvByLayer: Map<number, KvQuantSpec> | null;
|
|
315
|
+
/** Compiled decode runner for the B=1 serial-class case (Phase 3.2) —
|
|
316
|
+
* same eligibility gate as generate.ts (gemma dense, kill switch
|
|
317
|
+
* MLX_BUN_COMPILED_DECODE); adapters never reach the batch lane. Set
|
|
318
|
+
* to null permanently on a failed step (serial disables per
|
|
319
|
+
* generation; the scheduler is one long-lived "generation"). */
|
|
320
|
+
#compiled: CompiledDecode | null;
|
|
165
321
|
|
|
166
322
|
constructor(private readonly model: RuntimeModel, opts: BatchSchedulerOptions) {
|
|
167
323
|
this.#maxBatch = Math.max(1, Math.floor(opts.maxBatch));
|
|
168
324
|
this.#lock = opts.lock;
|
|
169
325
|
this.#admissionHeld = opts.admissionHeld;
|
|
170
326
|
this.#prefillChunkSize = Math.max(1, Math.floor(opts.prefillChunkSize ?? 2048));
|
|
327
|
+
this.#kvBudgetBytes = opts.kvBudgetBytes;
|
|
328
|
+
this.#promptCache = opts.promptCache;
|
|
171
329
|
const proto = model.makeCache(); // fresh caches hold no buffers
|
|
172
330
|
this.#kinds = proto.map((c) =>
|
|
173
|
-
c
|
|
331
|
+
isBatchableCache(c)
|
|
332
|
+
? "owned-batch"
|
|
333
|
+
: c instanceof RotatingKVCache
|
|
334
|
+
? "rot"
|
|
335
|
+
: c instanceof SSMCache
|
|
336
|
+
? "ssm"
|
|
337
|
+
: "full",
|
|
174
338
|
);
|
|
339
|
+
this.#compressedProjectors = proto.every(isBatchableCache)
|
|
340
|
+
? proto.map((cache) => (tokens: number) => cache.projectedBytes(tokens))
|
|
341
|
+
: null;
|
|
342
|
+
this.#batchCacheMaxTokens = proto.every(isBatchableCache)
|
|
343
|
+
? Math.min(...proto.map((cache) => cache.maxTokens ?? Number.MAX_SAFE_INTEGER))
|
|
344
|
+
: null;
|
|
345
|
+
this.#kvByLayer = opts.kvConfig?.length
|
|
346
|
+
? new Map(opts.kvConfig.map((e) => [e.layerIdx, e]))
|
|
347
|
+
: null;
|
|
175
348
|
this.#rotMaxSize = proto.map((c) => (c instanceof RotatingKVCache ? c.maxSize : 0));
|
|
176
349
|
for (const c of proto) c.dispose();
|
|
350
|
+
this.#compiled =
|
|
351
|
+
flagOn("MLX_BUN_COMPILED_DECODE", true) &&
|
|
352
|
+
model.config.modelType.startsWith("gemma4") &&
|
|
353
|
+
!model.config.text.enableMoeBlock
|
|
354
|
+
? CompiledDecode.for(model as Gemma4Model)
|
|
355
|
+
: null;
|
|
177
356
|
}
|
|
178
357
|
|
|
179
358
|
get activeRows(): number {
|
|
180
359
|
return this.#running.length;
|
|
181
360
|
}
|
|
182
361
|
|
|
362
|
+
get pendingRows(): number {
|
|
363
|
+
return this.#pending.length + (this.#prefill ? 1 : 0);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Projected KV bytes of one row at its worst case (full prompt + full
|
|
367
|
+
* completion; the sliding-window term is window-capped by kvBytesAt). */
|
|
368
|
+
#rowKvBytes(row: Row): number {
|
|
369
|
+
const tokens = row.promptTokens + row.req.maxTokens;
|
|
370
|
+
return this.#compressedProjectors
|
|
371
|
+
? this.#compressedProjectors.reduce((sum, project) => sum + project(tokens), 0)
|
|
372
|
+
: kvBytesAt(this.model.config, tokens);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async #forwardHidden(ids: MlxArray, cache: Cache[]): Promise<MlxArray> {
|
|
376
|
+
const asyncModel = this.model as RuntimeModel & {
|
|
377
|
+
forwardHiddenAsync?: (ids: MlxArray, cache: Cache[]) => Promise<MlxArray>;
|
|
378
|
+
};
|
|
379
|
+
return typeof asyncModel.forwardHiddenAsync === "function"
|
|
380
|
+
? await asyncModel.forwardHiddenAsync(ids, cache)
|
|
381
|
+
: this.model.forwardHidden(ids, cache);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Projected aggregate KV of everything admitted (running + mid-prefill). */
|
|
385
|
+
get projectedKvBytes(): number {
|
|
386
|
+
let total = this.#running.reduce((a, r) => a + this.#rowKvBytes(r), 0);
|
|
387
|
+
if (this.#prefill) total += this.#rowKvBytes(this.#prefill.row);
|
|
388
|
+
return total;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
get kvBudgetBytes(): number | undefined {
|
|
392
|
+
return this.#kvBudgetBytes;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** KV-budget admission for the queue head. True = admit now. A candidate
|
|
396
|
+
* that cannot fit even alone is rejected here (never deadlocks the
|
|
397
|
+
* queue); one that fits alone but not alongside the current batch waits. */
|
|
398
|
+
#kvAdmits(candidate: Row): boolean {
|
|
399
|
+
const requestedTokens = candidate.promptTokens + candidate.req.maxTokens;
|
|
400
|
+
if (
|
|
401
|
+
this.#batchCacheMaxTokens !== null &&
|
|
402
|
+
requestedTokens > this.#batchCacheMaxTokens
|
|
403
|
+
) {
|
|
404
|
+
this.#pending.shift();
|
|
405
|
+
candidate.reject(new RangeError(
|
|
406
|
+
`context limit: prompt ${candidate.promptTokens} + max_tokens ` +
|
|
407
|
+
`${candidate.req.maxTokens} exceeds ${this.#batchCacheMaxTokens}`,
|
|
408
|
+
));
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
if (this.#kvBudgetBytes === undefined) return true;
|
|
412
|
+
const need = this.#rowKvBytes(candidate);
|
|
413
|
+
if (need > this.#kvBudgetBytes && this.#running.length === 0 && !this.#prefill) {
|
|
414
|
+
this.#pending.shift();
|
|
415
|
+
candidate.reject(
|
|
416
|
+
new Error(
|
|
417
|
+
`kv budget: request needs ~${(need / 1e9).toFixed(2)} GB KV ` +
|
|
418
|
+
`(prompt ${candidate.promptTokens} + max_tokens ${candidate.req.maxTokens}), ` +
|
|
419
|
+
`over --kv-budget ${(this.#kvBudgetBytes / 1e9).toFixed(2)} GB — ` +
|
|
420
|
+
`lower max_tokens or raise the budget`,
|
|
421
|
+
),
|
|
422
|
+
);
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
return this.projectedKvBytes + need <= this.#kvBudgetBytes;
|
|
426
|
+
}
|
|
427
|
+
|
|
183
428
|
/** Submit a request; resolves when its row finishes (EOS, stop, or length). */
|
|
184
429
|
submit(req: BatchRequest): Promise<BatchStats> {
|
|
430
|
+
if (req.signal?.aborted) return Promise.reject(req.signal.reason);
|
|
185
431
|
return new Promise<BatchStats>((resolve, reject) => {
|
|
432
|
+
let abortListener: (() => void) | null = null;
|
|
433
|
+
const cleanup = () => {
|
|
434
|
+
if (abortListener) req.signal?.removeEventListener("abort", abortListener);
|
|
435
|
+
abortListener = null;
|
|
436
|
+
};
|
|
186
437
|
this.#pending.push({
|
|
187
|
-
req,
|
|
438
|
+
req,
|
|
439
|
+
resolve: (stats) => { cleanup(); resolve(stats); },
|
|
440
|
+
reject: (error) => { cleanup(); reject(error); },
|
|
188
441
|
current: 0, generated: 0, sampled: 0, promptTokens: req.promptIds.length,
|
|
442
|
+
cachedTokens: 0, fed: [], fedTainted: false, merged: false,
|
|
189
443
|
});
|
|
444
|
+
if (req.signal) {
|
|
445
|
+
abortListener = () => this.kick();
|
|
446
|
+
req.signal.addEventListener("abort", abortListener, { once: true });
|
|
447
|
+
}
|
|
190
448
|
this.#ensureLoop();
|
|
191
449
|
});
|
|
192
450
|
}
|
|
@@ -207,6 +465,12 @@ export class BatchScheduler {
|
|
|
207
465
|
let release: (() => void) | null = null;
|
|
208
466
|
try {
|
|
209
467
|
while (true) {
|
|
468
|
+
for (let i = this.#pending.length - 1; i >= 0; i--) {
|
|
469
|
+
const row = this.#pending[i]!;
|
|
470
|
+
if (!row.req.signal?.aborted) continue;
|
|
471
|
+
this.#pending.splice(i, 1);
|
|
472
|
+
row.reject(row.req.signal.reason);
|
|
473
|
+
}
|
|
210
474
|
const held = this.#admissionHeld?.() === true;
|
|
211
475
|
// Work = running rows, an in-flight prefill (finish it even under
|
|
212
476
|
// drain — the row is already half-admitted), or admissible pending.
|
|
@@ -225,8 +489,31 @@ export class BatchScheduler {
|
|
|
225
489
|
// Start at most one joiner prefill (drain-aware); it advances one
|
|
226
490
|
// chunk per iteration, interleaved with the decode step below.
|
|
227
491
|
if (!this.#prefill && !held &&
|
|
228
|
-
this.#pending.length > 0 && this.#running.length < this.#maxBatch
|
|
229
|
-
|
|
492
|
+
this.#pending.length > 0 && this.#running.length < this.#maxBatch &&
|
|
493
|
+
this.#kvAdmits(this.#pending[0]!)) {
|
|
494
|
+
const row = this.#pending.shift()!;
|
|
495
|
+
// Prompt-cache reuse (Phase 3.2): start the solo prefill from the
|
|
496
|
+
// longest usable cached prefix — the serial lane's take(), same
|
|
497
|
+
// semantics (entry ownership transfers; put back or dispose).
|
|
498
|
+
const hit = this.#promptCache?.take(row.req.promptIds) ?? null;
|
|
499
|
+
if (hit) row.cachedTokens = hit.tokens.length;
|
|
500
|
+
// Boundary snapshot (the serial lane's A1, ported): a trim-free
|
|
501
|
+
// strict-prefix entry at min(stableLen, len-1) for every
|
|
502
|
+
// substantial cold prefill — same gates as runGeneration
|
|
503
|
+
// (>= 256 tokens, extends past the cached prefix). Without this
|
|
504
|
+
// the batch lane's ONLY entry was the untrimmable [prompt+gen]
|
|
505
|
+
// finish-time put — a total ctx-repeat miss once rings wrap
|
|
506
|
+
// (12B batched 84 s vs serial 0.4 s, 2026-07-06).
|
|
507
|
+
const len = row.req.promptIds.length;
|
|
508
|
+
const boundary = Math.min(row.req.snapshotAt ?? len, len - 1);
|
|
509
|
+
const snapAt =
|
|
510
|
+
this.#promptCache && boundary >= 256 && boundary > (hit?.tokens.length ?? 0)
|
|
511
|
+
? boundary
|
|
512
|
+
: null;
|
|
513
|
+
this.#prefill = hit
|
|
514
|
+
? { row, solo: hit.caches, pos: hit.tokens.length, retain: hit.retain, snapAt }
|
|
515
|
+
: { row, solo: this.model.makeCache(), pos: 0, snapAt };
|
|
516
|
+
}
|
|
230
517
|
|
|
231
518
|
if (this.#prefill) {
|
|
232
519
|
const p = this.#prefill;
|
|
@@ -237,6 +524,7 @@ export class BatchScheduler {
|
|
|
237
524
|
// the running batch.
|
|
238
525
|
this.#prefill = null;
|
|
239
526
|
for (const c of p.solo) c.dispose();
|
|
527
|
+
p.retain?.();
|
|
240
528
|
p.row.reject(e);
|
|
241
529
|
}
|
|
242
530
|
}
|
|
@@ -247,7 +535,9 @@ export class BatchScheduler {
|
|
|
247
535
|
// still interleave: a mid-prefill joiner leaves #prefill non-null, so
|
|
248
536
|
// the loop falls through to run a decode step between chunks.
|
|
249
537
|
if (this.#prefill === null && !held &&
|
|
250
|
-
this.#pending.length > 0 && this.#running.length < this.#maxBatch
|
|
538
|
+
this.#pending.length > 0 && this.#running.length < this.#maxBatch &&
|
|
539
|
+
(this.#kvBudgetBytes === undefined ||
|
|
540
|
+
this.projectedKvBytes + this.#rowKvBytes(this.#pending[0]!) <= this.#kvBudgetBytes))
|
|
251
541
|
continue;
|
|
252
542
|
|
|
253
543
|
if (this.#running.length > 0) {
|
|
@@ -256,10 +546,25 @@ export class BatchScheduler {
|
|
|
256
546
|
} catch (e) {
|
|
257
547
|
// A forward/sampling error can't be attributed to one row — drop
|
|
258
548
|
// the batch. (A row's onToken error is contained in #emitRows.)
|
|
549
|
+
// dropOnly: mid-step caches may be inconsistent — never put().
|
|
259
550
|
for (const row of this.#running) row.reject(e);
|
|
260
|
-
this.#applyFilter([]);
|
|
551
|
+
this.#applyFilter([], true);
|
|
261
552
|
}
|
|
262
553
|
}
|
|
554
|
+
// The per-step macrotask hop exists so joiners/admin interleave at
|
|
555
|
+
// token granularity. With ONE row and an empty queue there is
|
|
556
|
+
// nobody to interleave — rate-limit the hop to the serial loop's
|
|
557
|
+
// 25 ms cadence (identical responsiveness budget; generate()'s
|
|
558
|
+
// hoppingOnToken does exactly this). This was the B=1 drive-loop
|
|
559
|
+
// tax: one setImmediate round-trip per token ≈ the 0.4–0.8% gap
|
|
560
|
+
// vs pure serial (paired-measured 2026-07-05).
|
|
561
|
+
if (
|
|
562
|
+
this.#running.length === 1 && this.#pending.length === 0 &&
|
|
563
|
+
!this.#prefill && this.#admissionHeld?.() !== true &&
|
|
564
|
+
performance.now() - this.#lastHop < 25
|
|
565
|
+
)
|
|
566
|
+
continue;
|
|
567
|
+
this.#lastHop = performance.now();
|
|
263
568
|
await new Promise<void>((r) => setImmediate(r));
|
|
264
569
|
}
|
|
265
570
|
} finally {
|
|
@@ -268,31 +573,115 @@ export class BatchScheduler {
|
|
|
268
573
|
}
|
|
269
574
|
}
|
|
270
575
|
|
|
576
|
+
/** Per-layer mixed-precision conversion of a joiner's SOLO caches — the
|
|
577
|
+
* scheduler-side mirror of the serial maybeQuantizeKv (generate.ts): same
|
|
578
|
+
* per-layer map, same skip rules (empty cache, already quantized), same
|
|
579
|
+
* streaming discipline (evalAll the converted layer's state so the bf16
|
|
580
|
+
* source frees before the next layer converts). Called at every prefill
|
|
581
|
+
* chunk boundary AND once before merge, exactly where the serial loop
|
|
582
|
+
* calls maybeQuantizeKv — that placement is what makes a row's quantized
|
|
583
|
+
* bytes bit-exact vs serial `--kv-quant config`. P1: plain KVCache
|
|
584
|
+
* layers only (the gateway guarantees the config maps only to those). */
|
|
585
|
+
#quantizeSolo(solo: Cache[]): void {
|
|
586
|
+
if (!this.#kvByLayer) return;
|
|
587
|
+
for (let i = 0; i < solo.length; i++) {
|
|
588
|
+
const e = this.#kvByLayer.get(i);
|
|
589
|
+
const c = solo[i]!;
|
|
590
|
+
if (!e || c.offset === 0) continue;
|
|
591
|
+
// Milestone 2: rotating layers convert too — RotatingKVCache.
|
|
592
|
+
// toQuantized → RotatingQuantizedKVCache, exactly maybeQuantizeKv's
|
|
593
|
+
// dispatch (generate.ts). Converted caches don't match either class
|
|
594
|
+
// (the four cache classes are siblings), so re-conversion never fires.
|
|
595
|
+
if (!(c instanceof KVCache || c instanceof RotatingKVCache)) continue;
|
|
596
|
+
const q = c.toQuantized(e.groupSize, e.bits);
|
|
597
|
+
solo[i] = q;
|
|
598
|
+
ops.evalAll(q.state());
|
|
599
|
+
clearCache();
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
271
603
|
/** Advance a joiner's solo prefill by one chunk. Non-final chunks forward +
|
|
272
604
|
* eval the cache and return false (the caller interleaves a decode step).
|
|
273
605
|
* The final chunk samples token 0, emits it, and — if the row survives —
|
|
274
606
|
* merges it into the running batch; returns true (admission complete). */
|
|
275
607
|
async #prefillChunk(p: PrefillState): Promise<boolean> {
|
|
608
|
+
p.row.req.signal?.throwIfAborted();
|
|
276
609
|
const prompt = p.row.req.promptIds;
|
|
277
|
-
|
|
610
|
+
// Boundary-snapshot chunking (mirrors generate.ts's snapshotAt split):
|
|
611
|
+
// chunk edges land EXACTLY on snapAt so the clone is taken while the
|
|
612
|
+
// caches hold precisely that prefix — slicing tokens against caches at
|
|
613
|
+
// any other offset would store corrupt entries. The extra split only
|
|
614
|
+
// changes chunk shapes for opted-in rows: the same numerics class as a
|
|
615
|
+
// prompt-cache-hit continuation prefill (GEMV/GEMM reduction order).
|
|
616
|
+
if (p.snapAt !== null && p.pos < p.snapAt) {
|
|
617
|
+
const end = Math.min(p.pos + this.#prefillChunkSize, p.snapAt);
|
|
618
|
+
const chunk = prompt.slice(p.pos, end);
|
|
619
|
+
const ids = ops.fromInt32(chunk, [1, chunk.length]);
|
|
620
|
+
const h = await this.#forwardHidden(ids, p.solo);
|
|
621
|
+
ids.dispose();
|
|
622
|
+
h.dispose();
|
|
623
|
+
ops.evalAll(p.solo.flatMap((c) => c.state()));
|
|
624
|
+
this.#quantizeSolo(p.solo); // serial converts at every chunk boundary
|
|
625
|
+
clearCache();
|
|
626
|
+
p.pos = end;
|
|
627
|
+
if (p.pos === p.snapAt) {
|
|
628
|
+
try {
|
|
629
|
+
this.#promptCache!.put(prompt.slice(0, p.snapAt), cloneKvCaches(p.solo));
|
|
630
|
+
} catch (e) {
|
|
631
|
+
console.warn(`batch-lane boundary snapshot skipped: ${(e as Error).message}`);
|
|
632
|
+
}
|
|
633
|
+
p.snapAt = null;
|
|
634
|
+
}
|
|
635
|
+
return false;
|
|
636
|
+
}
|
|
637
|
+
// Oracle prefill convention (see generate.ts prefill): drain only to
|
|
638
|
+
// len-1 in chunks, then step-0 logits from an L=1 forward of the LAST
|
|
639
|
+
// prompt token — mirrors mlx-lm's batched engine (insert_segments'
|
|
640
|
+
// forced final 1-token segment + GenerationBatch._step,
|
|
641
|
+
// generate.py:1645/1327) and keeps this lane bit-exact with the serial
|
|
642
|
+
// lane (unified == --batch 1 == oracle). The L=1 step runs on the solo
|
|
643
|
+
// caches BEFORE merge, so per-row serial equivalence is by construction.
|
|
644
|
+
// MLX_BUN_PREFILL_TAIL_SPLIT=0 restores the old convention.
|
|
645
|
+
const tailSplit = flagOn("MLX_BUN_PREFILL_TAIL_SPLIT", true);
|
|
646
|
+
if ((tailSplit ? prompt.length - p.pos - 1 : prompt.length - p.pos) >
|
|
647
|
+
this.#prefillChunkSize) {
|
|
278
648
|
const chunk = prompt.slice(p.pos, p.pos + this.#prefillChunkSize);
|
|
279
649
|
const ids = ops.fromInt32(chunk, [1, chunk.length]);
|
|
280
|
-
const h = this
|
|
650
|
+
const h = await this.#forwardHidden(ids, p.solo);
|
|
281
651
|
ids.dispose();
|
|
282
652
|
h.dispose(); // logits never computed for non-final chunks
|
|
283
653
|
ops.evalAll(p.solo.flatMap((c) => c.state()));
|
|
654
|
+
this.#quantizeSolo(p.solo); // serial converts at every chunk boundary
|
|
284
655
|
clearCache(); // serial prefill's per-chunk clear (generate.ts)
|
|
285
656
|
p.pos += this.#prefillChunkSize;
|
|
286
657
|
return false;
|
|
287
658
|
}
|
|
288
659
|
|
|
660
|
+
// Tail split: forward the remaining head [p.pos, len-1) as a plain chunk
|
|
661
|
+
// (serial chunk-boundary semantics: eval + quantize + clear), leaving
|
|
662
|
+
// exactly one token for the L=1 step-0 forward below. Head + step share
|
|
663
|
+
// this tick — same chunk-tick count as before, plus one L=1 dispatch
|
|
664
|
+
// (~a single decode step) in the admission tick's no-yield window.
|
|
665
|
+
if (tailSplit && p.pos < prompt.length - 1) {
|
|
666
|
+
const head = prompt.slice(p.pos, prompt.length - 1);
|
|
667
|
+
const ids = ops.fromInt32(head, [1, head.length]);
|
|
668
|
+
const h = await this.#forwardHidden(ids, p.solo);
|
|
669
|
+
ids.dispose();
|
|
670
|
+
h.dispose();
|
|
671
|
+
ops.evalAll(p.solo.flatMap((c) => c.state()));
|
|
672
|
+
this.#quantizeSolo(p.solo);
|
|
673
|
+
clearCache();
|
|
674
|
+
p.pos = prompt.length - 1;
|
|
675
|
+
}
|
|
676
|
+
|
|
289
677
|
// Final chunk: slice the LAST hidden position BEFORE the lm_head — running
|
|
290
678
|
// logitsFromHidden on the whole [1,Lp,H] hidden materializes a [1,Lp,V]
|
|
291
679
|
// transient (~4.3 GB bf16 at Gemma V=262k, 8k prompt). Same reorder as the
|
|
292
|
-
// serial path (generate.ts prefill).
|
|
680
|
+
// serial path (generate.ts prefill). Under tailSplit this chunk is exactly
|
|
681
|
+
// the last prompt token (L=1).
|
|
293
682
|
const chunk = prompt.slice(p.pos);
|
|
294
683
|
const ids = ops.fromInt32(chunk, [1, chunk.length]);
|
|
295
|
-
const h = this
|
|
684
|
+
const h = await this.#forwardHidden(ids, p.solo);
|
|
296
685
|
ids.dispose();
|
|
297
686
|
const [, Lc, H] = h.shape as [number, number, number];
|
|
298
687
|
const hLast = h.slice([0, Lc - 1, 0], [1, Lc, H]);
|
|
@@ -308,12 +697,38 @@ export class BatchScheduler {
|
|
|
308
697
|
p.row.generated = 1;
|
|
309
698
|
clearCache(); // drop the prefill transients (serial's token-0 clear)
|
|
310
699
|
|
|
700
|
+
// Grammar (B1): the mask0 was applied inside the sample closure (the
|
|
701
|
+
// controller is primed at compile). After reading token 0, advance the
|
|
702
|
+
// matcher — fires the async fill for the NEXT step, overlapping the merge
|
|
703
|
+
// below. If the grammar is already satisfied at token 0 (a 1-token grammar,
|
|
704
|
+
// e.g. guided_choice landing on a single-token option), emit + finish("stop")
|
|
705
|
+
// WITHOUT merging into the batch — the row never joins #running.
|
|
706
|
+
if (p.row.req.grammar) {
|
|
707
|
+
p.row.req.grammar.accept(tok);
|
|
708
|
+
if (p.row.req.grammar.isTerminated) {
|
|
709
|
+
const stop = await this.#emit(p.row, tok);
|
|
710
|
+
// Token 0 was sampled but never fed — the caches cover exactly the
|
|
711
|
+
// prompt, a clean prompt-only entry (put-or-dispose).
|
|
712
|
+
this.#putOrDispose(p.solo, p.row.req.promptIds, p.retain);
|
|
713
|
+
this.#finish(p.row, stop === "continue" ? "stop" : stop);
|
|
714
|
+
return true;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
311
718
|
const stop = await this.#emit(p.row, tok);
|
|
312
719
|
if (stop !== "continue") {
|
|
313
|
-
|
|
720
|
+
this.#putOrDispose(p.solo, p.row.req.promptIds, p.retain);
|
|
314
721
|
this.#finish(p.row, stop);
|
|
315
722
|
return true;
|
|
316
723
|
}
|
|
724
|
+
// Default tail-split path: the caches were already converted at the head
|
|
725
|
+
// boundary above (oracle composition: prefill ids[:-1] → convert → L=1
|
|
726
|
+
// step-0), so this call is an idempotent no-op (converted caches match
|
|
727
|
+
// neither serial class). It is LOAD-BEARING only under
|
|
728
|
+
// MLX_BUN_PREFILL_TAIL_SPLIT=0 — the old serial order: token 0 sampled
|
|
729
|
+
// from the unconverted final-chunk logits, THEN the caches convert
|
|
730
|
+
// (before decode step 1 == before the merge).
|
|
731
|
+
this.#quantizeSolo(p.solo);
|
|
317
732
|
await this.#mergeJoiner(p);
|
|
318
733
|
return true;
|
|
319
734
|
}
|
|
@@ -324,12 +739,41 @@ export class BatchScheduler {
|
|
|
324
739
|
async #mergeJoiner(p: PrefillState): Promise<void> {
|
|
325
740
|
await this.#flushPipeline();
|
|
326
741
|
|
|
742
|
+
// ADOPT, don't copy (unified-engine plan Phase 3.2): a row joining an
|
|
743
|
+
// EMPTY batch keeps its solo caches as the batch inners — a pointer
|
|
744
|
+
// handoff, zero bytes moved (the old path ran the full merge machinery
|
|
745
|
+
// to produce a byte-identical [1,...] copy). The copy now happens only
|
|
746
|
+
// when a SECOND row joins and a genuinely new layout must exist. The
|
|
747
|
+
// prize beyond the saved copy: the lone row's caches stay SERIAL-CLASS
|
|
748
|
+
// (KVCache / RotatingKVCache / QuantizedKVCache), so the B=1 step is
|
|
749
|
+
// literally the serial graph, and compiled decode + prompt-cache
|
|
750
|
+
// take/put become possible for it. The rot branch below knows how to
|
|
751
|
+
// treat an adopted RotatingKVCache as the merge's first row.
|
|
752
|
+
if (!this.#inners) {
|
|
753
|
+
this.#inners = p.solo as LayerInner[];
|
|
754
|
+
this.#fullLeftPad = [0];
|
|
755
|
+
this.#adoptedRetain = p.retain ?? null;
|
|
756
|
+
this.#running.push(p.row);
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
|
|
327
760
|
const prev = this.#inners;
|
|
328
761
|
const prevPad = this.#fullLeftPad;
|
|
329
762
|
const B = this.#running.length;
|
|
330
763
|
const newInners: LayerInner[] = [];
|
|
331
764
|
let newFullPad = this.#fullLeftPad;
|
|
332
765
|
for (let layer = 0; layer < this.#kinds.length; layer++) {
|
|
766
|
+
if (this.#kinds[layer] === "owned-batch") {
|
|
767
|
+
const solo = p.solo[layer]!;
|
|
768
|
+
if (!isBatchableCache(solo))
|
|
769
|
+
throw new Error(`batch-capable layer ${layer} lost its cache capability`);
|
|
770
|
+
const merged = solo.makeEmptyBatch();
|
|
771
|
+
const previous = prev?.[layer];
|
|
772
|
+
merged.mergeRows(previous ? [previous, solo] : [solo]);
|
|
773
|
+
newFullPad = [...merged.leftPad];
|
|
774
|
+
newInners.push(merged);
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
333
777
|
if (this.#kinds[layer] === "ssm") {
|
|
334
778
|
// No temporal axis, no left-pad: B-axis concat of the state slots.
|
|
335
779
|
// mergeRows steals the solo arrays when the batch starts cold, so the
|
|
@@ -340,13 +784,120 @@ export class BatchScheduler {
|
|
|
340
784
|
));
|
|
341
785
|
continue;
|
|
342
786
|
}
|
|
787
|
+
if (p.solo[layer] instanceof QuantizedKVCache) {
|
|
788
|
+
// Phase 3.1 — quantized full layer: same merge/extend shapes as the
|
|
789
|
+
// bf16 branch below, over (packed, scales, biases) triples. The solo
|
|
790
|
+
// row was converted by #quantizeSolo with the serial ops, so its
|
|
791
|
+
// bytes already bit-match serial `--kv-quant config`; this branch
|
|
792
|
+
// only re-arranges rows along the batch axis.
|
|
793
|
+
const qSolo = p.solo[layer] as QuantizedKVCache;
|
|
794
|
+
const [qk, qv] = qSolo.temporalView();
|
|
795
|
+
const qRow = { keys: qk, values: qv };
|
|
796
|
+
const dispose3 = (t: { packed: MlxArray; scales: MlxArray; biases: MlxArray }) => {
|
|
797
|
+
t.packed.dispose(); t.scales.dispose(); t.biases.dispose();
|
|
798
|
+
};
|
|
799
|
+
const prevQ = prev?.[layer] as QuantizedKVCache | undefined;
|
|
800
|
+
if (prevQ && process.env.MLX_BUN_BATCH_EXTEND !== "0") {
|
|
801
|
+
const [k0, v0] = prevQ.temporalView();
|
|
802
|
+
const ext = extendQuantRows(k0, v0, prevPad, qRow);
|
|
803
|
+
dispose3(k0); dispose3(v0);
|
|
804
|
+
newFullPad = ext.leftPad;
|
|
805
|
+
const c = new QuantizedKVCache(qSolo.groupSize, qSolo.bits);
|
|
806
|
+
c.restoreState(ext.keys, ext.values, ext.width);
|
|
807
|
+
newInners.push(c);
|
|
808
|
+
} else {
|
|
809
|
+
const rows: { keys: typeof qk; values: typeof qv }[] = [];
|
|
810
|
+
if (prevQ) {
|
|
811
|
+
const [k0, v0] = prevQ.temporalView(); // [B,H,off,*]
|
|
812
|
+
const S = k0.packed.shape[2]!;
|
|
813
|
+
for (let b = 0; b < B; b++) {
|
|
814
|
+
const pad = prevPad[b]!;
|
|
815
|
+
const cutRow = (t: typeof k0): typeof k0 => ({
|
|
816
|
+
packed: t.packed.slice([b, 0, pad, 0], [b + 1, t.packed.shape[1]!, S, t.packed.shape[3]!]),
|
|
817
|
+
scales: t.scales.slice([b, 0, pad, 0], [b + 1, t.scales.shape[1]!, S, t.scales.shape[3]!]),
|
|
818
|
+
biases: t.biases.slice([b, 0, pad, 0], [b + 1, t.biases.shape[1]!, S, t.biases.shape[3]!]),
|
|
819
|
+
});
|
|
820
|
+
rows.push({ keys: cutRow(k0), values: cutRow(v0) });
|
|
821
|
+
}
|
|
822
|
+
dispose3(k0); dispose3(v0);
|
|
823
|
+
}
|
|
824
|
+
rows.push(qRow);
|
|
825
|
+
const merged = mergeQuantRows(rows);
|
|
826
|
+
newFullPad = merged.leftPad;
|
|
827
|
+
const c = new QuantizedKVCache(qSolo.groupSize, qSolo.bits);
|
|
828
|
+
c.restoreState(merged.keys, merged.values, merged.width);
|
|
829
|
+
newInners.push(c);
|
|
830
|
+
for (const r of rows) { dispose3(r.keys); dispose3(r.values); }
|
|
831
|
+
}
|
|
832
|
+
// qRow views are disposed via the rows loop above or here for extend
|
|
833
|
+
if (prevQ && process.env.MLX_BUN_BATCH_EXTEND !== "0") { dispose3(qRow.keys); dispose3(qRow.values); }
|
|
834
|
+
else if (!prevQ) { /* disposed in the rows loop */ }
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
// NOTE: the quantized-rotating branch below never uses this layer's
|
|
838
|
+
// bf16 temporalView — calling it before the branch leaked the pair
|
|
839
|
+
// (six arrays per second-row join on quantized rotating layers,
|
|
840
|
+
// introduced in 859572d; 2026-07-07 review fix). The view is taken
|
|
841
|
+
// AFTER the branch, on the paths that actually consume it.
|
|
343
842
|
const soloC = p.solo[layer] as KVCache | RotatingKVCache;
|
|
843
|
+
if (this.#kinds[layer] === "rot" && p.solo[layer] instanceof RotatingQuantizedKVCache) {
|
|
844
|
+
// Milestone 2 — QUANTIZED rotating layer: the solo row converted at
|
|
845
|
+
// the serial boundaries (#quantizeSolo), so its ring bytes are the
|
|
846
|
+
// serial oracle's; this branch re-arranges temporal triples across
|
|
847
|
+
// the batch axis (the bf16 rot merge over triples).
|
|
848
|
+
const qSolo = p.solo[layer] as RotatingQuantizedKVCache;
|
|
849
|
+
const [qk, qv] = qSolo.temporalView();
|
|
850
|
+
const rows: QuantRow[] = [];
|
|
851
|
+
const offsets: number[] = [];
|
|
852
|
+
const dispose3 = (t: QuantRow) => {
|
|
853
|
+
t.keys.packed.dispose(); t.keys.scales.dispose(); t.keys.biases.dispose();
|
|
854
|
+
t.values.packed.dispose(); t.values.scales.dispose(); t.values.biases.dispose();
|
|
855
|
+
};
|
|
856
|
+
const prevC = prev?.[layer];
|
|
857
|
+
if (prevC instanceof BatchedRotatingQuantCache) {
|
|
858
|
+
const [k0, v0] = prevC.temporalView(); // [B,H,valid,*] triples, temporal
|
|
859
|
+
const valid = k0.packed.shape[2]!;
|
|
860
|
+
for (let b = 0; b < B; b++) {
|
|
861
|
+
const pad = Math.max(0, prevC.leftPad[b]!);
|
|
862
|
+
const cutRow = (t: typeof k0): typeof k0 => ({
|
|
863
|
+
packed: t.packed.slice([b, 0, pad, 0], [b + 1, t.packed.shape[1]!, valid, t.packed.shape[3]!]),
|
|
864
|
+
scales: t.scales.slice([b, 0, pad, 0], [b + 1, t.scales.shape[1]!, valid, t.scales.shape[3]!]),
|
|
865
|
+
biases: t.biases.slice([b, 0, pad, 0], [b + 1, t.biases.shape[1]!, valid, t.biases.shape[3]!]),
|
|
866
|
+
});
|
|
867
|
+
rows.push({ keys: cutRow(k0), values: cutRow(v0) });
|
|
868
|
+
offsets.push(prevC.offsetArr[b]!);
|
|
869
|
+
}
|
|
870
|
+
for (const t of [k0, v0]) { t.packed.dispose(); t.scales.dispose(); t.biases.dispose(); }
|
|
871
|
+
} else if (prevC instanceof RotatingQuantizedKVCache) {
|
|
872
|
+
// Adopted lone row (Phase 3.2 adopt): its chronological triples
|
|
873
|
+
// are the merge's first row, pad 0 by definition.
|
|
874
|
+
const [k0, v0] = prevC.temporalView();
|
|
875
|
+
rows.push({ keys: k0, values: v0 });
|
|
876
|
+
offsets.push(prevC.offset);
|
|
877
|
+
}
|
|
878
|
+
rows.push({ keys: qk, values: qv });
|
|
879
|
+
offsets.push(qSolo.offset);
|
|
880
|
+
newInners.push(BatchedRotatingQuantCache.merge(
|
|
881
|
+
rows, offsets, this.#rotMaxSize[layer]!, qSolo.groupSize, qSolo.bits,
|
|
882
|
+
));
|
|
883
|
+
for (const r of rows) dispose3(r);
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
344
886
|
const [sk, sv] = soloC.temporalView();
|
|
345
887
|
const newRow: Row1 = { keys: sk, values: sv };
|
|
346
888
|
if (this.#kinds[layer] === "rot") {
|
|
347
889
|
const rows: Row1[] = [];
|
|
348
890
|
const offsets: number[] = [];
|
|
349
|
-
const
|
|
891
|
+
const prevC = prev?.[layer];
|
|
892
|
+
if (prevC instanceof RotatingKVCache) {
|
|
893
|
+
// Adopted lone row (Phase 3.2): a plain serial rotating cache —
|
|
894
|
+
// its chronological view is the merge's first row, same as a
|
|
895
|
+
// fresh solo (pad 0 by definition).
|
|
896
|
+
const [k0, v0] = prevC.temporalView();
|
|
897
|
+
rows.push({ keys: k0, values: v0 });
|
|
898
|
+
offsets.push(prevC.offset);
|
|
899
|
+
}
|
|
900
|
+
const prevRot = prevC instanceof BatchedRotatingCache ? prevC : undefined;
|
|
350
901
|
if (prevRot) {
|
|
351
902
|
const [k0, v0] = prevRot.temporalView(); // [B,H,valid,D]
|
|
352
903
|
const [, H, valid, D] = k0.shape as [number, number, number, number];
|
|
@@ -366,32 +917,58 @@ export class BatchScheduler {
|
|
|
366
917
|
newInners.push(BatchedRotatingCache.merge(rows, offsets, this.#rotMaxSize[layer]!));
|
|
367
918
|
for (const r of rows) { r.keys.dispose(); r.values.dispose(); }
|
|
368
919
|
} else {
|
|
369
|
-
const rows: Row1[] = [];
|
|
370
920
|
const prevFull = prev?.[layer] as KVCache | undefined;
|
|
371
|
-
if (prevFull) {
|
|
921
|
+
if (prevFull && process.env.MLX_BUN_BATCH_EXTEND !== "0") {
|
|
922
|
+
// extend-join (mlx-lm BatchKVCache.extend semantics, P0): append
|
|
923
|
+
// the new right-justified row to the running buffer in ONE pad +
|
|
924
|
+
// ONE concat — no per-row extraction. Existing pads grow, never
|
|
925
|
+
// shrink (the re-merge below re-normalizes them instead; both are
|
|
926
|
+
// masked, both token-exact vs their mlx-lm protocol twin).
|
|
927
|
+
// MLX_BUN_BATCH_EXTEND=0 = the O(B·S) re-merge, kill switch/A-B.
|
|
372
928
|
const [k0, v0] = prevFull.temporalView(); // [B,H,off,D]
|
|
373
|
-
const
|
|
374
|
-
const vD = v0.shape[3]!;
|
|
375
|
-
for (let b = 0; b < B; b++) {
|
|
376
|
-
const pad = prevPad[b]!;
|
|
377
|
-
rows.push({
|
|
378
|
-
keys: k0.slice([b, 0, pad, 0], [b + 1, H, off, D]),
|
|
379
|
-
values: v0.slice([b, 0, pad, 0], [b + 1, H, off, vD]),
|
|
380
|
-
});
|
|
381
|
-
}
|
|
929
|
+
const ext = extendKVRows(k0, v0, prevPad, newRow);
|
|
382
930
|
k0.dispose(); v0.dispose();
|
|
931
|
+
newFullPad = ext.leftPad;
|
|
932
|
+
const c = new KVCache();
|
|
933
|
+
c.restoreState(ext.keys, ext.values, ext.width);
|
|
934
|
+
newInners.push(c);
|
|
935
|
+
newRow.keys.dispose(); newRow.values.dispose();
|
|
936
|
+
} else {
|
|
937
|
+
const rows: Row1[] = [];
|
|
938
|
+
if (prevFull) {
|
|
939
|
+
const [k0, v0] = prevFull.temporalView(); // [B,H,off,D]
|
|
940
|
+
const [, H, off, D] = k0.shape as [number, number, number, number];
|
|
941
|
+
const vD = v0.shape[3]!;
|
|
942
|
+
for (let b = 0; b < B; b++) {
|
|
943
|
+
const pad = prevPad[b]!;
|
|
944
|
+
rows.push({
|
|
945
|
+
keys: k0.slice([b, 0, pad, 0], [b + 1, H, off, D]),
|
|
946
|
+
values: v0.slice([b, 0, pad, 0], [b + 1, H, off, vD]),
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
k0.dispose(); v0.dispose();
|
|
950
|
+
}
|
|
951
|
+
rows.push(newRow);
|
|
952
|
+
const merged = mergeKVRows(rows);
|
|
953
|
+
newFullPad = merged.leftPad;
|
|
954
|
+
const c = new KVCache();
|
|
955
|
+
c.restoreState(merged.keys, merged.values, merged.width);
|
|
956
|
+
newInners.push(c);
|
|
957
|
+
for (const r of rows) { r.keys.dispose(); r.values.dispose(); }
|
|
383
958
|
}
|
|
384
|
-
rows.push(newRow);
|
|
385
|
-
const merged = mergeKVRows(rows);
|
|
386
|
-
newFullPad = merged.leftPad;
|
|
387
|
-
const c = new KVCache();
|
|
388
|
-
c.restoreState(merged.keys, merged.values, merged.width);
|
|
389
|
-
newInners.push(c);
|
|
390
|
-
for (const r of rows) { r.keys.dispose(); r.values.dispose(); }
|
|
391
959
|
}
|
|
392
960
|
}
|
|
393
961
|
if (prev) for (const c of prev) c.dispose();
|
|
962
|
+
// An adopted row's entry-backed arrays are gone after the prev dispose;
|
|
963
|
+
// run its retain now. The joiner's likewise after its solo dispose.
|
|
964
|
+
this.#adoptedRetain?.();
|
|
965
|
+
this.#adoptedRetain = null;
|
|
394
966
|
for (const c of p.solo) c.dispose();
|
|
967
|
+
p.retain?.();
|
|
968
|
+
// Every row in a REAL merge has its KV interleaved in batched buffers —
|
|
969
|
+
// no longer prompt-cache put() candidates.
|
|
970
|
+
for (const r of this.#running) r.merged = true;
|
|
971
|
+
p.row.merged = true;
|
|
395
972
|
this.#inners = newInners;
|
|
396
973
|
this.#fullLeftPad = newFullPad;
|
|
397
974
|
this.#running.push(p.row);
|
|
@@ -407,6 +984,11 @@ export class BatchScheduler {
|
|
|
407
984
|
* step; filter drops the row (mlx-lm behaves identically). Length-finished
|
|
408
985
|
* rows are known in advance and are NOT sampled (placeholder slot). */
|
|
409
986
|
async #step(): Promise<void> {
|
|
987
|
+
if (STEP_TRACE) {
|
|
988
|
+
const now = performance.now();
|
|
989
|
+
if (STEP_T.lastEnd) STEP_T.gap += now - STEP_T.lastEnd;
|
|
990
|
+
STEP_T.t0 = now;
|
|
991
|
+
}
|
|
410
992
|
const rows = this.#running;
|
|
411
993
|
const B = rows.length;
|
|
412
994
|
const inners = this.#inners!;
|
|
@@ -414,58 +996,166 @@ export class BatchScheduler {
|
|
|
414
996
|
// A row is live if it still needs tokens sampled; a row whose pending
|
|
415
997
|
// unread token is its last (sampled == maxTokens) only awaits emission.
|
|
416
998
|
const anyLive = rows.some((r) => r.sampled < r.req.maxTokens);
|
|
999
|
+
// Grammar (B1): if any row has a LIVE grammar controller (not terminated,
|
|
1000
|
+
// still sampling), take the read-before-build shape — the matcher's
|
|
1001
|
+
// acceptToken needs the token id as a JS number, so the previous step's
|
|
1002
|
+
// [B] token array is read back NOW (before building the next graph),
|
|
1003
|
+
// accept()ed per row (firing async fills that overlap the forward), then
|
|
1004
|
+
// ready() is awaited before this step's sample. This is the serial loop's
|
|
1005
|
+
// grammar resolution transplanted to the batch. Batches with NO live
|
|
1006
|
+
// grammar row keep the pipelined path byte-identical (zero cost when
|
|
1007
|
+
// unused). The trade: while a grammar row is live the batch runs
|
|
1008
|
+
// effectively NO_PIPELINE (readback no longer overlaps GPU compute) —
|
|
1009
|
+
// bounded by the readback (~0.1 ms) + fills (0.004–0.19 ms/row, overlapped
|
|
1010
|
+
// with the graph build). Serial grammar pays the identical trade today.
|
|
1011
|
+
const hasLiveGrammar = anyLive && rows.some(
|
|
1012
|
+
(r) => r.req.grammar && !r.req.grammar.isTerminated,
|
|
1013
|
+
);
|
|
1014
|
+
if (hasLiveGrammar) return this.#stepGrammar();
|
|
1015
|
+
|
|
417
1016
|
let nextToks: MlxArray | null = null;
|
|
1017
|
+
let nextReal: boolean[] | null = null;
|
|
418
1018
|
if (anyLive) {
|
|
1019
|
+
// Fed-token accounting (prompt-cache put): a COLD step feeds each
|
|
1020
|
+
// row's `current` (values known now, always a real emitted token); a
|
|
1021
|
+
// pipelined step feeds the pending array, whose values are pushed at
|
|
1022
|
+
// the read below gated on the per-slot real flags.
|
|
1023
|
+
if (!this.#pendingToks) for (const r of rows) r.fed.push(r.current);
|
|
419
1024
|
// Per-layer forward cache: rot layers use the persistent
|
|
420
1025
|
// BatchedRotatingCache directly; ssm layers are already [B,...] state
|
|
421
1026
|
// with no padding (no mask, no per-row RoPE — pass through); full
|
|
422
|
-
// layers get a fresh BatchedDecodeMaskCache wrapper
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
)
|
|
1027
|
+
// layers get a fresh BatchedDecodeMaskCache wrapper — UNLESS no row
|
|
1028
|
+
// has left padding. UNPADDED FAST PATH (unified-engine plan Phase 2,
|
|
1029
|
+
// the B=1 case above all): with every leftPad 0 the wrapper's two
|
|
1030
|
+
// jobs vanish — the padding mask (KVCache.makeMask(1) is the empty
|
|
1031
|
+
// mask, exactly the serial loop's) and the per-row rope positions
|
|
1032
|
+
// (every row sits at the shared scalar offset). The bare cache then
|
|
1033
|
+
// dispatches the SAME per-step graph serial builds; the wrapper
|
|
1034
|
+
// otherwise costs a host mask build + ~8 device nodes PER FULL LAYER
|
|
1035
|
+
// PER TOKEN (the Phase-0 constant ~4–6 ms/step host tax at B=1).
|
|
1036
|
+
const unpadded = this.#fullLeftPad.every((p) => p === 0);
|
|
1037
|
+
// Compiled decode at B=1 (Phase 3.2): after adopt-don't-copy, a lone
|
|
1038
|
+
// row's caches are SERIAL-CLASS, so the serial engine's compiled step
|
|
1039
|
+
// replays the same C++ graph here — closing the batch lane's last
|
|
1040
|
+
// B=1 host-tax gap (e4b's ~7%). Guards: gemma dense (constructor),
|
|
1041
|
+
// serial-class caches (supports — a merged batch's BatchedRotating
|
|
1042
|
+
// layers fail it), unpadded, and a uint32 pipeline register (the
|
|
1043
|
+
// trace signature; per-row int32 samplers take the graph path).
|
|
1044
|
+
// Grammar batches use #stepGrammar and stay on the graph path.
|
|
1045
|
+
let lg: MlxArray | null = null;
|
|
1046
|
+
let evalWith: MlxArray[] = [];
|
|
1047
|
+
if (
|
|
1048
|
+
this.#compiled && B === 1 && unpadded &&
|
|
1049
|
+
(!this.#pendingToks || this.#pendingToks.dtype === Dtype.uint32) &&
|
|
1050
|
+
// A filtered-to-one BATCHED rot-quant cache subclasses the serial
|
|
1051
|
+
// class (so supports() passes) but carries batched ring state —
|
|
1052
|
+
// exclude it; only truly-adopted serial caches replay compiled.
|
|
1053
|
+
!inners.some((c) => c instanceof BatchedRotatingQuantCache) &&
|
|
1054
|
+
CompiledDecode.supports(inners as Cache[])
|
|
1055
|
+
) {
|
|
1056
|
+
let cur = this.#pendingToks;
|
|
1057
|
+
let owned = false;
|
|
1058
|
+
if (!cur) {
|
|
1059
|
+
const i = ops.fromInt32([rows[0]!.current], [1]);
|
|
1060
|
+
cur = i.astype(Dtype.uint32);
|
|
1061
|
+
i.dispose();
|
|
1062
|
+
owned = true;
|
|
1063
|
+
}
|
|
1064
|
+
try {
|
|
1065
|
+
const r = this.#compiled.step(cur, inners as Cache[]);
|
|
1066
|
+
lg = r.logits; // [1,1,V]
|
|
1067
|
+
evalWith = r.evalWith;
|
|
1068
|
+
} catch (e) {
|
|
1069
|
+
// A failed step is transactional (see generate.ts) — safe to
|
|
1070
|
+
// re-forward the same token on the graph path below.
|
|
1071
|
+
this.#compiled = null;
|
|
1072
|
+
console.warn(`batch lane: compiled decode disabled: ${e}`);
|
|
1073
|
+
} finally {
|
|
1074
|
+
if (owned) cur.dispose();
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
let fwd: Cache[] | null = null;
|
|
428
1078
|
try {
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
1079
|
+
if (!lg) {
|
|
1080
|
+
fwd = inners.map((c) =>
|
|
1081
|
+
c instanceof BatchedRotatingCache || c instanceof BatchedRotatingQuantCache ||
|
|
1082
|
+
c instanceof SSMCache || isBatchableCache(c) || unpadded
|
|
1083
|
+
? c
|
|
1084
|
+
: c instanceof QuantizedKVCache
|
|
1085
|
+
? new BatchedQuantDecodeMaskCache(c, B, this.#fullLeftPad)
|
|
1086
|
+
: new BatchedDecodeMaskCache(c, B, this.#fullLeftPad, null),
|
|
1087
|
+
);
|
|
1088
|
+
const ids = this.#pendingToks
|
|
1089
|
+
? ops.reshape(this.#pendingToks, [B, 1]) // feed the unread tokens
|
|
1090
|
+
: ops.fromInt32(rows.map((r) => r.current), [B, 1]); // pipeline cold
|
|
1091
|
+
const h = await this.#forwardHidden(ids, fwd);
|
|
1092
|
+
ids.dispose();
|
|
1093
|
+
lg = this.model.logitsFromHidden(h); // [B,1,V]
|
|
1094
|
+
h.dispose();
|
|
1095
|
+
}
|
|
436
1096
|
const V = lg.shape[2]!;
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
1097
|
+
// Vectorized homogeneous sampling (batching-perf-path P0): when every
|
|
1098
|
+
// LIVE row is plain greedy, one log-softmax+argmax over [B,V] replaces
|
|
1099
|
+
// B slice/sample/concat graphs. Per-row identical math (row-independent
|
|
1100
|
+
// ops, same per-row shapes — argmax over log-softmax mirrors the
|
|
1101
|
+
// closure's toLogprobs→argmax exactly, tie behavior included). Doomed
|
|
1102
|
+
// rows get a real argmax instead of the placeholder 0 — equally
|
|
1103
|
+
// harmless (the slot is filtered before it is ever emitted; its only
|
|
1104
|
+
// use is one KV write on the row's own, about-to-evict row).
|
|
1105
|
+
const vecOk =
|
|
1106
|
+
process.env.MLX_BUN_BATCH_VEC_SAMPLE !== "0" &&
|
|
1107
|
+
rows.every((r) => r.sampled >= r.req.maxTokens || r.req.plainGreedy);
|
|
1108
|
+
// Doomed slots hold placeholders (vec path: a real argmax value,
|
|
1109
|
+
// equally not part of the row's stream) — flag them so the fed
|
|
1110
|
+
// accounting at the read stays per-row exact.
|
|
1111
|
+
nextReal = rows.map((r) => r.sampled < r.req.maxTokens);
|
|
1112
|
+
if (vecOk) {
|
|
1113
|
+
const flat = ops.reshape(lg, [B, V]);
|
|
1114
|
+
lg.dispose();
|
|
1115
|
+
const lp = toLogprobs(flat);
|
|
1116
|
+
flat.dispose();
|
|
1117
|
+
nextToks = ops.argmaxAxis(lp, -1); // [B]
|
|
1118
|
+
lp.dispose();
|
|
1119
|
+
for (const row of rows) if (row.sampled < row.req.maxTokens) row.sampled++;
|
|
1120
|
+
} else {
|
|
1121
|
+
const sampled: MlxArray[] = [];
|
|
1122
|
+
for (let b = 0; b < B; b++) {
|
|
1123
|
+
const row = rows[b]!;
|
|
1124
|
+
if (row.sampled >= row.req.maxTokens) {
|
|
1125
|
+
// Length-doomed row: evicted right after the emission below ever
|
|
1126
|
+
// uses this slot as input — placeholder keeps the [B] alignment.
|
|
1127
|
+
sampled.push(ops.fromInt32([0], [1]));
|
|
1128
|
+
continue;
|
|
1129
|
+
}
|
|
1130
|
+
const rl = lg.slice([b, 0, 0], [b + 1, 1, V]);
|
|
1131
|
+
const rl2 = ops.reshape(rl, [1, V]);
|
|
1132
|
+
rl.dispose();
|
|
1133
|
+
sampled.push(row.req.sample(rl2, row.sampled));
|
|
1134
|
+
row.sampled++;
|
|
1135
|
+
rl2.dispose();
|
|
445
1136
|
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
sampled.push(row.req.sample(rl2, row.sampled));
|
|
450
|
-
row.sampled++;
|
|
451
|
-
rl2.dispose();
|
|
1137
|
+
lg.dispose();
|
|
1138
|
+
nextToks = ops.concatAxis(sampled, 0); // [B]
|
|
1139
|
+
for (const t of sampled) t.dispose();
|
|
452
1140
|
}
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
ops.asyncEvalAll([nextToks]); // dispatch; read NEXT iteration
|
|
1141
|
+
// dispatch; read NEXT iteration. evalWith: the compiled step's
|
|
1142
|
+
// cache-update nodes must ride the same async_eval (generate.ts).
|
|
1143
|
+
ops.asyncEvalAll([nextToks, ...evalWith]);
|
|
457
1144
|
} finally {
|
|
458
1145
|
// Free the step's RoPE arrays; do NOT dispose (full wrappers would free
|
|
459
1146
|
// their persistent inner; rot caches persist across steps).
|
|
460
|
-
for (const c of fwd) (c as { releaseRopeArr?: () => void }).releaseRopeArr?.();
|
|
1147
|
+
if (fwd) for (const c of fwd) (c as { releaseRopeArr?: () => void }).releaseRopeArr?.();
|
|
461
1148
|
}
|
|
462
1149
|
}
|
|
463
1150
|
this.#steps++;
|
|
464
1151
|
if (this.#steps % 256 === 0) clearCache(); // serial's cadence, not per-step
|
|
1152
|
+
if (STEP_TRACE) STEP_T.build += performance.now() - STEP_T.t0;
|
|
465
1153
|
|
|
466
1154
|
// Read + emit the PREVIOUS step's tokens while the new step computes.
|
|
467
1155
|
const prev = this.#pendingToks;
|
|
1156
|
+
const prevReal = this.#pendingReal;
|
|
468
1157
|
this.#pendingToks = nextToks;
|
|
1158
|
+
this.#pendingReal = nextReal;
|
|
469
1159
|
// Kill switch / A-B lever (house style, cf. MLX_BUN_COMPILED_DECODE=0):
|
|
470
1160
|
// MLX_BUN_BATCH_NO_PIPELINE=1 reads THIS step's tokens synchronously —
|
|
471
1161
|
// set from process start `prev` is always null, so the flush below IS the
|
|
@@ -475,10 +1165,23 @@ export class BatchScheduler {
|
|
|
475
1165
|
return;
|
|
476
1166
|
}
|
|
477
1167
|
if (prev) {
|
|
478
|
-
const
|
|
1168
|
+
const tRead = STEP_TRACE ? performance.now() : 0;
|
|
1169
|
+
const toks = prev.toIntTokens();
|
|
479
1170
|
prev.dispose();
|
|
1171
|
+
// These values were the step's forward input (fed) iff a forward ran.
|
|
1172
|
+
// Placeholder slots are excluded from `fed` — and taint their row if
|
|
1173
|
+
// they ever fed (see Row.fedTainted; structurally unreachable today).
|
|
1174
|
+
if (anyLive)
|
|
1175
|
+
for (let b = 0; b < B; b++) {
|
|
1176
|
+
if (!prevReal || prevReal[b]) rows[b]!.fed.push(toks[b]!);
|
|
1177
|
+
else rows[b]!.fedTainted = true;
|
|
1178
|
+
}
|
|
1179
|
+
if (STEP_TRACE) STEP_T.read += performance.now() - tRead;
|
|
1180
|
+
const tEmit = STEP_TRACE ? performance.now() : 0;
|
|
480
1181
|
await this.#emitRows(toks); // also filters #pendingToks on eviction
|
|
1182
|
+
if (STEP_TRACE) { STEP_T.emit += performance.now() - tEmit; STEP_T.n++; }
|
|
481
1183
|
}
|
|
1184
|
+
if (STEP_TRACE) STEP_T.lastEnd = performance.now();
|
|
482
1185
|
}
|
|
483
1186
|
|
|
484
1187
|
/** Read out the pipeline register (if any): emit its tokens and evict
|
|
@@ -487,17 +1190,149 @@ export class BatchScheduler {
|
|
|
487
1190
|
const prev = this.#pendingToks;
|
|
488
1191
|
if (!prev) return;
|
|
489
1192
|
this.#pendingToks = null;
|
|
490
|
-
|
|
1193
|
+
this.#pendingReal = null; // flushed values never fed — nothing to account
|
|
1194
|
+
const toks = prev.toIntTokens();
|
|
491
1195
|
prev.dispose();
|
|
1196
|
+
// Grammar rows: the flushed tokens are EMITTED below, so their matchers
|
|
1197
|
+
// must advance here — #stepGrammar's accept only covers tokens it reads
|
|
1198
|
+
// from a live pending array, and after this flush it cold-starts (no
|
|
1199
|
+
// accept). Skipping this left the matcher one token behind its stream on
|
|
1200
|
+
// every mid-decode join → one-step-stale masks → invalid output (found
|
|
1201
|
+
// by the feature-matrix conformance gate, 2026-07-03; the fill fired
|
|
1202
|
+
// here is awaited by the next #stepGrammar's ready()).
|
|
1203
|
+
for (let b = 0; b < this.#running.length && b < toks.length; b++) {
|
|
1204
|
+
const g = this.#running[b]!.req.grammar;
|
|
1205
|
+
if (g && !g.isTerminated) g.accept(toks[b]!);
|
|
1206
|
+
}
|
|
492
1207
|
await this.#emitRows(toks);
|
|
493
1208
|
}
|
|
494
1209
|
|
|
1210
|
+
/** The read-before-build decode step for batches with ≥1 live grammar row
|
|
1211
|
+
* (B1). Mirrors the serial loop's grammar resolution: read the previous
|
|
1212
|
+
* step's [B] tokens NOW (acceptToken needs JS numbers), accept() per row
|
|
1213
|
+
* (fires async fills), build the forward graph (fills overlap), await
|
|
1214
|
+
* ready(), sample per live row (the closure applies applyMask), then emit
|
|
1215
|
+
* the values read in step 1 — no second readback. Terminated grammar rows
|
|
1216
|
+
* keep their [B] slot through the forward (placeholder, not sampled) and
|
|
1217
|
+
* finish("stop") in #emitRows. On a cold start (first step after prefill)
|
|
1218
|
+
* there is no pending array to read; the prefill already accepted token 0
|
|
1219
|
+
* and fired the fill, so we just await ready() + sample. */
|
|
1220
|
+
async #stepGrammar(): Promise<void> {
|
|
1221
|
+
const rows = this.#running;
|
|
1222
|
+
const B = rows.length;
|
|
1223
|
+
const inners = this.#inners!;
|
|
1224
|
+
|
|
1225
|
+
// (1) Read the pending [B] token array (host copy for accept). The device
|
|
1226
|
+
// array is kept for the forward input below; disposed after sampling.
|
|
1227
|
+
// Cold start: pendingToks null → prevVals empty (prefill handled tok0).
|
|
1228
|
+
const prev = this.#pendingToks;
|
|
1229
|
+
const prevVals: number[] =
|
|
1230
|
+
prev ? prev.toIntTokens() : [];
|
|
1231
|
+
if (process.env.MLX_BUN_GRAMMAR_DEBUG === "1")
|
|
1232
|
+
console.log(`[sg] B=${B} prevVals=${JSON.stringify(prevVals)} current=${JSON.stringify(rows.map((r) => r.current))} sampled=${JSON.stringify(rows.map((r) => r.sampled))}`);
|
|
1233
|
+
|
|
1234
|
+
// (2) accept() per live grammar row — fires that row's async bitmask fill.
|
|
1235
|
+
for (let b = 0; b < B && prevVals.length; b++) {
|
|
1236
|
+
const g = rows[b]!.req.grammar;
|
|
1237
|
+
if (g && !g.isTerminated) g.accept(prevVals[b]!);
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
let nextToks: MlxArray | null = null;
|
|
1241
|
+
let nextReal: boolean[] | null = null;
|
|
1242
|
+
const anyLive = rows.some((r) => r.sampled < r.req.maxTokens);
|
|
1243
|
+
if (anyLive) {
|
|
1244
|
+
// Fed-token accounting (prompt-cache put) — mirror of #step's read:
|
|
1245
|
+
// real slots push, a placeholder that fed taints its row.
|
|
1246
|
+
const prevReal = this.#pendingReal;
|
|
1247
|
+
if (prev)
|
|
1248
|
+
for (let b = 0; b < B; b++) {
|
|
1249
|
+
if (!prevReal || prevReal[b]) rows[b]!.fed.push(prevVals[b]!);
|
|
1250
|
+
else rows[b]!.fedTainted = true;
|
|
1251
|
+
}
|
|
1252
|
+
else for (const r of rows) r.fed.push(r.current);
|
|
1253
|
+
// Unpadded fast path — same rule as #step (bare caches == serial graph).
|
|
1254
|
+
const unpadded = this.#fullLeftPad.every((p) => p === 0);
|
|
1255
|
+
const fwd: Cache[] = inners.map((c) =>
|
|
1256
|
+
c instanceof BatchedRotatingCache || c instanceof BatchedRotatingQuantCache ||
|
|
1257
|
+
c instanceof SSMCache || isBatchableCache(c) || unpadded
|
|
1258
|
+
? c
|
|
1259
|
+
: c instanceof QuantizedKVCache
|
|
1260
|
+
? new BatchedQuantDecodeMaskCache(c, B, this.#fullLeftPad)
|
|
1261
|
+
: new BatchedDecodeMaskCache(c, B, this.#fullLeftPad, null),
|
|
1262
|
+
);
|
|
1263
|
+
try {
|
|
1264
|
+
// (3) Build the forward graph (host-side; the fills overlap it).
|
|
1265
|
+
const ids = prev
|
|
1266
|
+
? ops.reshape(prev, [B, 1]) // feed the unread tokens (device array)
|
|
1267
|
+
: ops.fromInt32(rows.map((r) => r.current), [B, 1]); // pipeline cold
|
|
1268
|
+
const h = await this.#forwardHidden(ids, fwd);
|
|
1269
|
+
ids.dispose();
|
|
1270
|
+
const lg = this.model.logitsFromHidden(h); // [B,1,V]
|
|
1271
|
+
h.dispose();
|
|
1272
|
+
const V = lg.shape[2]!;
|
|
1273
|
+
|
|
1274
|
+
// (4) await ready() on every live grammar row — the fills fired in (2)
|
|
1275
|
+
// overlapped the graph build above. Usually already resolved.
|
|
1276
|
+
const liveGrammar = rows.filter(
|
|
1277
|
+
(r) => r.req.grammar && !r.req.grammar.isTerminated,
|
|
1278
|
+
);
|
|
1279
|
+
if (liveGrammar.length)
|
|
1280
|
+
await Promise.all(liveGrammar.map((r) => r.req.grammar!.ready()));
|
|
1281
|
+
|
|
1282
|
+
// (5) Sample per live row (the closure applies applyMask after the
|
|
1283
|
+
// logits processors). Terminated grammar rows + length-doomed rows
|
|
1284
|
+
// take a placeholder (one harmless KV write, then evicted).
|
|
1285
|
+
const sampled: MlxArray[] = [];
|
|
1286
|
+
nextReal = rows.map(
|
|
1287
|
+
(r) => r.sampled < r.req.maxTokens && !r.req.grammar?.isTerminated,
|
|
1288
|
+
);
|
|
1289
|
+
for (let b = 0; b < B; b++) {
|
|
1290
|
+
const row = rows[b]!;
|
|
1291
|
+
if (
|
|
1292
|
+
row.sampled >= row.req.maxTokens ||
|
|
1293
|
+
row.req.grammar?.isTerminated
|
|
1294
|
+
) {
|
|
1295
|
+
sampled.push(ops.fromInt32([0], [1]));
|
|
1296
|
+
continue;
|
|
1297
|
+
}
|
|
1298
|
+
const rl = lg.slice([b, 0, 0], [b + 1, 1, V]);
|
|
1299
|
+
const rl2 = ops.reshape(rl, [1, V]);
|
|
1300
|
+
rl.dispose();
|
|
1301
|
+
sampled.push(row.req.sample(rl2, row.sampled));
|
|
1302
|
+
row.sampled++;
|
|
1303
|
+
rl2.dispose();
|
|
1304
|
+
}
|
|
1305
|
+
lg.dispose();
|
|
1306
|
+
nextToks = ops.concatAxis(sampled, 0); // [B]
|
|
1307
|
+
for (const t of sampled) t.dispose();
|
|
1308
|
+
ops.asyncEvalAll([nextToks]);
|
|
1309
|
+
} finally {
|
|
1310
|
+
for (const c of fwd)
|
|
1311
|
+
(c as { releaseRopeArr?: () => void }).releaseRopeArr?.();
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
this.#steps++;
|
|
1315
|
+
if (this.#steps % 256 === 0) clearCache();
|
|
1316
|
+
|
|
1317
|
+
// The device array `prev` has now fed the forward + been read for accept;
|
|
1318
|
+
// dispose it and install the new pending array. On cold start prev is null.
|
|
1319
|
+
prev?.dispose();
|
|
1320
|
+
this.#pendingToks = nextToks;
|
|
1321
|
+
this.#pendingReal = nextReal;
|
|
1322
|
+
|
|
1323
|
+
// (6) Emit the values read in (1) — no second readback. Terminated grammar
|
|
1324
|
+
// rows finish("stop") via #emit's isTerminated check; the filter evicts.
|
|
1325
|
+
// Cold start: prevVals empty (prefill emitted tok0) → nothing to emit.
|
|
1326
|
+
if (prevVals.length) await this.#emitRows(prevVals);
|
|
1327
|
+
}
|
|
1328
|
+
|
|
495
1329
|
/** Emit one read-back token per running row; evict finished rows. A row's
|
|
496
1330
|
* onToken throwing rejects THAT row and evicts it — siblings continue. */
|
|
497
1331
|
async #emitRows(toks: number[]): Promise<void> {
|
|
498
1332
|
const rows = this.#running;
|
|
499
1333
|
const B = rows.length;
|
|
500
1334
|
const keep: number[] = [];
|
|
1335
|
+
const done: { b: number; row: Row }[] = []; // clean finishes (stop/length)
|
|
501
1336
|
for (let b = 0; b < B; b++) {
|
|
502
1337
|
const row = rows[b]!;
|
|
503
1338
|
row.generated++;
|
|
@@ -506,21 +1341,41 @@ export class BatchScheduler {
|
|
|
506
1341
|
disp = await this.#emit(row, toks[b]!);
|
|
507
1342
|
} catch (e) {
|
|
508
1343
|
row.reject(e); // containment: this row only (mlx-lm `remove`)
|
|
1344
|
+
row.merged = true; // poison: a rejected row is never put() back
|
|
1345
|
+
row.fedTainted = true;
|
|
509
1346
|
continue; // not kept → evicted by the filter below
|
|
510
1347
|
}
|
|
511
1348
|
if (disp === "continue") keep.push(b);
|
|
512
|
-
else
|
|
1349
|
+
else {
|
|
1350
|
+
done.push({ b, row });
|
|
1351
|
+
this.#finish(row, disp);
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
if (keep.length < B) {
|
|
1355
|
+
// Per-row prompt-cache extraction (mlx-lm server.py:864-880 →
|
|
1356
|
+
// BatchGenerator.extract_cache): a MERGED row's KV lives interleaved
|
|
1357
|
+
// in the batched inners, so it is pulled out into fresh serial caches
|
|
1358
|
+
// BEFORE filter() mutates them. Never-merged lone rows skip this —
|
|
1359
|
+
// #applyFilter's keep=[] path put()s their adopted caches zero-copy.
|
|
1360
|
+
// Rejected rows aren't in `done`; the whole-batch error path calls
|
|
1361
|
+
// #applyFilter(dropOnly) directly and never reaches here.
|
|
1362
|
+
for (const { b, row } of done) this.#extractAndPut(b, row);
|
|
1363
|
+
this.#applyFilter(keep);
|
|
513
1364
|
}
|
|
514
|
-
if (keep.length < B) this.#applyFilter(keep);
|
|
515
1365
|
}
|
|
516
1366
|
|
|
517
1367
|
/** Account one sampled token for a row. Mirrors generate(): EOS terminates
|
|
518
1368
|
* WITHOUT an onToken call; otherwise onToken(token) runs and `false` halts;
|
|
519
1369
|
* reaching maxTokens ends with "length". Advances row.current on continue. */
|
|
520
1370
|
async #emit(row: Row, token: number): Promise<"continue" | "stop" | "length"> {
|
|
1371
|
+
row.req.signal?.throwIfAborted();
|
|
521
1372
|
if (row.req.eosTokenIds.includes(token)) return "stop";
|
|
522
1373
|
const cont = await row.req.onToken(token);
|
|
523
1374
|
if (cont === false) return "stop";
|
|
1375
|
+
// Grammar termination: the matcher is satisfied (e.g. closing `}` accepted).
|
|
1376
|
+
// The final token has been delivered via onToken above; halt with "stop" so
|
|
1377
|
+
// the row finishes + evicts rather than sampling into an all--inf mask.
|
|
1378
|
+
if (row.req.grammar?.isTerminated) return "stop";
|
|
524
1379
|
if (row.generated >= row.req.maxTokens) return "length";
|
|
525
1380
|
row.current = token;
|
|
526
1381
|
return "continue";
|
|
@@ -530,32 +1385,148 @@ export class BatchScheduler {
|
|
|
530
1385
|
row.resolve({
|
|
531
1386
|
promptTokens: row.promptTokens,
|
|
532
1387
|
generatedTokens: row.generated,
|
|
533
|
-
cachedTokens:
|
|
1388
|
+
cachedTokens: row.cachedTokens,
|
|
534
1389
|
finishReason: reason,
|
|
535
1390
|
});
|
|
536
1391
|
}
|
|
537
1392
|
|
|
1393
|
+
/** Finish-time disposition of serial-class caches covering exactly
|
|
1394
|
+
* `tokens`: put() into the prompt cache when the hook is present and the
|
|
1395
|
+
* offset lines up (defensive — a mismatch means the accounting is wrong
|
|
1396
|
+
* and the entry would corrupt future hits), else dispose. `retain` rides
|
|
1397
|
+
* along per the PromptCacheEntry contract (runs after dispose). */
|
|
1398
|
+
#putOrDispose(caches: Cache[], tokens: number[], retain?: () => void): void {
|
|
1399
|
+
if (this.#promptCache) {
|
|
1400
|
+
const withOff = caches.find(
|
|
1401
|
+
(c) => typeof (c as { offset?: unknown }).offset === "number",
|
|
1402
|
+
) as { offset: number } | undefined;
|
|
1403
|
+
if (withOff && withOff.offset === tokens.length) {
|
|
1404
|
+
this.#promptCache.put(tokens, caches, "", retain);
|
|
1405
|
+
return;
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
for (const c of caches) c.dispose();
|
|
1409
|
+
retain?.();
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/** Extract a finishing MERGED row's KV into fresh serial caches and put()
|
|
1413
|
+
* them keyed by [promptIds + fed] — the batch-lane mirror of mlx-lm
|
|
1414
|
+
* server.py:872 (extract_cache → prompt_cache.insert_cache). Gates:
|
|
1415
|
+
* promptTokens >= 256 (the boundary-snapshot substantiality gate,
|
|
1416
|
+
* server.ts) and an exact coverage key (!fedTainted). Refusal
|
|
1417
|
+
* (#extractRowCaches null) just disposes-by-omission — the row's KV dies
|
|
1418
|
+
* with the filter, exactly the pre-extraction behavior. */
|
|
1419
|
+
#extractAndPut(b: number, row: Row): void {
|
|
1420
|
+
if (!this.#promptCache || !row.merged || row.fedTainted) return;
|
|
1421
|
+
if (row.promptTokens < 256) return;
|
|
1422
|
+
const tokens = [...row.req.promptIds, ...row.fed];
|
|
1423
|
+
const caches = this.#extractRowCaches(b, tokens.length);
|
|
1424
|
+
if (!caches) return;
|
|
1425
|
+
// Materialize the owned copies without stalling the in-flight step (the
|
|
1426
|
+
// slices depend on this step's KV writes): async — the batched source
|
|
1427
|
+
// buffers free once the copies land, instead of being pinned by a lazy
|
|
1428
|
+
// graph inside an idle cache entry.
|
|
1429
|
+
ops.asyncEvalAll(caches.flatMap((c) => c.state()));
|
|
1430
|
+
this.#putOrDispose(caches, tokens);
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
/** Row `b` of every layer as OWNED serial-class caches, or null when a
|
|
1434
|
+
* layer kind can't be extracted (then the caller drops the row's KV as
|
|
1435
|
+
* before). Bit-exactness: merge/extend/filter/decode are byte-preserving
|
|
1436
|
+
* per row (tests/batched-decode-parity, tests/batched-rotating,
|
|
1437
|
+
* tests/batched-rotating-quant) and each extract is a pure slice+copy of
|
|
1438
|
+
* those bytes (tests/batched-extract), so an extracted row's bytes ==
|
|
1439
|
+
* the solo run's. */
|
|
1440
|
+
#extractRowCaches(b: number, expectTokens: number): Cache[] | null {
|
|
1441
|
+
const out: Cache[] = [];
|
|
1442
|
+
for (const inner of this.#inners!) {
|
|
1443
|
+
let c: Cache | null;
|
|
1444
|
+
if (isBatchableCache(inner)) c = inner.extractRow(b);
|
|
1445
|
+
else if (inner instanceof BatchedRotatingQuantCache) c = inner.extractRow(b);
|
|
1446
|
+
else if (inner instanceof BatchedRotatingCache) c = inner.extractRow(b);
|
|
1447
|
+
else if (inner instanceof SSMCache)
|
|
1448
|
+
// Coverage gate: recurrent state is UNTRIMMABLE, so an entry is only
|
|
1449
|
+
// valid when the row's own advance count equals the [promptIds+fed]
|
|
1450
|
+
// key EXACTLY (a mismatched entry would silently corrupt every future
|
|
1451
|
+
// exact-hit). Defensive — a miss here means the fed accounting broke.
|
|
1452
|
+
c = inner.conv && inner.rowOffset(b) === expectTokens ? inner.extractRow(b) : null;
|
|
1453
|
+
else if (
|
|
1454
|
+
inner instanceof RotatingQuantizedKVCache || // adopted serial state never
|
|
1455
|
+
inner instanceof RotatingKVCache // coexists with a merged row (defensive)
|
|
1456
|
+
)
|
|
1457
|
+
c = null;
|
|
1458
|
+
else if (inner instanceof QuantizedKVCache)
|
|
1459
|
+
c = inner.keys ? extractQuantRow(inner, this.#fullLeftPad[b]!, b) : null;
|
|
1460
|
+
else c = inner.keys ? extractKVRow(inner, this.#fullLeftPad[b]!, b) : null;
|
|
1461
|
+
if (!c) {
|
|
1462
|
+
for (const d of out) d.dispose();
|
|
1463
|
+
return null;
|
|
1464
|
+
}
|
|
1465
|
+
out.push(c);
|
|
1466
|
+
}
|
|
1467
|
+
return out;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
538
1470
|
/** Evict rows not in `keep` (sorted ascending) from the batched KV and the
|
|
539
1471
|
* pipeline register. */
|
|
540
|
-
#applyFilter(keep: number[]): void {
|
|
1472
|
+
#applyFilter(keep: number[], dropOnly = false): void {
|
|
541
1473
|
const inners = this.#inners!;
|
|
542
1474
|
if (keep.length === 0) {
|
|
543
|
-
|
|
1475
|
+
// Prompt-cache put (Phase 3.2): a lone NEVER-MERGED row's inners are
|
|
1476
|
+
// its adopted serial-class caches, covering exactly prompt+fed — hand
|
|
1477
|
+
// them back to the cache instead of disposing. dropOnly (the batch-
|
|
1478
|
+
// drop error path) and poisoned rows dispose as before; MERGED rows'
|
|
1479
|
+
// KV was already extracted per row in #emitRows (owned copies), so
|
|
1480
|
+
// disposing the batched inners here is safe either way.
|
|
1481
|
+
const solo =
|
|
1482
|
+
!dropOnly && this.#running.length === 1 && !this.#running[0]!.merged
|
|
1483
|
+
? this.#running[0]!
|
|
1484
|
+
: null;
|
|
1485
|
+
if (solo) {
|
|
1486
|
+
this.#putOrDispose(
|
|
1487
|
+
inners as Cache[],
|
|
1488
|
+
[...solo.req.promptIds, ...solo.fed],
|
|
1489
|
+
this.#adoptedRetain ?? undefined,
|
|
1490
|
+
);
|
|
1491
|
+
} else {
|
|
1492
|
+
for (const c of inners) c.dispose();
|
|
1493
|
+
this.#adoptedRetain?.();
|
|
1494
|
+
}
|
|
1495
|
+
this.#adoptedRetain = null;
|
|
544
1496
|
this.#inners = null;
|
|
545
1497
|
this.#fullLeftPad = [];
|
|
546
1498
|
this.#running = [];
|
|
547
1499
|
this.#pendingToks?.dispose();
|
|
548
1500
|
this.#pendingToks = null;
|
|
1501
|
+
this.#pendingReal = null;
|
|
549
1502
|
return;
|
|
550
1503
|
}
|
|
551
1504
|
const out: LayerInner[] = [];
|
|
552
1505
|
for (const inner of inners) {
|
|
553
|
-
if (inner
|
|
1506
|
+
if (isBatchableCache(inner)) {
|
|
1507
|
+
inner.filterRows(keep);
|
|
1508
|
+
out.push(inner);
|
|
1509
|
+
} else if (inner instanceof BatchedRotatingCache) {
|
|
554
1510
|
inner.filter(keep); // in-place (mutates + drops rows + reduces padding)
|
|
555
1511
|
out.push(inner);
|
|
1512
|
+
} else if (inner instanceof BatchedRotatingQuantCache) {
|
|
1513
|
+
inner.filter(keep); // in-place B-axis take on all six components
|
|
1514
|
+
out.push(inner);
|
|
1515
|
+
} else if (inner instanceof RotatingQuantizedKVCache || inner instanceof RotatingKVCache) {
|
|
1516
|
+
// Adopted lone-row state exists only at B=1, where the only filter
|
|
1517
|
+
// is the keep=[] dispose-all handled above — unreachable.
|
|
1518
|
+
throw new Error("applyFilter: adopted serial rotating cache cannot be row-filtered");
|
|
556
1519
|
} else if (inner instanceof SSMCache) {
|
|
557
1520
|
inner.filter(keep); // in-place B-axis take on both state slots
|
|
558
1521
|
out.push(inner);
|
|
1522
|
+
} else if (inner instanceof QuantizedKVCache) {
|
|
1523
|
+
const [k0, v0] = inner.temporalView();
|
|
1524
|
+
const f = filterQuantRows(k0, v0, keep);
|
|
1525
|
+
for (const t of [k0, v0]) { t.packed.dispose(); t.scales.dispose(); t.biases.dispose(); }
|
|
1526
|
+
const c = new QuantizedKVCache(inner.groupSize, inner.bits);
|
|
1527
|
+
c.restoreState(f.keys, f.values, inner.offset);
|
|
1528
|
+
out.push(c);
|
|
1529
|
+
inner.dispose();
|
|
559
1530
|
} else {
|
|
560
1531
|
const [k0, v0] = inner.temporalView();
|
|
561
1532
|
const f = filterKVRows(k0, v0, keep);
|
|
@@ -576,10 +1547,11 @@ export class BatchScheduler {
|
|
|
576
1547
|
this.#pendingToks.dispose();
|
|
577
1548
|
this.#pendingToks = next;
|
|
578
1549
|
}
|
|
1550
|
+
if (this.#pendingReal) this.#pendingReal = keep.map((i) => this.#pendingReal![i]!);
|
|
579
1551
|
}
|
|
580
1552
|
|
|
581
1553
|
#readToken(t: MlxArray): number {
|
|
582
|
-
const v =
|
|
1554
|
+
const v = t.toIntTokens()[0]!;
|
|
583
1555
|
t.dispose();
|
|
584
1556
|
return v;
|
|
585
1557
|
}
|