mlx-bun 0.3.0 → 0.4.0

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.
Files changed (150) hide show
  1. package/docs/README.md +4 -1
  2. package/docs/archive/investigations/openjarvis-fit.md +198 -0
  3. package/docs/archive/investigations/qwen38-27b-campaign-log.md +1092 -0
  4. package/docs/design/batching.md +1321 -49
  5. package/docs/design/decode-speed-program.md +331 -1163
  6. package/docs/design/generic-model-support.md +79 -3
  7. package/docs/design/kv-cache.md +198 -54
  8. package/docs/design/speculative-decoding.md +387 -24
  9. package/docs/design/turboquant.md +123 -13
  10. package/docs/design/unified-engine-frontier-plan.md +144 -142
  11. package/docs/planning/PRODUCT_ROADMAP.md +16 -0
  12. package/docs/planning/release-notes-v0.4.0.md +45 -0
  13. package/docs/reference/benchmarks.md +2457 -6
  14. package/docs/reference/cli.md +41 -2
  15. package/docs/reference/distribution.md +16 -9
  16. package/docs/reference/environment.md +55 -5
  17. package/docs/reference/models.md +13 -4
  18. package/docs/reference/server-api.md +79 -25
  19. package/docs/reference/server-config.md +178 -84
  20. package/docs/reference/troubleshooting.md +13 -21
  21. package/package.json +1 -1
  22. package/src/backends/mlx/assistant-target.ts +46 -0
  23. package/src/backends/mlx/autoregressive-draft-rows.ts +120 -0
  24. package/src/backends/mlx/batch-group.ts +368 -224
  25. package/src/backends/mlx/cache-attachment.ts +67 -0
  26. package/src/backends/mlx/cache-layout.ts +31 -0
  27. package/src/backends/mlx/checkpoint-state.ts +53 -0
  28. package/src/backends/mlx/continuation-persistence.ts +106 -0
  29. package/src/backends/mlx/continuation-request.ts +43 -0
  30. package/src/backends/mlx/continuation.ts +77 -0
  31. package/src/backends/mlx/draft-target.ts +7 -22
  32. package/src/backends/mlx/full-kv-row-append.ts +32 -0
  33. package/src/backends/mlx/full-kv-row-donor.ts +54 -0
  34. package/src/backends/mlx/full-transitioning-kv-rows.ts +65 -0
  35. package/src/backends/mlx/gateway-binding.ts +77 -18
  36. package/src/backends/mlx/kv-codec.ts +9 -0
  37. package/src/backends/mlx/kv-maintenance.ts +133 -0
  38. package/src/backends/mlx/kv-tensor-rows.ts +212 -0
  39. package/src/backends/mlx/model-serving.ts +1 -1
  40. package/src/backends/mlx/prefill-cohort.ts +89 -0
  41. package/src/backends/mlx/prefill-rows.ts +200 -0
  42. package/src/backends/mlx/prefill.ts +1 -1
  43. package/src/backends/mlx/projected-context-rows.ts +72 -0
  44. package/src/backends/mlx/request-state-policy.ts +41 -0
  45. package/src/backends/mlx/rollback.ts +41 -3
  46. package/src/backends/mlx/row-sampling.ts +49 -0
  47. package/src/backends/mlx/serial-executor.ts +35 -66
  48. package/src/backends/mlx/speculative-group.ts +359 -0
  49. package/src/backends/mlx/speculative-round.ts +161 -0
  50. package/src/backends/mlx/state-rows.ts +104 -0
  51. package/src/backends/mlx/state-views.ts +21 -1
  52. package/src/backends/mlx/target-layout-capability.ts +35 -0
  53. package/src/backends/mlx/transitioning-kv-rows.ts +165 -0
  54. package/src/cli.ts +11 -36
  55. package/src/contracts/execution.ts +11 -0
  56. package/src/contracts/prefix-cache.ts +23 -0
  57. package/src/contracts/resources.ts +7 -0
  58. package/src/contracts/sampling.ts +23 -0
  59. package/src/contracts/scheduling.ts +18 -0
  60. package/src/engine/execution-plan.ts +18 -16
  61. package/src/engine/resources.ts +12 -1
  62. package/src/engine/scheduler.ts +10 -5
  63. package/src/fit.ts +29 -15
  64. package/src/generate.ts +77 -173
  65. package/src/inference/draft-acceptance.ts +41 -0
  66. package/src/inference/draft-output.ts +49 -0
  67. package/src/inference/rollback.ts +2 -2
  68. package/src/kv-scheme.ts +70 -12
  69. package/src/kv-store.ts +70 -16
  70. package/src/lab/paged-kv/paged-kv-rows.ts +83 -0
  71. package/src/lab/paged-kv/paged-kv.ts +25 -11
  72. package/src/mlx/array.ts +2 -1
  73. package/src/model/artifact-identity.ts +73 -0
  74. package/src/model/batched-kv.ts +235 -0
  75. package/src/model/batched-mask.ts +12 -4
  76. package/src/model/batched-quant.ts +8 -2
  77. package/src/model/batched-quantized-kv.ts +144 -0
  78. package/src/model/batched-rotating-quant.ts +76 -15
  79. package/src/model/batched-rotating-state.ts +76 -0
  80. package/src/model/batched-rotating.ts +64 -22
  81. package/src/model/batched-row-storage.ts +52 -0
  82. package/src/model/batched-ssm.ts +52 -0
  83. package/src/model/batched-turboquant-kv.ts +93 -0
  84. package/src/model/compiled-decode.ts +7 -1
  85. package/src/model/decoded-kv-donor.ts +12 -0
  86. package/src/model/delayed-quantized-kv.ts +72 -0
  87. package/src/model/delayed-rotating-quantized-kv.ts +93 -0
  88. package/src/model/delayed-turboquant-kv.ts +55 -0
  89. package/src/model/full-prefill-padding.ts +56 -0
  90. package/src/model/full-prefill-row.ts +105 -0
  91. package/src/model/gemma4-base.ts +162 -80
  92. package/src/model/gemma4.ts +34 -26
  93. package/src/model/generated/gemma4-12b.ts +4 -1
  94. package/src/model/generated/gemma4-26b.ts +4 -1
  95. package/src/model/generated/gemma4-e4b.ts +4 -1
  96. package/src/model/glm52-cache.ts +80 -4
  97. package/src/model/glm52-mla.ts +12 -6
  98. package/src/model/glm52.ts +19 -40
  99. package/src/model/kv-attention-view.ts +67 -0
  100. package/src/model/minicpm5.ts +11 -7
  101. package/src/model/qwen3-delta-state.ts +74 -0
  102. package/src/model/qwen3-delta.ts +59 -36
  103. package/src/model/qwen3_5.ts +82 -42
  104. package/src/model/rotating-kv-donor.ts +35 -0
  105. package/src/model/rotating-kv-layout.ts +262 -0
  106. package/src/model/rotating-row-transaction.ts +35 -0
  107. package/src/model/speculative-rotating-kv.ts +101 -0
  108. package/src/model/ssm-prefill-padding.ts +39 -0
  109. package/src/model/trellis-linear.ts +2 -2
  110. package/src/model/turboquant-codec.ts +83 -0
  111. package/src/model/universal/dense.ts +14 -13
  112. package/src/preflight.ts +31 -15
  113. package/src/prompt-cache.ts +42 -44
  114. package/src/sampler.ts +36 -25
  115. package/src/serve/chat-stage.ts +2 -2
  116. package/src/serve/generation-gateway.ts +39 -30
  117. package/src/serve/model-host.ts +3 -4
  118. package/src/serve/request-plan.ts +4 -3
  119. package/src/serve/request-prep.ts +2 -4
  120. package/src/serve/text-completion-stage.ts +2 -2
  121. package/src/server.ts +64 -108
  122. package/src/spec/assistant-rows.ts +92 -0
  123. package/src/spec/assistant-source.ts +28 -18
  124. package/src/spec/cache-identity.ts +22 -0
  125. package/src/spec/deepspec-rows.ts +29 -0
  126. package/src/spec/deepspec-source.ts +12 -10
  127. package/src/spec/dflash-source.ts +28 -4
  128. package/src/spec/drafter.ts +153 -142
  129. package/src/spec/dspark/deepspec-module.ts +95 -120
  130. package/src/spec/dspark/module-dflash.ts +126 -170
  131. package/src/spec/glm52-mtp-graph.ts +29 -0
  132. package/src/spec/glm52-mtp-rows.ts +132 -0
  133. package/src/spec/glm52-mtp-source.ts +53 -136
  134. package/src/spec/glm52-mtp-state.ts +33 -0
  135. package/src/spec/ngram-source.ts +92 -20
  136. package/src/spec/projected-draft-rows.ts +73 -0
  137. package/src/spec/qwen-mtp-module.ts +171 -0
  138. package/src/spec/qwen-mtp-rows.ts +219 -0
  139. package/src/spec/qwen-mtp-source.ts +81 -226
  140. package/src/spec/qwen-mtp-state.ts +30 -0
  141. package/src/spec/serve-loop.ts +70 -68
  142. package/src/spec/source.ts +80 -15
  143. package/src/spec/two-model-rows.ts +61 -0
  144. package/src/spec/two-model.ts +43 -101
  145. package/src/ssd-cache.ts +33 -21
  146. package/src/ssd-durability.ts +11 -7
  147. package/src/web/app.js +57 -57
  148. package/src/backends/mlx/memory-guard.ts +0 -40
  149. package/src/spec/prefix-state.ts +0 -45
  150. /package/docs/{planning → archive/planning}/release-notes-v0.3.0.md +0 -0
