mlx-bun 0.0.8 → 0.0.10
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 +112 -20
- package/docs/design/batching-perf-path.md +232 -0
- package/docs/design/batching-v2-plan.md +515 -0
- package/docs/design/bucketing-stage.md +196 -0
- package/docs/design/compat-cli-surface-design.md +11 -11
- package/docs/design/docs-reorg-plan.md +1 -1
- package/docs/design/dreaming-nightly-pipeline.md +291 -0
- package/docs/design/dspark-speculative-decoding.md +106 -0
- package/docs/design/generic-model-support.md +381 -0
- package/docs/design/memory-inference-path.md +422 -0
- package/docs/design/memory-synthesis.md +287 -0
- package/docs/design/minicpm5-decode-megakernel.md +484 -0
- package/docs/design/mlx-lm-tool-parity-plan.md +535 -0
- package/docs/design/omlx-adoption-map.md +90 -0
- package/docs/design/orpo-dynamic-lambda.md +41 -0
- package/docs/design/orpo-training.md +35 -5
- package/docs/design/parallel-slots.md +12 -1
- package/docs/design/parity-tier-dag.md +13 -5
- package/docs/design/segmented-backward-training.md +15 -0
- package/docs/design/spec-decode-larger-targets.md +229 -0
- package/docs/design/ssd-kv-cold-tier.md +158 -0
- package/docs/design/tauri-desktop-app.md +2 -2
- package/docs/design/the-dreaming-handoff.md +141 -0
- package/docs/design/the-dreaming-master-plan.md +446 -0
- package/docs/design/write-pipeline-entity.md +468 -0
- package/docs/investigations/curve-bisector-routing.md +1 -1
- package/docs/investigations/decode-roofline-lookagain.md +348 -0
- package/docs/investigations/dspark-handoff.md +58 -0
- package/docs/investigations/kernel-perf-review-2026-07.md +171 -0
- package/docs/planning/PRODUCT_ROADMAP.md +2 -2
- package/docs/planning/curve-sampler-research-plan.md +331 -0
- package/docs/planning/memory-docs-and-dag-plan.md +185 -0
- package/docs/planning/pi-integration-review.md +178 -0
- package/docs/planning/release-notes-v0.0.10.md +57 -0
- package/docs/planning/release-notes-v0.0.9.md +122 -0
- package/docs/planning/web-ui-pass-plan.md +213 -0
- package/docs/planning/website-readme-pass-plan.md +224 -0
- package/docs/reference/cli.md +255 -0
- package/docs/reference/embedding.md +7 -2
- package/docs/reference/library-api.md +22 -3
- package/docs/reference/memory.md +5 -5
- package/docs/reference/models.md +137 -0
- package/docs/reference/orpo-quickstart.md +6 -0
- package/docs/reference/server-api.md +187 -9
- package/docs/reference/server-config.md +43 -9
- package/docs/reference/training.md +31 -12
- package/package.json +1 -1
- package/src/anthropic.ts +22 -0
- package/src/assets/curve-designer.html +1 -1
- package/src/chat-template.ts +25 -7
- package/src/cli.ts +1077 -90
- package/src/config.ts +4 -1
- package/src/download.ts +99 -8
- package/src/embed.ts +77 -0
- package/src/eval/perplexity.ts +164 -0
- package/src/eval/runner.ts +15 -6
- package/src/eval/tasks/bfcl.ts +1 -1
- package/src/generate.ts +149 -18
- package/src/harness-pi.ts +43 -17
- package/src/index.ts +2 -0
- package/src/kv-store.ts +240 -98
- package/src/lora.ts +2 -2
- package/src/memory/article.ts +558 -0
- package/src/memory/chunk-validation.ts +150 -0
- package/src/memory/chunk.ts +305 -12
- package/src/memory/cluster.ts +344 -17
- package/src/memory/coverage.ts +134 -0
- package/src/memory/crosslink.ts +0 -0
- package/src/memory/db.ts +184 -45
- package/src/memory/entity.ts +203 -0
- package/src/memory/gate.ts +187 -0
- package/src/memory/history.ts +152 -0
- package/src/memory/ingest.ts +324 -11
- package/src/memory/model.ts +321 -0
- package/src/memory/normalize.ts +671 -0
- package/src/memory/parse.ts +22 -0
- package/src/memory/pipeline.ts +242 -30
- package/src/memory/prompts.ts +42 -0
- package/src/memory/query.ts +334 -0
- package/src/memory/reconcile.ts +734 -0
- package/src/memory/redirect.ts +108 -0
- package/src/memory/reindex.ts +325 -0
- package/src/memory/resolve.ts +307 -0
- package/src/memory/route.ts +272 -0
- package/src/memory/schedule.ts +6 -4
- package/src/memory/stages.ts +774 -0
- package/src/memory/synthesize.ts +1629 -20
- package/src/memory/tools.ts +446 -96
- package/src/memory/vault.ts +462 -35
- package/src/memory/wikify.ts +1057 -16
- package/src/mlx/array.ts +21 -0
- package/src/mlx/ffi.ts +3 -0
- package/src/mlx/ops.ts +72 -0
- package/src/model/compiled-decode.ts +43 -8
- package/src/model/factory.ts +29 -8
- package/src/model/flash-attention.ts +4 -2
- package/src/model/fused-decode-kernel.ts +14 -7
- package/src/model/fused-mlp-kernel.ts +169 -0
- package/src/model/fused-swiglu-kernel.ts +240 -0
- package/src/model/gemma4-base.ts +96 -7
- package/src/model/gemma4.ts +33 -2
- package/src/model/minicpm5.ts +106 -8
- package/src/model/qwen3-delta.ts +48 -1
- package/src/model/qwen3.ts +276 -0
- package/src/model/qwen3_5.ts +11 -2
- package/src/model/steel-linear-kernel.ts +96 -0
- package/src/model/support.ts +38 -8
- package/src/model/universal/archs.ts +332 -0
- package/src/model/universal/dense.ts +576 -0
- package/src/model/universal/modules.ts +163 -0
- package/src/model/universal/rope.ts +366 -0
- package/src/pi-provider.ts +1 -1
- package/src/pi-terminal.ts +29 -8
- package/src/pi-web.ts +19 -2
- package/src/prompt-cache.ts +55 -9
- package/src/quantize/config-writer.ts +1 -1
- package/src/registry.ts +219 -8
- package/src/responses.ts +23 -0
- package/src/sampler.ts +220 -9
- package/src/serve/batch-scheduler.ts +307 -83
- package/src/serve/generation-gateway.ts +156 -29
- package/src/server.ts +746 -73
- package/src/spec/drafter.ts +81 -49
- package/src/spec/dspark/data-dflash.ts +156 -0
- package/src/spec/dspark/data.ts +201 -0
- package/src/spec/dspark/generate-dflash.ts +193 -0
- package/src/spec/dspark/generate.ts +290 -0
- package/src/spec/dspark/loss.ts +213 -0
- package/src/spec/dspark/module-dflash.ts +361 -0
- package/src/spec/dspark/module.ts +540 -0
- package/src/spec/dspark/sample.ts +112 -0
- package/src/spec/generate.ts +40 -19
- package/src/ssd-cache.ts +213 -0
- package/src/status-page.html +1 -1
- package/src/tokenizer.ts +7 -0
- package/src/train/flash-cce.ts +81 -23
- package/src/train/fuse.ts +243 -0
- package/src/train/index.ts +3 -0
- package/src/train/job.ts +8 -0
- package/src/train/loss.ts +341 -40
- package/src/train/prefix-shared.ts +62 -11
- package/src/train/segmented.ts +215 -55
- package/src/train/trainer.ts +112 -38
- package/src/web/skills/memory/SKILL.md +40 -17
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// loop). Owns ONE running batch and drives it forward one decode step at a
|
|
3
3
|
// time, admitting waiting requests and evicting finished ones between steps —
|
|
4
4
|
// iteration-level (continuous) scheduling, not static batching. See
|
|
5
|
-
// docs/design/parallel-slots.md.
|
|
5
|
+
// docs/design/parallel-slots.md and docs/design/batching-v2-plan.md.
|
|
6
6
|
//
|
|
7
7
|
// The numerically-hard parts are verified primitives:
|
|
8
8
|
// - the batched FORWARD (per-row RoPE/mask) is bit-parity with mlx-lm B=N
|
|
@@ -21,12 +21,38 @@
|
|
|
21
21
|
// mask). Full layers share one leftPad/offset (all rows advance together); the
|
|
22
22
|
// rotating caches self-track per-row leftPad/offset as the ring wraps. The
|
|
23
23
|
// per-row absolute position stays consistent across both (full: offset-leftPad;
|
|
24
|
-
// rot: offsetArr) — see docs/design/parallel-slots.md.
|
|
24
|
+
// rot: offsetArr) — see docs/design/parallel-slots.md. Hybrid gated-DeltaNet
|
|
25
|
+
// models (Qwen3.5) add "ssm" layers: SSMCache state is plain [B,...] with no
|
|
26
|
+
// temporal axis and no padding (rows solo-prefill unpadded, decode feeds one
|
|
27
|
+
// real token per row), so merge/filter are B-axis concat/take and the cache
|
|
28
|
+
// passes through the step unwrapped (no mask, no per-row RoPE — full layers
|
|
29
|
+
// carry positions). Gate: GenerationGateway.willBatch on cache capability.
|
|
30
|
+
//
|
|
31
|
+
// Engine mechanics (the serial decode loop's hygiene, transplanted —
|
|
32
|
+
// batching-v2-plan step 3):
|
|
33
|
+
// - PIPELINED decode: each step builds the NEXT step's graph from the
|
|
34
|
+
// still-unread sampled-token array (asyncEval), then reads the PREVIOUS
|
|
35
|
+
// step's tokens while the new step computes — mlx-lm GenerationBatch._step.
|
|
36
|
+
// The pipeline is flushed (read + emit) before a join merges, so admission
|
|
37
|
+
// never has to reconcile an in-flight token array with a new row.
|
|
38
|
+
// - clearCache every 256 steps (serial's cadence; mlx-lm batched uses 512),
|
|
39
|
+
// not every step — per-step clears trashed the buffer pool each token.
|
|
40
|
+
// - CHUNKED, INTERLEAVED admission: a joiner prefills prefillChunkSize
|
|
41
|
+
// tokens per loop iteration with one batch decode step run in between, so
|
|
42
|
+
// running rows stall at most one chunk per joiner (mlx-lm interleaves the
|
|
43
|
+
// same way), and the prefill transient stays bounded.
|
|
44
|
+
// - Failure containment: one row's onToken throwing evicts THAT row (its
|
|
45
|
+
// promise rejects); siblings keep decoding (mlx-lm `remove` semantics). A
|
|
46
|
+
// forward/sampling error still drops the whole batch (can't be attributed
|
|
47
|
+
// to a row).
|
|
25
48
|
//
|
|
26
49
|
// Bun-async, NO threads: a single detached driver loop owns the GPU for batched
|
|
27
50
|
// mode (an ExclusiveLock keeps the serial fallback off the GPU concurrently).
|
|
51
|
+
// When `admissionHeld` reports a waiting serial-lane request, the loop stops
|
|
52
|
+
// admitting, finishes the running rows, and releases the lock so the serial
|
|
53
|
+
// request runs (mlx-lm's drain_batch) — resumed via kick().
|
|
28
54
|
// Joins re-merge the whole batch; the keep-the-running-batch `extend`
|
|
29
|
-
// optimization is a later refinement.
|
|
55
|
+
// optimization is a later refinement (batching-v2-plan item a).
|
|
30
56
|
|
|
31
57
|
import { MlxArray } from "../mlx/array";
|
|
32
58
|
import * as ops from "../mlx/ops";
|
|
@@ -34,12 +60,28 @@ import { clearCache } from "../mlx/ffi";
|
|
|
34
60
|
import { KVCache, RotatingKVCache, type Cache } from "../model/gemma4-base";
|
|
35
61
|
import { BatchedDecodeMaskCache, mergeKVRows, filterKVRows } from "../model/batched-mask";
|
|
36
62
|
import { BatchedRotatingCache } from "../model/batched-rotating";
|
|
63
|
+
import { SSMCache } from "../model/qwen3-delta";
|
|
37
64
|
import type { RuntimeModel } from "../model/factory";
|
|
38
65
|
|
|
66
|
+
/** Decode-pipeline kill switch (read once at load, like the serial loop's
|
|
67
|
+
* MLX_BUN_COMPILED_DECODE): 1 ⇒ read each step's tokens synchronously. */
|
|
68
|
+
const NO_PIPELINE = process.env.MLX_BUN_BATCH_NO_PIPELINE === "1";
|
|
69
|
+
|
|
70
|
+
// NOTE — oMLX-style "adaptive burst decode" (several steps per event-loop
|
|
71
|
+
// yield, omlx/engine_core.py _step_burst) was ported here 2026-07-02 and
|
|
72
|
+
// REFUTED by measurement: on this runtime it REGRESSED cpm5 B=4 aggregate
|
|
73
|
+
// 345→289 tok/s, batch-lane B=1 149→121, and TTFT ~+100 ms (first-token SSE
|
|
74
|
+
// flush waits out the burst budget). Their win exists because each Python
|
|
75
|
+
// step hand-off ping-pongs the GIL with asyncio/uvicorn (~1 ms/token); Bun's
|
|
76
|
+
// setImmediate hop costs microseconds, so bursting here only delays socket
|
|
77
|
+
// flushes. Don't re-add without new evidence — the per-yield step below is
|
|
78
|
+
// the measured optimum (docs/design/batching-perf-path.md P4 notes).
|
|
79
|
+
|
|
39
80
|
/** A token sampler for one row: (logits [1,V], step) → token array [1] on
|
|
40
81
|
* device. Greedy is `(l) => ops.argmaxAxis(l, -1)`; richer closures fold in
|
|
41
82
|
* temperature / top-p / logits processors + history (built per request from
|
|
42
|
-
* its sampler options, exactly like generate()'s sampleStep).
|
|
83
|
+
* its sampler options, exactly like generate()'s sampleStep). Called once per
|
|
84
|
+
* step 0..maxTokens-1 for this row (never beyond maxTokens). */
|
|
43
85
|
export type RowSampler = (logits1V: MlxArray, step: number) => MlxArray;
|
|
44
86
|
|
|
45
87
|
export interface BatchRequest {
|
|
@@ -49,8 +91,9 @@ export interface BatchRequest {
|
|
|
49
91
|
sample: RowSampler;
|
|
50
92
|
/** Called per emitted (non-EOS) token, in order. Returning `false` halts this
|
|
51
93
|
* row (a decoded-text stop sequence fired) — matches generate()'s onToken
|
|
52
|
-
* contract. EOS terminates the row WITHOUT an onToken call.
|
|
53
|
-
*
|
|
94
|
+
* contract. EOS terminates the row WITHOUT an onToken call. Throwing evicts
|
|
95
|
+
* THIS row only (its submit promise rejects; siblings continue). May be
|
|
96
|
+
* async; keep it cheap — it runs inline in the step loop. */
|
|
54
97
|
onToken: (token: number) => void | boolean | Promise<void | boolean>;
|
|
55
98
|
}
|
|
56
99
|
|
|
@@ -67,11 +110,20 @@ interface Row {
|
|
|
67
110
|
req: BatchRequest;
|
|
68
111
|
resolve: (s: BatchStats) => void;
|
|
69
112
|
reject: (e: unknown) => void;
|
|
70
|
-
current: number; // last emitted token, fed at the next step
|
|
113
|
+
current: number; // last emitted token, fed at the next step (pipeline cold)
|
|
71
114
|
generated: number; // tokens emitted so far (incl. a terminating EOS)
|
|
115
|
+
sampled: number; // sample() calls so far (leads `generated` by 1 in-pipeline)
|
|
72
116
|
promptTokens: number;
|
|
73
117
|
}
|
|
74
118
|
|
|
119
|
+
/** A joiner mid-prefill: `pos` prompt tokens are already in `solo`. Advanced
|
|
120
|
+
* one chunk per loop iteration, interleaved with batch decode steps. */
|
|
121
|
+
interface PrefillState {
|
|
122
|
+
row: Row;
|
|
123
|
+
solo: Cache[];
|
|
124
|
+
pos: number;
|
|
125
|
+
}
|
|
126
|
+
|
|
75
127
|
/** Held while the batch is active; the serial fallback acquires the same lock. */
|
|
76
128
|
export interface ExclusiveLock {
|
|
77
129
|
acquire(): Promise<() => void>;
|
|
@@ -81,9 +133,15 @@ export interface BatchSchedulerOptions {
|
|
|
81
133
|
/** Max rows in the running batch (mlx-lm `--decode-concurrency`). */
|
|
82
134
|
maxBatch: number;
|
|
83
135
|
lock?: ExclusiveLock;
|
|
136
|
+
/** Drain signal: while true, no NEW rows are admitted (running rows finish,
|
|
137
|
+
* the lock is released) so a waiting serial-lane request can run — mlx-lm's
|
|
138
|
+
* drain_batch. Pair with kick() when it flips back to false. */
|
|
139
|
+
admissionHeld?: () => boolean;
|
|
140
|
+
/** Joiner prefill chunk length (default 2048, the serial loop's constant). */
|
|
141
|
+
prefillChunkSize?: number;
|
|
84
142
|
}
|
|
85
143
|
|
|
86
|
-
type LayerInner = KVCache | BatchedRotatingCache;
|
|
144
|
+
type LayerInner = KVCache | BatchedRotatingCache | SSMCache;
|
|
87
145
|
type Row1 = { keys: MlxArray; values: MlxArray };
|
|
88
146
|
|
|
89
147
|
export class BatchScheduler {
|
|
@@ -91,19 +149,31 @@ export class BatchScheduler {
|
|
|
91
149
|
#inners: LayerInner[] | null = null; // per-layer batched KV; null when empty
|
|
92
150
|
#fullLeftPad: number[] = []; // per-row padding for FULL layers (rot self-tracks)
|
|
93
151
|
#pending: Row[] = [];
|
|
152
|
+
#prefill: PrefillState | null = null; // the (single) joiner mid-prefill
|
|
153
|
+
/** Sampled-but-unread token array [B], aligned with #running — the decode
|
|
154
|
+
* pipeline register. Filtered/disposed alongside the batched KV. */
|
|
155
|
+
#pendingToks: MlxArray | null = null;
|
|
156
|
+
#steps = 0; // decode-step counter (clearCache cadence)
|
|
94
157
|
#looping = false;
|
|
95
158
|
#wake: (() => void) | null = null;
|
|
96
159
|
readonly #maxBatch: number;
|
|
97
160
|
readonly #lock: ExclusiveLock | undefined;
|
|
98
|
-
readonly #
|
|
161
|
+
readonly #admissionHeld: (() => boolean) | undefined;
|
|
162
|
+
readonly #prefillChunkSize: number;
|
|
163
|
+
readonly #kinds: ("full" | "rot" | "ssm")[]; // per-layer attention type
|
|
99
164
|
readonly #rotMaxSize: number[]; // per-layer sliding window (rot layers only)
|
|
100
165
|
|
|
101
166
|
constructor(private readonly model: RuntimeModel, opts: BatchSchedulerOptions) {
|
|
102
167
|
this.#maxBatch = Math.max(1, Math.floor(opts.maxBatch));
|
|
103
168
|
this.#lock = opts.lock;
|
|
169
|
+
this.#admissionHeld = opts.admissionHeld;
|
|
170
|
+
this.#prefillChunkSize = Math.max(1, Math.floor(opts.prefillChunkSize ?? 2048));
|
|
104
171
|
const proto = model.makeCache(); // fresh caches hold no buffers
|
|
105
|
-
this.#kinds = proto.map((c) =>
|
|
172
|
+
this.#kinds = proto.map((c) =>
|
|
173
|
+
c instanceof RotatingKVCache ? "rot" : c instanceof SSMCache ? "ssm" : "full",
|
|
174
|
+
);
|
|
106
175
|
this.#rotMaxSize = proto.map((c) => (c instanceof RotatingKVCache ? c.maxSize : 0));
|
|
176
|
+
for (const c of proto) c.dispose();
|
|
107
177
|
}
|
|
108
178
|
|
|
109
179
|
get activeRows(): number {
|
|
@@ -115,12 +185,17 @@ export class BatchScheduler {
|
|
|
115
185
|
return new Promise<BatchStats>((resolve, reject) => {
|
|
116
186
|
this.#pending.push({
|
|
117
187
|
req, resolve, reject,
|
|
118
|
-
current: 0, generated: 0, promptTokens: req.promptIds.length,
|
|
188
|
+
current: 0, generated: 0, sampled: 0, promptTokens: req.promptIds.length,
|
|
119
189
|
});
|
|
120
190
|
this.#ensureLoop();
|
|
121
191
|
});
|
|
122
192
|
}
|
|
123
193
|
|
|
194
|
+
/** Wake the driver loop (e.g. after admissionHeld flips back to false). */
|
|
195
|
+
kick(): void {
|
|
196
|
+
this.#ensureLoop();
|
|
197
|
+
}
|
|
198
|
+
|
|
124
199
|
#ensureLoop(): void {
|
|
125
200
|
if (this.#wake) { this.#wake(); return; }
|
|
126
201
|
if (this.#looping) return;
|
|
@@ -132,29 +207,58 @@ export class BatchScheduler {
|
|
|
132
207
|
let release: (() => void) | null = null;
|
|
133
208
|
try {
|
|
134
209
|
while (true) {
|
|
135
|
-
|
|
136
|
-
|
|
210
|
+
const held = this.#admissionHeld?.() === true;
|
|
211
|
+
// Work = running rows, an in-flight prefill (finish it even under
|
|
212
|
+
// drain — the row is already half-admitted), or admissible pending.
|
|
213
|
+
const hasWork =
|
|
214
|
+
this.#running.length > 0 ||
|
|
215
|
+
this.#prefill !== null ||
|
|
216
|
+
(!held && this.#pending.length > 0);
|
|
217
|
+
if (!hasWork) {
|
|
218
|
+
if (release) { release(); release = null; } // let the serial lane in
|
|
219
|
+
await new Promise<void>((r) => { this.#wake = r; });
|
|
220
|
+
this.#wake = null;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (!release && this.#lock) release = await this.#lock.acquire();
|
|
137
224
|
|
|
138
|
-
|
|
139
|
-
|
|
225
|
+
// Start at most one joiner prefill (drain-aware); it advances one
|
|
226
|
+
// chunk per iteration, interleaved with the decode step below.
|
|
227
|
+
if (!this.#prefill && !held &&
|
|
228
|
+
this.#pending.length > 0 && this.#running.length < this.#maxBatch)
|
|
229
|
+
this.#prefill = { row: this.#pending.shift()!, solo: this.model.makeCache(), pos: 0 };
|
|
230
|
+
|
|
231
|
+
if (this.#prefill) {
|
|
232
|
+
const p = this.#prefill;
|
|
140
233
|
try {
|
|
141
|
-
await this.#
|
|
234
|
+
if (await this.#prefillChunk(p)) this.#prefill = null;
|
|
142
235
|
} catch (e) {
|
|
143
|
-
row
|
|
236
|
+
// Prefill/admission failure is per-row: reject the joiner, keep
|
|
237
|
+
// the running batch.
|
|
238
|
+
this.#prefill = null;
|
|
239
|
+
for (const c of p.solo) c.dispose();
|
|
240
|
+
p.row.reject(e);
|
|
144
241
|
}
|
|
145
242
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
243
|
+
|
|
244
|
+
// Burst admission: when a joiner just COMPLETED and more batchable
|
|
245
|
+
// requests are queued, admit them before the next decode step (the
|
|
246
|
+
// admit-all-then-step grouping the mlx-lm goldens gate). Long prompts
|
|
247
|
+
// still interleave: a mid-prefill joiner leaves #prefill non-null, so
|
|
248
|
+
// the loop falls through to run a decode step between chunks.
|
|
249
|
+
if (this.#prefill === null && !held &&
|
|
250
|
+
this.#pending.length > 0 && this.#running.length < this.#maxBatch)
|
|
151
251
|
continue;
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
252
|
+
|
|
253
|
+
if (this.#running.length > 0) {
|
|
254
|
+
try {
|
|
255
|
+
await this.#step();
|
|
256
|
+
} catch (e) {
|
|
257
|
+
// A forward/sampling error can't be attributed to one row — drop
|
|
258
|
+
// the batch. (A row's onToken error is contained in #emitRows.)
|
|
259
|
+
for (const row of this.#running) row.reject(e);
|
|
260
|
+
this.#applyFilter([]);
|
|
261
|
+
}
|
|
158
262
|
}
|
|
159
263
|
await new Promise<void>((r) => setImmediate(r));
|
|
160
264
|
}
|
|
@@ -164,40 +268,79 @@ export class BatchScheduler {
|
|
|
164
268
|
}
|
|
165
269
|
}
|
|
166
270
|
|
|
167
|
-
/**
|
|
168
|
-
*
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const
|
|
271
|
+
/** Advance a joiner's solo prefill by one chunk. Non-final chunks forward +
|
|
272
|
+
* eval the cache and return false (the caller interleaves a decode step).
|
|
273
|
+
* The final chunk samples token 0, emits it, and — if the row survives —
|
|
274
|
+
* merges it into the running batch; returns true (admission complete). */
|
|
275
|
+
async #prefillChunk(p: PrefillState): Promise<boolean> {
|
|
276
|
+
const prompt = p.row.req.promptIds;
|
|
277
|
+
if (prompt.length - p.pos > this.#prefillChunkSize) {
|
|
278
|
+
const chunk = prompt.slice(p.pos, p.pos + this.#prefillChunkSize);
|
|
279
|
+
const ids = ops.fromInt32(chunk, [1, chunk.length]);
|
|
280
|
+
const h = this.model.forwardHidden(ids, p.solo);
|
|
281
|
+
ids.dispose();
|
|
282
|
+
h.dispose(); // logits never computed for non-final chunks
|
|
283
|
+
ops.evalAll(p.solo.flatMap((c) => c.state()));
|
|
284
|
+
clearCache(); // serial prefill's per-chunk clear (generate.ts)
|
|
285
|
+
p.pos += this.#prefillChunkSize;
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Final chunk: slice the LAST hidden position BEFORE the lm_head — running
|
|
290
|
+
// logitsFromHidden on the whole [1,Lp,H] hidden materializes a [1,Lp,V]
|
|
291
|
+
// transient (~4.3 GB bf16 at Gemma V=262k, 8k prompt). Same reorder as the
|
|
292
|
+
// serial path (generate.ts prefill).
|
|
293
|
+
const chunk = prompt.slice(p.pos);
|
|
294
|
+
const ids = ops.fromInt32(chunk, [1, chunk.length]);
|
|
295
|
+
const h = this.model.forwardHidden(ids, p.solo);
|
|
173
296
|
ids.dispose();
|
|
174
|
-
const
|
|
297
|
+
const [, Lc, H] = h.shape as [number, number, number];
|
|
298
|
+
const hLast = h.slice([0, Lc - 1, 0], [1, Lc, H]);
|
|
175
299
|
h.dispose();
|
|
176
|
-
const
|
|
177
|
-
|
|
300
|
+
const lg = this.model.logitsFromHidden(hLast); // [1,1,V]
|
|
301
|
+
hLast.dispose();
|
|
302
|
+
const V = lg.shape[2]!;
|
|
303
|
+
const last2 = ops.reshape(lg, [1, V]);
|
|
178
304
|
lg.dispose();
|
|
179
|
-
const
|
|
180
|
-
last.dispose();
|
|
181
|
-
const tok = this.#readToken(row.req.sample(last2, 0));
|
|
305
|
+
const tok = this.#readToken(p.row.req.sample(last2, 0));
|
|
182
306
|
last2.dispose();
|
|
183
|
-
row.
|
|
184
|
-
|
|
307
|
+
p.row.sampled = 1;
|
|
308
|
+
p.row.generated = 1;
|
|
309
|
+
clearCache(); // drop the prefill transients (serial's token-0 clear)
|
|
185
310
|
|
|
186
|
-
const stop = await this.#emit(row, tok);
|
|
311
|
+
const stop = await this.#emit(p.row, tok);
|
|
187
312
|
if (stop !== "continue") {
|
|
188
|
-
for (const c of solo) c.dispose();
|
|
189
|
-
this.#finish(row, stop);
|
|
190
|
-
return;
|
|
313
|
+
for (const c of p.solo) c.dispose();
|
|
314
|
+
this.#finish(p.row, stop);
|
|
315
|
+
return true;
|
|
191
316
|
}
|
|
317
|
+
await this.#mergeJoiner(p);
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Merge a fully-prefilled joiner with the running batch, layer by layer
|
|
322
|
+
* (re-merge; `extend` is the later refinement). Flushes the decode pipeline
|
|
323
|
+
* first so the row set is settled and the next step starts cold. */
|
|
324
|
+
async #mergeJoiner(p: PrefillState): Promise<void> {
|
|
325
|
+
await this.#flushPipeline();
|
|
192
326
|
|
|
193
|
-
// Re-merge the running batch + the new row, layer by layer.
|
|
194
327
|
const prev = this.#inners;
|
|
195
328
|
const prevPad = this.#fullLeftPad;
|
|
196
329
|
const B = this.#running.length;
|
|
197
330
|
const newInners: LayerInner[] = [];
|
|
198
331
|
let newFullPad = this.#fullLeftPad;
|
|
199
332
|
for (let layer = 0; layer < this.#kinds.length; layer++) {
|
|
200
|
-
|
|
333
|
+
if (this.#kinds[layer] === "ssm") {
|
|
334
|
+
// No temporal axis, no left-pad: B-axis concat of the state slots.
|
|
335
|
+
// mergeRows steals the solo arrays when the batch starts cold, so the
|
|
336
|
+
// unconditional p.solo dispose below stays safe either way.
|
|
337
|
+
newInners.push(SSMCache.mergeRows(
|
|
338
|
+
(prev?.[layer] as SSMCache | undefined) ?? null,
|
|
339
|
+
p.solo[layer] as SSMCache,
|
|
340
|
+
));
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
const soloC = p.solo[layer] as KVCache | RotatingKVCache;
|
|
201
344
|
const [sk, sv] = soloC.temporalView();
|
|
202
345
|
const newRow: Row1 = { keys: sk, values: sv };
|
|
203
346
|
if (this.#kinds[layer] === "rot") {
|
|
@@ -248,55 +391,123 @@ export class BatchScheduler {
|
|
|
248
391
|
}
|
|
249
392
|
}
|
|
250
393
|
if (prev) for (const c of prev) c.dispose();
|
|
251
|
-
for (const c of solo) c.dispose();
|
|
394
|
+
for (const c of p.solo) c.dispose();
|
|
252
395
|
this.#inners = newInners;
|
|
253
396
|
this.#fullLeftPad = newFullPad;
|
|
254
|
-
this.#running.push(row);
|
|
397
|
+
this.#running.push(p.row);
|
|
255
398
|
}
|
|
256
399
|
|
|
257
|
-
/** One batched decode step
|
|
400
|
+
/** One PIPELINED batched decode step (mlx-lm GenerationBatch._step):
|
|
401
|
+
* 1. forward all rows from the UNREAD pending token array (or, pipeline
|
|
402
|
+
* cold, from the rows' last emitted tokens), sample each live row's next
|
|
403
|
+
* token on its [1,V] slice, asyncEval the new [B] token array;
|
|
404
|
+
* 2. THEN sync-read the previous step's tokens (overlapping the readback
|
|
405
|
+
* with this step's compute), emit them, and evict finished rows.
|
|
406
|
+
* Rows that finish get one extra harmless KV write from the already-built
|
|
407
|
+
* step; filter drops the row (mlx-lm behaves identically). Length-finished
|
|
408
|
+
* rows are known in advance and are NOT sampled (placeholder slot). */
|
|
258
409
|
async #step(): Promise<void> {
|
|
259
410
|
const rows = this.#running;
|
|
260
411
|
const B = rows.length;
|
|
261
412
|
const inners = this.#inners!;
|
|
262
|
-
|
|
263
|
-
//
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
413
|
+
|
|
414
|
+
// A row is live if it still needs tokens sampled; a row whose pending
|
|
415
|
+
// unread token is its last (sampled == maxTokens) only awaits emission.
|
|
416
|
+
const anyLive = rows.some((r) => r.sampled < r.req.maxTokens);
|
|
417
|
+
let nextToks: MlxArray | null = null;
|
|
418
|
+
if (anyLive) {
|
|
419
|
+
// Per-layer forward cache: rot layers use the persistent
|
|
420
|
+
// BatchedRotatingCache directly; ssm layers are already [B,...] state
|
|
421
|
+
// with no padding (no mask, no per-row RoPE — pass through); full
|
|
422
|
+
// layers get a fresh BatchedDecodeMaskCache wrapper.
|
|
423
|
+
const fwd: Cache[] = inners.map((c) =>
|
|
424
|
+
c instanceof BatchedRotatingCache || c instanceof SSMCache
|
|
425
|
+
? c
|
|
426
|
+
: new BatchedDecodeMaskCache(c, B, this.#fullLeftPad, null),
|
|
427
|
+
);
|
|
428
|
+
try {
|
|
429
|
+
const ids = this.#pendingToks
|
|
430
|
+
? ops.reshape(this.#pendingToks, [B, 1]) // feed the unread tokens
|
|
431
|
+
: ops.fromInt32(rows.map((r) => r.current), [B, 1]); // pipeline cold
|
|
432
|
+
const h = this.model.forwardHidden(ids, fwd);
|
|
433
|
+
ids.dispose();
|
|
434
|
+
const lg = this.model.logitsFromHidden(h); // [B,1,V]
|
|
435
|
+
h.dispose();
|
|
436
|
+
const V = lg.shape[2]!;
|
|
437
|
+
const sampled: MlxArray[] = [];
|
|
438
|
+
for (let b = 0; b < B; b++) {
|
|
439
|
+
const row = rows[b]!;
|
|
440
|
+
if (row.sampled >= row.req.maxTokens) {
|
|
441
|
+
// Length-doomed row: evicted right after the emission below ever
|
|
442
|
+
// uses this slot as input — placeholder keeps the [B] alignment.
|
|
443
|
+
sampled.push(ops.fromInt32([0], [1]));
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
const rl = lg.slice([b, 0, 0], [b + 1, 1, V]);
|
|
447
|
+
const rl2 = ops.reshape(rl, [1, V]);
|
|
448
|
+
rl.dispose();
|
|
449
|
+
sampled.push(row.req.sample(rl2, row.sampled));
|
|
450
|
+
row.sampled++;
|
|
451
|
+
rl2.dispose();
|
|
452
|
+
}
|
|
453
|
+
lg.dispose();
|
|
454
|
+
nextToks = ops.concatAxis(sampled, 0); // [B]
|
|
455
|
+
for (const t of sampled) t.dispose();
|
|
456
|
+
ops.asyncEvalAll([nextToks]); // dispatch; read NEXT iteration
|
|
457
|
+
} finally {
|
|
458
|
+
// Free the step's RoPE arrays; do NOT dispose (full wrappers would free
|
|
459
|
+
// their persistent inner; rot caches persist across steps).
|
|
460
|
+
for (const c of fwd) (c as { releaseRopeArr?: () => void }).releaseRopeArr?.();
|
|
282
461
|
}
|
|
283
|
-
lg.dispose();
|
|
284
|
-
const tokArr = ops.concatAxis(sampled, 0); // [B]
|
|
285
|
-
for (const t of sampled) t.dispose();
|
|
286
|
-
toks = [...tokArr.toFloat32()].map((x) => Math.round(x));
|
|
287
|
-
tokArr.dispose();
|
|
288
|
-
} finally {
|
|
289
|
-
// Free the step's RoPE arrays; do NOT dispose (full wrappers would free
|
|
290
|
-
// their persistent inner; rot caches persist across steps).
|
|
291
|
-
for (const c of fwd) (c as { releaseRopeArr?: () => void }).releaseRopeArr?.();
|
|
292
462
|
}
|
|
293
|
-
|
|
463
|
+
this.#steps++;
|
|
464
|
+
if (this.#steps % 256 === 0) clearCache(); // serial's cadence, not per-step
|
|
294
465
|
|
|
466
|
+
// Read + emit the PREVIOUS step's tokens while the new step computes.
|
|
467
|
+
const prev = this.#pendingToks;
|
|
468
|
+
this.#pendingToks = nextToks;
|
|
469
|
+
// Kill switch / A-B lever (house style, cf. MLX_BUN_COMPILED_DECODE=0):
|
|
470
|
+
// MLX_BUN_BATCH_NO_PIPELINE=1 reads THIS step's tokens synchronously —
|
|
471
|
+
// set from process start `prev` is always null, so the flush below IS the
|
|
472
|
+
// whole phase 2. Same math either way (pipelining is scheduling).
|
|
473
|
+
if (NO_PIPELINE) {
|
|
474
|
+
await this.#flushPipeline();
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
if (prev) {
|
|
478
|
+
const toks = [...prev.toFloat32()].map((x) => Math.round(x));
|
|
479
|
+
prev.dispose();
|
|
480
|
+
await this.#emitRows(toks); // also filters #pendingToks on eviction
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Read out the pipeline register (if any): emit its tokens and evict
|
|
485
|
+
* finished rows, leaving the pipeline cold. Called before a join merges. */
|
|
486
|
+
async #flushPipeline(): Promise<void> {
|
|
487
|
+
const prev = this.#pendingToks;
|
|
488
|
+
if (!prev) return;
|
|
489
|
+
this.#pendingToks = null;
|
|
490
|
+
const toks = [...prev.toFloat32()].map((x) => Math.round(x));
|
|
491
|
+
prev.dispose();
|
|
492
|
+
await this.#emitRows(toks);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Emit one read-back token per running row; evict finished rows. A row's
|
|
496
|
+
* onToken throwing rejects THAT row and evicts it — siblings continue. */
|
|
497
|
+
async #emitRows(toks: number[]): Promise<void> {
|
|
498
|
+
const rows = this.#running;
|
|
499
|
+
const B = rows.length;
|
|
295
500
|
const keep: number[] = [];
|
|
296
501
|
for (let b = 0; b < B; b++) {
|
|
297
502
|
const row = rows[b]!;
|
|
298
503
|
row.generated++;
|
|
299
|
-
|
|
504
|
+
let disp: "continue" | "stop" | "length";
|
|
505
|
+
try {
|
|
506
|
+
disp = await this.#emit(row, toks[b]!);
|
|
507
|
+
} catch (e) {
|
|
508
|
+
row.reject(e); // containment: this row only (mlx-lm `remove`)
|
|
509
|
+
continue; // not kept → evicted by the filter below
|
|
510
|
+
}
|
|
300
511
|
if (disp === "continue") keep.push(b);
|
|
301
512
|
else this.#finish(row, disp);
|
|
302
513
|
}
|
|
@@ -324,7 +535,8 @@ export class BatchScheduler {
|
|
|
324
535
|
});
|
|
325
536
|
}
|
|
326
537
|
|
|
327
|
-
/** Evict rows not in `keep` (sorted ascending) from the batched KV
|
|
538
|
+
/** Evict rows not in `keep` (sorted ascending) from the batched KV and the
|
|
539
|
+
* pipeline register. */
|
|
328
540
|
#applyFilter(keep: number[]): void {
|
|
329
541
|
const inners = this.#inners!;
|
|
330
542
|
if (keep.length === 0) {
|
|
@@ -332,6 +544,8 @@ export class BatchScheduler {
|
|
|
332
544
|
this.#inners = null;
|
|
333
545
|
this.#fullLeftPad = [];
|
|
334
546
|
this.#running = [];
|
|
547
|
+
this.#pendingToks?.dispose();
|
|
548
|
+
this.#pendingToks = null;
|
|
335
549
|
return;
|
|
336
550
|
}
|
|
337
551
|
const out: LayerInner[] = [];
|
|
@@ -339,6 +553,9 @@ export class BatchScheduler {
|
|
|
339
553
|
if (inner instanceof BatchedRotatingCache) {
|
|
340
554
|
inner.filter(keep); // in-place (mutates + drops rows + reduces padding)
|
|
341
555
|
out.push(inner);
|
|
556
|
+
} else if (inner instanceof SSMCache) {
|
|
557
|
+
inner.filter(keep); // in-place B-axis take on both state slots
|
|
558
|
+
out.push(inner);
|
|
342
559
|
} else {
|
|
343
560
|
const [k0, v0] = inner.temporalView();
|
|
344
561
|
const f = filterKVRows(k0, v0, keep);
|
|
@@ -352,6 +569,13 @@ export class BatchScheduler {
|
|
|
352
569
|
this.#inners = out;
|
|
353
570
|
this.#fullLeftPad = keep.map((i) => this.#fullLeftPad[i]!);
|
|
354
571
|
this.#running = keep.map((i) => this.#running[i]!);
|
|
572
|
+
if (this.#pendingToks) {
|
|
573
|
+
const idx = ops.fromInt32(keep, [keep.length]);
|
|
574
|
+
const next = ops.takeAxis(this.#pendingToks, idx, 0);
|
|
575
|
+
idx.dispose();
|
|
576
|
+
this.#pendingToks.dispose();
|
|
577
|
+
this.#pendingToks = next;
|
|
578
|
+
}
|
|
355
579
|
}
|
|
356
580
|
|
|
357
581
|
#readToken(t: MlxArray): number {
|