mlx-bun 0.0.9 → 0.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -21
- package/THIRD_PARTY_LICENSES.md +1 -0
- package/docs/archive/mlx-bun-lab-report.html +275 -0
- package/docs/archive/wwdc-mlx-bun.html +848 -0
- package/docs/design/audio-input-plan.md +356 -0
- package/docs/design/batching-perf-path.md +255 -0
- package/docs/design/batching-v2-plan.md +23 -9
- package/docs/design/bucketing-stage.md +2 -1
- package/docs/design/compat-cli-surface-design.md +4 -3
- package/docs/design/decode-speed-program.md +172 -0
- package/docs/design/docs-reorg-plan.md +1 -1
- package/docs/design/dspark-serving-program.md +371 -0
- package/docs/design/dspark-speculative-decoding.md +167 -5
- package/docs/design/faithful-l1-consolidation.md +310 -0
- package/docs/design/generic-model-support.md +8 -5
- package/docs/design/grammar-spec-batching-integration.md +301 -0
- package/docs/design/minicpm5-decode-megakernel.md +16 -11
- package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
- package/docs/design/omlx-adoption-map.md +98 -0
- package/docs/design/orpo-training.md +34 -30
- package/docs/design/paged-kv-cache.md +214 -0
- package/docs/design/parallel-slots.md +82 -49
- package/docs/design/parity-tier-dag.md +43 -33
- package/docs/design/repo-cleanup-plan.md +189 -0
- package/docs/design/runtime-isolation.md +19 -3
- package/docs/design/segmented-backward-training.md +18 -2
- package/docs/design/ssd-kv-cold-tier.md +288 -0
- package/docs/design/structured-output.md +484 -0
- package/docs/design/the-dreaming-master-plan.md +2 -3
- package/docs/design/turboquant-kv.md +235 -0
- package/docs/design/unified-engine-frontier-plan.md +774 -0
- package/docs/design/web-chat-beat-matrix.md +315 -0
- package/docs/design/web-chat-redesign.md +1112 -0
- package/docs/investigations/decode-roofline-lookagain.md +92 -6
- package/docs/investigations/dspark-handoff.md +182 -16
- package/docs/investigations/kernel-perf-review-2026-07.md +105 -26
- package/docs/investigations/lab-build-journal.md +2 -2
- package/docs/investigations/openwiki-evaluation.md +249 -0
- package/docs/planning/IDEAS.md +14 -0
- package/docs/planning/PRODUCT_ROADMAP.md +2 -3
- package/docs/planning/chat-ui-vision-memory-first.md +199 -0
- package/docs/planning/chat-ui-vision.md +50 -0
- package/docs/planning/memory-docs-and-dag-plan.md +15 -6
- package/docs/planning/release-notes-v0.0.10.md +57 -0
- package/docs/planning/release-notes-v0.0.11.md +239 -0
- package/docs/reference/cli.md +35 -16
- package/docs/reference/distribution.md +13 -6
- package/docs/reference/embedding.md +35 -14
- package/docs/reference/features-matrix.md +97 -0
- package/docs/reference/library-api.md +30 -9
- package/docs/reference/memory.md +26 -6
- package/docs/reference/models.md +11 -1
- package/docs/reference/orpo-quickstart.md +3 -4
- package/docs/reference/server-api.md +634 -22
- package/docs/reference/server-config.md +251 -119
- package/docs/reference/training.md +20 -16
- package/package.json +5 -3
- package/src/anthropic.ts +13 -0
- package/src/audio/conformer.ts +647 -0
- package/src/audio/decode.ts +133 -0
- package/src/audio/features.ts +202 -0
- package/src/audio/transcode.ts +67 -0
- package/src/cli.ts +402 -107
- package/src/config.ts +49 -2
- package/src/download.ts +40 -0
- package/src/eval/README.md +7 -6
- package/src/eval/kl.ts +59 -3
- package/src/eval/quality-db.ts +2 -2
- package/src/eval/runner.ts +3 -3
- package/src/expert-offload.ts +15 -2
- package/src/flags.ts +13 -0
- package/src/generate.ts +330 -16
- package/src/grammar.ts +585 -0
- package/src/hub-rest.ts +242 -0
- package/src/kv-store.ts +618 -122
- package/src/lora.ts +8 -0
- package/src/md-modules.d.ts +42 -0
- package/src/media-fetch.ts +284 -0
- package/src/memory/rest.ts +245 -0
- package/src/memory/vault.ts +74 -0
- package/src/mlx/array.ts +127 -19
- package/src/mlx/autograd.ts +2 -2
- package/src/mlx/ffi.ts +27 -3
- package/src/mlx/metal-kernel.ts +7 -4
- package/src/mlx/ops.ts +179 -23
- package/src/mlx/turboquant-ops.ts +831 -0
- package/src/mlx/turboquant-tables.ts +237 -0
- package/src/model/batched-mask.ts +62 -1
- package/src/model/batched-quant.ts +206 -0
- package/src/model/batched-rotating-quant.ts +346 -0
- package/src/model/batched-rotating.ts +46 -1
- package/src/model/compiled-decode.ts +4 -15
- package/src/model/factory.ts +10 -4
- package/src/model/gemma4-base.ts +391 -34
- package/src/model/gemma4.ts +122 -33
- package/src/model/generated/gemma4-12b.ts +9 -13
- package/src/model/generated/gemma4-26b.ts +10 -5
- package/src/model/generated/gemma4-e4b.ts +14 -9
- package/src/model/minicpm5.ts +58 -102
- package/src/model/paged-kv.ts +253 -0
- package/src/model/qwen3-delta.ts +123 -15
- package/src/model/qwen3-moe.ts +404 -0
- package/src/model/qwen3.ts +9 -8
- package/src/model/qwen3_5.ts +99 -25
- package/src/model/support.ts +12 -1
- package/src/model/universal/dense.ts +40 -5
- package/src/model/universal/rope.ts +36 -21
- package/src/pi-web.ts +1299 -82
- package/src/preflight.ts +27 -8
- package/src/prompt-cache.ts +338 -32
- package/src/quantize/quantizer.ts +16 -1
- package/src/registry.ts +61 -3
- package/src/serve/batch-scheduler.ts +1001 -76
- package/src/serve/generation-gateway.ts +292 -30
- package/src/serve/isolate.ts +417 -0
- package/src/serve/lane-registry.ts +44 -0
- package/src/serve/session-search.ts +326 -0
- package/src/server.ts +1460 -105
- package/src/spec/assistant-source.ts +150 -0
- package/src/spec/deepspec-source.ts +143 -0
- package/src/spec/dflash-source.ts +124 -0
- package/src/spec/dspark/ab-stats.ts +136 -0
- package/src/spec/dspark/calibration.ts +112 -0
- package/src/spec/dspark/deepspec-module.ts +801 -0
- package/src/spec/dspark/generate-dflash.ts +72 -20
- package/src/spec/dspark/loader.ts +23 -0
- package/src/spec/dspark/module-dflash.ts +278 -36
- package/src/spec/dspark/quantize-drafter.ts +91 -0
- package/src/spec/generate.ts +59 -25
- package/src/spec/ngram-source.ts +132 -0
- package/src/spec/serve-loop.ts +536 -0
- package/src/spec/source.ts +93 -0
- package/src/spec/two-model.ts +171 -0
- package/src/ssd-cache.ts +285 -0
- package/src/tokenizer.ts +288 -2
- package/src/tool-approvals.ts +131 -0
- package/src/tool-call.ts +408 -20
- package/src/train/flash-cce.ts +29 -26
- package/src/train/job.ts +0 -2
- package/src/train/loss.ts +53 -11
- package/src/train/prefix-shared.ts +30 -7
- package/src/train/segmented.ts +150 -46
- package/src/train/trainer.ts +22 -15
- package/src/vision/prompt.ts +217 -48
- package/src/web/app.html +1184 -1789
- package/src/web/app.js +5667 -0
- package/src/web/icon.svg +13 -0
- package/src/web/manifest.webmanifest +24 -0
- package/src/web/src/adapters-panel.ts +288 -0
- package/src/web/src/api.ts +57 -0
- package/src/web/src/assistant.ts +485 -0
- package/src/web/src/chat.ts +1050 -0
- package/src/web/src/composer.ts +959 -0
- package/src/web/src/dataset.ts +132 -0
- package/src/web/src/finetune.ts +267 -0
- package/src/web/src/hub.ts +428 -0
- package/src/web/src/main.ts +98 -0
- package/src/web/src/markdown.ts +0 -0
- package/src/web/src/memory-panel.ts +572 -0
- package/src/web/src/model-picker.ts +159 -0
- package/src/web/src/palette.ts +346 -0
- package/src/web/src/protocol.ts +62 -0
- package/src/web/src/quantize.ts +167 -0
- package/src/web/src/rag.ts +274 -0
- package/src/web/src/sessions.ts +469 -0
- package/src/web/src/shell.ts +804 -0
- package/src/web/src/status.ts +252 -0
- package/src/web/src/ui-catalog.ts +92 -0
- package/src/web/sw.js +71 -0
- package/src/web/vendor/README.md +61 -0
- package/src/web/vendor/hljs-LICENSE +29 -0
- package/src/web/vendor/hljs-theme.css +59 -0
- package/src/web/vendor/hljs.js +26 -0
- package/src/model/fused-decode-kernel.ts +0 -312
- package/src/model/fused-geglu-kernel.ts +0 -198
- package/src/model/fused-mlp-kernel.ts +0 -169
- package/src/model/fused-swiglu-kernel.ts +0 -240
- package/src/model/steel-linear-kernel.ts +0 -96
- package/src/status-page.html +0 -497
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# Repo cleanup plan
|
|
2
|
+
|
|
3
|
+
Status: PLANNED (2026-07-02, written post-v0.0.10). The audit behind it:
|
|
4
|
+
`.git` was 179 MB, driven by 497 machine-specific golden `.bin` blobs
|
|
5
|
+
(~290 MB working tree, every regen compounding history); root has ~30
|
|
6
|
+
entries of mixed tracked/untracked provenance; `repro/` was undocumented.
|
|
7
|
+
Already done in v0.0.10: `.bin` goldens untracked + gitignored +
|
|
8
|
+
`goldens/README.md` policy; CLAUDE.md root-layout map. This plan is the
|
|
9
|
+
rest, phased so each step is safe, reversible (except C1, which is the
|
|
10
|
+
point), and independently shippable.
|
|
11
|
+
|
|
12
|
+
Principle: the hygiene discipline IS the product edge — every phase ends
|
|
13
|
+
with a guardrail so the mess can't re-form, not just a one-time sweep.
|
|
14
|
+
|
|
15
|
+
## What can actually be lost? Nothing. (Read this before re-litigating.)
|
|
16
|
+
|
|
17
|
+
Every binary this plan untracks or purges survives in MULTIPLE independent
|
|
18
|
+
places, so "we might lose the goldens/fixtures" is not a reason to stall:
|
|
19
|
+
|
|
20
|
+
1. **Both dev laptops' working trees** — `git rm --cached` and `.gitignore`
|
|
21
|
+
never touch disk; the files sit exactly where they were.
|
|
22
|
+
2. **Git history** — until Phase C runs, every previously-committed byte is
|
|
23
|
+
still in `.git` and recoverable with `git show <rev>:<path>`.
|
|
24
|
+
3. **The Phase-C mirror backup** — `scripts/history-rewrite.sh` refuses to
|
|
25
|
+
rewrite before writing `~/mlx-bun-history-backup-<date>.tar` (a full
|
|
26
|
+
pre-rewrite mirror, kept forever). Even after the purge, every historical
|
|
27
|
+
blob is one `tar -x` away.
|
|
28
|
+
4. **Regen scripts** — goldens regenerate from `scripts/regen-*` + the
|
|
29
|
+
oracle venv; the universal-rope fixtures from their gen script.
|
|
30
|
+
5. **Pinned-hash fetch** — the two LoRA adapter fixtures are sha256-pinned
|
|
31
|
+
in `scripts/fetch-test-fixtures.sh`, sourced from the `test-fixtures-v1`
|
|
32
|
+
GitHub release (one command to create — see the script header) or
|
|
33
|
+
either laptop.
|
|
34
|
+
|
|
35
|
+
And no test can break: every suite that reads a binary fixture gates on
|
|
36
|
+
FILE PRESENCE and skips cleanly when it's absent (goldens via
|
|
37
|
+
tests/goldens.ts, LoRA via `haveAdapters`, universal-rope via its own
|
|
38
|
+
check). Untracked ≠ deleted ≠ unrunnable.
|
|
39
|
+
|
|
40
|
+
## Phase A — root consolidation (30 min, zero risk) — DONE 2026-07-02
|
|
41
|
+
|
|
42
|
+
- `[x]` **A1. `lab/`**: `git mv repro lab/repro && git mv spikes lab/spikes`.
|
|
43
|
+
One home for "code that proves a point but isn't the product": upstream
|
|
44
|
+
bug reproductions and feasibility probes. Update the CLAUDE.md layout map
|
|
45
|
+
+ the two docs that reference `repro/` (grep first: `grep -rn "repro/"
|
|
46
|
+
docs/ *.md`).
|
|
47
|
+
- Also updated every path reference (CLAUDE.md, PLAN.md, PLAN-archive.md,
|
|
48
|
+
docs/investigations/lab-build-journal.md, docs/design/docs-reorg-plan.md,
|
|
49
|
+
and the `repro/`-pointing comments in src/mlx/{ffi,array,autograd,ops}.ts
|
|
50
|
+
+ tests/{ffi-jit,train-autograd}.test.ts + the repro's own ISSUE.md).
|
|
51
|
+
- `[x]` **A2. root strays** (all untracked; no git surgery): delete
|
|
52
|
+
`train.log`; move `benchmarks-e4b-flag-matrix-2026-06-15.json` into
|
|
53
|
+
`reports/`; confirm `.DS_Store` is gitignored globally.
|
|
54
|
+
- `train.log` deleted; benchmark json moved to `reports/`; `.DS_Store`
|
|
55
|
+
already globally gitignored (`.gitignore`).
|
|
56
|
+
- `[x]` **A3. `archive/` → `docs/archive/`**: the two frozen HTML reports
|
|
57
|
+
are docs, not a root concern. `git mv`, update CLAUDE.md map.
|
|
58
|
+
- Exit: root = code (`src tests scripts`), product surfaces (`docs website
|
|
59
|
+
packaging bin extensions`), fixtures (`goldens fixtures lab`), meta
|
|
60
|
+
(README/PLAN/STATUS/CLAUDE + configs), and gitignored working dirs. Every
|
|
61
|
+
tracked root entry appears in the CLAUDE.md layout map.
|
|
62
|
+
|
|
63
|
+
## Phase B — tracked-content policy + guardrail (1 h) — DONE 2026-07-02 (B2 closed same day)
|
|
64
|
+
|
|
65
|
+
- `[x]` **B1. Binary-in-git gate**: `scripts/check-hygiene.ts` — FAILS on
|
|
66
|
+
any tracked file that is (a) >1 MB, or (b) matches `*.bin|*.safetensors|
|
|
67
|
+
*.dylib|*.gguf|*.metallib` — outside an explicit, size-capped, justified
|
|
68
|
+
allowlist. Wired into `scripts/test.sh` (runs before tests) AND CI
|
|
69
|
+
(`.github/workflows/ci.yml` `hygiene` job, gates typecheck). Supports
|
|
70
|
+
`--staged` for pre-commit use. This is the "can't re-form" guarantee.
|
|
71
|
+
- `[x]` **B2. fixtures/ audit**: 13 MB, 2 tracked `.safetensors`.
|
|
72
|
+
Decision per file:
|
|
73
|
+
- `fixtures/adapters/{upper,french}/adapters.safetensors` (6.6 MB each):
|
|
74
|
+
KEEP tracked, on the gate's explicit allowlist (cap 7 MB). They are
|
|
75
|
+
stable one-time-trained LoRA adapter INPUTS (trained 2026-06-10, never
|
|
76
|
+
churned) for the opt-in `MLX_BUN_TEST_LORA` hot-swap test, which also
|
|
77
|
+
requires the e4b base. The tracked `adapter_config.json` + `data-*/`
|
|
78
|
+
jsonl fully describe how to reproduce them; a bit-exact regen script
|
|
79
|
+
(trainer-of-record: optiq `lora train` vs mlx-lm — the saved config is
|
|
80
|
+
ambiguous) is a Josh task to confirm + smoke. NOT untracked now because
|
|
81
|
+
an untested untrack would break the lora hot-swap parity test, and these
|
|
82
|
+
do not churn (the goldens-bloat problem was *churning* artifacts). This
|
|
83
|
+
is a deliberate allowlist exception, not the default `fixtures/ ≤ 2 MB`
|
|
84
|
+
policy.
|
|
85
|
+
- **CLOSED later the same day**: untracked + gitignored after pinning the
|
|
86
|
+
bytes by sha256 in `scripts/fetch-test-fixtures.sh` (fetch from the
|
|
87
|
+
`test-fixtures-v1` release / either laptop / pre-rewrite history; the
|
|
88
|
+
gated LoRA test skips cleanly when absent — verified). The regen-
|
|
89
|
+
ambiguity concern was real but orthogonal: preservation is by pinned
|
|
90
|
+
original bytes, not by retraining. Allowlist entry removed; gate green
|
|
91
|
+
with ZERO multi-MB binaries tracked.
|
|
92
|
+
- `[x]` **B3. tests/ binary audit**: 27 tracked `.bin` under
|
|
93
|
+
`tests/fixtures/universal-rope/` (4–8 KB each, ~108 KB total). All are
|
|
94
|
+
model-free CI-load-bearing bit-exact oracle fixtures (used by
|
|
95
|
+
`tests/universal-rope.test.ts`, which skips cleanly if absent),
|
|
96
|
+
regenerated by `scripts/gen-universal-rope-fixtures.py`. STAY, on the
|
|
97
|
+
allowlist (cap 16 KB each). Also allowlisted: `tests/fixtures/qwen-delta-golden.json`
|
|
98
|
+
(1.08 MB text JSON golden, model-free, regen script exists).
|
|
99
|
+
- Exit: `git ls-files | grep -E '\.(bin|safetensors|dylib)$'` returns only
|
|
100
|
+
allowlisted entries; the gate enforces it forever.
|
|
101
|
+
|
|
102
|
+
## Phase C — history rewrite (the 179 MB) — DONE 2026-07-02 (Josh go)
|
|
103
|
+
|
|
104
|
+
Executed 2026-07-02 on Josh's explicit go. Result: `.git` **182 MB →
|
|
105
|
+
20 MB**. Notes on what actually ran (vs the sketch below):
|
|
106
|
+
|
|
107
|
+
- C1: dry-run `--analyze` confirmed goldens `.bin` = the entire bloat.
|
|
108
|
+
Actual invocation, narrower than sketched (no size threshold needed):
|
|
109
|
+
`git filter-repo --invert-paths --path-glob 'goldens/*.bin'
|
|
110
|
+
--path-glob 'goldens/*/*.bin'` — both globs needed because git
|
|
111
|
+
pathspec `**` doesn't span the flat + one-deep layout; covers all 497
|
|
112
|
+
historical blobs. Verified pre-push: 343 commits in/out, HEAD tree
|
|
113
|
+
bit-identical to original, zero `.bin` left anywhere in goldens
|
|
114
|
+
history, tags remapped.
|
|
115
|
+
- C2: force-pushed main + branches + tags. Mirror backup at
|
|
116
|
+
`~/mlx-bun-mirror-backup-2026-07-02.tar.gz` (143 MB) — delete once
|
|
117
|
+
both boxes verified. M1 Max done: fetch --force + reset --hard,
|
|
118
|
+
surviving branches remapped, stale agent-session worktrees/branches
|
|
119
|
+
deleted (Josh: only main matters), stray `refs/codex`/`refs/original`
|
|
120
|
+
pruned, reflog expired, gc'd to 20 MB. **M4 Pro remaining (Josh)**:
|
|
121
|
+
`git fetch origin --tags --force --prune && git reset --hard
|
|
122
|
+
origin/main` (or fresh clone).
|
|
123
|
+
- C3: `v0.0.10` resolves; release assets untouched (they live outside
|
|
124
|
+
git history); hygiene gate green; regen path proven live —
|
|
125
|
+
`bun scripts/regen-fused-sdpa-goldens.ts` rewrote
|
|
126
|
+
`goldens/apple-m1-max/fused-sdpa.{json,bin}`. The full suite could not
|
|
127
|
+
be run from the executing session (tooling restriction); run it once
|
|
128
|
+
per box as the final check.
|
|
129
|
+
|
|
130
|
+
**Now ONE COMMAND**: `scripts/history-rewrite.sh` (dry-run by default,
|
|
131
|
+
`MLX_BUN_REWRITE_GO=1` to execute) — it enforces every precondition below,
|
|
132
|
+
writes the permanent mirror backup first, purges goldens `.bin` + the
|
|
133
|
+
adapter safetensors from history, force-pushes, and prints the re-clone
|
|
134
|
+
steps for the other laptop.
|
|
135
|
+
|
|
136
|
+
One-time, destructive-by-design, ~15 min total. Preconditions: BOTH
|
|
137
|
+
laptops fully pushed (verify `git log origin/main..HEAD` empty on each);
|
|
138
|
+
no open PRs; a fresh `git clone --mirror` tarball kept as belt-and-
|
|
139
|
+
suspenders until both machines are re-cloned.
|
|
140
|
+
|
|
141
|
+
- `[x]` **C1. rewrite**: `git filter-repo --strip-blobs-bigger-than 200K
|
|
142
|
+
--path-glob 'goldens/**/*.bin' --invert-paths` (exact invocation to be
|
|
143
|
+
dry-run first with `--analyze`; target: drop all historical goldens
|
|
144
|
+
`.bin` while keeping every text file's history intact).
|
|
145
|
+
- `[x]` **C2. force-push** `main` (+ tags), re-clone on BOTH laptops
|
|
146
|
+
(M1 Max + M4 Pro), re-link local-only assets (nothing tracked is lost —
|
|
147
|
+
the blobs being dropped are regenerable and already untracked at HEAD).
|
|
148
|
+
- `[x]` **C3. verify**: `du -sh .git` (expect ~15–25 MB), `bash
|
|
149
|
+
scripts/test.sh` green, one `scripts/regen-*` smoke to prove the regen
|
|
150
|
+
path, tag `v0.0.10` still resolves + release assets untouched (GitHub
|
|
151
|
+
releases store assets outside git history).
|
|
152
|
+
- NOT doing LFS: 290 MB of churning machine-specific artifacts is exactly
|
|
153
|
+
what LFS quotas punish, and the regen scripts make the artifacts
|
|
154
|
+
reproducible — a fixture server is overkill for a two-laptop project.
|
|
155
|
+
- Exit: fresh clone < 30 MB ✓ (20 MB); M1 Max on rewritten history ✓;
|
|
156
|
+
M4 Pro pending its one-line reset (see notes above).
|
|
157
|
+
|
|
158
|
+
## Phase D — docs debt (follow-the-map, 1–2 h, independent) — gate DONE 2026-07-02
|
|
159
|
+
|
|
160
|
+
- `[~]` **D1.** PLAN.md is 139 KB — move closed phases older than Phase 17
|
|
161
|
+
into PLAN-archive.md (the established pattern; keep PLAN.md scannable).
|
|
162
|
+
- **Finding (2026-07-02):** no candidates currently. Every phase <17 in
|
|
163
|
+
PLAN.md (6, 7, 12, 13, 14, 15, 16) is marked `[~]` or `[ ]` — none are
|
|
164
|
+
`[x]`/CLOSED, and the established archive pattern archives only
|
|
165
|
+
`[x]`-marked phases. Phases 6 and 16 have *all* their checkboxes
|
|
166
|
+
checked but carry a deliberate `[~]`; reclassifying them to closed +
|
|
167
|
+
archiving is a Josh state-call (they may have ongoing narrative
|
|
168
|
+
threads). The closed `oMLX` phase (2026-07-02) and Phase 19 are
|
|
169
|
+
*newer* than 17, so they stay in PLAN.md as recent history per the
|
|
170
|
+
criterion. Revisit D1 when a <17 phase is confirmed closed.
|
|
171
|
+
- `[x]` **D2.** `docs/investigations/curve-runs/` + two untracked HTML
|
|
172
|
+
strays in docs/investigations: gitignore or move under `reports/`.
|
|
173
|
+
- Already satisfied: `docs/investigations/curve-runs/` is gitignored
|
|
174
|
+
(`.gitignore`), and the four HTML strays (`curve-terrain.html`,
|
|
175
|
+
`hlg-ab.html`, `hlg-report.html`, `hlg-explorer.html`) are gitignored
|
|
176
|
+
via `docs/investigations/*.html`. No action needed.
|
|
177
|
+
- `[x]` **D3.** The CLAUDE.md doc-map lists are hand-maintained; add a
|
|
178
|
+
`scripts/check-hygiene.ts` assertion that every `docs/**/*.md` appears in
|
|
179
|
+
the map (same guardrail spirit as B1).
|
|
180
|
+
- Implemented in `check-hygiene.ts` (`docs-map` check). First run caught
|
|
181
|
+
10 drifted docs (3 design, 1 investigation, 6 planning) — all added to
|
|
182
|
+
the CLAUDE.md map. Gate now green; CI enforces it on every push/PR.
|
|
183
|
+
|
|
184
|
+
## Ordering & ownership
|
|
185
|
+
|
|
186
|
+
A and D anytime (safe, agent-executable solo) — DONE 2026-07-02. B gate
|
|
187
|
+
DONE 2026-07-02 (B2 adapter-untrack deferred to Josh — see B2 finding).
|
|
188
|
+
C executed 2026-07-02 on Josh's explicit go — only remaining tail is the
|
|
189
|
+
M4 Pro reset + deleting the backup tarball once both boxes are green.
|
|
@@ -1,9 +1,25 @@
|
|
|
1
1
|
# Runtime isolation — the AI may crash, the UI never may
|
|
2
2
|
|
|
3
|
-
Status: **
|
|
3
|
+
Status: **P1 LANDED 2026-07-05** (opt-in `--isolate`; default flip after
|
|
4
|
+
bake). Phase 1 (yields) fully landed same day (the gateway onToken hop
|
|
5
|
+
existed; prefill chunk yields added). **ARCHITECTURE DECISION (2026-07-05,
|
|
6
|
+
deviates from the IPC sketch below):** the engine child is the ENTIRE
|
|
7
|
+
existing server on a unix domain socket; the parent is a thin reverse
|
|
8
|
+
proxy (src/serve/isolate.ts). Rationale: the gateway-as-IPC-client cut
|
|
9
|
+
would serialize grammar controllers (WASM), vision embeddings (GPU
|
|
10
|
+
arrays), sampler closures, and prompt-cache hooks over a message protocol
|
|
11
|
+
— weeks of re-plumbing; HTTP-over-UDS reuses the whole serving stack
|
|
12
|
+
verbatim, streams SSE for free, and cancellation is abort propagation.
|
|
13
|
+
The inter-process API is the existing /v1 surface — no second protocol.
|
|
14
|
+
**Measured (M1 Max, cpm5, paired)**: proxied 197.2 vs direct 197.9 tok/s
|
|
15
|
+
(−0.4%, noise), TTFT +2 ms, SSE per-token granularity preserved (96 reads
|
|
16
|
+
/ 96 tokens), parent answers in 0.6 ms mid-decode (criterion: <50 ms),
|
|
17
|
+
child kill → 502 + respawn (gated model-free + E2E:
|
|
18
|
+
tests/isolate-proxy.test.ts, tests/isolate-e2e.test.ts). Not proxied yet:
|
|
19
|
+
/ws/chat (501 + pointer). P2 = child-per-model pool (multi-model switch by
|
|
20
|
+
spawn-overlap); P3 = GPU-lease unification. The original design follows.
|
|
4
21
|
Owner: serving layer / runtime
|
|
5
|
-
Default today: inference runs
|
|
6
|
-
thread. This doc proposes moving it to a **subprocess**.
|
|
22
|
+
Default today: inference runs in-process unless `--isolate` is passed.
|
|
7
23
|
|
|
8
24
|
## Goal
|
|
9
25
|
|
|
@@ -184,6 +184,21 @@ quant. The trivial case to validate the machinery on a real model.
|
|
|
184
184
|
|
|
185
185
|
## 5. The segmentation strategy (the part to get right)
|
|
186
186
|
|
|
187
|
+
> **2026-07-02 CORRECTION — this section's cost model is WRONG, measured.**
|
|
188
|
+
> The full-attention isolation planner was built and A/B'd @8K on real chunk
|
|
189
|
+
> rows (scripts/experiments/seg-isolation-smoke.ts, MLX_BUN_SEG_MEM_LOG=1
|
|
190
|
+
> phase probes): ZERO peak win (18.09 vs 18.02 GB seg2). The premise "sliding
|
|
191
|
+
> layers are O(L·window) in the backward" is false — mlx's sdpa BACKWARD
|
|
192
|
+
> materializes the O(L²) scores for EVERY layer (~3.5 GB/layer @8K e4b; the
|
|
193
|
+
> sliding window is only an additive mask; a sliding PAIR costs +7.1 GB ≈
|
|
194
|
+
> sliding+full's +7.15). The worst segment is therefore set by LAYER COUNT
|
|
195
|
+
> alone and `segment_size` is the whole knob: **seg1 = 14.59 GB @8K, +3% step
|
|
196
|
+
> time, loss identical — fits the 24 GB M4 Pro today.** The head vjp adds
|
|
197
|
+
> ~3 GB (full-[M,V] responseOnlyCe — kernel-review backlog #8). Going below
|
|
198
|
+
> ~14 GB needs that head fix plus an O(L)-memory attention backward (flash
|
|
199
|
+
> track), not smarter segmentation. Section kept for the (still-correct)
|
|
200
|
+
> donor/boundary constraints; ignore its sliding-vs-full peak arithmetic.
|
|
201
|
+
|
|
187
202
|
NOT a naive "divide by N". Compute the split points from the model:
|
|
188
203
|
|
|
189
204
|
**Constraints & inputs (all readable from the model/config):**
|
|
@@ -234,8 +249,9 @@ returns the split indices + which donor K/V to save, computed from the above.
|
|
|
234
249
|
- The chunk data needs ≥4096 (median 4092; 0% ≤2048; 98% ≤8192). Success = e4b
|
|
235
250
|
trains at 8192 ≤ ~10 GB AND the chunk eval (`scripts/chunk-eval.ts`) shows a
|
|
236
251
|
quality delta on e4b like MiniCPM5's.
|
|
237
|
-
- Training
|
|
238
|
-
|
|
252
|
+
- Training needs no env-flag sanitization (the no-vjp fused CustomKernels that
|
|
253
|
+
once required `MLX_BUN_PERF_KERNEL=0 MLX_BUN_FUSED_GELU=0` were deleted
|
|
254
|
+
2026-07-05; see docs/design/unified-engine-frontier-plan.md §6).
|
|
239
255
|
- MLX `Date.now()`/random caveats don't apply here; standard determinism via seed.
|
|
240
256
|
|
|
241
257
|
## 7. Key files
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# SSD cold tier for the prompt/KV cache
|
|
2
|
+
|
|
3
|
+
Status: **LANDED P1–P3 (2026-07-02, same day)** — P4 hardening extras remain.
|
|
4
|
+
**Layer-0 upgrade (2026-07-05, unified-engine plan):** D2 finished properly —
|
|
5
|
+
the two-tier take/restore/trim dance moved INSIDE `PromptCache.take()`
|
|
6
|
+
(structural `ColdTier` interface; the server binds SsdCacheStore + model into
|
|
7
|
+
it), so EVERY consumer tiers automatically: the serial lane's hand-rolled
|
|
8
|
+
block in runGeneration is gone, and the BATCH SCHEDULER restores prefixes
|
|
9
|
+
from disk at admission (gated end-to-end: tests/batch-scheduler.test.ts
|
|
10
|
+
"SSD tier through the batch lane"). `PromptCache.onPut` drives the debounced
|
|
11
|
+
write-behind snapshot for both lanes' puts. NEW: **idle demotion**
|
|
12
|
+
(`--ssd-demote-idle`, default 300 s with the tier on) — entries unused past
|
|
13
|
+
the threshold spill to disk and free their GPU arrays; RAM drains between
|
|
14
|
+
agent bursts while prefixes stay reachable via zero-copy restore. The sweep
|
|
15
|
+
runs only when the gateway is fully idle (a runExclusive would otherwise
|
|
16
|
+
drain a live batch). Rationale (Josh): prefill is the cost being avoided —
|
|
17
|
+
12B prefill is 168 tok/s, so a 30k agent context is ~3 min of GPU compute vs
|
|
18
|
+
a ~1 s NVMe restore; SSD-vs-RAM (40×) is the wrong comparison, SSD-vs-
|
|
19
|
+
recompute (50–180×, growing with context) is the real one.
|
|
20
|
+
|
|
21
|
+
**Measured (M1 Max, MiniCPM5, 13.7k-token prefix, kv_config quant):**
|
|
22
|
+
- Restart TTFT: **12,083 ms (full prefill) → 236 ms** restored — 2% of full
|
|
23
|
+
prefill, 13,675 tokens back via a 3 ms zero-copy mmap. Beats oMLX's 1–3 s
|
|
24
|
+
claim and both exit criteria (<2.5 s, ≤10%).
|
|
25
|
+
- Steady-state decode: **0% overhead** (192.0 vs 192.3 tok/s, flag off/on) —
|
|
26
|
+
nothing runs on the token loop, as designed (vs oMLX's measured ~20%).
|
|
27
|
+
- Correctness: SSD-restored + trimmed continuation is **identical to the RAM
|
|
28
|
+
tier's cache-hit output** (control experiment); divergence from a fresh
|
|
29
|
+
full prefill is the pre-existing bf16 prefix-reuse property both tiers
|
|
30
|
+
share, not introduced by persistence.
|
|
31
|
+
|
|
32
|
+
What landed: kv-store v2 (`src/kv-store.ts` — all five cache kinds incl.
|
|
33
|
+
quantized + SSM, invalidation metadata, per-tensor hashes, atomic writes;
|
|
34
|
+
`restoreState` added to both quantized cache classes), `src/ssd-cache.ts`
|
|
35
|
+
(SsdCacheStore per D4), tier integration (`src/prompt-cache.ts` spill hook +
|
|
36
|
+
retain thunks; `src/server.ts` tiered take with post-restore trim,
|
|
37
|
+
debounced write-behind snapshots under the gateway lock, `/stats.ssd_cache`),
|
|
38
|
+
flags `--ssd-cache <dir>` / `--ssd-cache-max <GB>` / `--ssd-cache-verify`.
|
|
39
|
+
Tests: `tests/kv-store.test.ts` (fork-compare token-identical for bf16,
|
|
40
|
+
quantized, SSM), `tests/ssd-cache.test.ts` (restart recovery, quarantine,
|
|
41
|
+
corruption, eviction, supersede). One deviation from the plan below: find()
|
|
42
|
+
returns PARTIAL prefix matches and the server trims after restore
|
|
43
|
+
(divergent-tail conversations — full-entry-only missed the real pattern). Inspired by oMLX's paged SSD cache
|
|
44
|
+
(Apache 2.0 — `omlx/cache/paged_ssd_cache.py`, `boundary_snapshot_store.py`;
|
|
45
|
+
we port ideas, not code — add a `THIRD_PARTY_LICENSES.md` note if any code
|
|
46
|
+
ends up derived). Their headline: coding-agent TTFT 30–90 s → 1–3 s by
|
|
47
|
+
restoring long prefixes from SSD across requests AND restarts. Their
|
|
48
|
+
measured costs (independent tests): ~2–3× cold TTFT and ~20% steady-state
|
|
49
|
+
decode overhead — this design exists to get the win without those costs.
|
|
50
|
+
Flag-gated, off by default.
|
|
51
|
+
|
|
52
|
+
**Head start**: `src/kv-store.ts` already implements page-aligned KV
|
|
53
|
+
persistence (`MLXBUNKV1`, 16 KiB-aligned tensors) with zero-copy COW mmap
|
|
54
|
+
reload (`MmapFile.open(path, "cow")` → `MlxArray.fromPointer` — exactly
|
|
55
|
+
the Metal page-alignment constraint, proven by expert-offload), including
|
|
56
|
+
mid-rotation `RotatingKVCache` restore (`restoreState`,
|
|
57
|
+
`src/model/gemma4-base.ts:880`). `scripts/experiments/cold-start.ts` is a
|
|
58
|
+
working sub-1 s cold-restore harness. The SSD tier is wiring +
|
|
59
|
+
quantized-cache support + an index/eviction/recovery layer.
|
|
60
|
+
|
|
61
|
+
## Current state / gaps
|
|
62
|
+
|
|
63
|
+
- `src/prompt-cache.ts`: byte-capped LRU RAM tier (default 2 GB,
|
|
64
|
+
`--prompt-cache`), prefix-entry based, adapter-namespaced, serial lane
|
|
65
|
+
only, race-free under the gateway lock. **A restart loses everything.**
|
|
66
|
+
- `kv-store.ts` gaps: (1) no quantized kinds — `saveKvCache` throws for
|
|
67
|
+
`QuantizedKVCache`/`RotatingQuantizedKVCache` and neither has
|
|
68
|
+
`restoreState`, yet serving defaults to the model's `kv_config.json`
|
|
69
|
+
scheme, so the default production cache can't persist today; quantized
|
|
70
|
+
state is 6 arrays/layer (packed u32 + scales/biases bf16 × K/V);
|
|
71
|
+
(2) no invalidation metadata / checksums / atomic writes; (3) latent
|
|
72
|
+
bug: `loadKvCache` checks `caches.length !== model.layers.length` but
|
|
73
|
+
`makeCache()` returns donor layers only — wrong for KV-shared e4b
|
|
74
|
+
(verify in Phase 1).
|
|
75
|
+
- Versioning primitive exists: `configFingerprint`
|
|
76
|
+
(`src/model/fingerprint.ts`) — sha256 over graph-shaping config incl.
|
|
77
|
+
`kvQuant`.
|
|
78
|
+
|
|
79
|
+
## Design decisions
|
|
80
|
+
|
|
81
|
+
**D1 — Whole-prefix-entry spill, not paged blocks.** oMLX pages for
|
|
82
|
+
multi-user shared-prefix dedup; we are single-user with a prefix-entry
|
|
83
|
+
cache that grows monotonically per conversation. Paging is exactly where
|
|
84
|
+
their overheads come from: per-block SHA256 + save-queue bookkeeping
|
|
85
|
+
*during decode* (their ~20% tax) and block-granular safetensors restore
|
|
86
|
+
with copies (their 2–3× cold TTFT). Ours: **zero work on the token loop**
|
|
87
|
+
(prefix tokens ARE the key), spill/persist only at request boundaries and
|
|
88
|
+
evictions, restore = one zero-copy mmap with lazy page fault-in (~5 GB/s
|
|
89
|
+
NVMe; 2 GB entry ≈ 0.4 s amortized into the first forward). Partial-prefix
|
|
90
|
+
reuse works without paging: restore + `Cache.trim()`. Revisit paging only
|
|
91
|
+
for multi-user batch dedup (v2, out of scope).
|
|
92
|
+
|
|
93
|
+
**D2 — One store, two tiers.** `PromptCache` grows a cold tier behind the
|
|
94
|
+
same `take`/`put` API:
|
|
95
|
+
- RAM→SSD: `put()`'s evict loop spills (snapshot bytes → async writer →
|
|
96
|
+
dispose GPU arrays) instead of just disposing.
|
|
97
|
+
- SSD→RAM: `take()` prefers a strictly-longer SSD prefix; restored entry
|
|
98
|
+
carries its `MmapFile`, unmapped on entry dispose (extensions copy into
|
|
99
|
+
fresh anonymous buffers via concat-grow anyway).
|
|
100
|
+
- **Restart persistence needs write-behind at request completion**, not
|
|
101
|
+
just spill-on-evict: after `promptCache.put(...)` in `runGeneration`
|
|
102
|
+
(`src/server.ts:1019`), a debounced, coalesced-per-ns async snapshot.
|
|
103
|
+
Byte extraction (the only GPU-thread work) runs on the idle serial lane
|
|
104
|
+
between requests; file I/O is async. (oMLX's boundary-snapshot idea at
|
|
105
|
+
whole-entry granularity.)
|
|
106
|
+
|
|
107
|
+
**D3 — Extend the existing format to `MLXBUNKV2`**, not safetensors
|
|
108
|
+
(safetensors offsets aren't page-aligned; oMLX pays a copy via `mx.load`).
|
|
109
|
+
Add: `"qkv"`/`"rotating-qkv"` kinds (six tensor slots + groupSize/bits;
|
|
110
|
+
slice quantized buffers to `[.., :offset, :]` — don't persist 256-step
|
|
111
|
+
slack), header fields `formatVersion`/`modelId`/`configFingerprint`/`ns`/
|
|
112
|
+
`tokenizerHash`/`createdAt`, per-tensor checksums + header checksum.
|
|
113
|
+
Integrity: header checksum always verified on scan; full verification
|
|
114
|
+
opt-in (`--ssd-cache-verify`) because eager reads destroy lazy fault-in;
|
|
115
|
+
default trusts atomic writes (tmp + fsync + rename). Corrupt/incompatible
|
|
116
|
+
→ delete + log, never fatal. `loadKvCache` becomes model-generic
|
|
117
|
+
(accept `{ makeCache(): Cache[] }`, donor-count check).
|
|
118
|
+
|
|
119
|
+
**D4 — Files are the database.**
|
|
120
|
+
`<ssd-dir>/<configFingerprint>/<nsHash>/<uuid>.mlxkv`. Startup recovery =
|
|
121
|
+
header-only directory scan → in-memory `SsdIndex`
|
|
122
|
+
(`{ns, tokens, path, bytes, mtimeMs}[]`); tokens live in the header JSON.
|
|
123
|
+
LRU = file mtime (`utimes()` on hit); byte cap enforced at write time by
|
|
124
|
+
oldest-mtime eviction; disk-full → drop write, warn once, keep serving.
|
|
125
|
+
|
|
126
|
+
**D5 — Flags & interactions.** `--ssd-cache <dir>` +
|
|
127
|
+
`--ssd-cache-max <GiB>` (default 32) → `ServerOptions.ssdCacheDir/MaxBytes`.
|
|
128
|
+
Requires the RAM tier (`--prompt-cache 0` + `--ssd-cache` = startup
|
|
129
|
+
error). `--memory-budget` unchanged (restored caches are clean file-backed
|
|
130
|
+
pages until dirtied; admission already assumes worst-case bf16). `/status`
|
|
131
|
+
gains `ssd: { entries, bytes, max_bytes, restores, spills, restore_ms_last }`.
|
|
132
|
+
v1 is serial-lane only (where `PromptCache` lives); batch-lane interplay is
|
|
133
|
+
docs/design/batching-perf-path.md P3's problem.
|
|
134
|
+
|
|
135
|
+
## Phases
|
|
136
|
+
|
|
137
|
+
- **P0 — Baseline bench (½ d)**: `scripts/experiments/ssd-cache-bench.ts`
|
|
138
|
+
grown from `cold-start.ts`: ≥16k-token prefix on the standard snapshot,
|
|
139
|
+
kv_config quant active; measure full-prefill TTFT / warm RAM-hit TTFT /
|
|
140
|
+
steady-state decode. Exit: baseline table committed.
|
|
141
|
+
- **P1 — kv-store v2 (2–3 d, highest uncertainty)**: `restoreState` on
|
|
142
|
+
both quantized cache classes; all four kinds in save/load; header v2;
|
|
143
|
+
atomic writes; model-generic load. Exit: round-trip tests all kinds
|
|
144
|
+
(incl. wrapped ring) + **greedy decode parity** — N tokens continued
|
|
145
|
+
from a restored cache bit-exact vs never-persisted; `tsc` 0.
|
|
146
|
+
- **P2 — `src/ssd-cache.ts` store (1–2 d)**: `SsdCacheStore
|
|
147
|
+
{ scan, find(prompt, ns), store (async, extraction split from I/O),
|
|
148
|
+
evictToCap, remove, stats }`. Exit: unit tests — restart recovery,
|
|
149
|
+
incompatible-fingerprint quarantine, corrupt-header deletion, mtime cap
|
|
150
|
+
eviction, disk-full soft-fail.
|
|
151
|
+
- **P3 — Tier integration + flags (2–3 d)**: cold tier in `PromptCache`,
|
|
152
|
+
debounced end-of-request snapshots, CLI/`/status`. **Exit (headline)**:
|
|
153
|
+
(1) restart TTFT on a 16k prefix **< 2.5 s and ≤ 10% of full-prefill**
|
|
154
|
+
(beat oMLX's 1–3 s; a true-cold miss runs the unchanged prefill path);
|
|
155
|
+
(2) steady-state decode overhead **< 2%** vs flag off (vs their ~20%);
|
|
156
|
+
(3) flag absent ⇒ zero behavior change.
|
|
157
|
+
- **P4 — Hardening + docs (1–2 d)**: kill-during-write crash test,
|
|
158
|
+
adapter-ns isolation, kv-quant-scheme-change invalidation, README/help,
|
|
159
|
+
license note. Exit: suite green; on/off × cold/warm/restart matrix
|
|
160
|
+
recorded.
|
|
161
|
+
|
|
162
|
+
Total ~7–10 working days.
|
|
163
|
+
|
|
164
|
+
## Risks
|
|
165
|
+
|
|
166
|
+
- **mmap lifetime vs mlx laziness**: unmap only in entry `dispose()` after
|
|
167
|
+
successor state is evaluated; COW mapping already covers buffer donation.
|
|
168
|
+
- **Byte-extraction stall on multi-GB entries**: runs between requests on
|
|
169
|
+
the idle lane; measure in P3; chunk with yields if needed.
|
|
170
|
+
- **COW dirty-page accounting**: extended restored caches migrate to
|
|
171
|
+
anonymous memory — probe with `phys_footprint` (expert-offload
|
|
172
|
+
discipline) before making footprint claims.
|
|
173
|
+
- **Stale-token poisoning**: `configFingerprint` + `tokenizerHash` + ns;
|
|
174
|
+
chat-template drift degrades to a shorter matched prefix (safe).
|
|
175
|
+
|
|
176
|
+
## Addendum 2026-07-07 — A7 closure: bounded memory on BOTH sides of the file
|
|
177
|
+
|
|
178
|
+
The 07-06/07 serve benches flagged elevated ctx/restart-leg RSS on
|
|
179
|
+
--ssd-cache arms ("A7"). Root-caused in three parts; the first two were
|
|
180
|
+
real defects, both fixed in `src/kv-store.ts`:
|
|
181
|
+
|
|
182
|
+
- **Write side** (residual after the v3 streaming writer of 4f9bad9): the
|
|
183
|
+
per-tensor `rawBytes()` readback ended in a JS-heap `.slice()`; under GC
|
|
184
|
+
lag the dead copies outlive the flush loop, re-spiking RSS toward the
|
|
185
|
+
whole entry. The writer now hashes and writes from a ZERO-COPY view of
|
|
186
|
+
the contiguous mlx buffer (`MlxArray.rawBytesView`) — no JS-heap copy
|
|
187
|
+
exists at any point; save-transient mlx memory measured 0 bytes for
|
|
188
|
+
contiguous sources (one tensor otherwise).
|
|
189
|
+
- **Restore side** (never covered by A7's original scope): the zero-copy
|
|
190
|
+
restore (COW mmap + `fromPointer`) became a per-restore PROCESS-LIFETIME
|
|
191
|
+
mapping leak once the 2026-07-06 FFI-dtor fix removed the unmap signal,
|
|
192
|
+
and the exactly-offset-sized restored buffers made the first
|
|
193
|
+
post-restore decode step concat-copy the entire entry into fresh mlx
|
|
194
|
+
memory. Restore is now a **streamed copy** — each tensor is copied into
|
|
195
|
+
an mlx-owned leaf (`fromBytesCopy`, no dtor contract), its clean file
|
|
196
|
+
pages dropped (`MADV_DONTNEED`; tensor offsets are 16 KiB-aligned = the
|
|
197
|
+
arm64 page size), the mapping unmapped before `loadKvCache` returns, and
|
|
198
|
+
plain-KV tensors land in STEP-rounded capacity with ≥1 token of slack so
|
|
199
|
+
the first step updates in place. Measured (512 MB synthetic entry): mlx
|
|
200
|
+
peak during restore+first-step = live entry + ONE tensor (552 vs 520 MB
|
|
201
|
+
active); `vmmap` confirms no `.mlxkv` mapping survives; 12B real-model
|
|
202
|
+
cold cache-load → first token 277 ms (parity with the old zero-copy
|
|
203
|
+
~240 ms claim — the old path paid the same whole-entry copy on the first
|
|
204
|
+
decode step anyway). Byte identity of the copy-restore is pinned for all
|
|
205
|
+
five cache kinds (save → load(verify) → re-save hash-identical;
|
|
206
|
+
tests/kv-store.test.ts "copy-restore byte identity") plus the real-model
|
|
207
|
+
bf16/quantized/SSM continuation suites.
|
|
208
|
+
- **What was NOT a defect**: most of the benched leg delta is `ps` RSS
|
|
209
|
+
ACCOUNTING — the write-behind's hash+write CPU-touches the live KV
|
|
210
|
+
entry, which makes those already-allocated unified-memory pages visible
|
|
211
|
+
to `ps` (GPU-written buffers and python-arm KV never show). The bench
|
|
212
|
+
report now footnotes this instead of promising a pending fix.
|
|
213
|
+
|
|
214
|
+
Supersedes the D2 line "restored entry carries its MmapFile, unmapped on
|
|
215
|
+
entry dispose" and the D1 "restore = one zero-copy mmap with lazy page
|
|
216
|
+
fault-in" mechanics above — the economics (SSD-vs-recompute) and the
|
|
217
|
+
zero-work-on-token-loop property are unchanged.
|
|
218
|
+
|
|
219
|
+
## Addendum 2026-07-07 — write-behind scheduling contract: flush only while idle
|
|
220
|
+
|
|
221
|
+
The 07-07 serve bench showed decode@ctx losing to mlx-lm on the bun arms
|
|
222
|
+
(e4b −9.3%, 12B −3.9%) while short-ctx decode won on every model — and the
|
|
223
|
+
mixed arm (4× smaller flush bytes) BEAT the bf16 arms at decode@ctx. Root
|
|
224
|
+
cause: "non-blocking" write-behind was only non-blocking at the event-loop
|
|
225
|
+
level. Every per-tensor flush step is (1) `ops.contiguous` — a kernel on
|
|
226
|
+
the SAME GPU stream decode uses — then (2) `rawBytesView()` → a synchronous
|
|
227
|
+
`mlx_array_eval` that blocks the JS thread until the stream drains, then
|
|
228
|
+
(3) a synchronous multi-MB `writeSync`. The `setImmediate` pacing
|
|
229
|
+
interleaved those slices exactly between decode tokens, so a ~16k entry's
|
|
230
|
+
flush overlapping the bench's cached ctx repeats taxed their 64-token
|
|
231
|
+
decode windows (reproduced standalone: e4b 9.5k-token entry, repeat decode
|
|
232
|
+
37.9 vs cold 47.1 tok/s; flat 44–46 across all three samples post-fix).
|
|
233
|
+
mlx-lm runs no equivalent background work — the gap was pure self-inflicted
|
|
234
|
+
contention.
|
|
235
|
+
|
|
236
|
+
**The contract now:** the flush only progresses while the engine is idle.
|
|
237
|
+
- `GenerationGateway.busy` covers BOTH lanes (mutex held/awaited — the
|
|
238
|
+
serial lane shows zero rows — OR batch rows active/pending);
|
|
239
|
+
`gateway.onIdle()` is the poll-based waiter (`generation-gateway.ts`).
|
|
240
|
+
- `saveKvCacheAsync`/`SsdCacheStore.storeAsync` accept a per-step
|
|
241
|
+
`waitTurn` gate awaited before EVERY tensor step (including the first),
|
|
242
|
+
so a request arriving MID-flush pauses the remaining tensors until idle
|
|
243
|
+
again; the server passes `() => gateway.onIdle()` at both chain sites
|
|
244
|
+
(write-behind snapshots AND eviction/demotion spills). Gate failures are
|
|
245
|
+
swallowed — scheduling advice must never corrupt the write path.
|
|
246
|
+
- `MLX_BUN_SSD_WRITEBEHIND=0` disables write-behind snapshots entirely
|
|
247
|
+
(kill switch + paired-A/B lever; restart survival then degrades to
|
|
248
|
+
spill-on-evict only).
|
|
249
|
+
|
|
250
|
+
Accepted tradeoffs: durability waits for a quiet moment (single-user
|
|
251
|
+
serving quiesces constantly; sustained hammering defers the flush AND the
|
|
252
|
+
spill clones' GPU-memory release, bounded by the serial chain); one
|
|
253
|
+
in-flight tensor step (~10–15 MB) can still land ahead of a just-arrived
|
|
254
|
+
request; and the restart window tightens — the flush now runs in idle gaps
|
|
255
|
+
instead of overlapping decodes, so the bench's 2.5 s pre-kill beat must
|
|
256
|
+
absorb it (verified surviving: e4b 9.5k entry, restart cached=9575 with
|
|
257
|
+
the gate on).
|
|
258
|
+
|
|
259
|
+
## Addendum 2026-07-07 (later) — bounded retention + admission-safe snapshots
|
|
260
|
+
|
|
261
|
+
The post-merge review found two operational holes in the contract above:
|
|
262
|
+
|
|
263
|
+
1. **Snapshot timer could stall batch admission.** `scheduleSsdSnapshot`'s
|
|
264
|
+
debounced timer fired `gateway.runExclusive` unconditionally; the
|
|
265
|
+
exclusive registers a serial WAITER, which holds batch admission until
|
|
266
|
+
every running row drains naturally — a background durability timer
|
|
267
|
+
periodically freezing admission under `--batch N` sustained load. Fixed
|
|
268
|
+
with the same guard `demoteIdle` always had: the timer checks
|
|
269
|
+
`activeRows/pendingRows` first and RE-ARMS (5 s, unref'd) while busy —
|
|
270
|
+
entries are immutable, a late snapshot is equally valid.
|
|
271
|
+
2. **Starved-gate clone retention was unbounded.** Every eviction/demotion/
|
|
272
|
+
snapshot chained GPU-pinning zero-copy clones onto a bare serial promise
|
|
273
|
+
chain, and every flush step awaits `onIdle()` — under continuous traffic
|
|
274
|
+
the chain starves and resident memory = prompt-cache cap + every queued
|
|
275
|
+
clone, defeating the byte cap exactly under the load causing evictions.
|
|
276
|
+
Now all three producers go through **`SpillQueue`** (kv-store.ts):
|
|
277
|
+
queued bytes are capped (default 2 GB = a quarter of the 8 GB RAM-cache
|
|
278
|
+
default; `MLX_BUN_SSD_SPILL_QUEUE_GB` overrides) — over cap the OLDEST
|
|
279
|
+
not-mid-store item drops with its clones disposed immediately. A dropped
|
|
280
|
+
spill is a future cache miss, never a wrong result: the contention-free
|
|
281
|
+
alternative to letting the flush cut into decode. `/stats.ssd_cache`
|
|
282
|
+
exposes `pending_spills` / `pending_spill_bytes` / `dropped_spills`.
|
|
283
|
+
|
|
284
|
+
Explicitly accepted (documented, not built): **no shutdown flush** — a
|
|
285
|
+
SIGINT/SIGTERM during (or within the debounce after) traffic loses queued
|
|
286
|
+
write-behind entries; restart survival degrades to whatever already
|
|
287
|
+
flushed. This is a best-effort cache tier; the cost of a lost entry is one
|
|
288
|
+
re-prefill. `SpillQueue.drain()` exists if a shutdown hook is ever wanted.
|