@@ -0,0 +1,35 @@
1
+ import { Glm52Cache } from "../../model/glm52-cache";
2
+ import { SpeculativeRotatingKVCache } from "../../model/speculative-rotating-kv";
3
+ import { RotatingAffineLayout, SpeculativeRotatingAffineLayout } from "../../model/rotating-kv-layout";
4
+ import type { Cache } from "../../model/gemma4-base";
5
+ import { KVCache, QuantizedKVCache, TurboQuantKVCache, RotatingKVCache, RotatingQuantizedKVCache } from "../../model/gemma4-base";
6
+ import { DelayedRotatingQuantizedKVCache } from "../../model/delayed-rotating-quantized-kv";
7
+ import { DelayedQuantizedKVCache } from "../../model/delayed-quantized-kv";
8
+ import { DelayedTurboQuantKVCache } from "../../model/delayed-turboquant-kv";
9
+ import { SSMCache } from "../../model/qwen3-delta";
10
+ import { BatchedSSMCache } from "../../model/batched-ssm";
11
+ import { BatchedKVCache } from "../../model/batched-kv";
12
+ import { BatchedQuantizedKVCache } from "../../model/batched-quantized-kv";
13
+ import { BatchedTurboQuantKVCache } from "../../model/batched-turboquant-kv";
14
+
15
+ export type TargetRowLayout = Glm52Cache | DelayedRotatingQuantizedKVCache | RotatingAffineLayout | SpeculativeRotatingKVCache | BatchedKVCache | BatchedQuantizedKVCache | BatchedTurboQuantKVCache |
16
+ BatchedSSMCache | DelayedTurboQuantKVCache | DelayedQuantizedKVCache;
17
+
18
+ /** The storage binding advertises a row transaction factory. The gateway can
19
+ * compose supported model graphs without identifying their model family. */
20
+ export function targetRowLayoutFactory(cache: Cache): (() => TargetRowLayout) | undefined {
21
+ if (cache instanceof Glm52Cache) return () => cache.makeEmptyBatch();
22
+ if (cache instanceof DelayedRotatingQuantizedKVCache)
23
+ return () => new DelayedRotatingQuantizedKVCache(cache.maxSize, cache.groupSize, cache.bits, cache.start, cache.maintain, undefined, true);
24
+ if (cache instanceof RotatingAffineLayout || cache instanceof RotatingQuantizedKVCache)
25
+ return () => new SpeculativeRotatingAffineLayout(cache.maxSize, cache.groupSize, cache.bits);
26
+ if (cache instanceof RotatingKVCache || cache instanceof SpeculativeRotatingKVCache) return () => new SpeculativeRotatingKVCache(cache.maxSize);
27
+ if (cache instanceof DelayedTurboQuantKVCache || cache instanceof DelayedQuantizedKVCache ||
28
+ cache instanceof BatchedTurboQuantKVCache || cache instanceof BatchedKVCache ||
29
+ cache instanceof BatchedQuantizedKVCache || cache instanceof BatchedSSMCache) return () => cache.makeEmptyBatch();
30
+ if (cache instanceof TurboQuantKVCache) return () => new BatchedTurboQuantKVCache(cache.kBits, cache.vBits);
31
+ if (cache instanceof SSMCache) return () => new BatchedSSMCache();
32
+ if (cache instanceof QuantizedKVCache) return () => new BatchedQuantizedKVCache(cache.groupSize, cache.bits);
33
+ if (cache instanceof KVCache) return () => new BatchedKVCache();
34
+ return undefined;
35
+ }
@@ -0,0 +1,165 @@
1
+ import type { MlxArray } from "../../mlx/array";
2
+ import * as ops from "../../mlx/ops";
3
+ import type { BatchableCache, Cache, Mask } from "../../model/gemma4-base";
4
+ import { KvTensorRows } from "./kv-tensor-rows";
5
+ import { leaseCacheStates, minimumReusableOffset } from "./state-views";
6
+ import { withResource, disposeResources } from "../../engine/resources";
7
+ import { cloneKvCaches } from "../../kv-store";
8
+
9
+ /** Encoded layouts retain physical row alignment across precision changes. */
10
+ export interface TransitionedKvLayout extends BatchableCache {
11
+ alignRows?(leftPad: readonly number[]): void;
12
+ }
13
+ export interface SpeculativeTransitionedKvLayout extends TransitionedKvLayout {
14
+ specRoundBegin(): void;
15
+ specRoundCommit(): void;
16
+ specRoundRollback(keep: number | readonly number[]): void;
17
+ }
18
+ export interface KvRowTransition<Layout extends TransitionedKvLayout> {
19
+ readonly signature: string;
20
+ maintain(rows: Cache[]): void;
21
+ converted(row: Cache): boolean;
22
+ makeLayout(): Layout;
23
+ packRows?(layout: Layout, rows: readonly Cache[]): void;
24
+ prepareRows?(rows: Cache[]): Cache[];
25
+ extractRow?(row: Cache): Cache;
26
+ rollbackRow?(row: Cache, before: number, keep: number, preserveColumns: boolean): void;
27
+ }
28
+
29
+
30
+ export interface TransitioningKvPositions {
31
+ readonly rowOffsets: readonly number[];
32
+ readonly leftPad: readonly number[];
33
+ readonly offset: number;
34
+ readonly batchSize: number | null;
35
+ readonly ropeOffsetArr: MlxArray | undefined;
36
+ sync(rows: readonly Cache[], preserve: boolean): void;
37
+ filterRows(keep: readonly number[]): void;
38
+ makeMask(tokens: number, window: number | null): Mask;
39
+ dispose(): void;
40
+ }
41
+ export class FullKvPositions extends KvTensorRows implements TransitioningKvPositions {
42
+ sync(rows: readonly Cache[], preserve: boolean): void {
43
+ this.mergeRows(preserve
44
+ ? [{ planes: [], rowOffsets: rows.map(row => row.offset), leftPad: [...this.leftPad] }]
45
+ : rows.map(row => ({ planes: [], rowOffsets: [row.offset], leftPad: [0] })));
46
+ }
47
+ }
48
+
49
+ /** Owns membership, committed positions and precision transitions. Codec and
50
+ * attention adapters own tensor access; scheduling and persistence use the
51
+ * existing batch and checkpoint contracts. Row conversion never examines an
52
+ * uncommitted speculative suffix. */
53
+ export abstract class TransitioningKvRows<Layout extends TransitionedKvLayout> implements BatchableCache {
54
+ protected readonly positions: TransitioningKvPositions;
55
+ protected rows: Cache[] = [];
56
+ protected packed?: Layout;
57
+ #prefilling = false;
58
+
59
+ readonly stateNeedsDispose = true;
60
+ constructor(readonly transition: KvRowTransition<Layout>, row?: Cache, positions: TransitioningKvPositions = new FullKvPositions()) {
61
+ this.positions = positions;
62
+ if (row) { this.rows = transition.prepareRows?.([row]) ?? [row]; this.syncPositions(); }
63
+ }
64
+ signature(): string { return this.transition.signature; }
65
+ get prefillMaintenance() { return this; }
66
+ beginPrefill(): void { this.#prefilling = true; }
67
+ commitPrefill(indices: readonly number[]): void {
68
+ if (this.packed) return;
69
+ for (const index of indices) {
70
+ const row = [this.rows[index]!];
71
+ try { this.transition.maintain(row); }
72
+ finally { this.rows[index] = row[0]!; }
73
+ }
74
+ }
75
+ endPrefill(): void { this.#prefilling = false; }
76
+ get minimumReusableOffset(): number { return this.packed?.minimumReusableOffset ?? minimumReusableOffset(this.rows); }
77
+ get rowOffsets(): readonly number[] { return this.packed?.rowOffsets ?? this.positions.rowOffsets; }
78
+ get leftPad(): readonly number[] { return this.packed?.leftPad ?? this.positions.leftPad; }
79
+ get offset(): number { return this.packed?.offset ?? this.positions.offset; }
80
+ get batchSize(): number | null { return this.packed?.batchSize ?? this.positions.batchSize; }
81
+ get ropeOffsetArr(): MlxArray | undefined { return this.packed ? this.packed.ropeOffsetArr : this.positions.ropeOffsetArr; }
82
+ bytesPerToken(): number { return this.packed?.bytesPerToken?.() ?? Math.max(0, ...this.rows.map(row => row.bytesPerToken?.() ?? 0)); }
83
+ projectedBytes(tokens: number): number { return this.bytesPerToken() * tokens; }
84
+ makeMask(tokens: number, window: number | null): Mask { return (this.packed ?? this.positions).makeMask(tokens, window); }
85
+ isTrimmable(): boolean { return this.packed?.isTrimmable() ?? this.rows.every(row => row.isTrimmable()); }
86
+ trim(count: number): void {
87
+ if (this.packed) this.packed.trim(count);
88
+ else { for (const row of this.rows) row.trim(count); this.syncPositions(true); }
89
+ }
90
+ protected syncPositions(preserve = false): void { this.positions.sync(this.rows, preserve); }
91
+ protected advance(): void {
92
+ if (this.packed) return;
93
+ if (!this.#prefilling) this.transition.maintain(this.rows);
94
+ if (this.rows.length && this.rows.every(row => this.transition.converted(row))) {
95
+ const packed = this.transition.makeLayout();
96
+ try {
97
+ if (this.transition.packRows) this.transition.packRows(packed, this.rows);
98
+ else packed.mergeRows(this.rows);
99
+ packed.alignRows?.(this.positions.leftPad);
100
+ }
101
+ catch (error) { packed.dispose(); throw error; }
102
+ const previous = this.rows; this.rows = []; this.packed = packed;
103
+ this.positions.dispose(); disposeResources(previous);
104
+ }
105
+ }
106
+ abstract updateAndFetch(k: MlxArray, v: MlxArray): [MlxArray, MlxArray];
107
+ abstract makeEmptyBatch(): TransitioningKvRows<Layout>;
108
+ mergeRows(rows: readonly Cache[]): void {
109
+ const owned: Cache[] = [];
110
+ try {
111
+ for (const row of rows) {
112
+ if (row instanceof TransitioningKvRows) {
113
+ for (let index = 0; index < row.batchSize!; index++) owned.push(row.extractRow(index));
114
+ } else owned.push(...cloneKvCaches([row]));
115
+ }
116
+ } catch (error) { disposeResources(owned); throw error; }
117
+ let prepared: Cache[];
118
+ try { prepared = this.transition.prepareRows?.(owned) ?? owned; }
119
+ catch (error) { disposeResources(owned); throw error; }
120
+ this.dispose(); this.rows = prepared; this.syncPositions();
121
+ }
122
+ filterRows(keep: readonly number[]): void {
123
+ if (this.packed) { this.packed.filterRows(keep); return; }
124
+ const previous = this.rows; this.rows = keep.map(index => previous[index]!);
125
+ disposeResources(previous.filter((_, index) => !keep.includes(index))); this.positions.filterRows(keep);
126
+ }
127
+ extractRow(row: number): Cache { return this.packed?.extractRow(row) ?? (this.transition.extractRow?.(this.rows[row]!) ?? cloneKvCaches([this.rows[row]!])[0]!); }
128
+ state(): MlxArray[] {
129
+ return withResource(leaseCacheStates(this.packed ? [this.packed] : this.rows), arrays => {
130
+ const views: MlxArray[] = [];
131
+ try { for (const array of arrays) views.push(ops.contiguous(array)); return views; }
132
+ catch (error) { disposeResources(views); throw error; }
133
+ });
134
+ }
135
+ dispose(): void {
136
+ this.#prefilling = false;
137
+ const previous = this.rows; this.rows = [];
138
+ this.packed?.dispose(); this.packed = undefined; this.positions.dispose(); disposeResources(previous);
139
+ }
140
+ }
141
+
142
+ /** Rollback is a method capability, separate from ordinary cache membership. */
143
+ export abstract class SpeculativeTransitioningKvRows<Layout extends SpeculativeTransitionedKvLayout> extends TransitioningKvRows<Layout> {
144
+ #beforeRound?: number[];
145
+ specRoundBegin(): void {
146
+ this.advance(); this.#beforeRound = [...this.rowOffsets]; this.packed?.specRoundBegin();
147
+ }
148
+ specRoundCommit(): void {
149
+ this.packed?.specRoundCommit(); this.#beforeRound = undefined; this.advance();
150
+ }
151
+ specRoundRollback(keep: number | readonly number[]): void {
152
+ if (this.packed) this.packed.specRoundRollback(keep);
153
+ else {
154
+ for (const [index, row] of this.rows.entries()) {
155
+ const count = typeof keep === "number" ? keep : keep[index]!;
156
+ const before = this.#beforeRound![index]!;
157
+ if (this.transition.rollbackRow) this.transition.rollbackRow(row, before, count, this.rows.length > 1);
158
+ else row.trim(row.offset - before - count);
159
+ }
160
+ this.syncPositions(true);
161
+ }
162
+ this.#beforeRound = undefined; this.advance();
163
+ }
164
+ override dispose(): void { this.#beforeRound = undefined; super.dispose(); }
165
+ }
package/src/cli.ts CHANGED
@@ -120,10 +120,11 @@ const SERVER_FLAGS = `Server options:
120
120
  --ssd-cache-verify Verify tensor hashes on every restore (reads all
121
121
  bytes eagerly — integrity paranoia only)
122
122
  --generation-checkpoint <tokens>
123
- Snapshot an in-flight serial generation to the SSD
123
+ Queue an in-flight generation snapshot to the SSD
124
124
  cache every N emitted tokens. Repeating the same
125
125
  request after restart replays and resumes it.
126
- Requires --ssd-cache and --batch 1.
126
+ Requires --ssd-cache; supports ordinary requests
127
+ with shared cache layouts.
127
128
  --isolate Run the inference engine as a CHILD process and
128
129
  keep this process a pure UI/API proxy — the UI
129
130
  stays instant under GPU load and survives engine
@@ -169,8 +170,8 @@ Model & quality:
169
170
  auto-detected — a full same-tokenizer model
170
171
  (mlx_lm.server parity), a Gemma "-assistant"
171
172
  KV-borrowing drafter, or a DSpark checkpoint.
172
- Serial lane only — with --batch N a mounted draft
173
- routes every request serial, like mlx_lm.server.
173
+ Supported draft providers use shared batched
174
+ execution; ordinary requests can batch too.
174
175
  --draft-kind <kind> Override draft-artifact detection:
175
176
  two-model | assistant | dspark | deepspec | mtp | ngram
176
177
  (deepspec = DeepSeek's released Gemma4DSparkModel
@@ -205,9 +206,8 @@ Model & quality:
205
206
  only; sliding-window layers stay bf16)]
206
207
  --paged-kv OPTIONAL vLLM-style paged KV storage (block pool +
207
208
  gather before the stock SDPA; env MLX_BUN_PAGED_KV=1).
208
- v1: serial only (pins --batch 1 unless --batch is
209
- given), Gemma4-family, bf16 — refuses --batch N>1,
210
- --kv-quant, --draft-model; bypasses the prompt
209
+ Gemma4-family, bf16; supports --batch N.
210
+ Refuses --kv-quant and --draft-model; bypasses the prompt
211
211
  cache; runs uncompiled decode. Bit-exact with the
212
212
  plain path; expect a small decode cost at batch=1
213
213
  (the gather copy). docs/design/kv-cache.md
@@ -1012,16 +1012,11 @@ function serverRuntimeFlags(): { port: number; serverOptions: import("./server")
1012
1012
  if (route.kvQuant !== undefined) serverOptions.kvQuant = route.kvQuant;
1013
1013
  if (route.turboQuant !== undefined) serverOptions.turboQuant = route.turboQuant;
1014
1014
  // --paged-kv (env: MLX_BUN_PAGED_KV=1): OPTIONAL vLLM-style block-pool KV
1015
- // storage, default off (docs/design/kv-cache.md). Serial-only in v1:
1016
- // with no explicit --batch, pin --batch 1 (the default is 8); an explicit
1017
- // --batch N>1 is refused loudly by createServer rather than downgraded.
1015
+ // storage, default off (docs/design/kv-cache.md). The state policy owns
1016
+ // block pools and cache reuse; paging preserves the selected batch size.
1018
1017
  if (flag("paged-kv") || runtimeValue("MLX_BUN_PAGED_KV") === "1") {
1019
1018
  const bsRaw = opt("paged-kv-block-size");
1020
1019
  serverOptions.pagedKv = bsRaw !== null ? { blockSize: Number(bsRaw) } : {};
1021
- if (batchRaw === null) {
1022
- serverOptions.batch = 1;
1023
- console.log("[paged-kv] serial-only in v1 — pinning --batch 1");
1024
- }
1025
1020
  }
1026
1021
  // Bind loopback unless asked otherwise (mlx_lm.server parity); --host
1027
1022
  // 0.0.0.0 is the explicit opt-in for LAN exposure. The chat-UI open and
@@ -1646,25 +1641,6 @@ switch (cmd) {
1646
1641
  const config = await loadModelConfig(m.path);
1647
1642
  const report = fit(config, m.sizeBytes, 8192, undefined, undefined, m.expertsBytes);
1648
1643
  sFit.done(`${style.bold(m.repoId)} ${style.dim(`· ${gb(m.sizeBytes)} · ~${report.predictedDecodeTps.toFixed(0)} tok/s predicted`)}${picked ? style.dim(" · auto-picked (override: mlx-bun serve <query>)") : ""}`);
1649
- // Near-ceiling advisory (admission doctrine: advise, never refuse). A
1650
- // model near the DEFAULT Metal wired ceiling (~75% of RAM) dies with an
1651
- // UNCATCHABLE async-GPU-OOM on long prefills (M4 24 GB × 17 GB model,
1652
- // 2026-08-20) — surface the standard remedy up front instead.
1653
- {
1654
- const { maxRecommendedWorkingSetSize } = await import("./mlx/ffi");
1655
- const ceiling = maxRecommendedWorkingSetSize();
1656
- const sysctlRaw = Bun.spawnSync(["sysctl", "-n", "iogpu.wired_limit_mb"]).stdout.toString().trim();
1657
- const limitIsDefault = sysctlRaw === "0" || sysctlRaw === "";
1658
- if (limitIsDefault && ceiling > 0 && m.sizeBytes > 0.8 * ceiling) {
1659
- const ramGb = Number(Bun.spawnSync(["sysctl", "-n", "hw.memsize"]).stdout.toString().trim()) / 2 ** 30;
1660
- const suggestMb = Math.floor((ramGb - 2.5) * 1024);
1661
- console.error(style.dim(
1662
- ` ! ${gb(m.sizeBytes)} model vs ~${gb(ceiling)} default GPU ceiling — long prompts can\n` +
1663
- ` hit an uncatchable Metal OOM. If that happens, raise the limit first:\n` +
1664
- ` sudo sysctl iogpu.wired_limit_mb=${suggestMb} (resets on reboot)`,
1665
- ));
1666
- }
1667
- }
1668
1644
  const sNative = step("native runtime");
1669
1645
  await ensureNative(sNative);
1670
1646
  sNative.done("native runtime ready");
@@ -1684,9 +1660,8 @@ switch (cmd) {
1684
1660
  }
1685
1661
  // Speculative decoding (mlx_lm.server parity): --draft-model <path|query>
1686
1662
  // resolves like the main model; --num-draft-tokens defaults to 3 (the
1687
- // server's default upstream — mlx_lm.generate's is 2). Serial-lane-only:
1688
- // with --batch N, a mounted draft routes every request serial (upstream
1689
- // is_batchable = draft is None).
1663
+ // server's default upstream — mlx_lm.generate's is 2). Qualified providers
1664
+ // use shared execution; ordinary requests retain their own placement.
1690
1665
  const draftQuery = opt("draft-model");
1691
1666
  let draftModelDir: string | undefined;
1692
1667
  if (draftQuery !== null) {
@@ -17,9 +17,20 @@ export interface ExecutionCapabilities {
17
17
  readonly continuous: boolean;
18
18
  readonly quantizedBatch: boolean;
19
19
  readonly grammarBatch: boolean;
20
+ readonly adapterBatch?: boolean;
21
+ readonly pagedBatch?: boolean;
20
22
  readonly checkpoints: boolean;
23
+ /** Bound ordinary driver can restore/capture this request configuration. */
24
+ readonly sharedCheckpoints?: boolean;
21
25
  /** Model-qualified speculative execution can retain this request's KV codec. */
22
26
  readonly speculativeKvQuant?: boolean;
27
+ readonly speculativeTurboQuant?: boolean;
28
+ readonly turboQuantBatch?: boolean;
29
+ readonly speculativeLogprobs?: boolean;
30
+ /** The grouped provider supports the target's mounted adapter context. */
31
+ readonly sharedSpeculativeAdapters?: boolean;
32
+ /** Methods supplied by the model's shared execution binding. */
33
+ readonly groupedMethods?: readonly string[];
23
34
  /** A graph-owned compiled step exists; cache geometry can still decline it. */
24
35
  readonly compiledDecode?: boolean;
25
36
  }
@@ -0,0 +1,23 @@
1
+ /** A reusable prefix and the backend/method state that covers its tokens.
2
+ * The caller owns a returned hit. After releasing or returning its state,
3
+ * it must release any retained backing through retain. */
4
+ export interface PrefixCacheHit<State, Attachment = never> {
5
+ tokens: number[];
6
+ caches: State;
7
+ attachments?: Attachment;
8
+ retain?: () => void;
9
+ }
10
+
11
+ /** Storage is independent of request scheduling and the method producing
12
+ * state. Both prefill and decode can publish processed-token prefixes.
13
+ * State is opaque here; its backend/method defines layout and alignment.
14
+ * Implementations own prefix selection, retention and storage tiers. */
15
+ export interface PrefixCache<State, Attachment = never> {
16
+ /** Return owned state for the longest usable prefix, or null on a miss.
17
+ * Namespace distinguishes incompatible artifacts, methods and adapters. */
18
+ take(prompt: number[], ns?: string): PrefixCacheHit<State, Attachment> | null;
19
+ /** Transfer state and its optional backing release to storage. If this
20
+ * throws, ownership stays with the caller; successful return transfers
21
+ * ownership even when storage immediately spills or disposes the entry. */
22
+ put(tokens: number[], caches: State, ns?: string, retain?: () => void, attachments?: Attachment): void;
23
+ }
@@ -16,3 +16,10 @@ export interface StateView<T> {
16
16
  export interface DisposableResource {
17
17
  dispose(): void;
18
18
  }
19
+
20
+ /** A prepared replacement for live state. Commit once between method rounds;
21
+ * it publishes already-built state without graph work or validation. Dispose
22
+ * releases the unpublished replacement, or the superseded state after commit. */
23
+ export interface PreparedStateChange extends DisposableResource {
24
+ commit(): void;
25
+ }
@@ -0,0 +1,23 @@
1
+ /** Sampling owns score processing, token selection, and request-local history.
2
+ * Score/token representations and sync/device execution belong to the backend;
3
+ * this contract has no scheduling, cache, or tensor-runtime dependency. */
4
+ export interface SampledToken<Token, Metadata> {
5
+ token: Token;
6
+ extras: Metadata | null;
7
+ }
8
+
9
+ export interface SamplingSession<Scores, DeviceToken, Result> {
10
+ /** Optional stateless operation shared across compatible rows and positions.
11
+ * It borrows scores and returns owned device tokens, without advancing history. */
12
+ readonly independent?: { sample(scores: Scores): DeviceToken };
13
+ readonly isPlainGreedy: boolean;
14
+ readonly capturesLogprobs: boolean;
15
+ readonly needsHistory: boolean;
16
+ /** Borrows scores; transfers ownership of the result to the caller. */
17
+ sample(logits: Scores, step: number): Result;
18
+ seedHistory(tokens: readonly number[]): void;
19
+ /** Commit accepted tokens when configured for manual history updates. */
20
+ commitDevice(token: DeviceToken): void;
21
+ commitNumbers(tokens: readonly number[]): void;
22
+ dispose(): void;
23
+ }
@@ -1,9 +1,27 @@
1
+ /** Backend-owned context shared by compatible requests. A key identifies
2
+ * equivalent execution state; enter returns its scoped release. Scheduling
3
+ * observes group readiness without inspecting this context. */
4
+ export interface ExecutionContext {
5
+ readonly key: string;
6
+ enter(): () => void;
7
+ }
8
+
1
9
  /** A backend execution group exposes bounded work units and readiness. Queue
2
10
  * payloads, tensors, cache merging and sampling remain inside its adapter. */
3
11
  export interface ExecutionGroup {
4
12
  readonly active: number;
5
13
  readonly queued: number;
6
14
  readonly preparing: boolean;
15
+ readonly preparingRows?: number;
16
+ /** The preparation owner can accept another row before its next work unit. */
17
+ readonly canPrepareMore?: boolean;
18
+ /** Work estimates affect grouping only. A request larger than this budget
19
+ * still runs alone; it is never rejected or sent to another executor. */
20
+ readonly preparingTokens?: number;
21
+ readonly nextPreparationTokens?: number;
22
+ readonly maxPreparationTokens?: number;
23
+ /** The latest preparation published output that can be flushed by yielding. */
24
+ readonly preparationPublishedOutput?: boolean;
7
25
  readonly maxActive: number;
8
26
  readonly admissionHeld: boolean;
9
27
  readonly closed: boolean;
@@ -1,33 +1,35 @@
1
1
  import type { ExecutionCapabilities, ExecutionFeatures, ExecutionRequirements, ResolvedExecution } from "../contracts/execution";
2
2
 
3
- /** Preserve the shipped support matrix. A configured draft still forces serial
4
- * placement even when this request must use the ordinary AR method. */
3
+ /** Select the request method, then place it using executor capabilities. */
5
4
  export function resolveExecution(
6
5
  request: ExecutionRequirements, capabilities: ExecutionCapabilities,
7
6
  features: ExecutionFeatures = { pagedKv: false, fill: false },
8
7
  ): ResolvedExecution {
9
8
  const reasons: string[] = [];
10
- const continuousExclusions = [
9
+ const sharedRequestExclusions = [
11
10
  [!capabilities.continuous, "continuous-unavailable"],
12
- [capabilities.method !== "autoregressive", "method-requires-serial"],
13
11
  [request.hasVision, "media-requires-serial"],
14
- [request.hasAdapters, "adapters-require-serial"],
15
- [request.wantsLogprobs, "logprobs-require-serial"],
16
- [request.userSeed, "explicit-seed-requires-serial"],
12
+ [request.hasAdapters && !capabilities.adapterBatch, "adapters-require-serial"],
17
13
  [request.kvQuant && !capabilities.quantizedBatch, "kv-scheme-requires-serial"],
18
- [request.turboQuant, "turbo-kv-requires-serial"],
19
- [request.hasDraft, "configured-draft-requires-serial"],
14
+ [request.turboQuant && !capabilities.turboQuantBatch, "turbo-kv-requires-serial"],
20
15
  [request.hasGrammar && !capabilities.grammarBatch, "grammar-batching-disabled"],
21
- [features.pagedKv, "paged-kv-requires-serial"],
16
+ [features.pagedKv && !capabilities.pagedBatch, "paged-kv-requires-serial"],
22
17
  ] as const;
23
- for (const [excluded, reason] of continuousExclusions) if (excluded) reasons.push(reason);
24
- const mechanism = reasons.length ? "serial" : "continuous";
18
+ const sharedAdapterMethod = capabilities.sharedSpeculativeAdapters === true &&
19
+ !sharedRequestExclusions.some(([excluded]) => excluded) &&
20
+ capabilities.groupedMethods?.includes("speculative") === true;
25
21
  const speculative = capabilities.method === "autoregressive" && request.hasDraft &&
26
- !request.hasVision && !request.hasAdapters && !request.wantsLogprobs &&
22
+ !request.hasVision && (!request.hasAdapters || sharedAdapterMethod) && (!request.wantsLogprobs || capabilities.speculativeLogprobs === true) &&
27
23
  (!request.kvQuant || capabilities.speculativeKvQuant === true) &&
28
- !request.turboQuant && !features.pagedKv;
29
- if (request.hasDraft && !speculative) reasons.push("draft-incompatible-with-request");
24
+ (!request.turboQuant || capabilities.speculativeTurboQuant === true) && !features.pagedKv;
30
25
  const method = speculative ? "speculative" : capabilities.method;
26
+ const continuousExclusions = [sharedRequestExclusions[0],
27
+ [!(capabilities.groupedMethods ?? ["autoregressive"]).includes(method), "method-requires-serial"],
28
+ ...sharedRequestExclusions.slice(1),
29
+ ] as const;
30
+ for (const [excluded, reason] of continuousExclusions) if (excluded) reasons.push(reason);
31
+ const mechanism = reasons.length ? "serial" : "continuous";
32
+ if (request.hasDraft && !speculative) reasons.push("draft-incompatible-with-request");
31
33
  const pagedKv = features.pagedKv && !request.hasVision && !request.hasAdapters;
32
34
  if (features.pagedKv && !pagedKv) reasons.push("paged-kv-bypassed-for-media-or-adapters");
33
35
  const promptCache = method !== "speculative" && !request.hasVision && !pagedKv;
@@ -36,7 +38,7 @@ export function resolveExecution(
36
38
  !request.wantsLogprobs && !request.kvQuant && !request.turboQuant;
37
39
  if (features.fill && !fill) reasons.push("fill-incompatible-with-request");
38
40
  const checkpoint = capabilities.checkpoints && method === "autoregressive" &&
39
- mechanism === "serial" && promptCache && !request.hasGrammar &&
41
+ (mechanism === "serial" || capabilities.sharedCheckpoints === true) && promptCache && !request.hasGrammar &&
40
42
  !features.fill && !request.wantsLogprobs;
41
43
  const compiledDecode = features.compiledDecode === true && capabilities.compiledDecode === true &&
42
44
  method === "autoregressive" && !request.hasAdapters && !pagedKv;
@@ -1,4 +1,4 @@
1
- import type { DisposableResource, ResourceOwner } from "../contracts/resources";
1
+ import type { DisposableResource, PreparedStateChange, ResourceOwner } from "../contracts/resources";
2
2
 
3
3
  export function ownResource<T>(value: T, release: (value: T) => void): ResourceOwner<T> {
4
4
  let held: { value: T } | undefined = { value };
@@ -35,6 +35,17 @@ export function cleanupFailure(error: unknown, cleanup: () => void): never {
35
35
  throw error;
36
36
  }
37
37
 
38
+ /** Complete every preparation before publishing any owner's replacement.
39
+ * Old state is released only after all owners have committed. */
40
+ export function applyStateChanges(prepare: readonly (() => PreparedStateChange)[]): void {
41
+ const changes: PreparedStateChange[] = [];
42
+ try {
43
+ for (const build of prepare) changes.push(build());
44
+ for (const change of changes) change.commit();
45
+ } catch (error) { return cleanupFailure(error, () => disposeResources(changes.splice(0))); }
46
+ disposeResources(changes);
47
+ }
48
+
38
49
  export function withResource<T, R>(owner: Pick<ResourceOwner<T>, "borrow" | "close">, use: (value: T) => R): R {
39
50
  try { return use(owner.borrow()); }
40
51
  catch (error) { cleanupFailure(error, () => owner.close()); }
@@ -30,15 +30,20 @@ export async function driveExecutionGroup(
30
30
 
31
31
  if (!group.preparing && !held && group.queued && group.active < group.maxActive)
32
32
  group.admitNext();
33
+ while (group.preparing && group.canPrepareMore && !held && group.queued &&
34
+ group.active + (group.preparingRows ?? 1) < group.maxActive && group.canBurst() &&
35
+ (group.preparingTokens ?? 0) + (group.nextPreparationTokens ?? 0) <= (group.maxPreparationTokens ?? Infinity)) {
36
+ if (!group.admitNext()) break;
37
+ }
33
38
  if (group.preparing) {
34
39
  const activeBefore = group.active;
35
40
  await group.advancePreparation();
36
- // A backend may emit its first output while preparing the first row.
37
- // Flush a lone admission before decode; queued short admissions still
38
- // group together. Re-enter policy after yielding to observe new work,
41
+ // A bounded preparation may publish output before its state work ends.
42
+ // Flush it before the next work unit; queued short admissions still
43
+ // form a group across yields. Re-enter policy to observe new work,
39
44
  // cancellation, admission holds and shutdown.
40
- if (yieldAfterPreparation && !activeBefore && !group.preparing &&
41
- group.active === 1 && !group.queued) {
45
+ if (group.preparationPublishedOutput ||
46
+ (yieldAfterPreparation && !activeBefore && !group.preparing && group.active === 1 && !group.queued)) {
42
47
  lastYield = clock.now();
43
48
  await clock.yield();
44
49
  continue;
package/src/fit.ts CHANGED
@@ -13,12 +13,12 @@ import type { ModelConfig } from "./config";
13
13
  import type { MemoryPlan } from "./admission-plan";
14
14
  import {
15
15
  kvBytesAt,
16
- kvGeometry,
17
16
  kvQuantBytesPerElement,
17
+ sdpaFallbackBytes,
18
18
  type KvSchemeOptions,
19
19
  } from "./kv-scheme";
20
20
 
21
- export { kvBytesAt, kvQuantBytesPerElement } from "./kv-scheme";
21
+ export { kvBytesAt, kvQuantBytesPerElement, sdpaFallbackBytes } from "./kv-scheme";
22
22
  export type FitKvScheme = KvSchemeOptions;
23
23
 
24
24
  /** Decode-efficiency vs theoretical bandwidth ceiling, measured on the
@@ -161,23 +161,37 @@ export function fit(
161
161
  kvScheme?: FitKvScheme,
162
162
  ): FitReport {
163
163
  const usable = usableBytes ?? machine.ramBytes * WIRED_FRACTION;
164
- const transient = Math.min(chunk, ctx) * TRANSIENT_PER_TOKEN;
164
+ // Prefill transient = the calibrated per-chunk-token constant + the
165
+ // SDPA-fallback scores tensor (heads × min(chunk, ctx) × ctx × 2 B) for
166
+ // attention layers whose head dim MLX cannot fuse (kv-scheme.ts). The
167
+ // second term is what grows with context on Qwen3.x (head_dim 256) and
168
+ // Gemma 4 (256/512) and was unbilled before 2026-09-08.
169
+ const transientAt = (n: number) =>
170
+ Math.min(chunk, n) * TRANSIENT_PER_TOKEN + sdpaFallbackBytes(config, chunk, n);
171
+ const transient = transientAt(ctx);
165
172
  const kv = kvBytesAt(config, ctx, kvScheme);
166
173
  const total = weightsBytes + kv + transient;
167
174
 
168
- // solve max context: weights + kv(ctx) + transient ≤ usable.
169
- // Below the window both KV terms are linear in ctx; above it the
170
- // sliding term saturates and only full-attention layers keep growing.
171
- const g = kvGeometry(config, kvScheme);
172
- const fixed = weightsBytes + chunk * TRANSIENT_PER_TOKEN + g.linearStateBytes;
175
+ // solve max context: weights + kv(n) + transient(n) ≤ usable. The same
176
+ // formula `fits` uses, so the solved ceiling always fits. It is monotone
177
+ // in n (KV linear, sliding saturating at the window, scores quadratic
178
+ // below the chunk then linear), so bisect over [0, maxPositionEmbeddings].
179
+ const totalAt = (n: number) => weightsBytes + kvBytesAt(config, n, kvScheme) + transientAt(n);
173
180
  let maxCtx = 0;
174
- if (usable > fixed) {
175
- const budget = usable - fixed;
176
- const linear = Math.floor(budget / (g.fullBytesPerToken + g.slidingBytesPerToken));
177
- maxCtx = linear <= g.window
178
- ? linear
179
- : Math.floor((budget - g.slidingBytesPerToken * g.window) / g.fullBytesPerToken);
180
- maxCtx = Math.min(maxCtx, config.text.maxPositionEmbeddings);
181
+ {
182
+ let lo = 0;
183
+ let hi = Math.max(0, Math.floor(config.text.maxPositionEmbeddings));
184
+ if (totalAt(hi) <= usable) lo = hi;
185
+ else {
186
+ // invariant: totalAt(lo) ≤ usable (lo = 0 may itself not fit → 0)
187
+ if (totalAt(0) > usable) hi = 0;
188
+ while (hi - lo > 1) {
189
+ const mid = Math.floor((lo + hi) / 2);
190
+ if (totalAt(mid) <= usable) lo = mid;
191
+ else hi = mid;
192
+ }
193
+ }
194
+ maxCtx = lo;
181
195
  }
182
196
 
183
197
  // decode reads all weights + the KV cache once per token — except MoE