mlx-bun 0.0.10 → 0.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +107 -32
- package/THIRD_PARTY_LICENSES.md +10 -0
- package/docs/archive/mlx-bun-lab-report.html +275 -0
- package/docs/archive/wwdc-mlx-bun.html +848 -0
- package/docs/design/audio-input-plan.md +356 -0
- package/docs/design/batching-perf-path.md +58 -35
- package/docs/design/batching-v2-plan.md +23 -9
- package/docs/design/bucketing-stage.md +2 -1
- package/docs/design/colibri-glm52-port.md +1479 -0
- package/docs/design/compat-cli-surface-design.md +4 -3
- package/docs/design/decode-speed-program.md +172 -0
- package/docs/design/docs-reorg-plan.md +1 -1
- package/docs/design/dspark-serving-program.md +371 -0
- package/docs/design/dspark-speculative-decoding.md +167 -5
- package/docs/design/faithful-l1-consolidation.md +310 -0
- package/docs/design/generic-model-support.md +8 -5
- package/docs/design/grammar-spec-batching-integration.md +301 -0
- package/docs/design/minicpm5-decode-megakernel.md +16 -11
- package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
- package/docs/design/omlx-adoption-map.md +14 -6
- package/docs/design/orpo-training.md +34 -30
- package/docs/design/paged-kv-cache.md +214 -0
- package/docs/design/parallel-slots.md +82 -49
- package/docs/design/parity-tier-dag.md +43 -33
- package/docs/design/pre-colibri-stabilization.md +329 -0
- package/docs/design/repo-cleanup-plan.md +189 -0
- package/docs/design/runtime-isolation.md +19 -3
- package/docs/design/segmented-backward-training.md +3 -2
- package/docs/design/ssd-kv-cold-tier.md +130 -0
- package/docs/design/structured-output.md +484 -0
- package/docs/design/the-dreaming-master-plan.md +2 -3
- package/docs/design/turboquant-kv.md +235 -0
- package/docs/design/unified-engine-frontier-plan.md +774 -0
- package/docs/design/web-chat-beat-matrix.md +315 -0
- package/docs/design/web-chat-redesign.md +1112 -0
- package/docs/investigations/colibri-oracle-pin.md +1284 -0
- package/docs/investigations/dspark-handoff.md +182 -16
- package/docs/investigations/indexshare-performance-spike.md +357 -0
- package/docs/investigations/lab-build-journal.md +2 -2
- package/docs/investigations/openwiki-evaluation.md +249 -0
- package/docs/planning/IDEAS.md +14 -0
- package/docs/planning/PRODUCT_ROADMAP.md +2 -3
- package/docs/planning/chat-ui-vision-memory-first.md +199 -0
- package/docs/planning/chat-ui-vision.md +50 -0
- package/docs/planning/memory-docs-and-dag-plan.md +15 -6
- package/docs/planning/release-notes-v0.0.11.md +239 -0
- package/docs/planning/release-notes-v0.0.12.md +80 -0
- package/docs/reference/cli.md +63 -18
- package/docs/reference/distribution.md +37 -6
- package/docs/reference/embedding.md +40 -16
- package/docs/reference/features-matrix.md +99 -0
- package/docs/reference/library-api.md +69 -20
- package/docs/reference/memory.md +26 -6
- package/docs/reference/models.md +60 -3
- package/docs/reference/orpo-quickstart.md +3 -4
- package/docs/reference/server-api.md +717 -24
- package/docs/reference/server-config.md +265 -129
- package/docs/reference/training.md +24 -16
- package/package.json +5 -3
- package/src/anthropic.ts +67 -44
- package/src/audio/conformer.ts +647 -0
- package/src/audio/decode.ts +133 -0
- package/src/audio/features.ts +202 -0
- package/src/audio/transcode.ts +67 -0
- package/src/chat-template.ts +155 -10
- package/src/cli.ts +483 -113
- package/src/config.ts +116 -24
- package/src/diffusion/diffusion-generate.ts +14 -8
- package/src/download.ts +143 -12
- package/src/eval/README.md +7 -6
- package/src/eval/ifeval.ts +63 -120
- package/src/eval/kl.ts +59 -3
- package/src/eval/quality-db.ts +2 -2
- package/src/eval/runner.ts +17 -8
- package/src/eval/tasks/ifeval.ts +167 -31
- package/src/expert-io.ts +391 -0
- package/src/expert-offload.ts +15 -2
- package/src/expert-residency.ts +1134 -0
- package/src/expert-usage.ts +415 -0
- package/src/flags.ts +13 -0
- package/src/generate.ts +438 -35
- package/src/grammar.ts +585 -0
- package/src/hf-push.ts +1 -1
- package/src/hub-rest.ts +242 -0
- package/src/index.ts +14 -2
- package/src/jobs/runner.ts +37 -18
- package/src/kv-store.ts +586 -93
- package/src/lora.ts +85 -14
- package/src/md-modules.d.ts +42 -0
- package/src/media-fetch.ts +426 -0
- package/src/memory/rest.ts +245 -0
- package/src/memory/vault.ts +74 -0
- package/src/mlx/array.ts +149 -19
- package/src/mlx/autograd.ts +53 -16
- package/src/mlx/checkpoint.ts +46 -13
- package/src/mlx/custom-vjp.ts +83 -35
- package/src/mlx/ffi.ts +27 -3
- package/src/mlx/metal-kernel.ts +7 -4
- package/src/mlx/ops.ts +153 -23
- package/src/mlx/turboquant-ops.ts +831 -0
- package/src/mlx/turboquant-tables.ts +237 -0
- package/src/model/batched-mask.ts +62 -1
- package/src/model/batched-quant.ts +206 -0
- package/src/model/batched-rotating-quant.ts +346 -0
- package/src/model/batched-rotating.ts +46 -1
- package/src/model/compiled-decode.ts +4 -15
- package/src/model/factory.ts +103 -6
- package/src/model/gemma4-base.ts +395 -39
- package/src/model/gemma4.ts +122 -33
- package/src/model/generated/gemma4-12b.ts +9 -13
- package/src/model/generated/gemma4-26b.ts +10 -5
- package/src/model/generated/gemma4-e4b.ts +14 -9
- package/src/model/glm52-atlas.ts +511 -0
- package/src/model/glm52-cache.ts +706 -0
- package/src/model/glm52-config.ts +262 -0
- package/src/model/glm52-container.ts +305 -0
- package/src/model/glm52-coupling.ts +388 -0
- package/src/model/glm52-dsa.ts +602 -0
- package/src/model/glm52-expert-layout.ts +253 -0
- package/src/model/glm52-layout.ts +152 -0
- package/src/model/glm52-memory.ts +473 -0
- package/src/model/glm52-mla.ts +786 -0
- package/src/model/glm52-moe.ts +223 -0
- package/src/model/glm52-pilot.ts +537 -0
- package/src/model/glm52-reference.ts +445 -0
- package/src/model/glm52-residency.ts +557 -0
- package/src/model/glm52-resident-weights.ts +135 -0
- package/src/model/glm52-streamed-experts.ts +453 -0
- package/src/model/glm52-streamed-metal.ts +610 -0
- package/src/model/glm52-weights.ts +503 -0
- package/src/model/glm52.ts +1153 -0
- package/src/model/minicpm5.ts +58 -102
- package/src/model/paged-kv.ts +253 -0
- package/src/model/qwen3-delta.ts +76 -15
- package/src/model/qwen3-moe.ts +404 -0
- package/src/model/qwen3.ts +9 -8
- package/src/model/qwen3_5.ts +88 -23
- package/src/model/support.ts +19 -1
- package/src/model/universal/dense.ts +40 -5
- package/src/model/universal/rope.ts +36 -21
- package/src/native/expert_io.c +523 -0
- package/src/native-pack.ts +6 -4
- package/src/pi-web.ts +1299 -82
- package/src/preflight.ts +27 -8
- package/src/prompt-cache.ts +293 -33
- package/src/quantize/quantizer.ts +16 -1
- package/src/registry.ts +57 -2
- package/src/responses.ts +62 -49
- package/src/serve/batch-scheduler.ts +1050 -78
- package/src/serve/generation-gateway.ts +252 -24
- package/src/serve/isolate.ts +417 -0
- package/src/serve/lane-registry.ts +44 -0
- package/src/serve/session-search.ts +326 -0
- package/src/server.ts +1701 -175
- package/src/spec/assistant-source.ts +150 -0
- package/src/spec/deepspec-source.ts +143 -0
- package/src/spec/dflash-source.ts +124 -0
- package/src/spec/dspark/ab-stats.ts +136 -0
- package/src/spec/dspark/calibration.ts +112 -0
- package/src/spec/dspark/deepspec-module.ts +801 -0
- package/src/spec/dspark/generate-dflash.ts +72 -20
- package/src/spec/dspark/loader.ts +23 -0
- package/src/spec/dspark/module-dflash.ts +278 -36
- package/src/spec/dspark/quantize-drafter.ts +91 -0
- package/src/spec/generate.ts +59 -25
- package/src/spec/glm52-mtp-source.ts +392 -0
- package/src/spec/ngram-source.ts +132 -0
- package/src/spec/serve-loop.ts +617 -0
- package/src/spec/source.ts +118 -0
- package/src/spec/two-model.ts +171 -0
- package/src/ssd-cache.ts +111 -35
- package/src/tokenizer.ts +288 -2
- package/src/tool-approvals.ts +131 -0
- package/src/tool-call.ts +438 -22
- package/src/train/flash-cce.ts +2 -2
- package/src/train/job.ts +0 -12
- package/src/train/lora-params.ts +17 -6
- package/src/train/trainer.ts +60 -30
- package/src/vision/prompt.ts +217 -48
- package/src/web/app.html +1197 -1791
- package/src/web/app.js +5694 -0
- package/src/web/icon.svg +13 -0
- package/src/web/manifest.webmanifest +24 -0
- package/src/web/src/adapters-panel.ts +288 -0
- package/src/web/src/api.ts +57 -0
- package/src/web/src/assistant.ts +485 -0
- package/src/web/src/chat.ts +1050 -0
- package/src/web/src/composer.ts +959 -0
- package/src/web/src/dataset.ts +132 -0
- package/src/web/src/finetune.ts +267 -0
- package/src/web/src/hub.ts +428 -0
- package/src/web/src/main.ts +98 -0
- package/src/web/src/markdown.ts +0 -0
- package/src/web/src/memory-panel.ts +572 -0
- package/src/web/src/model-picker.ts +159 -0
- package/src/web/src/palette.ts +346 -0
- package/src/web/src/protocol.ts +62 -0
- package/src/web/src/quantize.ts +167 -0
- package/src/web/src/rag.ts +274 -0
- package/src/web/src/sessions.ts +469 -0
- package/src/web/src/shell.ts +804 -0
- package/src/web/src/status.ts +300 -0
- package/src/web/src/ui-catalog.ts +92 -0
- package/src/web/sw.js +71 -0
- package/src/web/vendor/README.md +61 -0
- package/src/web/vendor/hljs-LICENSE +29 -0
- package/src/web/vendor/hljs-theme.css +59 -0
- package/src/web/vendor/hljs.js +26 -0
- package/src/web-tools.ts +54 -2
- package/src/weights.ts +82 -19
- package/src/model/fused-decode-kernel.ts +0 -312
- package/src/model/fused-geglu-kernel.ts +0 -198
- package/src/model/fused-mlp-kernel.ts +0 -169
- package/src/model/fused-swiglu-kernel.ts +0 -240
- package/src/model/steel-linear-kernel.ts +0 -96
- package/src/status-page.html +0 -497
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// DraftSource — the seam between the serve-time speculative verify loop
|
|
2
|
+
// (src/spec/serve-loop.ts) and whatever produces draft tokens. Designed in
|
|
3
|
+
// docs/design/mlx-lm-tool-parity-plan.md §7 so every drafter shares ONE
|
|
4
|
+
// verify/accept executor:
|
|
5
|
+
// - TwoModelSource (src/spec/two-model.ts) — mlx-lm parity (L1 oracle:
|
|
6
|
+
// mlx_lm.server --draft-model), a full second model. Ignores `target`.
|
|
7
|
+
// - AssistantSource (src/spec/assistant-source.ts) — the optiq KV-borrowing
|
|
8
|
+
// Gemma drafter (src/spec/drafter.ts; L2 oracle: optiq spec_generate).
|
|
9
|
+
// Reads the target's donor K/V + anchor hidden each step.
|
|
10
|
+
// - DflashSource (src/spec/dflash-source.ts) — DSpark (L3, KL/quality-gated).
|
|
11
|
+
// Taps the target's multi-layer hiddens (prefill + verify) into a growing
|
|
12
|
+
// H_ctx (docs/design/dspark-speculative-decoding.md).
|
|
13
|
+
// - NgramSource (src/spec/ngram-source.ts) — model-free prompt lookup.
|
|
14
|
+
// - Glm52NativeMtpSource (src/spec/glm52-mtp-source.ts) — the target
|
|
15
|
+
// artifact's native Colibri MTP row.
|
|
16
|
+
//
|
|
17
|
+
// The sources differ ONLY in what fills the draft; the serve loop, admission
|
|
18
|
+
// accounting, and stats never change. The KV-borrowing sources need target
|
|
19
|
+
// state the two-model source doesn't — carried by the `target` view at open()
|
|
20
|
+
// and the optional prefill/draft/commit arguments below (all no-ops for
|
|
21
|
+
// two-model). See [[dspark-seam-kv-borrowing]].
|
|
22
|
+
|
|
23
|
+
import type { MlxArray } from "../mlx/array";
|
|
24
|
+
import type { RuntimeModel } from "../model/factory";
|
|
25
|
+
import type { Cache } from "../model/gemma4";
|
|
26
|
+
|
|
27
|
+
/** Read-only handle to the target's live decode state, handed to every source
|
|
28
|
+
* at open(). Two-model ignores it; KV-borrowing sources read the target's
|
|
29
|
+
* model (embed / lm-head / the draft module) and its LIVE cache array (donor
|
|
30
|
+
* K/V views + offset — the SAME caches the serve loop drives). */
|
|
31
|
+
export interface TargetView {
|
|
32
|
+
readonly model: RuntimeModel;
|
|
33
|
+
readonly caches: Cache[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A per-request draft-token producer. Created per generation (owns its own
|
|
37
|
+
* draft-side state), disposed by the serve loop's finally. */
|
|
38
|
+
export interface DraftSource {
|
|
39
|
+
/** Target prefill shape required by this source's oracle. Most mlx-lm
|
|
40
|
+
* sources leave the final prompt token pending; native Colibri MTP starts
|
|
41
|
+
* from a full-prompt target forward. */
|
|
42
|
+
readonly prefillMode?: "tail-split" | "full";
|
|
43
|
+
/** Request one fixed target kernel family across the speculative verify
|
|
44
|
+
* batch. Native GLM MTP uses the direct-Colibri SPEC_PIN contract. */
|
|
45
|
+
readonly pinTargetKernelFamily?: boolean;
|
|
46
|
+
|
|
47
|
+
/** Multi-layer target tap the source needs captured on the target's prefill
|
|
48
|
+
* AND every verify forward (DSpark's H_ctx; e4b {20,31,41,42}). When set,
|
|
49
|
+
* the serve loop sets model.hiddenTap around those forwards and passes the
|
|
50
|
+
* captured context [1,L,m*H] into prefill()/commit(). Undefined for sources
|
|
51
|
+
* that don't tap (two-model, assistant). */
|
|
52
|
+
readonly tapLayers?: number[];
|
|
53
|
+
|
|
54
|
+
/** Process the prompt (two-model: prefill the draft model's cache;
|
|
55
|
+
* assistant/dflash: read the target's state — mostly a no-op, but DSpark
|
|
56
|
+
* seeds H_ctx from `ctxML`, the tapped prefill context [1,Lp,m*H], present
|
|
57
|
+
* iff tapLayers is set). */
|
|
58
|
+
prefill(promptIds: number[], ctxML?: MlxArray): void | Promise<void>;
|
|
59
|
+
|
|
60
|
+
/** Propose 0..n tokens (RETURN LENGTH IS AUTHORITATIVE — a source may
|
|
61
|
+
* return fewer than n, e.g. DSpark's confidence-scheduled draft-length
|
|
62
|
+
* pruning; ZERO means "skip drafting this round" — DeepSpec ℓ=0 semantics,
|
|
63
|
+
* the serve loop degenerates to one plain target step, still tapped +
|
|
64
|
+
* committed for context-growing sources). The serve loop verifies over
|
|
65
|
+
* exactly the returned length. `feed` is the token(s) the draft has not
|
|
66
|
+
* yet consumed: [pending]
|
|
67
|
+
* after a rejection/first round, or [lastDraft, bonus] after an all-accept
|
|
68
|
+
* round (mlx-lm's re-feed rule, generate.py:645-648). `stepBase` is the
|
|
69
|
+
* emitted-token index, threaded to the sampler for per-step RNG streams.
|
|
70
|
+
* `anchorHidden` is the target's final hidden [1,1,H] at the pending/anchor
|
|
71
|
+
* position — the assistant source borrows it for its first draft step;
|
|
72
|
+
* two-model and dflash ignore it. */
|
|
73
|
+
draft(
|
|
74
|
+
feed: number[],
|
|
75
|
+
n: number,
|
|
76
|
+
stepBase: number,
|
|
77
|
+
anchorHidden?: MlxArray,
|
|
78
|
+
): number[] | Promise<number[]>;
|
|
79
|
+
|
|
80
|
+
/** Verify outcome for the last round: kAccept of d accepted, where d is the
|
|
81
|
+
* length draft() actually RETURNED (≤ n). Two-model: trim the draft cache
|
|
82
|
+
* by max(d - kAccept - 1, 0) — mlx-lm's rewind rule (generate.py:589-591).
|
|
83
|
+
* DSpark: grow H_ctx by the accepted window from `vCtxML`, the verified
|
|
84
|
+
* window's tapped context [1,d+1,m*H] (present iff tapLayers is set), and
|
|
85
|
+
* drop the rejected tips. Native GLM MTP receives the target's verified
|
|
86
|
+
* hidden window [1,d+1,H] in `verifiedHidden` and the kAccept accepted token
|
|
87
|
+
* ids in `acceptedTokens`; it uses them to rebuild accepted MTP KV rows
|
|
88
|
+
* from target state rather than recursively drafted hidden state. */
|
|
89
|
+
commit(
|
|
90
|
+
d: number,
|
|
91
|
+
kAccept: number,
|
|
92
|
+
vCtxML?: MlxArray,
|
|
93
|
+
verifiedHidden?: MlxArray,
|
|
94
|
+
acceptedTokens?: readonly number[],
|
|
95
|
+
): void | Promise<void>;
|
|
96
|
+
|
|
97
|
+
/** Resident per-request draft weights, for admission accounting (0 when the
|
|
98
|
+
* provider owns the weights). */
|
|
99
|
+
readonly weightsBytes: number;
|
|
100
|
+
dispose(): void;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Server-lifetime owner of the draft machinery (the loaded draft model);
|
|
104
|
+
* open() mints a per-request DraftSource. */
|
|
105
|
+
export interface DraftProvider {
|
|
106
|
+
/** Human-readable id (registry id / path tail) for logs + cache namespacing. */
|
|
107
|
+
readonly id: string;
|
|
108
|
+
readonly weightsBytes: number;
|
|
109
|
+
open(opts: {
|
|
110
|
+
/** The request's sampler over logprobs [1,V] → token array [1] (the SAME
|
|
111
|
+
* sampler as the target — mlx-lm parity; greedy drafting under a
|
|
112
|
+
* temperature>0 request is NOT parity). */
|
|
113
|
+
sampler: (logprobs: MlxArray, step: number) => MlxArray;
|
|
114
|
+
/** The target model + its live caches (donor views / offset). */
|
|
115
|
+
target: TargetView;
|
|
116
|
+
}): DraftSource;
|
|
117
|
+
dispose(): void;
|
|
118
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Two-model speculative drafting — mlx-lm parity (`mlx_lm.server
|
|
2
|
+
// --draft-model`). A full second model drafts autoregressively; the serve
|
|
3
|
+
// loop (src/spec/serve-loop.ts) verifies with the target. L1 oracle:
|
|
4
|
+
// mlx_lm.server with the same target/draft pair, greedy, token-for-token
|
|
5
|
+
// (spec-vs-spec — both batch the verify lm-head, so neither is bit-exact to
|
|
6
|
+
// stock decode at knife-edges; see src/spec/generate.ts header).
|
|
7
|
+
//
|
|
8
|
+
// Faithfulness notes (read from the oracle venv's generate.py):
|
|
9
|
+
// - drafts are sampled with the REQUEST sampler (generate.py:593-601) —
|
|
10
|
+
// greedy drafting under a temperature>0 request is not parity;
|
|
11
|
+
// - draft-cache rewind is max(n - kAccept - 1, 0) (generate.py:589-591),
|
|
12
|
+
// with the all-accept re-feed of the last draft handled by the serve
|
|
13
|
+
// loop's `feed` (generate.py:645-648);
|
|
14
|
+
// - logits processors do NOT run on draft steps (they're target-side).
|
|
15
|
+
|
|
16
|
+
import { MlxArray } from "../mlx/array";
|
|
17
|
+
import * as ops from "../mlx/ops";
|
|
18
|
+
import { clearCache } from "../mlx/ffi";
|
|
19
|
+
import { flagOn } from "../flags";
|
|
20
|
+
import { loadModelConfig } from "../config";
|
|
21
|
+
import { Weights } from "../weights";
|
|
22
|
+
import { createModel, type RuntimeModel } from "../model/factory";
|
|
23
|
+
import type { Cache } from "../model/gemma4";
|
|
24
|
+
import { toLogprobs } from "../sampler";
|
|
25
|
+
import type { DraftProvider, DraftSource } from "./source";
|
|
26
|
+
|
|
27
|
+
/** mlx-lm's prefill_step_size — the draft drain chunks at the same stride
|
|
28
|
+
* as the target's (serve-loop.ts); chunking is numerically exact for
|
|
29
|
+
* causal attention. */
|
|
30
|
+
const PREFILL_CHUNK = 2048;
|
|
31
|
+
|
|
32
|
+
export class TwoModelProvider implements DraftProvider {
|
|
33
|
+
readonly id: string;
|
|
34
|
+
readonly weightsBytes: number;
|
|
35
|
+
|
|
36
|
+
private constructor(
|
|
37
|
+
readonly model: RuntimeModel,
|
|
38
|
+
private readonly weights: Weights,
|
|
39
|
+
id: string,
|
|
40
|
+
) {
|
|
41
|
+
this.id = id;
|
|
42
|
+
this.weightsBytes = [...weights.shards.files.values()].reduce(
|
|
43
|
+
(a, f) => a + f.mmap.size,
|
|
44
|
+
0,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Load the draft model from a snapshot dir. Vocab-size mismatch with the
|
|
49
|
+
* target is a WARNING (mlx-lm parity, server.py:363-368); the tokenizer-
|
|
50
|
+
* family hard check lives at the server (it owns both tokenizers). */
|
|
51
|
+
static async load(modelDir: string, targetVocabSize?: number): Promise<TwoModelProvider> {
|
|
52
|
+
const config = await loadModelConfig(modelDir);
|
|
53
|
+
const weights = await Weights.open(modelDir);
|
|
54
|
+
const model = createModel(weights, config);
|
|
55
|
+
if (
|
|
56
|
+
targetVocabSize !== undefined &&
|
|
57
|
+
config.text.vocabSize !== targetVocabSize
|
|
58
|
+
)
|
|
59
|
+
console.warn(
|
|
60
|
+
`draft model vocab size ${config.text.vocabSize} != target ${targetVocabSize} — ` +
|
|
61
|
+
`speculation will accept nothing useful (mlx-lm warns identically)`,
|
|
62
|
+
);
|
|
63
|
+
return new TwoModelProvider(model, weights, modelDir.split("/").filter(Boolean).at(-1)!);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
open(opts: Parameters<DraftProvider["open"]>[0]): DraftSource {
|
|
67
|
+
return new TwoModelSource(this.model, opts.sampler);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
dispose(): void {
|
|
71
|
+
this.weights.dispose();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
class TwoModelSource implements DraftSource {
|
|
76
|
+
readonly weightsBytes = 0; // provider-owned weights; per-request adds caches only
|
|
77
|
+
private caches: Cache[];
|
|
78
|
+
|
|
79
|
+
constructor(
|
|
80
|
+
private readonly model: RuntimeModel,
|
|
81
|
+
private readonly sampler: (lp: MlxArray, step: number) => MlxArray,
|
|
82
|
+
) {
|
|
83
|
+
this.caches = this.model.makeCache();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
prefill(promptIds: number[]): void {
|
|
87
|
+
// Oracle convention (mlx-lm speculative_generate_step._prefill, re-anchored
|
|
88
|
+
// 2026-07-07): the DRAFT model also drains only to len-1 (`while y.size >
|
|
89
|
+
// 1`) — the last prompt token stays unprocessed and is consumed by the
|
|
90
|
+
// FIRST draft() round's feed ([pending] = that same token under the serve
|
|
91
|
+
// loop's oracle shape), mirroring _draft_generate's first _step. Draft-side
|
|
92
|
+
// full-prefill was the residual knife-edge flipper in the 2026-07-07 live
|
|
93
|
+
// oracle gate (γ=2 haiku cell). MLX_BUN_PREFILL_TAIL_SPLIT=0 reverts.
|
|
94
|
+
// (mlx-lm chunks its drain at prefill_step_size=2048; chunking is
|
|
95
|
+
// numerically exact for causal attention — same KV, same positions —
|
|
96
|
+
// so we chunk too. Pre-2026-07-07 this was a SINGLE forward of the
|
|
97
|
+
// whole head: nothing enforced the "<2048 serve regime" the old
|
|
98
|
+
// comment assumed, and a 32k prompt through the spec lane ran one
|
|
99
|
+
// 32k-position draft forward — a large activation/mask transient on a
|
|
100
|
+
// 24 GB box, and untested numerics vs the chunked oracle.)
|
|
101
|
+
const tailSplit = flagOn("MLX_BUN_PREFILL_TAIL_SPLIT", true);
|
|
102
|
+
const upTo = tailSplit && promptIds.length > 1 ? promptIds.length - 1 : promptIds.length;
|
|
103
|
+
for (let at = 0; at < upTo; at += PREFILL_CHUNK) {
|
|
104
|
+
const end = Math.min(at + PREFILL_CHUNK, upTo);
|
|
105
|
+
const ids = ops.fromInt32(promptIds.slice(at, end), [1, end - at]);
|
|
106
|
+
const h = this.model.forwardHidden(ids, this.caches);
|
|
107
|
+
ids.dispose();
|
|
108
|
+
// The head's last-position logits are not needed: the serve loop's
|
|
109
|
+
// first `feed` is consumed at the top of draft().
|
|
110
|
+
h.dispose();
|
|
111
|
+
clearCache();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** One forward over `feed`, sample the first draft from its last position,
|
|
116
|
+
* then n-1 single-token steps — mirrors _draft_generate's autoregressive
|
|
117
|
+
* chain (with async_eval pipelining upstream; eager here, the draft is
|
|
118
|
+
* small and the serve loop overlaps nothing with it in v1). */
|
|
119
|
+
draft(feed: number[], n: number, stepBase: number): number[] {
|
|
120
|
+
const drafts: number[] = [];
|
|
121
|
+
let input = feed;
|
|
122
|
+
for (let k = 0; k < n; k++) {
|
|
123
|
+
const ids = ops.fromInt32(input, [1, input.length]);
|
|
124
|
+
const h = this.model.forwardHidden(ids, this.caches);
|
|
125
|
+
ids.dispose();
|
|
126
|
+
const L = h.shape[1]!;
|
|
127
|
+
const H = h.shape[2]!;
|
|
128
|
+
const hLast = h.slice([0, L - 1, 0], [1, L, H]);
|
|
129
|
+
h.dispose();
|
|
130
|
+
const logits = this.model.logitsFromHidden(hLast);
|
|
131
|
+
hLast.dispose();
|
|
132
|
+
const V = logits.shape[logits.shape.length - 1]!;
|
|
133
|
+
const flat = ops.reshape(logits, [1, V]);
|
|
134
|
+
logits.dispose();
|
|
135
|
+
const lp = toLogprobs(flat);
|
|
136
|
+
flat.dispose();
|
|
137
|
+
const tokArr = this.sampler(lp, stepBase + k);
|
|
138
|
+
lp.dispose();
|
|
139
|
+
const tok = ops.itemUint32(tokArr);
|
|
140
|
+
tokArr.dispose();
|
|
141
|
+
drafts.push(tok);
|
|
142
|
+
input = [tok];
|
|
143
|
+
}
|
|
144
|
+
return drafts;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
commit(n: number, kAccept: number): void {
|
|
148
|
+
const rewind = Math.max(n - kAccept - 1, 0);
|
|
149
|
+
if (rewind > 0) {
|
|
150
|
+
for (const c of this.caches) {
|
|
151
|
+
if (!c.isTrimmable()) {
|
|
152
|
+
// The serve loop stops speculating on the TARGET-side trim check
|
|
153
|
+
// before this can matter; a wrapped draft ring here just means the
|
|
154
|
+
// source is done being useful. Recreate is the safe recovery.
|
|
155
|
+
this.resetCaches();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
c.trim(rewind);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private resetCaches(): void {
|
|
164
|
+
for (const c of this.caches) c.dispose();
|
|
165
|
+
this.caches = this.model.makeCache();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
dispose(): void {
|
|
169
|
+
for (const c of this.caches) c.dispose();
|
|
170
|
+
}
|
|
171
|
+
}
|
package/src/ssd-cache.ts
CHANGED
|
@@ -21,7 +21,7 @@ import { join } from "node:path";
|
|
|
21
21
|
import { randomUUID } from "node:crypto";
|
|
22
22
|
import { commonPrefixLength } from "./prompt-cache";
|
|
23
23
|
import {
|
|
24
|
-
saveKvCache, loadKvCache, readKvHeader,
|
|
24
|
+
saveKvCache, saveKvCacheAsync, loadKvCache, readKvHeader,
|
|
25
25
|
type KvSaveMeta, type KvLoadExpect, type LoadedKvCache,
|
|
26
26
|
} from "./kv-store";
|
|
27
27
|
import type { Cache } from "./model/gemma4-base";
|
|
@@ -32,8 +32,27 @@ export interface SsdIndexEntry {
|
|
|
32
32
|
tokens: number[];
|
|
33
33
|
bytes: number;
|
|
34
34
|
mtimeMs: number;
|
|
35
|
+
/** Whether every serialized cache can trim (derived from kinds+state at
|
|
36
|
+
* store/scan time). An untrimmable entry (wrapped ring, SSM) can only
|
|
37
|
+
* seed prompts it matches WITHOUT a trim — find() must not prefer it
|
|
38
|
+
* over a shorter usable one (the 2026-07-06 restart-0 defect: the
|
|
39
|
+
* [prompt+gen] file always won, always got rejected after restore). */
|
|
40
|
+
trimmable: boolean;
|
|
35
41
|
}
|
|
36
42
|
|
|
43
|
+
/** Trimmability from a file header alone (mirror of the live caches'
|
|
44
|
+
* isTrimmable(): kv/qkv/turboquant always trim; rotating kinds only pre-wrap
|
|
45
|
+
* (offset < maxSize); GLM compressed kinds always trim; ssm never). Keep in
|
|
46
|
+
* sync with gemma4-base/qwen3-delta/glm52-cache. */
|
|
47
|
+
const headerTrimmable = (caches: { kind: string; offset: number; maxSize?: number }[]): boolean =>
|
|
48
|
+
caches.every((c) =>
|
|
49
|
+
c.kind === "kv" || c.kind === "qkv" || c.kind === "turboquant" ||
|
|
50
|
+
c.kind === "mla" || c.kind === "mla-dsa" || c.kind === "mtp-mla"
|
|
51
|
+
? true
|
|
52
|
+
: c.kind === "rotating" || c.kind === "rotating-qkv"
|
|
53
|
+
? c.offset < (c.maxSize ?? 0)
|
|
54
|
+
: false);
|
|
55
|
+
|
|
37
56
|
export interface SsdStoreOptions {
|
|
38
57
|
dir: string;
|
|
39
58
|
maxBytes: number;
|
|
@@ -94,6 +113,7 @@ export class SsdCacheStore {
|
|
|
94
113
|
try {
|
|
95
114
|
const h = readKvHeader(path); // verifies the header hash
|
|
96
115
|
if (
|
|
116
|
+
h.modelId !== this.#opts.modelId ||
|
|
97
117
|
h.configFingerprint !== this.#opts.configFingerprint ||
|
|
98
118
|
h.tokenizerHash !== this.#opts.tokenizerHash
|
|
99
119
|
)
|
|
@@ -101,6 +121,7 @@ export class SsdCacheStore {
|
|
|
101
121
|
const st = statSync(path);
|
|
102
122
|
this.#index.push({
|
|
103
123
|
path, ns: h.ns ?? "", tokens: h.tokens, bytes: st.size, mtimeMs: st.mtimeMs,
|
|
124
|
+
trimmable: headerTrimmable(h.caches),
|
|
104
125
|
});
|
|
105
126
|
} catch {
|
|
106
127
|
try { rmSync(path, { force: true }); } catch {}
|
|
@@ -110,13 +131,15 @@ export class SsdCacheStore {
|
|
|
110
131
|
return this.#index.length;
|
|
111
132
|
}
|
|
112
133
|
|
|
113
|
-
/** Best stored entry for `prompt` in `ns`: the longest common
|
|
114
|
-
* (capped at prompt.length−1 — at least one token must forward).
|
|
115
|
-
* entry LONGER than the matched prefix is
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
134
|
+
/** Best USABLE stored entry for `prompt` in `ns`: the longest common
|
|
135
|
+
* prefix (capped at prompt.length−1 — at least one token must forward).
|
|
136
|
+
* An entry LONGER than the matched prefix is returned only when it can
|
|
137
|
+
* actually seed the prompt: the divergent tail must be trimmable after
|
|
138
|
+
* restore. Untrimmable entries (ring post-wrap, SSM) that would need a
|
|
139
|
+
* trim are SKIPPED here — before this gate, the big [prompt+gen] file
|
|
140
|
+
* always outranked the usable boundary snapshot, got restored, and was
|
|
141
|
+
* thrown away by take()'s backstop (the 2026-07-06 restart-0 + wasted
|
|
142
|
+
* 84 s restore-then-re-prefill path). Pure index lookup; no I/O. */
|
|
120
143
|
find(prompt: number[], ns = ""): { entry: SsdIndexEntry; prefixLen: number } | null {
|
|
121
144
|
let best: SsdIndexEntry | null = null;
|
|
122
145
|
let bestLen = 0;
|
|
@@ -124,18 +147,22 @@ export class SsdCacheStore {
|
|
|
124
147
|
if (e.ns !== ns) continue;
|
|
125
148
|
const p = Math.min(commonPrefixLength(e.tokens, prompt), prompt.length - 1);
|
|
126
149
|
if (p === 0 || p <= bestLen) continue;
|
|
150
|
+
if (e.tokens.length - p > 0 && !e.trimmable) continue;
|
|
127
151
|
bestLen = p;
|
|
128
152
|
best = e;
|
|
129
153
|
}
|
|
130
154
|
return best ? { entry: best, prefixLen: bestLen } : null;
|
|
131
155
|
}
|
|
132
156
|
|
|
133
|
-
/** Restore an entry
|
|
134
|
-
*
|
|
157
|
+
/** Restore an entry via kv-store's STREAMED COPY (bounded host
|
|
158
|
+
* transient — live entry + one tensor; no mapping survives the call,
|
|
159
|
+
* the caches own their bytes). Bumps LRU mtime. On ANY failure the
|
|
160
|
+
* file is dropped and null returned. */
|
|
135
161
|
restore(entry: SsdIndexEntry, model: { makeCache(): Cache[] }): LoadedKvCache | null {
|
|
136
162
|
const t0 = performance.now();
|
|
137
163
|
try {
|
|
138
164
|
const expect: KvLoadExpect = {
|
|
165
|
+
modelId: this.#opts.modelId,
|
|
139
166
|
configFingerprint: this.#opts.configFingerprint,
|
|
140
167
|
tokenizerHash: this.#opts.tokenizerHash,
|
|
141
168
|
ns: entry.ns,
|
|
@@ -147,7 +174,10 @@ export class SsdCacheStore {
|
|
|
147
174
|
this.stats.restores++;
|
|
148
175
|
this.stats.restoreMsLast = performance.now() - t0;
|
|
149
176
|
return loaded;
|
|
150
|
-
} catch {
|
|
177
|
+
} catch (err) {
|
|
178
|
+
// Loud: a failed restore silently degraded to a full re-prefill for
|
|
179
|
+
// a month before the 2026-07-06 bench made it observable.
|
|
180
|
+
console.warn(`[ssd-cache] restore failed, dropping ${entry.path}: ${(err as Error).message}`);
|
|
151
181
|
this.remove(entry.path);
|
|
152
182
|
return null;
|
|
153
183
|
}
|
|
@@ -165,35 +195,81 @@ export class SsdCacheStore {
|
|
|
165
195
|
const path = join(dir, `${randomUUID()}.mlxkv`);
|
|
166
196
|
try {
|
|
167
197
|
mkdirSync(dir, { recursive: true });
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
198
|
+
saveKvCache(path, tokens, caches, this.#meta(ns));
|
|
199
|
+
return this.#indexStored(path, tokens, caches, ns);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
return this.#storeFailed(path, err);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Non-blocking store (the write-behind persistence path): same file,
|
|
206
|
+
* index, and supersede semantics as store(), but the tensor flush
|
|
207
|
+
* yields the event loop between tensors so serving interleaves.
|
|
208
|
+
* Caller passes zero-copy CLONES it owns (a consistent snapshot no
|
|
209
|
+
* matter what the live entry does meanwhile) and disposes them after.
|
|
210
|
+
* `waitTurn` gates every per-tensor step (see saveKvCacheAsync) — the
|
|
211
|
+
* server passes the gateway's onIdle so the flush only progresses while
|
|
212
|
+
* the engine is idle, never mid-decode. */
|
|
213
|
+
async storeAsync(tokens: number[], caches: Cache[], ns = "", waitTurn?: () => Promise<void>): Promise<boolean> {
|
|
214
|
+
const dir = join(this.#root, nsHash(ns));
|
|
215
|
+
const path = join(dir, `${randomUUID()}.mlxkv`);
|
|
216
|
+
try {
|
|
217
|
+
mkdirSync(dir, { recursive: true });
|
|
218
|
+
await saveKvCacheAsync(path, tokens, caches, this.#meta(ns), waitTurn);
|
|
219
|
+
return this.#indexStored(path, tokens, caches, ns);
|
|
220
|
+
} catch (err) {
|
|
221
|
+
return this.#storeFailed(path, err);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
#meta(ns: string): KvSaveMeta {
|
|
226
|
+
return {
|
|
227
|
+
modelId: this.#opts.modelId,
|
|
228
|
+
configFingerprint: this.#opts.configFingerprint,
|
|
229
|
+
tokenizerHash: this.#opts.tokenizerHash,
|
|
230
|
+
ns,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
#indexStored(path: string, tokens: number[], caches: Cache[], ns: string): boolean {
|
|
235
|
+
const st = statSync(path);
|
|
236
|
+
if (st.size > this.#opts.maxBytes) {
|
|
237
|
+
rmSync(path, { force: true });
|
|
238
|
+
console.warn(`[ssd-cache] entry not stored: ${st.size} bytes exceeds the ${this.#opts.maxBytes}-byte cap`);
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
const trimmable = caches.every((c) => c.isTrimmable());
|
|
242
|
+
// Exact duplicates (same tokens) are replaced regardless of
|
|
243
|
+
// trimmability — the new file serves exactly the old one's matches.
|
|
244
|
+
for (const e of [...this.#index]) {
|
|
245
|
+
if (e.ns !== ns || e.tokens.length !== tokens.length) continue;
|
|
246
|
+
if (commonPrefixLength(e.tokens, tokens) === tokens.length) this.remove(e.path);
|
|
247
|
+
}
|
|
248
|
+
// Supersede shadowed ancestors of this prefix (same ns) — ONLY when
|
|
249
|
+
// the new entry can serve their prefixes (all caches trimmable; the
|
|
250
|
+
// same guard as PromptCache.put()). An untrimmable [prompt+gen]
|
|
251
|
+
// entry deleting the boundary snapshot was the gemma restart-0
|
|
252
|
+
// defect (2026-07-06): the only restorable file died the moment the
|
|
253
|
+
// unrestorable one landed.
|
|
254
|
+
if (trimmable) {
|
|
181
255
|
for (const e of [...this.#index]) {
|
|
182
256
|
if (e.ns !== ns || e.tokens.length >= tokens.length) continue;
|
|
183
257
|
if (commonPrefixLength(e.tokens, tokens) === e.tokens.length) this.remove(e.path);
|
|
184
258
|
}
|
|
185
|
-
this.#index.push({ path, ns, tokens, bytes: st.size, mtimeMs: Date.now() });
|
|
186
|
-
this.stats.spills++;
|
|
187
|
-
this.evictToCap();
|
|
188
|
-
return true;
|
|
189
|
-
} catch (err) {
|
|
190
|
-
try { rmSync(path, { force: true }); rmSync(`${path}.tmp`, { force: true }); } catch {}
|
|
191
|
-
if (!this.#warnedWriteFailure) {
|
|
192
|
-
this.#warnedWriteFailure = true;
|
|
193
|
-
console.warn(`[ssd-cache] store failed (disk full or unwritable?) — cold tier disabled for this entry: ${(err as Error).message}`);
|
|
194
|
-
}
|
|
195
|
-
return false;
|
|
196
259
|
}
|
|
260
|
+
this.#index.push({ path, ns, tokens, bytes: st.size, mtimeMs: Date.now(), trimmable });
|
|
261
|
+
this.stats.spills++;
|
|
262
|
+
this.evictToCap();
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
#storeFailed(path: string, err: unknown): boolean {
|
|
267
|
+
try { rmSync(path, { force: true }); rmSync(`${path}.tmp`, { force: true }); } catch {}
|
|
268
|
+
if (!this.#warnedWriteFailure) {
|
|
269
|
+
this.#warnedWriteFailure = true;
|
|
270
|
+
console.warn(`[ssd-cache] store failed (disk full or unwritable?) — cold tier disabled for this entry: ${(err as Error).message}`);
|
|
271
|
+
}
|
|
272
|
+
return false;
|
|
197
273
|
}
|
|
198
274
|
|
|
199
275
|
/** Enforce the byte cap: unlink oldest-mtime entries until under it. */
|