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
package/src/preflight.ts CHANGED
@@ -32,6 +32,9 @@ export interface MachineState {
32
32
  /** Foreign processes holding > bigRssMB resident (excludes kernel,
33
33
  * WindowServer, and our own bun/python benchmark processes). */
34
34
  bigProcesses: { rssMB: number; command: string }[];
35
+ /** Explicitly permitted CPU-only background commands; RSS limits still apply. */
36
+ allowedCpuProcesses?: string[];
37
+ backgroundCpuProcesses?: { rssMB: number; command: string }[];
35
38
  ok: boolean;
36
39
  problems: string[];
37
40
  at: string;
@@ -41,6 +44,29 @@ export interface PreflightLimits {
41
44
  maxSwapUsedMB?: number; // default 512
42
45
  minFreePercent?: number; // default 35
43
46
  bigRssMB?: number; // default 2048
47
+ allowCpuProcesses?: string[];
48
+ }
49
+
50
+ export function foreignProcessFindings(output: string, limits: PreflightLimits = {}) {
51
+ const bigProcesses: { rssMB: number; command: string }[] = [];
52
+ const backgroundCpuProcesses: { rssMB: number; command: string }[] = [];
53
+ for (const line of output.split("\n")) {
54
+ const m = line.match(/^\s*([\d.]+)\s+(\d+)\s+(.*)$/);
55
+ if (!m) continue;
56
+ const cpuPct = Number(m[1]);
57
+ const rssMB = Number(m[2]) / 1024;
58
+ const command = m[3]!;
59
+ if (rssMB < (limits.bigRssMB ?? 2048) && cpuPct < 50) continue;
60
+ if (/(bun|\.venv\/bin\/python|mlx_lm|optiq)/.test(command) ||
61
+ /(kernel_task|WindowServer|launchd|mds|spotlight)/i.test(command)) continue;
62
+ const finding = { rssMB: Math.round(rssMB),
63
+ command: command.slice(0, 80) + (cpuPct >= 50 ? ` (${cpuPct.toFixed(0)}% cpu)` : "") };
64
+ // Exact command match; permission to tolerate CPU activity never permits
65
+ // that process to consume the benchmark's memory headroom.
66
+ const allowed = rssMB < (limits.bigRssMB ?? 2048) && limits.allowCpuProcesses?.includes(command);
67
+ (allowed ? backgroundCpuProcesses : bigProcesses).push(finding);
68
+ }
69
+ return { bigProcesses, backgroundCpuProcesses };
44
70
  }
45
71
 
46
72
  function sh(cmd: string[]): string {
@@ -51,7 +77,6 @@ function sh(cmd: string[]): string {
51
77
  export function checkMachine(limits: PreflightLimits = {}): MachineState {
52
78
  const maxSwap = limits.maxSwapUsedMB ?? 512;
53
79
  const minFree = limits.minFreePercent ?? 35;
54
- const bigRss = limits.bigRssMB ?? 2048;
55
80
  const problems: string[] = [];
56
81
 
57
82
  // swap: "vm.swapusage: total = 7168.00M used = 6413.06M free = ..."
@@ -95,26 +120,15 @@ export function checkMachine(limits: PreflightLimits = {}): MachineState {
95
120
  // one clean sample doesn't prove absence (the run-spread stability
96
121
  // policy in bench-h2h.ts is the direct signal); a hot sample here is
97
122
  // still a cheap early warning.
98
- const SELF_PATTERN = /(bun|\.venv\/bin\/python|mlx_lm|optiq)/;
99
- const SYSTEM_PATTERN = /(kernel_task|WindowServer|launchd|mds|spotlight)/i;
100
- const BIG_CPU_PCT = 50;
101
- const bigProcesses: { rssMB: number; command: string }[] = [];
102
- for (const line of sh(["ps", "axo", "pcpu=,rss=,command="]).split("\n")) {
103
- const m = line.match(/^\s*([\d.]+)\s+(\d+)\s+(.*)$/);
104
- if (!m) continue;
105
- const cpuPct = Number(m[1]);
106
- const rssMB = Number(m[2]) / 1024;
107
- const command = m[3]!.slice(0, 80);
108
- if (rssMB < bigRss && cpuPct < BIG_CPU_PCT) continue;
109
- if (SELF_PATTERN.test(command) || SYSTEM_PATTERN.test(command)) continue;
110
- bigProcesses.push({ rssMB: Math.round(rssMB), command: cpuPct >= BIG_CPU_PCT ? `${command} (${cpuPct.toFixed(0)}% cpu)` : command });
111
- }
123
+ const { bigProcesses, backgroundCpuProcesses } = foreignProcessFindings(
124
+ sh(["ps", "axo", "pcpu=,rss=,command="]), limits);
112
125
  for (const p of bigProcesses)
113
126
  problems.push(`big process: ${p.rssMB} MB — ${p.command}`);
114
127
 
115
128
  return {
116
129
  swapUsedMB, freePercent, cpuSpeedLimit, gpuWiredLimitMB, loadAvg1m,
117
130
  bigProcesses,
131
+ allowedCpuProcesses: limits.allowCpuProcesses ?? [], backgroundCpuProcesses,
118
132
  ok: problems.length === 0,
119
133
  problems,
120
134
  at: new Date().toISOString(),
@@ -136,6 +150,8 @@ export function machineStateJson(s: MachineState): string {
136
150
  gpu_wired_mb: s.gpuWiredLimitMB,
137
151
  load1m: s.loadAvg1m,
138
152
  big_procs: s.bigProcesses.length,
153
+ allowed_cpu_processes: s.allowedCpuProcesses ?? [],
154
+ background_cpu_processes: s.backgroundCpuProcesses ?? [],
139
155
  ok: s.ok,
140
156
  at: s.at,
141
157
  });
@@ -1,4 +1,5 @@
1
- import { leaseCacheState } from "./backends/mlx/state-views";
1
+ import { type CheckpointAttachment, materializeCheckpoint, attachmentBytes, cloneAttachments, disposeAttachments } from "./backends/mlx/checkpoint-state";
2
+ import { leaseCacheState, minimumReusableOffset } from "./backends/mlx/state-views";
2
3
  import { withResource, cleanupFailure, disposeResources, ownResource } from "./engine/resources";
3
4
  // Byte-capped LRU prompt cache — the RAM tier of the Layer-0 KV store.
4
5
  //
@@ -10,12 +11,9 @@ import { withResource, cleanupFailure, disposeResources, ownResource } from "./e
10
11
  // const hit = cache.take(promptIds); // longest-prefix match
11
12
  // generate(model, promptIds, { cache: hit?.caches ?? fresh })
12
13
  // cache.put([...promptIds, ...generated], caches, ns, hit?.retain);
13
- // take() is NON-CONSUMING for trimmable caches: it serves zero-copy clones
14
- // and leaves the donor in place, so N agents can share one prefill.
15
- // Qwen hybrid entries containing recurrent SSM state transfer ownership on
16
- // an exact hit, avoiding an extra resident recurrent snapshot. put() supersedes
17
- // same-ns prefix-ancestors (when the new entry is trimmable) so a conversation
18
- // stays one entry, not one per turn.
14
+ // take() lends zero-copy views and leaves every RAM donor in place, including
15
+ // recurrent and method state. SSD persistence runs independently of RAM reuse.
16
+ // put() supersedes same-ns prefix-ancestors when the new entry is trimmable.
19
17
  //
20
18
  // TIERING (Layer 0, unified-engine plan): when a ColdTier is attached,
21
19
  // take() itself runs the two-tier dance — RAM peek vs cold find, restore
@@ -28,6 +26,7 @@ import { withResource, cleanupFailure, disposeResources, ownResource } from "./e
28
26
  // snapshot for both lanes.
29
27
 
30
28
  import type { Cache } from "./model/gemma4";
29
+ import type { PrefixCache, PrefixCacheHit } from "./contracts/prefix-cache";
31
30
  import { cloneKvCaches } from "./kv-store";
32
31
 
33
32
  /** Reference-counted release: wraps an entry's `retain` (e.g. an mmap
@@ -68,10 +67,10 @@ export interface ColdTier {
68
67
  /** Materialize a found entry as GPU-visible caches (zero-copy COW mmap;
69
68
  * pages fault in lazily). `retain` must run after the caches are
70
69
  * disposed (it unmaps the backing file). Null on any failure. */
71
- restore(handle: unknown): { tokens: number[]; caches: Cache[]; retain: () => void } | null;
70
+ restore(handle: unknown): { tokens: number[]; caches: Cache[]; attachments?: CheckpointAttachment[]; retain: () => void } | null;
72
71
  /** Borrow live caches and finish persistence before returning. A false
73
- * result prevents a required demotion or recurrent ownership transfer. */
74
- store(tokens: number[], caches: Cache[], ns: string): boolean | void;
72
+ * result prevents a required demotion. */
73
+ store(tokens: number[], caches: Cache[], ns: string, attachments?: CheckpointAttachment[]): boolean | void;
75
74
  }
76
75
 
77
76
  /** Spill sink for eviction/demotion (write-behind, 2026-07-06).
@@ -95,9 +94,7 @@ export interface SpillSink {
95
94
  spillSync?: (entry: PromptCacheEntry) => void;
96
95
  }
97
96
 
98
- export interface PromptCacheEntry {
99
- tokens: number[];
100
- caches: Cache[];
97
+ export interface PromptCacheEntry extends PrefixCacheHit<Cache[], CheckpointAttachment[]> {
101
98
  /** Namespace key — adapter spec for LoRA requests ("" = base model).
102
99
  * KV computed under one adapter must never seed another's prefill. */
103
100
  ns: string;
@@ -134,7 +131,7 @@ interface EntryRecord {
134
131
  share: { acquire(): () => void };
135
132
  }
136
133
 
137
- export class PromptCache {
134
+ export class PromptCache implements PrefixCache<Cache[], CheckpointAttachment[]> {
138
135
  readonly maxBytes: number;
139
136
  #entries: EntryRecord[] = [];
140
137
  #clock = 0;
@@ -161,6 +158,7 @@ export class PromptCache {
161
158
  spill: SpillSink | ((entry: PromptCacheEntry) => void) | null = null,
162
159
  cold: ColdTier | null = null,
163
160
  clone: (caches: Cache[]) => Cache[] = cloneKvCaches,
161
+ private readonly materialize: (caches: Cache[], attachments?: CheckpointAttachment[]) => void = materializeCheckpoint,
164
162
  ) {
165
163
  this.maxBytes = maxBytes;
166
164
  this.#spillOwned = typeof spill === "function" ? null : spill?.spillOwned ?? null;
@@ -185,23 +183,26 @@ export class PromptCache {
185
183
  // no backing mmap for retain to unmap, so nothing the clones
186
184
  // could alias goes away.
187
185
  let clones: Cache[] | null = null;
186
+ let attachments: CheckpointAttachment[] | undefined;
188
187
  try {
189
188
  clones = this.#clone(entry.caches);
190
- this.#spillOwned({ tokens: [...entry.tokens], caches: clones, ns: entry.ns });
189
+ attachments = cloneAttachments(entry.attachments);
190
+ this.#spillOwned({ tokens: [...entry.tokens], caches: clones, ns: entry.ns, attachments });
191
191
  } catch {
192
192
  // Failure degrades to no-spill; eviction/demotion never unwinds.
193
193
  if (clones) for (const c of clones) try { c.dispose(); } catch {}
194
+ try { disposeAttachments(attachments); } catch {}
194
195
  }
195
196
  } else {
196
197
  const spill =
197
198
  this.#spillSync ??
198
199
  (this.#cold
199
- ? (e: PromptCacheEntry) => this.#cold!.store(e.tokens, e.caches, e.ns)
200
+ ? (e: PromptCacheEntry) => this.#cold!.store(e.tokens, e.caches, e.ns, e.attachments)
200
201
  : null);
201
202
  if (spill) try { spill(entry); } catch {}
202
203
  }
203
204
  }
204
- disposeResources(entry.caches);
205
+ disposeResources([...entry.caches, { dispose: () => disposeAttachments(entry.attachments) }]);
205
206
  entry.retain?.(); // backing release requires successful cache disposal
206
207
  }
207
208
 
@@ -227,7 +228,7 @@ export class PromptCache {
227
228
  // Keep the entry resident if persistence throws. A failed SSD write
228
229
  // must not be presented as a successful demotion.
229
230
  if (this.#cold) {
230
- if (this.#cold.store(entry.tokens, entry.caches, entry.ns) === false) break;
231
+ if (this.#cold.store(entry.tokens, entry.caches, entry.ns, entry.attachments) === false) break;
231
232
  }
232
233
  else this.#spillSync?.(entry);
233
234
  this.#entries.splice(oldest, 1);
@@ -239,9 +240,9 @@ export class PromptCache {
239
240
  }
240
241
 
241
242
  /** Find the entry with the longest usable common prefix of `prompt`
242
- * across BOTH tiers. Trimmable RAM entries are served non-consumingly as
243
- * zero-copy clones; exact hits on Qwen hybrid entries containing recurrent
244
- * SSM state transfer the donor itself. The caller owns the returned caches —
243
+ * across BOTH tiers. Every RAM entry lends zero-copy views and retains its
244
+ * donor, including exact-boundary recurrent state and method attachments.
245
+ * The caller owns the returned views —
245
246
  * dispose them or put() an extended entry, honoring `retain` either way.
246
247
  *
247
248
  * Usable prefix = common prefix capped at prompt.length - 1 (at least
@@ -262,9 +263,9 @@ export class PromptCache {
262
263
  const e = this.#entries[i]!.entry;
263
264
  if (e.ns !== ns) continue;
264
265
  const p = Math.min(commonPrefixLength(e.tokens, prompt), prompt.length - 1);
265
- if (p <= bestLen) continue;
266
+ if (p <= bestLen || p < minimumReusableOffset(e.caches)) continue;
266
267
  const trimNeeded = e.tokens.length - p;
267
- if (trimNeeded > 0 && !e.caches.every((c) => c.isTrimmable())) continue;
268
+ if (trimNeeded > 0 && (!!e.attachments?.length || !e.caches.every((c) => c.isTrimmable()))) continue;
268
269
  bestLen = p;
269
270
  bestIdx = i;
270
271
  }
@@ -283,7 +284,8 @@ export class PromptCache {
283
284
  this.#disposeEntry({ ...entry, ns }, false));
284
285
  const result = withResource(owner, (entry) => {
285
286
  const trimNeeded = entry.tokens.length - hit.prefixLen;
286
- if (trimNeeded > 0 && !entry.caches.every((cache) => cache.isTrimmable())) {
287
+ if (hit.prefixLen < minimumReusableOffset(entry.caches)) return null;
288
+ if (trimNeeded > 0 && (!!entry.attachments?.length || !entry.caches.every((cache) => cache.isTrimmable()))) {
287
289
  console.warn(
288
290
  `[prompt-cache] cold entry unusable: ${entry.tokens.length} tokens, ` +
289
291
  `needs trim ${trimNeeded} but untrimmable (wrapped ring/SSM) — re-prefilling`,
@@ -312,28 +314,22 @@ export class PromptCache {
312
314
  const rec = this.#entries[bestIdx]!;
313
315
  rec.lastUsed = ++this.#clock;
314
316
  rec.lastUsedMs = Date.now();
315
- // Qwen recurrent caches are untrimmable. Transfer the exact-boundary
316
- // entry instead of retaining a second recurrent snapshot in RAM.
317
- // This reduced memory pressure in the recorded multi-turn reproduction.
318
- // Save the boundary before consumption: the extended recurrent state
319
- // cannot be trimmed back, and a delayed writer can no longer find it
320
- // in RAM after ownership moves. An already durable boundary is cheap.
321
- if (rec.entry.caches.some((c) => c.signature() === "ssm")) {
322
- if (this.#cold?.store(rec.entry.tokens, rec.entry.caches, ns) === false)
323
- throw new Error("prompt-cache: failed to persist recurrent boundary before transfer");
324
- this.#entries.splice(bestIdx, 1);
325
- return rec.entry;
326
- }
317
+ // Lend immutable views for every backend state. RAM retention and SSD
318
+ // durability remain cache responsibilities while the caller extends its
319
+ // own views. This keeps synchronous persistence out of a RAM hit.
327
320
  const clones = this.#clone(rec.entry.caches);
321
+ let attachments: CheckpointAttachment[] | undefined;
328
322
  const trimNeeded = rec.entry.tokens.length - bestLen;
329
323
  try {
324
+ attachments = cloneAttachments(rec.entry.attachments);
330
325
  if (trimNeeded > 0) for (const c of clones) c.trim(trimNeeded);
331
326
  } catch (error) {
332
- cleanupFailure(error, () => disposeResources(clones));
327
+ cleanupFailure(error, () => disposeResources([...clones, { dispose: () => disposeAttachments(attachments) }]));
333
328
  }
334
329
  return {
335
330
  tokens: rec.entry.tokens.slice(0, bestLen),
336
331
  caches: clones,
332
+ attachments,
337
333
  ns,
338
334
  retain: rec.share.acquire(),
339
335
  };
@@ -346,9 +342,9 @@ export class PromptCache {
346
342
  for (const { entry: e } of this.#entries) {
347
343
  if (e.ns !== ns) continue;
348
344
  const p = Math.min(commonPrefixLength(e.tokens, prompt), prompt.length - 1);
349
- if (p <= best) continue;
345
+ if (p <= best || p < minimumReusableOffset(e.caches)) continue;
350
346
  const trimNeeded = e.tokens.length - p; // same usability rule as take()
351
- if (trimNeeded > 0 && !e.caches.every((c) => c.isTrimmable())) continue;
347
+ if (trimNeeded > 0 && (!!e.attachments?.length || !e.caches.every((c) => c.isTrimmable()))) continue;
352
348
  best = p;
353
349
  }
354
350
  return best;
@@ -368,10 +364,12 @@ export class PromptCache {
368
364
  /** Insert (or reinsert) an entry; evicts LRU entries over the byte cap
369
365
  * (spilling each to the cold tier first, when one is attached). If the
370
366
  * entry itself exceeds the cap it is spilled + disposed, not stored. */
371
- put(tokens: number[], caches: Cache[], ns = "", retain?: () => void): void {
367
+ put(tokens: number[], caches: Cache[], ns = "", retain?: () => void, attachments?: CheckpointAttachment[]): void {
372
368
  // All cache-provided validation runs before ownership is adopted.
373
- const bytes = cacheBytes(caches);
374
- const trimmable = caches.every((cache) => cache.isTrimmable());
369
+ const bytes = cacheBytes(caches) + attachmentBytes(attachments);
370
+ const trimmable = !attachments?.length && caches.every((cache) => cache.isTrimmable());
371
+ // Storage takes resolved snapshots, independently of their producer.
372
+ this.materialize(caches, attachments);
375
373
  const discard = (entry: PromptCacheEntry, spill: boolean) => {
376
374
  try { this.#disposeEntry(entry, spill); }
377
375
  catch (error) {
@@ -380,7 +378,7 @@ export class PromptCache {
380
378
  console.warn(`[prompt-cache] entry cleanup failed: ${error}`);
381
379
  }
382
380
  };
383
- const entry: PromptCacheEntry = { tokens, caches, ns, retain };
381
+ const entry: PromptCacheEntry = { tokens, caches, ns, retain, attachments };
384
382
  if (bytes > this.maxBytes) {
385
383
  discard(entry, true);
386
384
  return;
@@ -415,7 +413,7 @@ export class PromptCache {
415
413
  if (trimmable) {
416
414
  for (const old of [...this.#entries]) {
417
415
  if (old === rec || old.entry.ns !== ns) continue;
418
- if (old.entry.tokens.length >= tokens.length) continue;
416
+ if (old.entry.tokens.length >= tokens.length || old.entry.tokens.length < minimumReusableOffset(caches)) continue;
419
417
  if (commonPrefixLength(old.entry.tokens, tokens) !== old.entry.tokens.length) continue;
420
418
  this.#entries = this.#entries.filter((r) => r !== old);
421
419
  discard(old.entry, false);
package/src/sampler.ts CHANGED
@@ -11,6 +11,8 @@
11
11
  import { Dtype } from "./mlx/ffi";
12
12
  import { MlxArray } from "./mlx/array";
13
13
  import * as ops from "./mlx/ops";
14
+ import type { TokenLogprobs } from "./contracts/generation";
15
+ import type { SampledToken, SamplingSession } from "./contracts/sampling";
14
16
  import { applyCurve, type CurveParams } from "./lab/curve/curve-sampler";
15
17
 
16
18
  /** Resolved HLG sampling config (the user-facing knobs). The mid gain is NOT
@@ -120,10 +122,7 @@ export interface StepExtras {
120
122
  topVals: MlxArray | null;
121
123
  }
122
124
 
123
- export interface StepSample<T> {
124
- token: T;
125
- extras: StepExtras | null;
126
- }
125
+ export type StepSample<T> = SampledToken<T, StepExtras>;
127
126
 
128
127
  interface StepGrammar {
129
128
  readonly isTerminated: boolean;
@@ -161,25 +160,8 @@ export interface NumberStepSamplerConfig extends StepSamplerConfigBase {
161
160
  grammarWait: "before-sample";
162
161
  }
163
162
 
164
- export interface DeviceStepSampler {
165
- readonly isPlainGreedy: boolean;
166
- readonly needsHistory: boolean;
167
- sample(logits: MlxArray, step: number): StepSample<MlxArray>;
168
- seedHistory(tokens: readonly number[]): void;
169
- commitDevice(token: MlxArray): void;
170
- commitNumbers(tokens: readonly number[]): void;
171
- dispose(): void;
172
- }
173
-
174
- export interface NumberStepSampler {
175
- readonly isPlainGreedy: boolean;
176
- readonly needsHistory: boolean;
177
- sample(logits: MlxArray, step: number): Promise<StepSample<number>>;
178
- seedHistory(tokens: readonly number[]): void;
179
- commitDevice(token: MlxArray): void;
180
- commitNumbers(tokens: readonly number[]): void;
181
- dispose(): void;
182
- }
163
+ export type DeviceStepSampler = SamplingSession<MlxArray, MlxArray, StepSample<MlxArray>>;
164
+ export type NumberStepSampler = SamplingSession<MlxArray, MlxArray, Promise<StepSample<number>>>;
183
165
 
184
166
  const GOLDEN = 0x9e3779b97f4a7c15n;
185
167
 
@@ -786,7 +768,7 @@ export function makeLogitsProcessors(opts: LogitsProcessorOptions = {}): LogitsP
786
768
  return out;
787
769
  }
788
770
 
789
- /** Whether a whole [B,V] row can use the scheduler's vectorized argmax path. */
771
+ /** Whether scores can use stateless greedy sampling across independent rows. */
790
772
  export function isPlainGreedy(
791
773
  opts: StepSamplerOptions,
792
774
  hasProcessors = makeLogitsProcessors(opts).length > 0,
@@ -809,6 +791,23 @@ export function disposeStepExtras(extras: StepExtras | null): void {
809
791
  extras.topVals?.dispose();
810
792
  }
811
793
 
794
+ /** Read captured probabilities with their token, without adding casts behind
795
+ * the next decode step. Consumes the capture on success and failure. */
796
+ export function readStepExtras(extras: StepExtras | null): TokenLogprobs | undefined {
797
+ if (!extras) return undefined;
798
+ try {
799
+ const out: TokenLogprobs = {};
800
+ if (extras.sel) out.logprob = extras.sel.toFloat32Host()[0]!;
801
+ if (extras.topIdx && extras.topVals) {
802
+ const ids = extras.topIdx.toIntTokens();
803
+ const vals = extras.topVals.toFloat32Host();
804
+ out.top = Array.from(ids, (id, i) => ({ id, logprob: vals[i]! }))
805
+ .sort((a, b) => b.logprob - a.logprob);
806
+ }
807
+ return out;
808
+ } finally { disposeStepExtras(extras); }
809
+ }
810
+
812
811
  export function makeStepSampler(
813
812
  options: StepSamplerOptions,
814
813
  config: DeviceStepSamplerConfig,
@@ -941,7 +940,10 @@ export function makeStepSampler(
941
940
  if (config.initialHistory) seedHistory(config.initialHistory);
942
941
 
943
942
  const common = {
943
+ independent: isPlainGreedy(options, processors.length > 0) && !capture && !config.sampler
944
+ ? independentGreedySampling : undefined,
944
945
  isPlainGreedy: isPlainGreedy(options, processors.length > 0),
946
+ capturesLogprobs: capture,
945
947
  needsHistory: processors.length > 0,
946
948
  seedHistory,
947
949
  commitDevice,
@@ -989,7 +991,16 @@ export function makeStepSampler(
989
991
  };
990
992
  }
991
993
 
992
- /** logits [1, V] → logprobs [1, V] (logits - logsumexp). */
994
+ /** Stateless sampling across rows or verification positions. Preserve the
995
+ * normalized-score argmax, including rounding-created ties. */
996
+ export const independentGreedySampling = Object.freeze({
997
+ sample(logits: MlxArray): MlxArray {
998
+ using logprobs = toLogprobs(logits);
999
+ return ops.argmaxAxis(logprobs, -1);
1000
+ },
1001
+ });
1002
+
1003
+ /** logits [..., V] → logprobs [..., V] (logits - logsumexp). */
993
1004
  export function toLogprobs(logits: MlxArray): MlxArray {
994
1005
  const lse = ops.logsumexpAxis(logits, -1, true);
995
1006
  const out = ops.sub(logits, lse);
@@ -25,7 +25,7 @@ export class ChatStage {
25
25
  private readonly prep: RequestPrep,
26
26
  private readonly promptCache: Pick<PromptCache, "peekPrefixLen">,
27
27
  /** Admission ceiling (fit() / the GLM memory plan), resolved once. */
28
- private readonly maxSafeContext: number,
28
+ private readonly contextLimit: number | null,
29
29
  /** `serve --adapter <dir>` startup default; a request's explicit
30
30
  * `adapter` (incl. "none") wins. */
31
31
  private readonly defaultAdapter?: string,
@@ -165,7 +165,7 @@ export class ChatStage {
165
165
  promptIds,
166
166
  options,
167
167
  requestedMaxTokens,
168
- maxSafeContext: this.maxSafeContext,
168
+ contextLimit: this.contextLimit,
169
169
  stream: request.stream,
170
170
  wantLogprobs,
171
171
  topLogprobs,
@@ -29,7 +29,7 @@ import { cleanupFailure, disposeResources } from "../engine/resources";
29
29
  // server.py's all-caches-have-merge check). GLM's capability owns compressed
30
30
  // MLA/DSA merge/extract/filter and byte projection; Qwen3.5's SSM cache owns
31
31
  // its row state. The remaining request-shape exclusions include vision, LoRA,
32
- // serial-only KV modes, and user-fixed seed
32
+ // serial-only KV modes
33
33
  // (reproducibility ⇒ solo, matching mlx-lm's _is_batchable). Temperature /
34
34
  // top-p / top-k DO batch (each row samples with its own seed). Full-attention
35
35
  // AND sliding-window (Gemma) models both batch — the scheduler assembles each
@@ -42,9 +42,9 @@ import { MlxArray } from "../mlx/array";
42
42
  import type { RuntimeModel } from "../model/factory";
43
43
  import type { GenerateOptions, GenerateStats, TokenLogprobs } from "../generate";
44
44
  import type { KvScheme } from "../kv-scheme";
45
+ import { createRowSampling } from "../backends/mlx/row-sampling";
45
46
  import { makeStepSampler } from "../sampler";
46
47
  import type { RowPromptCache } from "../backends/mlx/batch-group";
47
- import type { MlxMemoryBudget } from "../backends/mlx/memory-guard";
48
48
  import { bindMlxGateway, type MlxGatewayBinding, type MlxBatchGroup } from "../backends/mlx/gateway-binding";
49
49
  import type { RuntimeConfig } from "../runtime-config";
50
50
  import type { ExecutionRequirements, ResolvedExecution } from "../contracts/execution";
@@ -114,7 +114,7 @@ export type Vision = {
114
114
  };
115
115
 
116
116
  /** Per-token sink: returning `false` halts this generation (stop sequence).
117
- * `logprobs` is only populated on the serial lane when the request asked for
117
+ * `logprobs` is populated on either lane when the request asked for
118
118
  * logprobs capture (GenerateOptions.logprobs / topLogprobs). */
119
119
  export type OnToken = (
120
120
  token: number,
@@ -172,7 +172,6 @@ export class GenerationGateway {
172
172
  private readonly serialRun: SerialRun,
173
173
  private readonly opts: {
174
174
  kvBudgetBytes?: number;
175
- memoryBudget?: MlxMemoryBudget;
176
175
  checkpoints?: boolean;
177
176
  stateCodecs?: import("../kv-store").CacheCodecProvider;
178
177
  /** The server-wide KV scheme (server.ts kvScheme) — threaded to the
@@ -185,9 +184,7 @@ export class GenerationGateway {
185
184
  * back on finish. Safe to share with the serial lane — both use it
186
185
  * only inside this gateway's mutual-exclusion domain. */
187
186
  promptCache?: RowPromptCache;
188
- /** Finish cache writes before taking the generation lock. Writers
189
- * need that same lock, so this hook must never run inside it. */
190
- beforeSerial?: () => Promise<void>;
187
+ adapterNamespace?: (adapters: string[]) => string;
191
188
  } = {},
192
189
  ) {
193
190
  this.#binding = "createBatchGroup" in model ? model : bindMlxGateway(model);
@@ -235,16 +232,20 @@ export class GenerationGateway {
235
232
  await this.onIdle();
236
233
  }
237
234
 
238
- /** Resolves once the engine is idle (poll-based, ~`pollMs` granularity —
239
- * cheap vs the multi-hundred-ms flushes it paces; resolves without a
240
- * timer when already idle). The SSD write-behind gates each per-tensor
241
- * flush step on this so durability work never steals blocking
242
- * GPU-sync + writeSync slices from an active decode (the 2026-07-07
243
- * decode@ctx contamination — see server.ts's write-behind block). */
235
+ /** Observe idleness. Call runWhenIdle for background work that must also
236
+ * acquire ownership atomically with the idle check. */
244
237
  async onIdle(pollMs = 20): Promise<void> {
245
238
  while (this.busy) await new Promise<void>((r) => setTimeout(r, pollMs));
246
239
  }
247
240
 
241
+ /** Background work waits without requesting a batch drain. The final busy
242
+ * check and mutex acquisition have no await between them, so a foreground
243
+ * request cannot enter between the check and ownership registration. */
244
+ async runWhenIdle<T>(fn: () => Promise<T>, pollMs = 20): Promise<T> {
245
+ while (this.busy) await new Promise<void>(resolve => setTimeout(resolve, pollMs));
246
+ return this.runExclusive(fn);
247
+ }
248
+
248
249
  /** Cumulative rows routed to the batch lane since server start. The serial
249
250
  * lane never advances this — /stats' race-free lane-routing observable
250
251
  * (active/pending are instantaneous and read 0 once a request finishes). */
@@ -272,7 +273,7 @@ export class GenerationGateway {
272
273
  const frozenShape = Object.freeze(shape);
273
274
  const execution = this.#binding.plan(frozenShape, options, {
274
275
  continuous: this.batchingEnabled,
275
- quantizedBatch: shape.kvQuant && this.#kvBatchable(),
276
+ quantizedBatch: (shape.kvQuant || shape.turboQuant) && this.#kvBatchable(),
276
277
  checkpoints: this.opts.checkpoints === true,
277
278
  });
278
279
  return Object.freeze({ shape: frozenShape, mechanism: execution.mechanism, execution });
@@ -390,7 +391,7 @@ export class GenerationGateway {
390
391
  });
391
392
  };
392
393
  let started = false;
393
- return Promise.resolve().then(() => this.opts.beforeSerial?.()).then(() => this.runExclusive(async () => {
394
+ return this.runExclusive(async () => {
394
395
  signal?.throwIfAborted();
395
396
  started = true;
396
397
  const stats = await this.serialRun(
@@ -398,7 +399,7 @@ export class GenerationGateway {
398
399
  );
399
400
  signal?.throwIfAborted();
400
401
  return stats;
401
- }, trace, signal))
402
+ }, trace, signal)
402
403
  // Covers a serial waiter aborted before serialRun takes ownership.
403
404
  // generate() also disposes defensively; the operation is idempotent.
404
405
  .finally(() => {
@@ -407,16 +408,23 @@ export class GenerationGateway {
407
408
  });
408
409
  }
409
410
 
411
+ const adapters = options.adapters?.length ? [...options.adapters] : undefined;
412
+ const adapterKey = adapters ? JSON.stringify(adapters) : "";
413
+ const cacheNamespace = adapters
414
+ ? () => this.opts.adapterNamespace?.(adapters) ?? adapterKey : "";
415
+ const context = adapters
416
+ ? this.#binding.bindAdapterContext?.(adapters, `adapters:${adapterKey}`) : undefined;
417
+
410
418
  // The scheduler owns grammar ready/accept sequencing. StepSampler owns the
411
419
  // shared processors -> mask -> logprobs -> sample -> history contract.
412
- const stepSampler = makeStepSampler(options, {
413
- tokenRepresentation: "device",
414
- grammarWait: "external",
415
- historyUpdate: "after-sample",
416
- initialHistory: promptIds,
417
- });
418
- const sample = (logits1V: MlxArray, step: number): MlxArray =>
419
- stepSampler.sample(logits1V, step).token;
420
+ const method = this.#binding.methodRequest?.(placement.execution, options);
421
+ const continuation = method ? undefined : this.#binding.continuationRequest?.(placement.execution, options, promptIds, onToken);
422
+ const sampling = method ? undefined : continuation ?? createRowSampling(makeStepSampler(options, {
423
+ tokenRepresentation: "device", grammarWait: "external",
424
+ historyUpdate: "after-sample", initialHistory: promptIds,
425
+ captureSelectedLogprob: options.logprobs === true,
426
+ captureTopLogprobs: options.topLogprobs,
427
+ }), onToken);
420
428
 
421
429
  let st;
422
430
  this.#rowsSubmitted++;
@@ -425,13 +433,14 @@ export class GenerationGateway {
425
433
  });
426
434
  try {
427
435
  st = await this.#ensureScheduler().submit({
428
- promptIds,
436
+ promptIds, context, cacheNamespace, prefillChunkSize: options.prefillChunkSize,
437
+ continuation: continuation?.continuation,
438
+ statePolicy: this.#binding.statePolicy?.(placement.execution, options, promptIds.length + (options.maxTokens ?? 512)),
429
439
  compiledDecode: placement.execution?.compiledDecode,
430
440
  maxTokens: options.maxTokens ?? 512,
431
441
  eosTokenIds: options.eosTokenIds ?? this.#binding.config.eosTokenIds,
432
- sample,
433
- plainGreedy: stepSampler.isPlainGreedy,
434
- onToken,
442
+ ...(method ? { method, onToken } : { sample: sampling!.sample,
443
+ plainGreedy: sampling!.plainGreedy, onToken: sampling!.onToken }),
435
444
  onAdmitted: closeAdmission,
436
445
  trace,
437
446
  ...(signal ? { signal } : {}),
@@ -445,7 +454,7 @@ export class GenerationGateway {
445
454
  });
446
455
  } finally {
447
456
  closeAdmission?.();
448
- stepSampler.dispose();
457
+ sampling?.dispose();
449
458
  // The scheduler never owns per-row grammar state.
450
459
  options.grammar?.dispose();
451
460
  }
@@ -460,6 +469,7 @@ export class GenerationGateway {
460
469
  prefillTps: st.prefillMs > 0 ? ((st.promptTokens - st.cachedTokens) / st.prefillMs) * 1000 : 0,
461
470
  decodeTps: st.decodeMs > 0 && st.generatedTokens > 1 ? ((st.generatedTokens - 1) / st.decodeMs) * 1000 : 0,
462
471
  cacheTokens: [],
472
+ ...(st.spec ? { spec: st.spec } : {}),
463
473
  };
464
474
  }
465
475
 
@@ -470,7 +480,6 @@ export class GenerationGateway {
470
480
  maxBatch: this.#batch,
471
481
  stateCodecs: this.opts.stateCodecs,
472
482
  kvBudgetBytes: this.opts.kvBudgetBytes,
473
- memoryBudget: this.opts.memoryBudget,
474
483
  // Phase 3.1: the batchable kvConfig composition is applied by the
475
484
  // scheduler (solo rows convert at serial chunk boundaries, then
476
485
  // merge as quantized triples). Only threaded when the scheme
@@ -73,10 +73,9 @@ export interface ServerContext<Model = RuntimeModel> {
73
73
  * optiq serve injects these as server defaults (gen_config.py);
74
74
  * explicit request fields always win. */
75
75
  genDefaults: GenSamplingDefaults;
76
- /** Speculative decoding (`serve --draft-model`, mlx_lm.server parity).
77
- * Server-level: when set, EVERY request routes to the serial lane
78
- * (upstream: is_batchable = draft is None) and spec-eligible ones decode
79
- * through src/spec/serve-loop.ts. null = no draft configured. */
76
+ /** Spec-eligible requests use src/spec/serve-loop.ts; ordinary fallbacks
77
+ * use their own feature-compatible scheduling mechanism.
78
+ * null = no draft configured. */
80
79
  draft?: {
81
80
  provider: import("../spec/source").DraftProvider;
82
81
  numDraftTokens: number;
@@ -33,7 +33,7 @@ export interface RequestPlanInput {
33
33
  promptIds: number[];
34
34
  options: GenerateOptions & { stopSequences: string[] };
35
35
  requestedMaxTokens: number;
36
- maxSafeContext: number;
36
+ contextLimit: number | null;
37
37
  stream: boolean;
38
38
  wantLogprobs: boolean;
39
39
  topLogprobs: number;
@@ -114,11 +114,12 @@ export type PlanRequestResult = RequestPlan | RequestRejection;
114
114
  * options, adapter selection, and lane shape are derived here once for chat
115
115
  * and raw completions. */
116
116
  export function planRequest(input: RequestPlanInput): PlanRequestResult {
117
- const available = input.maxSafeContext - input.promptIds.length;
117
+ const available = input.contextLimit === null ? Infinity
118
+ : input.contextLimit - input.promptIds.length;
118
119
  if (available < 1) {
119
120
  return new RequestRejection(
120
121
  `prompt is ${input.promptIds.length} tokens but the memory budget caps ` +
121
- `safe context at ${input.maxSafeContext} — no room to generate; ` +
122
+ `safe context at ${input.contextLimit} — no room to generate; ` +
122
123
  "shorten the prompt or raise --memory-budget",
123
124
  input.ownership,
124
125
  );