mlx-bun 0.0.4
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/LICENSE +21 -0
- package/README.md +362 -0
- package/THIRD_PARTY_LICENSES.md +41 -0
- package/bin/mlx-bun.mjs +32 -0
- package/docs/curve-designer.html +303 -0
- package/docs/design/adapters-end-to-end.md +201 -0
- package/docs/design/compat-cli-surface-design.md +227 -0
- package/docs/design/docs-reorg-plan.md +152 -0
- package/docs/design/hlg-sampling.md +340 -0
- package/docs/design/memory-system.md +339 -0
- package/docs/design/optimization_plan.md +344 -0
- package/docs/design/parallel-slots.md +515 -0
- package/docs/design/runtime-isolation.md +247 -0
- package/docs/design/segmented-backward-training.md +508 -0
- package/docs/investigations/chunk-finetune-experiment.md +84 -0
- package/docs/investigations/curve-bisector-routing.md +123 -0
- package/docs/investigations/curve-runs/bisector-route.json +98 -0
- package/docs/investigations/curve-runs/bisector-trace.json +1393 -0
- package/docs/investigations/curve-runs/curve-terrain.json +1179 -0
- package/docs/investigations/curve-runs/curve-terrain.svg +42 -0
- package/docs/investigations/curve-terrain.html +105 -0
- package/docs/investigations/expert-offload-single-user-moe.md +292 -0
- package/docs/investigations/hlg-ab.html +34 -0
- package/docs/investigations/hlg-explorer.html +238 -0
- package/docs/investigations/hlg-figs/cliff.svg +1 -0
- package/docs/investigations/hlg-figs/cross.svg +1 -0
- package/docs/investigations/hlg-figs/edges.svg +1 -0
- package/docs/investigations/hlg-figs/k20-grids.svg +1 -0
- package/docs/investigations/hlg-figs/k20-peaks.svg +1 -0
- package/docs/investigations/hlg-figs/lw-curve.svg +1 -0
- package/docs/investigations/hlg-figs/terrain.svg +1 -0
- package/docs/investigations/hlg-report.html +262 -0
- package/docs/investigations/hlg-runs/hlg-ab.json +149 -0
- package/docs/investigations/hlg-runs/hlg-blind-prompts.md +87 -0
- package/docs/investigations/hlg-runs/hlg-blind.json +107 -0
- package/docs/investigations/hlg-runs/hlg-cliff.json +197 -0
- package/docs/investigations/hlg-runs/hlg-cross.json +505 -0
- package/docs/investigations/hlg-runs/hlg-edge.json +388 -0
- package/docs/investigations/hlg-runs/hlg-eval1.json +25 -0
- package/docs/investigations/hlg-runs/hlg-frontier.json +86 -0
- package/docs/investigations/hlg-runs/hlg-grid.json +242 -0
- package/docs/investigations/hlg-runs/hlg-grid2.json +343 -0
- package/docs/investigations/hlg-runs/hlg-lw-coarse.json +110 -0
- package/docs/investigations/hlg-runs/hlg-map.json +383 -0
- package/docs/investigations/hlg-runs/run0-original.log +159 -0
- package/docs/investigations/hlg-runs/runA-recommended.log +131 -0
- package/docs/investigations/hlg-runs/runAA-cliff-cpm5.log +26 -0
- package/docs/investigations/hlg-runs/runAB-grid2-e4b.log +18 -0
- package/docs/investigations/hlg-runs/runAC-peak-e4b-partial.log +25 -0
- package/docs/investigations/hlg-runs/runAD-ab-samples.log +8 -0
- package/docs/investigations/hlg-runs/runAE-edge-e4b.log +51 -0
- package/docs/investigations/hlg-runs/runAF-logit-calibration.log +10 -0
- package/docs/investigations/hlg-runs/runAG-eval-config2.log +16 -0
- package/docs/investigations/hlg-runs/runB-gain-variants.log +192 -0
- package/docs/investigations/hlg-runs/runC-pivots.log +162 -0
- package/docs/investigations/hlg-runs/runD-oetf.log +132 -0
- package/docs/investigations/hlg-runs/runE-eotf.log +162 -0
- package/docs/investigations/hlg-runs/runF-shaper-working.log +86 -0
- package/docs/investigations/hlg-runs/runG-shaper-sweep.log +101 -0
- package/docs/investigations/hlg-runs/runH-wsweep.log +176 -0
- package/docs/investigations/hlg-runs/runI-asweep.log +176 -0
- package/docs/investigations/hlg-runs/runJ-knobmap.log +49 -0
- package/docs/investigations/hlg-runs/runK-frontier.log +19 -0
- package/docs/investigations/hlg-runs/runL-widemap.log +65 -0
- package/docs/investigations/hlg-runs/runM-frontier-emb.log +15 -0
- package/docs/investigations/hlg-runs/runN-verify.log +116 -0
- package/docs/investigations/hlg-runs/runO-frontier-seed5000.log +15 -0
- package/docs/investigations/hlg-runs/runP-grid-AxSm.log +15 -0
- package/docs/investigations/hlg-runs/runQ-grid-tgW.log +15 -0
- package/docs/investigations/hlg-runs/runR-blind-transcript.log +79 -0
- package/docs/investigations/hlg-runs/runS-cross-coherence.log +33 -0
- package/docs/investigations/hlg-runs/runT-cross-cpm5.log +33 -0
- package/docs/investigations/hlg-runs/runU-cross-12b.log +33 -0
- package/docs/investigations/hlg-runs/runV-lw-e4b.log +22 -0
- package/docs/investigations/hlg-runs/runW-lw-12b.log +22 -0
- package/docs/investigations/hlg-runs/runX-lw-cpm5.log +22 -0
- package/docs/investigations/hlg-runs/runY-cliff-e4b.log +26 -0
- package/docs/investigations/hlg-runs/runZ-cliff-12b.log +26 -0
- package/docs/investigations/hlg-sampling-investigation.md +970 -0
- package/docs/investigations/lab-build-journal.md +157 -0
- package/docs/investigations/pi-builtin-investigation.md +308 -0
- package/docs/investigations/starter-model-port-handoff.md +323 -0
- package/docs/planning/IDEAS.md +26 -0
- package/docs/planning/PRODUCT_ROADMAP.md +426 -0
- package/docs/planning/ResearchTopics.md +119 -0
- package/docs/planning/chunk-task-roadmap.md +77 -0
- package/docs/planning/journal.md +155 -0
- package/docs/reference/distribution.md +121 -0
- package/docs/reference/embedding.md +168 -0
- package/docs/reference/library-api.md +153 -0
- package/docs/reference/server-api.md +224 -0
- package/docs/reference/server-config.md +243 -0
- package/docs/segmented-backward-handoff.md +148 -0
- package/package.json +68 -0
- package/src/anthropic.ts +491 -0
- package/src/chat-template.ts +186 -0
- package/src/cli.ts +1075 -0
- package/src/config.ts +229 -0
- package/src/curve-sampler.ts +122 -0
- package/src/dataset/generators.ts +1087 -0
- package/src/dataset/index.ts +12 -0
- package/src/dataset/job.ts +81 -0
- package/src/dataset/llm.ts +71 -0
- package/src/dataset/registry.ts +528 -0
- package/src/download.ts +283 -0
- package/src/eval/README.md +86 -0
- package/src/eval/kl-prompts.ts +29 -0
- package/src/eval/kl.ts +294 -0
- package/src/eval/quality-db.ts +92 -0
- package/src/eval/runner.ts +102 -0
- package/src/eval/score.ts +49 -0
- package/src/eval/tasks/bfcl.ts +500 -0
- package/src/eval/tasks/gsm8k.ts +85 -0
- package/src/eval/tasks/hashhop.ts +109 -0
- package/src/eval/tasks/humaneval.ts +252 -0
- package/src/eval/tasks/ifeval.ts +354 -0
- package/src/eval/tasks/mmlu.ts +145 -0
- package/src/evaldb.ts +199 -0
- package/src/expert-offload-build.ts +77 -0
- package/src/expert-offload.ts +61 -0
- package/src/expert-trace.ts +67 -0
- package/src/fit.ts +254 -0
- package/src/generate.ts +451 -0
- package/src/harness-pi.ts +177 -0
- package/src/hf-push.ts +628 -0
- package/src/index.ts +22 -0
- package/src/jobs/db.ts +139 -0
- package/src/jobs/index.ts +29 -0
- package/src/jobs/job-entry.ts +106 -0
- package/src/jobs/runner.ts +304 -0
- package/src/jobs/sse.ts +114 -0
- package/src/jobs/types.ts +58 -0
- package/src/kv-store.ts +185 -0
- package/src/lora.ts +314 -0
- package/src/md-modules.d.ts +7 -0
- package/src/mlx/array.ts +237 -0
- package/src/mlx/autograd.ts +267 -0
- package/src/mlx/checkpoint.ts +116 -0
- package/src/mlx/compile.ts +158 -0
- package/src/mlx/custom-vjp.ts +140 -0
- package/src/mlx/ffi.ts +433 -0
- package/src/mlx/metal-kernel.ts +130 -0
- package/src/mlx/ops.ts +707 -0
- package/src/mmap.ts +90 -0
- package/src/model/batched-mask.ts +204 -0
- package/src/model/batched-rotating.ts +300 -0
- package/src/model/compiled-decode.ts +681 -0
- package/src/model/factory.ts +27 -0
- package/src/model/fingerprint.ts +33 -0
- package/src/model/flash-attention.ts +674 -0
- package/src/model/fused-decode-kernel.ts +305 -0
- package/src/model/fused-geglu-kernel.ts +87 -0
- package/src/model/gemma4-base.ts +1616 -0
- package/src/model/gemma4.ts +1012 -0
- package/src/model/generated/gemma4-12b.ts +526 -0
- package/src/model/generated/gemma4-26b.ts +500 -0
- package/src/model/generated/gemma4-e4b.ts +757 -0
- package/src/model/generated/index.ts +18 -0
- package/src/model/minicpm5.ts +278 -0
- package/src/model/qwen3-delta.ts +224 -0
- package/src/model/qwen3_5.ts +429 -0
- package/src/model/support.ts +46 -0
- package/src/native-pack.ts +152 -0
- package/src/pi-provider.ts +95 -0
- package/src/pi-terminal.ts +264 -0
- package/src/pi-web.ts +924 -0
- package/src/preflight.ts +123 -0
- package/src/prompt-cache.ts +113 -0
- package/src/quantize/allocator.ts +627 -0
- package/src/quantize/calibration.ts +180 -0
- package/src/quantize/config-writer.ts +132 -0
- package/src/quantize/index.ts +57 -0
- package/src/quantize/job.ts +136 -0
- package/src/quantize/quantizer.ts +462 -0
- package/src/quantize/safetensors-writer.ts +171 -0
- package/src/quantize/sensitivity.ts +234 -0
- package/src/registry.ts +336 -0
- package/src/responses.ts +774 -0
- package/src/safetensors.ts +158 -0
- package/src/sampler.ts +517 -0
- package/src/serve/batch-scheduler.ts +362 -0
- package/src/serve/generation-gateway.ts +160 -0
- package/src/server.ts +1891 -0
- package/src/spec/drafter.ts +261 -0
- package/src/spec/generate.ts +221 -0
- package/src/status-page.html +497 -0
- package/src/tokenizer.ts +38 -0
- package/src/tool-call.ts +242 -0
- package/src/train/dataset.ts +461 -0
- package/src/train/export.ts +32 -0
- package/src/train/forward.ts +229 -0
- package/src/train/index.ts +43 -0
- package/src/train/job.ts +148 -0
- package/src/train/lora-params.ts +194 -0
- package/src/train/loss.ts +342 -0
- package/src/train/merge.ts +195 -0
- package/src/train/optimizer.ts +176 -0
- package/src/train/rank.ts +101 -0
- package/src/train/segmented.ts +520 -0
- package/src/train/trainer.ts +600 -0
- package/src/tui.ts +161 -0
- package/src/vision/embedder.ts +168 -0
- package/src/vision/preprocess.ts +247 -0
- package/src/vision/prompt.ts +158 -0
- package/src/vision/siglip.ts +599 -0
- package/src/web/app.html +2471 -0
- package/src/web/skills/web-research/SKILL.md +44 -0
- package/src/web/skills.ts +59 -0
- package/src/web-tools.ts +426 -0
- package/src/weights.ts +91 -0
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
# Parallel slots — configurable batched serving
|
|
2
|
+
|
|
3
|
+
Status: **design + phasing** (S0 landed; S1+ not started)
|
|
4
|
+
Owner: serving layer
|
|
5
|
+
Default: **off** (`--slots 1` = today's serialized path, untouched)
|
|
6
|
+
|
|
7
|
+
## Goal
|
|
8
|
+
|
|
9
|
+
Let the server process more than one generation request concurrently,
|
|
10
|
+
gated entirely behind a `--slots N` flag. `N=1` runs exactly what we
|
|
11
|
+
have today (the serialized promise chain). `N>1` loads a batch
|
|
12
|
+
scheduler that runs multiple sequences through one forward pass.
|
|
13
|
+
|
|
14
|
+
This is a server-path feature — serving speed is the user metric.
|
|
15
|
+
Direct/embedding mode stays batch=1.
|
|
16
|
+
|
|
17
|
+
## STATUS (2026-06-14) — LIVE for full-attention models
|
|
18
|
+
|
|
19
|
+
**`--batch N` now serves B>1** for full-attention models (CPM). The numerics
|
|
20
|
+
were proven first (the batched **forward primitive** `BatchedDecodeMaskCache` +
|
|
21
|
+
per-layer fixes = bit-parity with mlx-lm B=N across all 4 models; static-B AND
|
|
22
|
+
dynamic-B `mergeKVRows`/`filterKVRows` vs mlx-lm `BatchKVCache.merge`/`.extract`/
|
|
23
|
+
`.filter`, CPM L1), then the **scheduler** was built and wired in:
|
|
24
|
+
- `BatchScheduler` (`src/serve/batch-scheduler.ts`) — the detached async driver:
|
|
25
|
+
admission queue → running batch → continuous inject (`mergeKVRows`) / evict
|
|
26
|
+
(`filterKVRows`) → per-row sample + accounting. Gated teacher-forced (KL).
|
|
27
|
+
- `GenerationGateway` (`src/serve/generation-gateway.ts`) — lane picker +
|
|
28
|
+
`AsyncMutex` (serial↔batched GPU/`loraState` exclusivity) + the `_is_batchable`
|
|
29
|
+
predicate. Wired into both `handleChat` call sites → per-row SSE fan-out.
|
|
30
|
+
- End-to-end: `tests/batch-serving.test.ts` (live CPM `--batch 2` server).
|
|
31
|
+
|
|
32
|
+
**Sliding-window (Gemma) dynamic-B is now served too** (2026-06-14):
|
|
33
|
+
`BatchedRotatingCache` (`src/model/batched-rotating.ts`, port of mlx-lm
|
|
34
|
+
`BatchRotatingKVCache` incl. the ring-wrap rolled mask) is bit-exact vs mlx-lm
|
|
35
|
+
model-free (`tests/batched-rotating.test.ts`); the scheduler assembles each
|
|
36
|
+
layer's cache by type and Gemma 12B scheduled greedy == the mlx-lm B=2 golden
|
|
37
|
+
with eviction. So `--batch N` batches BOTH CPM and Gemma.
|
|
38
|
+
|
|
39
|
+
**This meets the mlx-lm-parity bar.** mlx-lm's batched path is bf16 (its
|
|
40
|
+
quantized batching is NYI — `BatchRotatingKVCache.to_quantized` raises), so bf16
|
|
41
|
+
continuous batching IS the drop-in. `--batch N` is therefore a bf16 MODE: KV
|
|
42
|
+
quant unset → bf16 (so the batch path engages out of the box, Option B); serial
|
|
43
|
+
default stays mixed-precision (optiq parity); an explicit `--kv-quant` under
|
|
44
|
+
`--batch N` routes those requests to serial (warned).
|
|
45
|
+
|
|
46
|
+
**Batched + mixed-precision KV quant is NOT a parity gap** — no ancestor does it
|
|
47
|
+
(mlx-lm NYI, optiq no batching), so there's no bit-exact oracle. It's an OPTIONAL
|
|
48
|
+
novel extension: a memory-density win (batching × 4-bit KV compound), gated by
|
|
49
|
+
KL + 6-task quality (per the tree framing), deferred. Other follow-ups (all
|
|
50
|
+
optional polish): the `extend` join op (today joins re-merge), KV-budget
|
|
51
|
+
admission, prompt-cache reuse under batching, and **paged KV** (zero-padding-waste
|
|
52
|
+
allocation).
|
|
53
|
+
|
|
54
|
+
## Why it helps (and when it doesn't)
|
|
55
|
+
|
|
56
|
+
The win is **throughput via memory bandwidth**. Decode on Apple Silicon
|
|
57
|
+
is bandwidth-bound (~273 GB/s on the M4 Pro): every token streams the
|
|
58
|
+
entire weight set through the GPU to produce one token. Batch B
|
|
59
|
+
sequences and you stream the weights **once** to produce **B** tokens.
|
|
60
|
+
Aggregate tok/s scales ~linearly until the compute roofline.
|
|
61
|
+
|
|
62
|
+
The crucial design point — **continuous batching, not static
|
|
63
|
+
batching**:
|
|
64
|
+
|
|
65
|
+
- *Static batching*: form a batch, run to completion, then accept the
|
|
66
|
+
next. A request arriving mid-flight waits for the whole batch to
|
|
67
|
+
finish. This is the wrong design; do not build it.
|
|
68
|
+
- *Continuous batching* (iteration-level scheduling): the scheduler
|
|
69
|
+
works per **decode step**. After each token it can inject a
|
|
70
|
+
newly-arrived request (after a short prefill) and retire finished
|
|
71
|
+
ones. A late request does **not** wait for the in-flight one to
|
|
72
|
+
finish — it joins at the next step.
|
|
73
|
+
|
|
74
|
+
So the benefit window is "any time two requests are alive in the same
|
|
75
|
+
wall-clock window" — not "a backlog has formed." Because generations
|
|
76
|
+
last seconds, requests overlap even at modest rates (Little's law:
|
|
77
|
+
concurrent ≈ arrival_rate × mean_generation_time).
|
|
78
|
+
|
|
79
|
+
When it does **not** help: traffic so light that requests never overlap
|
|
80
|
+
in time. Then there's only ever one live sequence and batching is idle
|
|
81
|
+
— which is exactly why `--slots 1` is and stays the default.
|
|
82
|
+
|
|
83
|
+
## Current code: what's batch=1 and what isn't
|
|
84
|
+
|
|
85
|
+
Good news: the hot path is already written over a batch dim `B`.
|
|
86
|
+
|
|
87
|
+
- `Attention.forward` reads `const [B, L] = x.shape` and threads `B`
|
|
88
|
+
through every reshape/transpose/SDPA — `src/model/gemma4.ts:152`.
|
|
89
|
+
- `KVCache` stores `[B, H, S, D]` and grows/slices generically —
|
|
90
|
+
`src/model/gemma4-base.ts:204` (STEP=256 growth).
|
|
91
|
+
- The byte-capped LRU prompt cache already accounts bytes, not count —
|
|
92
|
+
`src/prompt-cache.ts` (the precedent for a byte-budgeted KV pool).
|
|
93
|
+
|
|
94
|
+
`B=1` is baked into a finite, findable set of places:
|
|
95
|
+
|
|
96
|
+
| Location | Hardcoded |
|
|
97
|
+
| --- | --- |
|
|
98
|
+
| `src/generate.ts:295,315,378` | prompt/decode tensors built `[1, L]` / `[1, 1]` |
|
|
99
|
+
| `src/generate.ts:320,235` | `hLast` slice `[1, …]`; `sampleStep` reshape `[1, V]` |
|
|
100
|
+
| `src/generate.ts:247` | sampler history `[1]`, one sampled token/step |
|
|
101
|
+
| `src/model/gemma4.ts:647-653` | per-layer-input reshapes `[1, L, …]` |
|
|
102
|
+
| `src/model/gemma4-base.ts:210` | **one scalar `offset` per cache, shared across rows** |
|
|
103
|
+
| `src/model/gemma4.ts:174,206` | **scalar RoPE offset** for the whole batch |
|
|
104
|
+
| `src/model/gemma4-base.ts:256` | `makeMask` from that single offset (returns empty mask at N=1) |
|
|
105
|
+
|
|
106
|
+
## The one genuinely hard problem: per-sequence position
|
|
107
|
+
|
|
108
|
+
Batched sequences are different lengths and at different decode
|
|
109
|
+
positions, but the cache has a single `offset`, RoPE takes a scalar
|
|
110
|
+
offset, and the mask is built from one offset.
|
|
111
|
+
|
|
112
|
+
Solution (mlx-lm's, and ours): **left-padding + per-row mask + per-row
|
|
113
|
+
RoPE offsets.**
|
|
114
|
+
|
|
115
|
+
- Right-align every sequence so "the current token" is the same column
|
|
116
|
+
for all rows → one global advancing offset still works.
|
|
117
|
+
- Pad on the left; mask the padding with a real `[B, 1, 1, S]` boolean
|
|
118
|
+
mask — **even at the N=1 decode step**, which today returns an empty
|
|
119
|
+
mask (`gemma4-base.ts:257`). That shortcut is the first thing to change.
|
|
120
|
+
- RoPE positions become a per-row vector. **We already have the
|
|
121
|
+
primitive**: `ops.ropeDynamic` + the `cache.ropeOffsetArr` array-offset
|
|
122
|
+
path (`gemma4.ts:179,206`), currently used only by compiled decode.
|
|
123
|
+
|
|
124
|
+
Highest-risk sub-item: Gemma3 alternates sliding-window
|
|
125
|
+
(`RotatingKVCache`) and full-attention layers. Per-row offsets through
|
|
126
|
+
the ring cache's wrap semantics is the trickiest mask math in the
|
|
127
|
+
project. Validate the full-attention mask path first; bring sliding
|
|
128
|
+
layers online separately under teacher-forced parity.
|
|
129
|
+
|
|
130
|
+
### Reuse: batched PREFILL already exists (big de-risk, found 2026-06-14)
|
|
131
|
+
|
|
132
|
+
The training path already solves batched padded forward and proves it:
|
|
133
|
+
- `src/train/forward.ts` — `buildBatchedPadMask(B, L, validLengths, window)`
|
|
134
|
+
builds the `[B,1,L,L]` mask (causal AND per-row key-validity, per distinct
|
|
135
|
+
window) and `BatchedMaskCache` routes it through the stock attention by
|
|
136
|
+
wrapping a real cache and overriding only `makeMask`. Comment confirms
|
|
137
|
+
"KVCache/RotatingKVCache are already shape-generic over B."
|
|
138
|
+
- `tests/train-batch-e2e.test.ts` proves a B=2 padded forward's per-row loss
|
|
139
|
+
equals two independent B=1 forwards within bf16 tolerance — i.e. the mask
|
|
140
|
+
makes a real row's logits independent of the other (padded) row. That is
|
|
141
|
+
exactly the teacher-forced gate S1 needs, already passing.
|
|
142
|
+
- `tests/train-batch.test.ts` unit-tests the mask (fast, no model).
|
|
143
|
+
|
|
144
|
+
So batched **prefill** is ~done and reusable (offset-0, right-padded). The
|
|
145
|
+
genuinely new work is batched **decode**: a growing per-row KV with per-row
|
|
146
|
+
offsets, per-row RoPE (array-offset path), and a per-row N=1 key-validity
|
|
147
|
+
mask. First decode brick landed 2026-06-14: `src/model/batched-mask.ts`
|
|
148
|
+
`buildBatchedDecodeMask` (left-padded, nonzero-offset counterpart) +
|
|
149
|
+
`tests/batched-decode-mask.test.ts` (fast). The two mask builders consolidate
|
|
150
|
+
when prefill is wired into serving (S1a).
|
|
151
|
+
|
|
152
|
+
### S1b decode: design traps & sequencing (found 2026-06-14)
|
|
153
|
+
|
|
154
|
+
Reading the attention path surfaced two real subtleties — the kind that
|
|
155
|
+
produce plausible-but-wrong logits, so flagged before writing the cache:
|
|
156
|
+
|
|
157
|
+
1. **Prefill/decode padding conventions differ.** Training's batched prefill
|
|
158
|
+
RIGHT-pads (real tokens left-aligned, positions 0..len-1 → scalar RoPE
|
|
159
|
+
offset 0 is correct). Batched decode wants LEFT-padding (rows right-aligned
|
|
160
|
+
so every row's next write lands in the same column → one advancing offset).
|
|
161
|
+
Left-padding then forces **per-row RoPE** (real position = column −
|
|
162
|
+
leftPad[b]) via the array-offset path, and means the prefill KV must be
|
|
163
|
+
re-laid-out (right-aligned) before decode. First cut: prefill each request
|
|
164
|
+
solo, copy its KV into the right-aligned row of a shared `[B,H,Smax,D]`
|
|
165
|
+
decode buffer, then batch the decode.
|
|
166
|
+
2. **RoPE offset-timing trap (FIXED 2026-06-14).** `Attention.forward` read
|
|
167
|
+
`cache.ropeOffsetArr` twice — for K *before* `updateAndFetch` and for Q
|
|
168
|
+
*after* it (gemma4.ts ~179 vs ~195/201). Since `updateAndFetch` advances
|
|
169
|
+
`offset`, a per-row position array derived from the live offset would give
|
|
170
|
+
K and Q different positions. Fixed by capturing `offsetArr` once alongside
|
|
171
|
+
`offset` (line ~174). Behavior-preserving (real caches leave it unset;
|
|
172
|
+
compiled-decode passes a constant) — verified bit-exact against
|
|
173
|
+
`tests/compiled-decode.test.ts` (12B, 7/7).
|
|
174
|
+
|
|
175
|
+
**Sequencing (parity gate first, on the riskiest piece):**
|
|
176
|
+
- **S1b.1** — gated teacher-forced *decode* parity harness: manually assemble a
|
|
177
|
+
left-padded 2-row KV from two solo prefills, run K batched decode steps with
|
|
178
|
+
a `BatchedDecodeMaskCache` (delegates KV, returns `buildBatchedDecodeMask`,
|
|
179
|
+
exposes per-row `ropeOffsetArr` pinned to the pre-write offset), and assert
|
|
180
|
+
each row's logits match its solo greedy decode within bf16 tolerance. Gated
|
|
181
|
+
behind an opt-in env + local weights (like `train-batch-e2e`). Isolates
|
|
182
|
+
decode-batching numerics from the prefill-assembly plumbing.
|
|
183
|
+
**DONE 2026-06-14** — `BatchedDecodeMaskCache` (src/model/batched-mask.ts) +
|
|
184
|
+
`tests/batched-decode-parity.test.ts` (`MLX_BUN_TEST_BATCH_DECODE=1`,
|
|
185
|
+
MiniCPM5-1B). Passes: the unpadded row is **bit-exact** vs its solo decode
|
|
186
|
+
(batching isolates rows); the left-padded row matches within bounded bf16
|
|
187
|
+
reduction-order noise (≤0.23, stable over 8 steps — the shared buffer shifts
|
|
188
|
+
its keys by leftPad columns, so the attention sum accumulates in a different
|
|
189
|
+
order). **The gate earned its keep**: it caught `LlamaAttention`
|
|
190
|
+
(src/model/minicpm5.ts) roping Q/K with the scalar `cache.offset` and ignoring
|
|
191
|
+
`ropeOffsetArr` → left-padded rows mis-positioned (logit diff 8.7). Fixed to
|
|
192
|
+
use the dynamic array-offset path when present; MiniCPM5 solo parity stays
|
|
193
|
+
bit-exact vs the oracle. (Gemma4's `Attention` already had the array path; its
|
|
194
|
+
capture-once fix is verified bit-exact vs compiled-decode.) Lesson: each model
|
|
195
|
+
family's attention needs the per-row offset path wired separately.
|
|
196
|
+
- **S1b.2** — production KV assembly (solo prefill → left-aligned decode buffer).
|
|
197
|
+
- **S1b.3** — scheduler + B-token/step generate loop + per-row stream fan-out.
|
|
198
|
+
|
|
199
|
+
### The validation matrix — 3 parity layers × every model path
|
|
200
|
+
|
|
201
|
+
**Current state (2026-06-14).** Roster: **CPM (MiniCPM5-1B) + 3 Gemmas
|
|
202
|
+
(e4b / 12B / 26B)**. Every NEW model family (e.g. **Qwen**, planned) adds its
|
|
203
|
+
OWN handler path and its own copy of the three layers below — nothing here is
|
|
204
|
+
generic. This matrix is a snapshot; re-derive it when the roster changes.
|
|
205
|
+
|
|
206
|
+
**Framing (refined 2026-06-14 — it's a TREE, not strict layers).** mlx-lm is the
|
|
207
|
+
base library; **mlx-optiq and mlx-bun are both optimizations on top of it**
|
|
208
|
+
(optiq = mixed-precision per-layer KV quant, "drop-in for mlx_lm.server", no
|
|
209
|
+
batching; mlx-bun = per-model fused kernels + batched serving). Bit-parity with
|
|
210
|
+
an ancestor is just an **easy correctness test where we overlap one** — not a
|
|
211
|
+
porting goal. The "L1/L2/L3" labels below are **parity anchors / correctness
|
|
212
|
+
checkpoints**, not a strict stack: L1 = "matches mlx-lm (bf16)", L2 = "matches
|
|
213
|
+
optiq (mixed-precision quant)", L3 = "our own optimizations, no ancestor does
|
|
214
|
+
them → no oracle". A combo no ancestor does (e.g. **batched mixed-precision
|
|
215
|
+
serving** — mlx-lm can't do mixed-precision, optiq can't batch) is genuinely new
|
|
216
|
+
territory: gate it by KL + the 6-task quality + benchmarks, never bit-exactness.
|
|
217
|
+
The anchors are still useful — we can always dial back to a bit-exact-vs-ancestor
|
|
218
|
+
config as a correctness checkpoint and build outward.
|
|
219
|
+
|
|
220
|
+
- **L1 — mlx-lm parity (standard KV).** bf16 KV, **bit-exact vs mlx-lm**. The
|
|
221
|
+
foundation oracle. Path: monolith `Gemma4Model` / `MiniCPM5Model`, `ops.sdpa`,
|
|
222
|
+
perf kernels off.
|
|
223
|
+
- **L2 — mlx-optiq parity (mixed-precision KV quant).** quant/mixed KV,
|
|
224
|
+
**bit-exact vs optiq**. Path: `QuantizedKVCache` + `quantizedSdpa` *unfused*
|
|
225
|
+
(`MLX_BUN_PERF_KERNEL=0`).
|
|
226
|
+
- **L3 — our perf mode.** generated unrolled handlers + fused/perf kernels;
|
|
227
|
+
**deliberately beyond the oracle — there is NO bit-exact reference for L3**, by
|
|
228
|
+
design (these optimizations go past what mlx-lm/optiq do). The gate is **KL vs
|
|
229
|
+
the compatible upstream + the 6 eval-task mean (`src/eval/tasks/`) + perf
|
|
230
|
+
numbers** — never bit-exactness. Path: generated handler, perf-kernel +
|
|
231
|
+
fused-sdpa + fused-gelu on (the defaults). compiled-decode is inert here
|
|
232
|
+
(CustomKernel has no `output_shapes`) — a compat/benchmark artifact, not a
|
|
233
|
+
production mode. **L3's freedom is bounded by graceful degradation**: it may
|
|
234
|
+
diverge only because L2/L1 stay reachable and provably bit-exact.
|
|
235
|
+
|
|
236
|
+
Degradation levers: `--perf-kernel off` / `--fused-* off` drops L3→L2; bf16 KV
|
|
237
|
+
drops L2→L1. Each layer must stand alone and stay reachable (don't-delete-
|
|
238
|
+
optionality as a fallback ladder).
|
|
239
|
+
|
|
240
|
+
So a feature like batched decode (`--slots`) is "done" only when green at
|
|
241
|
+
**every (model × layer)** cell — and each cell may need its own fix:
|
|
242
|
+
|
|
243
|
+
| Model · path | L1 (bf16 / mlx-lm) | L2 (quant / optiq) | L3 (perf / KL) |
|
|
244
|
+
| --- | --- | --- | --- |
|
|
245
|
+
| CPM (MiniCPM5) | ✅ **oracle-verified** (= mlx-lm B=2) | ☐ quant + unfused-mask | ☐ |
|
|
246
|
+
| Gemma 12B | ✅ **oracle-verified** (= mlx-lm B=2, short-ctx) | ☐ generated + sliding-window | ☐ (gen rope fix) |
|
|
247
|
+
| Gemma e4b | ✅ **oracle-verified** (= mlx-lm B=2, short-ctx) | ☐ generated + same | ☐ (gen rope fix) |
|
|
248
|
+
| Gemma 26B | ✅ **oracle-verified** (= mlx-lm B=2, short-ctx) | ☐ generated + MoE | ☐ (gen rope fix) |
|
|
249
|
+
| *(Qwen, future)* | *new path* | *new path* | *new path* |
|
|
250
|
+
|
|
251
|
+
**🎯 L1 batched decode COMPLETE (2026-06-14d): the entire L1 row is green** —
|
|
252
|
+
all 4 model paths bit-parity with mlx-lm B=2 (CPM full-attn, Gemma 12B
|
|
253
|
+
sliding+full, e4b per-layer-input+KV-share, 26B MoE). Only one per-path fix was
|
|
254
|
+
needed (e4b's `[1,L,…]` per-layer-input hardcode); sliding (RotatingKVCache),
|
|
255
|
+
KV-sharing, and MoE were all already B-generic. Caveat: short-context (pre-wrap)
|
|
256
|
+
— ring-wrap (>window) is the one remaining L1 follow-up. Next rows: **L2**
|
|
257
|
+
(quantized KV → `quantizedSdpaUnfused` + 4-D mask, vs optiq) and **L3** (perf
|
|
258
|
+
kernels under batching, KL+quality).
|
|
259
|
+
|
|
260
|
+
Harness: `tests/batched-decode-parity.test.ts` has two paths — (a)
|
|
261
|
+
`runBatchedDecodeParity` = the internal-consistency KL check (B=N vs our B=1,
|
|
262
|
+
NOT the gate); (b) **`realBatchedGreedy` = the REAL gate**: left-pad → one
|
|
263
|
+
`BatchedDecodeMaskCache` (handles prefill at offset 0 AND per-step decode) →
|
|
264
|
+
batch-prefill → greedy batch-decode, trajectory compared bit-for-bit to the
|
|
265
|
+
mlx-lm B=N oracle fixture (`tests/fixtures/batched-golden-*.json`, generated by
|
|
266
|
+
`scripts/gen-batched-golden.py` in the oracle venv).
|
|
267
|
+
|
|
268
|
+
**CPM L1 oracle-verified 2026-06-14d:** mlx-bun's real batched greedy trajectory
|
|
269
|
+
== mlx-lm B=2 exactly (both rows incl. the left-padded one, 8 steps). Confirms
|
|
270
|
+
`BatchedDecodeMaskCache` == mlx-lm `BatchKVCache` (left-pad + per-row offset RoPE
|
|
271
|
+
+ `j>=leftPad` mask + fused bool sdpa), and that the wrapper handles batched
|
|
272
|
+
PREFILL too (offset 0 → left-pad prefill mask + `ropeOffsetArr = -leftPad`). The
|
|
273
|
+
earlier "KL 0.26" was measured vs the wrong oracle (our own B=1).
|
|
274
|
+
|
|
275
|
+
**Gate philosophy (revised twice, 2026-06-14):** batched decode is NOT bit-exact
|
|
276
|
+
vs single-stream (batching changes the attention kernel's reduction order). The
|
|
277
|
+
RIGHT oracle is **mlx-lm's OWN batch mode at the same B** — mlx-lm B=N ≡ mlx-bun
|
|
278
|
+
B=N, **bit-exact**. Comparing our B=N to our own B=1 (the KL harness) measures
|
|
279
|
+
the wrong thing and can't distinguish a real bug from inherent batched-kernel
|
|
280
|
+
behavior. mlx-lm's batch path (oracle venv, confirmed 2026-06-14):
|
|
281
|
+
`BatchKVCache`/`BatchRotatingKVCache(left_padding)` — left-padded, a **per-row
|
|
282
|
+
`offset` array** (`-left_padding`, `+=1`/step → real per-row position, feeding
|
|
283
|
+
RoPE), mask `j >= left_padding[b]` (`create_causal_mask(left_padding=…)`), and
|
|
284
|
+
for bf16 the **same fused bool-mask `mx.fast.scaled_dot_product_attention`** we
|
|
285
|
+
use. So our approach matches in principle and the **additive-mask idea is wrong**
|
|
286
|
+
(would deviate). Oracle generator: `scripts/gen-batched-golden.py` (drives
|
|
287
|
+
mlx-lm's real batch-prefill + greedy batch-decode, dumps per-row trajectories +
|
|
288
|
+
last-position logits). CPM B=2 oracle captured 2026-06-14.
|
|
289
|
+
|
|
290
|
+
**Remaining gap:** the KL harness solo-prefills each row then *assembles* a
|
|
291
|
+
batched cache, and compares to *solo* decode. mlx-lm batch-PREFILLS (B=N) — so
|
|
292
|
+
even prefill KV differs (B=1 vs B=N kernel). To gate faithfully we need a real
|
|
293
|
+
mlx-bun **batched prefill + batched decode** path, compared bit-exact to the
|
|
294
|
+
mlx-lm B=N golden. (The KL B2-vs-B1 harness stays as a cheap internal-consistency
|
|
295
|
+
check, not the gate.)
|
|
296
|
+
|
|
297
|
+
**Gemma 12B L1 — RESOLVED 2026-06-14d: it was the wrong oracle, not a bug.**
|
|
298
|
+
The real `realBatchedGreedy` path (batch-prefill + decode) matches the mlx-lm
|
|
299
|
+
B=2 oracle **exactly** for Gemma 12B (both rows incl. left-padded, sliding
|
|
300
|
+
layers via RotatingKVCache→BatchRotatingKVCache, short-context). So the "padded
|
|
301
|
+
row KL ~0.26" was entirely an artifact of the KL harness comparing our B=2 to
|
|
302
|
+
our own B=1 (different prefill + reduction order) — mlx-lm B=2 diverges from its
|
|
303
|
+
own B=1 the same way. No additive-mask fix was needed; the bool+fused path is
|
|
304
|
+
correct (it's what mlx-lm uses). The KL B2-vs-B1 harness stays only as an
|
|
305
|
+
internal-consistency check, never the gate.
|
|
306
|
+
|
|
307
|
+
**Caveat:** verified at short context (Lmax ≪ 1024 window → pre-wrap, sliding
|
|
308
|
+
window inactive). **Ring-wrap (context > window) is a separate validation** —
|
|
309
|
+
needs a long-context golden (mlx-lm BatchRotatingKVCache wrapped) + mlx-bun
|
|
310
|
+
RotatingKVCache wrapped past the window.
|
|
311
|
+
|
|
312
|
+
Batched-decode landmines found by the modes analysis (2026-06-14):
|
|
313
|
+
- **e4b**: `computePerLayerInputs` hardcodes `[1,L,…]` (gemma4.ts:647) → B>1
|
|
314
|
+
breaks; plus KV-sharing (sharer `SharedKv`).
|
|
315
|
+
- **Gemmas**: `RotatingKVCache` sliding-window ring — per-row decode mask + ring
|
|
316
|
+
write (the hard one). 26B adds MoE (`GatherQMM`/`SwitchGLU`) batched routing.
|
|
317
|
+
- **Generated handlers (L3, all 3 Gemmas)** repeat the K/Q rope timing trap —
|
|
318
|
+
they read `cache.ropeOffsetArr` twice around `updateAndFetch`
|
|
319
|
+
(generated/gemma4-12b.ts:63 & :74). The fix goes in the GENERATOR
|
|
320
|
+
`scripts/gen-model.ts`, then regenerate (the files are DO-NOT-EDIT).
|
|
321
|
+
- **Quant + batched**: the `[B,1,N,S]` array mask disqualifies the fused decode
|
|
322
|
+
kernel (`mask.mode===""` required) and the tiled path (2-D causal-equiv
|
|
323
|
+
required), so quant batched decode falls to `quantizedSdpaUnfused` — correct
|
|
324
|
+
but the perf kernel is bypassed (perf debt; a batched-aware fused decode kernel
|
|
325
|
+
is a later item). `quantizedSdpaUnfused` must be validated with a 4-D bool mask.
|
|
326
|
+
- **All cells**: compiled-decode forced off under `--slots>1`; LoRA batches only
|
|
327
|
+
within one adapter group (else drain).
|
|
328
|
+
|
|
329
|
+
## KV memory: dynamic allocation, not static partition
|
|
330
|
+
|
|
331
|
+
The naive model — reserve `budget / slots` per row, fixed — is rejected:
|
|
332
|
+
it wastes memory and imposes an arbitrary per-slot context cap. We
|
|
333
|
+
allocate **by need**. Three rungs, in increasing order of "who needs it
|
|
334
|
+
most" fidelity and cost:
|
|
335
|
+
|
|
336
|
+
1. **Static partition** — *not built.* Fixed per-slot ceiling. Wasteful.
|
|
337
|
+
2. **Dynamic shared-budget, contiguous caches (padded batch)** — the
|
|
338
|
+
shippable path. Admission is governed by *total projected bytes*
|
|
339
|
+
against one KV budget (mirrors the byte-capped `PromptCache`); each
|
|
340
|
+
sequence grows its own contiguous cache on demand (KVCache already
|
|
341
|
+
grows in 256-token steps). Short prompts use little; long ones take
|
|
342
|
+
more. **Caveat**: a padded batch shares one `[B, H, S, D]` buffer, so
|
|
343
|
+
the buffer width tracks the *longest live sequence* — short rows still
|
|
344
|
+
carry padding to that width. Partial "who needs it most."
|
|
345
|
+
3. **Paged KV (the true goal)** — fixed-size KV blocks in a shared pool,
|
|
346
|
+
gathered by a custom paged-attention Metal kernel. No padding waste;
|
|
347
|
+
the neediest sequence grabs more blocks, others fewer; maximizes
|
|
348
|
+
concurrent rows per GB. This is what "dynamically allocate based on
|
|
349
|
+
who needs it most" actually means. Cost: a custom attention kernel +
|
|
350
|
+
a block manager. Feasible — the project already ships custom Metal
|
|
351
|
+
kernels (`src/mlx/metal-kernel.ts`, `src/model/fused-decode-kernel.ts`).
|
|
352
|
+
|
|
353
|
+
Plan: ship rung 2 (S1–S2) for the throughput win + dynamic admission
|
|
354
|
+
with no new kernel; build rung 3 (S3+) as the memory-density upgrade.
|
|
355
|
+
Budget accounting is total-bytes from day one.
|
|
356
|
+
|
|
357
|
+
**Current state (2026-06-14):** the batched *primitive* is **rung 2** —
|
|
358
|
+
one shared `[B, H, S, D]` buffer per layer (`KVCache`/`RotatingKVCache`),
|
|
359
|
+
growing in 256-token steps, width = longest live sequence, left-pad
|
|
360
|
+
"waste" on short rows. But the **budget/admission half of rung 2 is NOT
|
|
361
|
+
built** (no `B × S_max` projection, no KV budget enforcement) — that's
|
|
362
|
+
scheduler work (S2), and the scheduler itself isn't built (`--slots`
|
|
363
|
+
inert). And **rung 3 (paged) is a deferred spike**. So today: rung-2
|
|
364
|
+
*allocation shape*, no *budget control*, not wired into serving.
|
|
365
|
+
|
|
366
|
+
KV quantization is a force multiplier: 4-bit KV (`generate.ts:67`,
|
|
367
|
+
already supported) is ~4× smaller, so the same budget buys ~4× the slots
|
|
368
|
+
or ~4× context per slot. Batching + KV-quant compound.
|
|
369
|
+
|
|
370
|
+
Note: the existing `2e9` (`server.ts:494`) is the **prompt (prefix-reuse)
|
|
371
|
+
cache**, not live decode KV. Live caches are made fresh per generation
|
|
372
|
+
(`server.ts:517`). Slots introduce a *new* budget: the pool of live
|
|
373
|
+
decode caches.
|
|
374
|
+
|
|
375
|
+
## The scheduler (port of mlx-lm `BatchGenerator` / `ResponseGenerator`)
|
|
376
|
+
|
|
377
|
+
When `slots > 1`, the single promise chain (`server.ts:487`) is replaced
|
|
378
|
+
by a scheduler owning:
|
|
379
|
+
|
|
380
|
+
- a **pending queue** of admitted requests;
|
|
381
|
+
- a **running batch** of ≤ `decodeConcurrency` rows;
|
|
382
|
+
- **admission control** against the KV budget;
|
|
383
|
+
- a **batchable gate** (mlx-lm `_is_batchable`): same model, compatible
|
|
384
|
+
sampler params, **no fixed seed**, no vision, compatible adapter set;
|
|
385
|
+
- **prefill phase** (≤ `promptConcurrency` rows) vs **decode phase**;
|
|
386
|
+
- **draining**: an incompatible request waits for the batch to clear,
|
|
387
|
+
then runs in its own batch.
|
|
388
|
+
|
|
389
|
+
Streaming fan-out is the main plumbing rework: today `generate()` yields
|
|
390
|
+
one token to one SSE stream (`server.ts:964,1037`). Batched, each step
|
|
391
|
+
produces B tokens that must route to B independent response streams,
|
|
392
|
+
each with its own `StopSequencer`/tool-call router. Touches
|
|
393
|
+
`server.ts`, `responses.ts`, `anthropic.ts`.
|
|
394
|
+
|
|
395
|
+
### Concrete plan (the `--batch N` engine, build order — 2026-06-14)
|
|
396
|
+
|
|
397
|
+
Started: `--batch N` flag landed (renamed from `--slots`; `--decode-concurrency`
|
|
398
|
+
accepted as an mlx_lm.server alias). N=1 → today's serial path; N>1 → the engine
|
|
399
|
+
below. The engine is a **mode** (whole server), not a load-fallback (keeps
|
|
400
|
+
determinism + the drop-in promise).
|
|
401
|
+
|
|
402
|
+
**Dynamic-B cache ops** (port of mlx-lm's cache methods — the new piece our
|
|
403
|
+
fixed-B verified forward doesn't have):
|
|
404
|
+
- **`merge(perRowCaches)`** — stack N single-row caches into one `[B,H,S,D]`
|
|
405
|
+
batch, left-pad-aligned (generalizes `realBatchedGreedy`'s assemble).
|
|
406
|
+
- **`filter(keep)`** — slice the B axis to drop finished rows (eviction).
|
|
407
|
+
- **`extend(other)`** — append a freshly-prefilled batch's rows (insertion).
|
|
408
|
+
- mlx-lm's flow: a joining request is **right-padded + prefilled** in a prompt
|
|
409
|
+
batch, then `finalize()` (dynamic-roll) → left-pad, then `merge`/`extend` into
|
|
410
|
+
the running generation batch. Our verified attention/mask/RoPE drop straight in.
|
|
411
|
+
|
|
412
|
+
**Engine loop (Bun-async, NOT Python threads):** an async loop owns the running
|
|
413
|
+
batch. Each iteration: (1) admit waiting+batchable requests up to N (prefill +
|
|
414
|
+
`extend`); (2) one batched decode step (the verified forward); (3) sample
|
|
415
|
+
per-row, push each row's token to its own SSE `ReadableStream` (per-row
|
|
416
|
+
`StopSequencer` + tool parser + sampler); (4) `filter` out rows that hit
|
|
417
|
+
EOS/stop/max-tokens, resolve their streams. HTTP handlers `await` their row's
|
|
418
|
+
stream; no thread, just the event loop + `mx.async_eval` pipelining.
|
|
419
|
+
|
|
420
|
+
**Build sequence:** (1) **DONE 2026-06-14** — dynamic-B cache `merge`/`filter` +
|
|
421
|
+
a pure unit test, then a gated parity test vs an mlx-lm *dynamic* golden (rows
|
|
422
|
+
join/leave mid-stream). `scripts/gen-batched-dynamic-golden.py` drives mlx-lm's
|
|
423
|
+
real `BatchKVCache.merge`/`.extract`/`.filter` through {A,B}→join C→evict A→{B,C};
|
|
424
|
+
`realDynamicBatchedGreedy` (`tests/batched-decode-parity.test.ts`) runs the same
|
|
425
|
+
scenario through `mergeKVRows`/`filterKVRows` and matches all 3 per-row greedy
|
|
426
|
+
trajectories token-for-token (CPM L1). Join = re-merge of extracted advanced-offset
|
|
427
|
+
rows + a fresh solo prefill (proves `mergeKVRows` on non-fresh rows); `extend`
|
|
428
|
+
(keep-the-running-batch optimization, mlx-lm's actual join op) is deferred into
|
|
429
|
+
the scheduler. Added `BatchedDecodeMaskCache.releaseRopeArr()`. (Originally
|
|
430
|
+
full-attention only; sliding-window `BatchedRotatingCache` landed 2026-06-14 —
|
|
431
|
+
see the STATUS block above.)
|
|
432
|
+
(2) **DONE 2026-06-14** — the async loop + per-row SSE wired into `createServer`
|
|
433
|
+
behind `--batch N`. `BatchScheduler` (`src/serve/batch-scheduler.ts`) is the
|
|
434
|
+
detached driver; `GenerationGateway` (`src/serve/generation-gateway.ts`) picks
|
|
435
|
+
the lane and an `AsyncMutex` keeps serial and batched off the GPU/`loraState`
|
|
436
|
+
together. Both `handleChat` call sites route through `gateway.run`; per-row
|
|
437
|
+
onToken closures give per-row SSE fan-out for free. Gated: `tests/batch-scheduler.test.ts`
|
|
438
|
+
(teacher-forced KL, evict + join) + `tests/batch-serving.test.ts` (live CPM
|
|
439
|
+
server, concurrent + streaming + drain). No serial regression (server 17/17,
|
|
440
|
+
tools 13/13).
|
|
441
|
+
(3) **DONE 2026-06-14** — `_is_batchable` gate is the `GenerationGateway.willBatch`
|
|
442
|
+
predicate (full-attention model + no vision/adapter/repetition-penalty/user-seed
|
|
443
|
+
→ batch; else serial). `B×S_max` memory admission is still TODO.
|
|
444
|
+
|
|
445
|
+
## Fallbacks (must degrade, never break)
|
|
446
|
+
|
|
447
|
+
- **Vision** → always solo. Image embeddings + bidirectional mask need
|
|
448
|
+
offset-0 single-sequence prefill (`gemma4.ts:690` throws otherwise).
|
|
449
|
+
- **LoRA** → batch only requests sharing the same adapter set, else
|
|
450
|
+
drain to solo. `loraState.active` is a single per-generation field
|
|
451
|
+
(`generate.ts:187`); cache namespaces are adapter-keyed
|
|
452
|
+
(`prompt-cache.ts:18`). Per-row adapters is a much harder problem;
|
|
453
|
+
group-by-adapter + drain is accepted (confirmed fine for our use).
|
|
454
|
+
- **Fixed seed / unusual sampler** → drain to solo (matches `_is_batchable`).
|
|
455
|
+
- **Compiled decode** (`generate.ts:348`) → start **disabled** under
|
|
456
|
+
batching (single-sequence shapeless replay); extend to `[B,1]` later.
|
|
457
|
+
- **Prompt cache** → per-row take/put still works, but the
|
|
458
|
+
"single queue ⇒ take/reinsert is race-free" invariant
|
|
459
|
+
(`prompt-cache.ts:7`, `lora.ts:7`) now depends on the **scheduler**
|
|
460
|
+
being the sole owner of cache mutations. Keep all take/put inside the
|
|
461
|
+
scheduler loop.
|
|
462
|
+
|
|
463
|
+
## Config surface
|
|
464
|
+
|
|
465
|
+
- `ServerOptions.slots` (default 1); later `kvBudgetBytes`,
|
|
466
|
+
`promptConcurrency`.
|
|
467
|
+
- CLI: `--slots N` (S0); later `--kv-budget <GB>`.
|
|
468
|
+
- `slots === 1` → today's serialized promise chain, untouched.
|
|
469
|
+
`slots > 1` → scheduler.
|
|
470
|
+
|
|
471
|
+
## Phasing (incremental, parity-gated)
|
|
472
|
+
|
|
473
|
+
- **S0 — config seam (DONE).** `--slots N` / `ServerOptions.slots`
|
|
474
|
+
plumbed, validated, surfaced (ready card + `/stats`). `N>1` warns that
|
|
475
|
+
batched execution lands in S1 and runs serially. No behavior change.
|
|
476
|
+
- **S1 — static 2-wide, base model only.** Split by the reuse finding:
|
|
477
|
+
- **S1a (prefill)** — reuse the training machinery (`buildBatchedPadMask`
|
|
478
|
+
/ `BatchedMaskCache`); wire the serving path to prefill B prompts in one
|
|
479
|
+
forward. Mostly generalization + consolidating the two mask builders.
|
|
480
|
+
- **S1b (decode)** — the new work: a batched KV cache that grows with
|
|
481
|
+
per-row offsets, per-row RoPE via the array-offset path, and the per-row
|
|
482
|
+
`[B,1,1,S]` decode mask (`buildBatchedDecodeMask`, landed 2026-06-14).
|
|
483
|
+
Plus the B-token-per-step decode loop + per-row stream fan-out.
|
|
484
|
+
**Teacher-forced gate**: a 2-row batch must produce per-row logits matching
|
|
485
|
+
two solo runs within bf16 tolerance (the train-batch-e2e methodology, now
|
|
486
|
+
applied to decode).
|
|
487
|
+
- **S2 — N-wide + continuous injection/eviction.** Rows retire on EOS;
|
|
488
|
+
queued requests prefill into freed rows. Dynamic byte-budget admission.
|
|
489
|
+
- **S3+ — paged KV** (custom paged-attention kernel + block manager),
|
|
490
|
+
KV-quant under batch, LoRA-group batching.
|
|
491
|
+
|
|
492
|
+
Every phase ships default-off behind `slots=1`; the serialized path is
|
|
493
|
+
never removed (only bypassed).
|
|
494
|
+
|
|
495
|
+
## Open questions / to measure
|
|
496
|
+
|
|
497
|
+
- Sliding-window per-row masking (above) — retire this correctness risk
|
|
498
|
+
early.
|
|
499
|
+
- `async_eval` decode pipelining (`generate.ts:386`) builds step n+1
|
|
500
|
+
before reading n; one row stopping mid-pipeline while others continue
|
|
501
|
+
needs care.
|
|
502
|
+
- Throughput ceiling: the B where we cross bandwidth→compute bound, and
|
|
503
|
+
aggregate tok/s vs solo — measured via `benchmark.sh` (preflight-gated;
|
|
504
|
+
loaded-machine numbers don't count).
|
|
505
|
+
|
|
506
|
+
## References
|
|
507
|
+
|
|
508
|
+
- mlx-lm continuous batching: `BatchGenerator` / `ResponseGenerator`,
|
|
509
|
+
flags `--decode-concurrency` (default 32), `--prompt-concurrency`
|
|
510
|
+
(default 8); `_is_batchable` gate; batch draining.
|
|
511
|
+
https://deepwiki.com/ml-explore/mlx-lm/3.3-http-server ·
|
|
512
|
+
https://github.com/ml-explore/mlx-lm/issues/499
|
|
513
|
+
- optiq: **no** batching — single-process serialized, advises external
|
|
514
|
+
max-concurrency; each in-flight request keeps a resident KV cache.
|
|
515
|
+
https://mlx-optiq.com/docs/serve
|