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
@@ -92,10 +92,8 @@ export function createRequestPrep(input: {
92
92
  ? Math.min(genTemp, NO_THINK_TEMPERATURE)
93
93
  : genTemp;
94
94
  return {
95
- // No invented cap: unset means "generate until EOS or the admitted
96
- // context is exhausted" — admission (planRequest) is the real limit
97
- // and clamps to maxSafeContext − promptLen. Only an explicit request
98
- // max_tokens or the operator's --max-tokens narrows it further.
95
+ // Unset means generate until EOS/stop. Only a client cap or an explicit
96
+ // operator limit constrains completion length; fit estimates do not.
99
97
  maxTokens: req.max_completion_tokens ?? req.max_tokens ??
100
98
  defaultGeneratedTokens,
101
99
  temperature: req.temperature ?? serverOptions.defaultTemperature ?? defaultTemp,
@@ -16,7 +16,7 @@ export class TextCompletionStage {
16
16
  constructor(
17
17
  private readonly ctx: ServingContext,
18
18
  private readonly prep: RequestPrep,
19
- private readonly maxSafeContext: number,
19
+ private readonly contextLimit: number | null,
20
20
  /** `--max-tokens` / the GLM plan's reservation. No invented fallback:
21
21
  * unset runs to EOS or the admitted context (with no template an EOS
22
22
  * may never come — that run is bounded by admission, the real limit). */
@@ -81,7 +81,7 @@ export class TextCompletionStage {
81
81
  promptIds,
82
82
  options,
83
83
  requestedMaxTokens,
84
- maxSafeContext: this.maxSafeContext,
84
+ contextLimit: this.contextLimit,
85
85
  stream: request.stream,
86
86
  wantLogprobs,
87
87
  topLogprobs,
package/src/server.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { ContinuationPersistence } from "./backends/mlx/continuation-persistence";
1
2
  import { modelServingBinding } from "./backends/mlx/model-serving";
2
3
  import type { ServingContext } from "./serve/model-host";
3
4
  export { generationCheckpointKey } from "./serve/checkpoint-identity";
@@ -109,13 +110,15 @@ export interface ServerOptions {
109
110
  * (`--kv-budget`, batching-perf-path P3). Joiners whose projected KV
110
111
  * (prompt + max_tokens, window-capped) would exceed it QUEUE until rows
111
112
  * evict; a request over the budget alone is rejected. Unset = no
112
- * aggregate cap (per-request admission via memoryBudget still applies). */
113
+ * aggregate cap (an explicit memoryBudget also limits each request). */
113
114
  kvBudgetBytes?: number;
114
115
  /** KV quantization override. Unset/"off" is bf16. "config" applies the
115
116
  * model's declared mixed-precision kv_config; supported per-layer schemes
116
117
  * compose with continuous scheduling. A number forces uniform bits
117
118
  * (group size 64, start 0) and uses the preserved serial executor. */
118
119
  kvQuant?: "off" | "config" | number;
120
+ /** Optional absolute row offset for KV conversion; omitted preserves start-zero server policy. */
121
+ quantizedKvStart?: number;
119
122
  /** TurboQuant scheme (docs/design/turboquant.md): a separate axis from
120
123
  * kvQuant above, mutually exclusive with it (`--kv-quant turbo[:k<bits>v
121
124
  * <bits>]` sets this instead of kvQuant). Solo-only in v1 — see
@@ -124,19 +127,13 @@ export interface ServerOptions {
124
127
  /** OPTIONAL paged KV cache (`--paged-kv`, docs/design/kv-cache.md):
125
128
  * vLLM-style block-pool storage for full-attention layers,
126
129
  * gather-to-contiguous before the stock SDPA. Default off (unset = the
127
- * plain KVCache path, byte-identical). v1 scope: serial batch=1,
128
- * Gemma4-family, bf16 — startup refuses `--batch N>1`, `--kv-quant`,
130
+ * plain KVCache path, byte-identical). Gemma4-family, bf16, B1/B>1;
131
+ * startup refuses `--kv-quant`,
129
132
  * turbo, and `--draft-model` combinations; paged requests bypass the
130
133
  * prompt cache and run uncompiled. Gated bit-exact vs the plain path. */
131
134
  pagedKv?: { blockSize?: number };
132
- /** Memory budget for the serving process (admission control — Phase 5).
133
- * Requests whose prompt + max_tokens exceed the budget's max safe
134
- * context are rejected with 400 instead of crashing the GPU: the OOM
135
- * crash class is UNCATCHABLE (Phase 6 — mlx throws from a Metal
136
- * completion handler ⇒ std::terminate; optiq serve died exactly this
137
- * way loading the 26B). Also caps the mlx allocator
138
- * (mlx_set_memory_limit) as defense in depth. Default: machine RAM ×
139
- * WIRED_FRACTION, admission check only, allocator untouched. */
135
+ /** Explicit opt-in process budget. Enforces the fit estimate's context
136
+ * limit and sets the MLX allocator limit. Unset keeps estimates advisory. */
140
137
  memoryBudgetBytes?: number;
141
138
  /** Who owns this server's lifetime: "serve" (persistent, mlx-bun
142
139
  * serve) or "pi-session" (dies with the pi session that started
@@ -287,16 +284,8 @@ export interface ContextAdmissionDecision {
287
284
  clamped: boolean;
288
285
  }
289
286
 
290
- /** Resolve a request's completion upper bound against the admitted context.
291
- *
292
- * `max_tokens` is a ceiling, not a promise that every token will be emitted:
293
- * when a prompt fits but a broad client-wide `max_tokens` would push the
294
- * reservation past the safe context, the bound is CLAMPED to the remaining
295
- * room instead of rejecting an otherwise valid request (the pre-v0.0.13
296
- * behavior 400'd a prompt with thousands of tokens of generation room over a
297
- * 17-token overshoot). This never weakens the OOM guard — generation stops at
298
- * the clamped ceiling, inside the admitted context. Only a prompt that leaves
299
- * no generation slot at all is rejected. */
287
+ /** Resolve a completion cap against an explicitly enforced context limit.
288
+ * Serving keeps fit estimates advisory unless the operator chooses a budget. */
300
289
  export function admitRequestContext(
301
290
  promptTokens: number,
302
291
  requestedMaxTokens: number,
@@ -334,7 +323,7 @@ export function createServer(
334
323
  // Both full-attention (CPM) and
335
324
  // sliding-window (Gemma) models batch — the scheduler assembles each layer's
336
325
  // cache by attention type. Non-batchable requests (vision / adapters /
337
- // user seed / unsupported explicit kv-quant) drain to the serial executor
326
+ // unsupported explicit kv-quant) drain to the serial executor
338
327
  // (see GenerationGateway.place). No inference setting is rewritten.
339
328
  // DEFAULT 8 (flipped 2026-07-05, Josh's call, after GATE-B1-SPEED): a
340
329
  // lone request through the batch lane IS the serial engine (adopted
@@ -352,8 +341,7 @@ export function createServer(
352
341
  throw new Error("--generation-checkpoint expects a positive integer token interval");
353
342
  if (!serverOptions.ssdCacheDir)
354
343
  throw new Error("--generation-checkpoint requires --ssd-cache <dir>");
355
- if (batch !== 1)
356
- throw new Error("--generation-checkpoint requires --batch 1 (in-flight resume is serial-only)");
344
+
357
345
  }
358
346
  const defaultGeneratedTokens =
359
347
  serverOptions.defaultMaxTokens ?? ctx.glmMemoryPlan?.maxGenerationTokens;
@@ -376,32 +364,11 @@ export function createServer(
376
364
  const resolvedKvScheme = resolveKvScheme({
377
365
  override: serverOptions.kvQuant,
378
366
  turboQuant: serverOptions.turboQuant,
367
+ quantizedKvStart: serverOptions.quantizedKvStart,
379
368
  config: ctx.kvConfig,
380
369
  });
381
370
  const kvScheme = resolvedKvScheme.generationOptions;
382
- // Phase 3.1: kvConfig whose layers are all full-attention BATCHES (the
383
- // scheduler applies the mixed scheme per row); uniform kvBits and configs
384
- // touching rotating layers still route those requests serial. The warning
385
- // only fires for the still-serial compositions.
386
- if (batch > 1 && kvScheme.kvBits)
387
- console.warn(
388
- `[batch] --batch ${batch} with uniform --kv-quant ${kvScheme.kvBits}: uniform ` +
389
- `quantized KV is serial-only (it touches rotating layers) — those requests ` +
390
- `won't batch. Use --kv-quant config for batched mixed-precision KV, or omit ` +
391
- `--kv-quant to batch in bf16. (docs/design/unified-engine-frontier-plan.md)`,
392
- );
393
- // TurboQuant is solo-only in v1 (novel cache class, not batchable by
394
- // construction — see GenerationGateway placement's cache-capability gate).
395
- if (batch > 1 && kvScheme.turboQuant)
396
- console.warn(
397
- `[batch] --batch ${batch} with --kv-quant turbo: TurboQuant is serial-only in v1 ` +
398
- `— those requests won't batch. Omit --kv-quant to batch in bf16. ` +
399
- `(docs/design/turboquant.md)`,
400
- );
401
- // Quantized KV (any axis) excludes the spec lane: drafted requests fall to
402
- // the normal serial path WITH the KV scheme applied rather than losing it
403
- // silently (mirrors the affine kvBits/kvConfig exclusion; the spec loop is
404
- // bf16-KV-only in v1).
371
+ // The model execution plan qualifies the method while retaining KV policy.
405
372
  if (ctx.draft && (kvScheme.turboQuant || kvScheme.kvBits || kvScheme.kvConfig?.length))
406
373
  console.warn(
407
374
  `[spec] --draft-model with quantized KV (--kv-quant ${serverOptions.turboQuant ? "turbo" : String(serverOptions.kvQuant)}): ` +
@@ -427,15 +394,9 @@ export function createServer(
427
394
  }
428
395
  // Paged KV v1 (docs/design/kv-cache.md): explicit refusals, not
429
396
  // silent downgrades — the incompatible combos would otherwise degrade
430
- // quietly (batch: paged caches can't merge; kv-quant: the swap would
431
- // drop the scheme, the exact composition bug the kvQuant gate above
432
- // exists to prevent; draft: the spec lane assumes serial-class caches).
397
+ // quietly (kv-quant: the swap would drop the scheme; speculative
398
+ // provider composition with paged storage is not implemented).
433
399
  if (serverOptions.pagedKv) {
434
- if (batch > 1)
435
- throw new Error(
436
- `--paged-kv is serial-only in v1 — add --batch 1 (got --batch ${batch}). ` +
437
- `Batched paging is the follow-up PR (docs/design/kv-cache.md).`,
438
- );
439
400
  if (kvScheme.kvBits || kvScheme.kvConfig?.length || kvScheme.turboQuant)
440
401
  throw new Error(
441
402
  `--paged-kv is bf16-only in v1 — omit --kv-quant (quantized paged ` +
@@ -521,11 +482,12 @@ export function createServer(
521
482
  // own their bytes and no mapping outlives loadKvCache — nothing
522
483
  // to pin, nothing to unmap. retain stays in the entry contract
523
484
  // as a no-op so callers' dispose ordering is unchanged.
524
- return { tokens: loaded.tokens, caches: loaded.caches, retain: () => {} };
485
+ return { tokens: loaded.tokens, caches: loaded.caches, attachments: loaded.attachments, retain: () => {} };
525
486
  },
526
- store: (tokens: number[], caches: import("./model/gemma4").Cache[], ns: string) => {
487
+ store: (tokens: number[], caches: import("./model/gemma4").Cache[], ns: string,
488
+ attachments?: import("./backends/mlx/checkpoint-state").CheckpointAttachment[]) => {
527
489
  if (ssdStore!.hasDurablePrefix(tokens, ns)) return true;
528
- return ssdStore!.store(tokens, caches, ns);
490
+ return ssdStore!.store(tokens, caches, ns, attachments);
529
491
  },
530
492
  }
531
493
  : null;
@@ -546,7 +508,7 @@ export function createServer(
546
508
  ssdStore
547
509
  ? {
548
510
  spillOwned: (entry) =>
549
- spillQueue!.enqueue({ tokens: entry.tokens, caches: entry.caches, ns: entry.ns }),
511
+ spillQueue!.enqueue({ tokens: entry.tokens, caches: entry.caches, ns: entry.ns, attachments: entry.attachments }),
550
512
  }
551
513
  : null,
552
514
  coldTier,
@@ -558,56 +520,38 @@ export function createServer(
558
520
  // so its KV prefill is already cached.
559
521
  const responseStore = new ResponseStore();
560
522
 
561
- // Admission ceiling, resolved once (Phase 5 memoryBudget enforcement).
562
- // fit() solves max safe context from weights + KV growth + prefill
563
- // transient. The active kv-quant scheme (uniform kvBits / per-layer
564
- // kvConfig) is billed at its true bytes/element so a quantized cache
565
- // advertises and admits the larger window it actually enables; only
566
- // TurboQuant still bills bf16 (conservative — no projector for its
567
- // layout yet, and it is solo-only in v1).
523
+ // Memory estimates remain available in diagnostics. Only an explicit
524
+ // operator budget or context cap constrains request planning.
568
525
  const memoryAdmission = ctx.glmMemoryPlan ?? fit(
569
526
  ctx.model.config, ctx.model.weightsBytes, 1,
570
527
  undefined, undefined, 0, serverOptions.memoryBudgetBytes,
571
528
  resolvedKvScheme.fitOptions,
572
529
  );
573
- // Isolated benchmark profile cap. It may narrow physical admission, never
574
- // enlarge it. The normal request planner still clamps prompt + output.
575
530
  const profileContext = runtimeValue("MLX_BUN_RD_CONTEXT_LIMIT");
576
- const contextLimit = profileContext === undefined ? null : Number(profileContext);
577
- if (contextLimit !== null && (!Number.isSafeInteger(contextLimit) || contextLimit < 1))
531
+ const profileLimit = profileContext === undefined ? null : Number(profileContext);
532
+ if (profileLimit !== null && (!Number.isSafeInteger(profileLimit) || profileLimit < 1))
578
533
  throw new Error("MLX_BUN_RD_CONTEXT_LIMIT must be a positive integer");
579
- const admission = contextLimit === null ? memoryAdmission : {
580
- ...memoryAdmission, maxSafeContext: Math.min(contextLimit, memoryAdmission.maxSafeContext),
581
- };
582
- // A zero ceiling is a warning, never a startup refusal: killing the
583
- // server here can only parrot the per-request admission message (which
584
- // still fires, with this same ceiling) or be a false positive from the
585
- // fit model itself — it can never save anything the request gate
586
- // doesn't. Serve until physically incapable.
587
- if (admission.maxSafeContext < 1)
588
- console.warn(
589
- `[admission] memory budget ${(admission.usableBytes / 1e9).toFixed(2)} GB leaves no ` +
590
- `safe context for ${ctx.modelId} (weights ${(ctx.model.weightsBytes / 1e9).toFixed(2)} GB) ` +
591
- `— serving anyway; generation requests will be refused until the budget is raised`,
592
- );
534
+ const budgetLimit = serverOptions.memoryBudgetBytes !== undefined
535
+ ? memoryAdmission.maxSafeContext : ctx.glmMemoryPlan?.contextTokens ?? null;
536
+ const contextLimit = profileLimit === null ? budgetLimit
537
+ : Math.min(profileLimit, budgetLimit ?? Infinity);
538
+ const admission = memoryAdmission;
593
539
  const allocatorLimit =
594
540
  admission.allocatorLimitBytes ?? serverOptions.memoryBudgetBytes;
595
541
  if (allocatorLimit) setMemoryLimit(allocatorLimit);
596
542
 
597
- const memoryBudget = {
598
- usableBytes: admission.usableBytes,
599
- kvOptions: resolvedKvScheme.fitOptions,
600
- promptCache,
601
- };
602
- const runGeneration = serving.createSerial({
543
+ let checkpointPersistence: ContinuationPersistence | undefined;
544
+ const executionServices: import("./backends/mlx/serial-executor").MlxSerialServices = {
545
+ get checkpointPersistence() { return checkpointPersistence; },
603
546
  promptCache, checkpoints: ssdStore,
604
- memoryBudget,
605
547
  checkpointEveryTokens: serverOptions.generationCheckpointTokens,
606
548
  identity: { artifact: ctx.profile.artifact, implementation: ctx.profile.profile.execution,
607
549
  stateAbi: "legacy-cache-array-v1", codecs: stateCodecs.id },
608
550
  adapterNamespace: (adapters) => ctx.adapters.cacheNamespace(adapters),
609
551
  cloneState,
610
- });
552
+ };
553
+ serving.gateway.configureContinuation?.(executionServices);
554
+ const runGeneration = serving.createSerial(executionServices);
611
555
 
612
556
  // Write-behind persistence (restart survival — the oMLX boundary-snapshot
613
557
  // idea at whole-entry granularity, spill-on-evict alone can't survive a
@@ -637,7 +581,7 @@ export function createServer(
637
581
  // pacing interleaved those slices exactly between decode tokens. A ~16k
638
582
  // entry's flush overlapping the bench's ctx repeats depressed decode@ctx
639
583
  // ~9% on e4b (mlx-lm runs no equivalent background work). Now every step
640
- // — including the first — runs through gateway.runExclusive(), so the
584
+ // — including the first — runs through gateway.runWhenIdle(), so the
641
585
  // flush only progresses while NOTHING is generating and pauses when a
642
586
  // request arrives mid-flush. Tradeoffs, accepted: durability waits for a
643
587
  // quiet moment (single-user serving quiesces constantly; sustained
@@ -647,7 +591,7 @@ export function createServer(
647
591
  // switch (restart survival then degrades to spill-on-evict only).
648
592
  const writeBehindOn = runtimeValue("MLX_BUN_SSD_WRITEBEHIND") !== "0";
649
593
  const ssdFlushStep = <T>(step: () => T): Promise<T> =>
650
- gateway.runExclusive(async () => step());
594
+ gateway.runWhenIdle(async () => step());
651
595
  // Bounded write-behind queue (2026-07-07 review fix — see SpillQueue in
652
596
  // kv-store.ts): pending clones pin their entries' GPU buffers while the
653
597
  // idle gate starves under sustained traffic, so QUEUED bytes are capped —
@@ -664,21 +608,26 @@ export function createServer(
664
608
  // its idle boundary to avoid competing with generation.
665
609
  const gateway = new GenerationGateway(serving.gateway, batch, runGeneration, {
666
610
  kvBudgetBytes: serverOptions.kvBudgetBytes,
667
- memoryBudget,
668
611
  checkpoints: !!(serverOptions.generationCheckpointTokens && ssdStore),
669
612
  stateCodecs,
670
613
  kvScheme: resolvedKvScheme,
671
614
  promptCache,
672
- beforeSerial: async () => { await flushDurability(); },
615
+ adapterNamespace: (adapters) => ctx.adapters.cacheNamespace(adapters),
673
616
  });
674
617
  const spillQueueGbRaw = Number(runtimeValue("MLX_BUN_SSD_SPILL_QUEUE_GB"));
675
618
  const spillQueueCapBytes =
676
619
  (Number.isFinite(spillQueueGbRaw) && spillQueueGbRaw >= 0 ? spillQueueGbRaw : 2) * 1024 ** 3;
620
+ // Both queues share the configured total: reserve half for checkpoints
621
+ // only when that service exists. Each retains the existing soft-cap rule.
622
+ const checkpointQueueBytes = ssdStore && serverOptions.generationCheckpointTokens
623
+ ? Math.floor(spillQueueCapBytes / 2) : 0;
624
+ if (ssdStore && serverOptions.generationCheckpointTokens)
625
+ checkpointPersistence = new ContinuationPersistence(ssdStore, { maxBytes: checkpointQueueBytes, runStep: ssdFlushStep });
677
626
  const spillQueue = ssdStore
678
627
  ? new SpillQueue(
679
- spillQueueCapBytes,
628
+ spillQueueCapBytes - checkpointQueueBytes,
680
629
  cacheBytes,
681
- (item) => ssdStore!.storeAsync(item.tokens, item.caches, item.ns, ssdFlushStep),
630
+ (item) => ssdStore!.storeAsync(item.tokens, item.caches, item.ns, ssdFlushStep, item.attachments),
682
631
  (caches) => { for (const c of caches) c.dispose(); },
683
632
  )
684
633
  : null;
@@ -691,24 +640,31 @@ export function createServer(
691
640
  (tokens, ns) => ssdStore.hasDurablePrefix(tokens, ns),
692
641
  )
693
642
  : null;
694
- const durabilityStats = (): DurabilitySnapshotStats =>
695
- durability?.stats ?? {
643
+ const durabilityStats = (): DurabilitySnapshotStats => {
644
+ const stats = durability?.stats ?? {
696
645
  pendingSnapshots: 0,
697
646
  pendingSpills: spillQueue?.pendingCount ?? 0,
698
647
  pendingSpillBytes: spillQueue?.pendingBytes ?? 0,
699
648
  droppedSpills: spillQueue?.droppedCount ?? 0,
700
649
  failedSpills: spillQueue?.failedCount ?? 0,
701
650
  };
651
+ const checkpoints = checkpointPersistence?.stats;
652
+ return { ...stats, pendingSpills: stats.pendingSpills + (checkpoints?.pendingCount ?? 0),
653
+ pendingSpillBytes: stats.pendingSpillBytes + (checkpoints?.pendingBytes ?? 0),
654
+ droppedSpills: stats.droppedSpills + (checkpoints?.dropped ?? 0),
655
+ failedSpills: stats.failedSpills + (checkpoints?.failed ?? 0) };
656
+ };
702
657
  const flushDurability = async (): Promise<DurabilityFlushResult> => {
703
- if (durability) return durability.flush();
704
658
  const started = performance.now();
705
- await spillQueue?.drain();
659
+ const checkpoints = await checkpointPersistence?.flush();
660
+ const result = durability ? await durability.flush() : null;
661
+ if (!result) await spillQueue?.drain();
706
662
  const stats = durabilityStats();
707
663
  return {
708
664
  ...stats,
709
- durable: stats.pendingSpills === 0,
710
- flushedSnapshots: 0,
711
- missingSnapshots: 0,
665
+ durable: (result?.durable ?? stats.pendingSpills === 0) && (checkpoints?.durable ?? true) && stats.pendingSpills === 0,
666
+ flushedSnapshots: result?.flushedSnapshots ?? 0,
667
+ missingSnapshots: result?.missingSnapshots ?? 0,
712
668
  elapsedMs: performance.now() - started,
713
669
  };
714
670
  };
@@ -777,10 +733,10 @@ export function createServer(
777
733
  const { templateOptionsFor } = prep;
778
734
  const preparation = createPreparationExecutor((work, signal) => gateway.runExclusive(work, undefined, signal), batch);
779
735
  const chatStage = new ChatStage(
780
- ctx, prep, promptCache, admission.maxSafeContext, serverOptions.defaultAdapter,
736
+ ctx, prep, promptCache, contextLimit, serverOptions.defaultAdapter,
781
737
  preparation, serving.buildPrompt);
782
738
  const textStage = new TextCompletionStage(
783
- ctx, prep, admission.maxSafeContext, defaultGeneratedTokens, serverOptions.defaultAdapter);
739
+ ctx, prep, contextLimit, defaultGeneratedTokens, serverOptions.defaultAdapter);
784
740
  const inferenceStage = new InferenceStage(completionExecutor);
785
741
  const openAiMeta = (id: string) => ({ id, created: Math.floor(Date.now() / 1000), model: ctx.modelId });
786
742
  /** Parse the JSON body under the request's trace; a bad body is a 400. */
@@ -1049,6 +1005,7 @@ export function createServer(
1049
1005
  },
1050
1006
  admission: {
1051
1007
  max_safe_context: admission.maxSafeContext,
1008
+ enforced_context_tokens: contextLimit,
1052
1009
  memory_budget_bytes:
1053
1010
  ctx.glmMemoryPlan?.processLimitBytes ??
1054
1011
  serverOptions.memoryBudgetBytes ?? null,
@@ -1074,7 +1031,6 @@ export function createServer(
1074
1031
  // A parent-managed GPU job holds this response open. The connection owns
1075
1032
  // the native lease, so parent death cannot strand the worker lock.
1076
1033
  if (serverOptions.unixSocket && url.pathname === "/admin/lease" && request.method === "POST") {
1077
- await flushDurability();
1078
1034
  const lease = await gateway.acquireExecutionLease(request.signal);
1079
1035
  let released = false;
1080
1036
  const release = () => {
@@ -0,0 +1,92 @@
1
+ import type { MlxArray } from "../mlx/array";
2
+ import * as ops from "../mlx/ops";
3
+ import { materializeCopy } from "../mlx/materialize";
4
+ import { disposeResources, applyStateChanges } from "../engine/resources";
5
+ import type { PreparedStateChange } from "../contracts/resources";
6
+ import type { AssistantRowsTarget, DraftPrefillGroup, DraftRowGroup, DraftRowCheckpoint, GroupedDraftProvider } from "./source";
7
+ import type { GemmaAssistantDrafter } from "./drafter";
8
+
9
+ /** The assistant owns only its last true target hidden and coverage. Donor
10
+ * attention stays with target storage; checkpoints contain no duplicate KV. */
11
+ class AssistantRows implements DraftPrefillGroup, DraftRowGroup {
12
+ readonly prefillMode = "tail-split" as const;
13
+ readonly tapLayers: readonly number[] = [];
14
+ #hidden: MlxArray | null = null;
15
+ #processed: number[] = [];
16
+ constructor(readonly target: AssistantRowsTarget, readonly drafter: GemmaAssistantDrafter,
17
+ readonly namespace: string, checkpoints: readonly (DraftRowCheckpoint | null)[]) { this.append(checkpoints); }
18
+ get rowCount(): number { return this.#processed.length; }
19
+ append(checkpoints: readonly (DraftRowCheckpoint | null)[]): void {
20
+ if (checkpoints.length) applyStateChanges([() => this.prepareAppend(checkpoints)]);
21
+ }
22
+ prepareAppend(checkpoints: readonly (DraftRowCheckpoint | null)[]): PreparedStateChange {
23
+ const processed = [...this.#processed, ...checkpoints.map(checkpoint => checkpoint?.processedTokens ?? 0)];
24
+ const example = this.#hidden ?? checkpoints.find(checkpoint => checkpoint !== null)?.attachment.tensors[0];
25
+ const held: MlxArray[] = []; let hidden: MlxArray | null = null;
26
+ try {
27
+ for (const checkpoint of checkpoints) if (checkpoint && (checkpoint.attachment.schema !== "gemma-assistant-v1" ||
28
+ checkpoint.attachment.metadata.processedTokens !== checkpoint.processedTokens))
29
+ throw new Error("invalid assistant checkpoint alignment");
30
+ if (example) {
31
+ const zero = (rows: number) => { const value = ops.zeros([rows,1,this.target.hiddenSize],example.dtype); held.push(value); return value; };
32
+ hidden = ops.concatAxis([...(this.rowCount ? [this.#hidden ?? zero(this.rowCount)] : []),
33
+ ...checkpoints.map(checkpoint => checkpoint?.attachment.tensors[0] ?? zero(1))],0);
34
+ }
35
+ } finally { disposeResources(held); }
36
+ return { commit: () => { const old = this.#hidden; this.#hidden=hidden; hidden=old; this.#processed=processed; },
37
+ dispose: () => { hidden?.dispose(); hidden=null; } };
38
+ }
39
+ filterRows(keep: readonly number[]): void {
40
+ if (!keep.length) { this.dispose(); return; }
41
+ using indices = ops.fromInt32([...keep],[keep.length]);
42
+ const next = this.#hidden && keep.length ? ops.takeAxis(this.#hidden,indices,0) : null;
43
+ this.#hidden?.dispose(); this.#hidden=next; this.#processed=keep.map(row=>this.#processed[row]!);
44
+ }
45
+ prefill(tokens: MlxArray, context?: MlxArray): void {
46
+ const [B,N] = tokens.shape as [number,number];
47
+ using tail = context!.slice([0,N-1,0],[B,N,context!.shape[2]!]);
48
+ const hidden = materializeCopy(tail);
49
+ this.#hidden?.dispose(); this.#hidden=hidden; this.#processed=this.#processed.map(count=>count+N);
50
+ }
51
+ materialize(): void { this.#hidden?.eval(); }
52
+ capture(row: number): DraftRowCheckpoint {
53
+ const processedTokens=this.#processed[row]!;
54
+ const hidden=this.#hidden!.slice([row,0,0],[row+1,1,this.target.hiddenSize]);
55
+ return { processedTokens, attachment: { schema:"gemma-assistant-v1",metadata:{processedTokens},tensors:[hidden] } };
56
+ }
57
+ draft(pending: readonly number[], depth: number, _steps: readonly number[]): number[][] {
58
+ if (!depth) return pending.map(()=>[]);
59
+ const B=pending.length, owned: MlxArray[]=[];
60
+ const donors=this.target.readDonors();
61
+ let ids=ops.fromInt32([...pending],[B,1]), hidden=this.#hidden!;
62
+ const tokens: MlxArray[]=[];
63
+ try {
64
+ for(let step=0;step<depth;step++) {
65
+ using emb=this.target.embed(ids);
66
+ const positions=donors.positions.map(position=>position+step);
67
+ const common=positions.every(position=>position===positions[0]);
68
+ using offsets=common ? null : ops.fromInt32(positions,[B]);
69
+ const next=this.drafter.forwardRows(emb,hidden,donors,offsets ?? positions[0]!);
70
+ owned.push(next.nextHidden); tokens.push(next.tokens); hidden=next.nextHidden;
71
+ ids.dispose(); ids=ops.reshape(next.tokens,[B,1]);
72
+ }
73
+ using packed=ops.concatAxis(tokens,0);
74
+ const values=packed.toIntTokens();
75
+ return pending.map((_,row)=>Array.from({length:depth},(_,step)=>values[step*B+row]!));
76
+ } finally { ids.dispose(); donors.dispose(); disposeResources([...owned,...tokens]); }
77
+ }
78
+ commit(accepted: readonly number[], context: MlxArray): void {
79
+ using indices=ops.fromInt32([...accepted],[accepted.length,1,1]);
80
+ const hidden=ops.takeAlongAxis(context,indices,1);
81
+ this.#hidden?.dispose(); this.#hidden=hidden;
82
+ this.#processed=this.#processed.map((count,row)=>count+accepted[row]!+1);
83
+ }
84
+ dispose(): void { this.#hidden?.dispose(); this.#hidden=null; this.#processed=[]; }
85
+ }
86
+
87
+ export function assistantGroups(drafter: GemmaAssistantDrafter, namespace: string): GroupedDraftProvider {
88
+ return {
89
+ openPrefill: ({target,checkpoints}) => new AssistantRows(target.assistantRows!,drafter,namespace,checkpoints),
90
+ open: ({target,checkpoints}) => new AssistantRows(target.assistantRows!,drafter,namespace,checkpoints),
91
+ };
92
+ }
@@ -1,3 +1,6 @@
1
+ import * as ops from "../mlx/ops";
2
+ import { artifactIdentity } from "../model/artifact-identity";
3
+ import { assistantGroups } from "./assistant-rows";
1
4
  // AssistantSource — the optiq KV-borrowing Gemma "-assistant" drafter
2
5
  // (src/spec/drafter.ts) behind the serve-time DraftSource seam. L2 oracle:
3
6
  // optiq spec_generate. This is the SAME drafter the standalone specGenerate
@@ -22,7 +25,7 @@ import { readdirSync, statSync } from "node:fs";
22
25
  import { join } from "node:path";
23
26
  import type { MlxArray } from "../mlx/array";
24
27
  import { GemmaAssistantDrafter } from "./drafter";
25
- import type { DraftProvider, DraftSource, TargetView, AssistantTarget } from "./source";
28
+ import type { DraftProvider, DraftSource, TargetView, AssistantRowsTarget } from "./source";
26
29
 
27
30
  /** Sum of the drafter's on-disk safetensors — admission accounting only. */
28
31
  function safetensorsBytes(dir: string): number {
@@ -35,20 +38,27 @@ function safetensorsBytes(dir: string): number {
35
38
  export class AssistantProvider implements DraftProvider {
36
39
  readonly id: string;
37
40
  readonly weightsBytes: number;
41
+ readonly grouped: import("./source").GroupedDraftProvider;
38
42
 
39
43
  private constructor(
40
44
  private readonly drafter: GemmaAssistantDrafter,
41
45
  id: string,
42
- weightsBytes: number,
46
+ weightsBytes: number, namespace: string,
43
47
  ) {
44
48
  this.id = id;
45
49
  this.weightsBytes = weightsBytes;
50
+ this.grouped = assistantGroups(drafter,namespace);
46
51
  }
47
52
 
48
53
  static async load(modelDir: string): Promise<AssistantProvider> {
49
54
  const drafter = await GemmaAssistantDrafter.load(modelDir);
50
55
  const id = modelDir.split("/").filter(Boolean).at(-1)!;
51
- return new AssistantProvider(drafter, id, safetensorsBytes(modelDir));
56
+ try {
57
+ const identity = await artifactIdentity(await Bun.file(`${modelDir}/config.json`).text(),
58
+ readdirSync(modelDir).filter(file => file.endsWith(".safetensors"))
59
+ .map(name => ({ name, path: join(modelDir, name) })));
60
+ return new AssistantProvider(drafter, id, safetensorsBytes(modelDir), `gemma-assistant-v1:${identity}`);
61
+ } catch (error) { drafter.dispose(); throw error; }
52
62
  }
53
63
 
54
64
  open(opts: Parameters<DraftProvider["open"]>[0]): DraftSource {
@@ -56,18 +66,18 @@ export class AssistantProvider implements DraftProvider {
56
66
  }
57
67
 
58
68
  dispose(): void {
59
- // The drafter's Weights are process-lifetime (pinned mmaps); nothing to free.
69
+ this.drafter.dispose();
60
70
  }
61
71
  }
62
72
 
63
73
  export class AssistantSource implements DraftSource {
64
74
  readonly weightsBytes = 0; // provider-owned weights; per-request adds nothing
65
- private readonly target: AssistantTarget;
75
+ private readonly target: AssistantRowsTarget;
66
76
 
67
- constructor(private readonly drafter: Pick<GemmaAssistantDrafter, "forward">, target: TargetView) {
68
- if (!target.assistant)
77
+ constructor(private readonly drafter: Pick<GemmaAssistantDrafter, "forwardRows">, target: TargetView) {
78
+ if (!target.assistantRows)
69
79
  throw new Error("assistant drafter requires a Gemma4 target with donor views");
70
- this.target = target.assistant;
80
+ this.target = target.assistantRows;
71
81
  }
72
82
 
73
83
  /** No own cache to prime — the drafter reads the target's live state. */
@@ -79,8 +89,8 @@ export class AssistantSource implements DraftSource {
79
89
  * specGenerate's 2a block. anchorHidden is borrowed; never disposed here. */
80
90
  draft(feed: number[], n: number, _stepBase: number, anchorHidden?: MlxArray): number[] {
81
91
  if (!anchorHidden) throw new Error("assistant drafter needs the target anchor hidden");
82
- const position = this.target.position();
83
92
  const shared = this.target.readDonors();
93
+ const position = shared.positions[0]!;
84
94
  const drafts: number[] = [];
85
95
  const ownedHiddens: MlxArray[] = [];
86
96
  let dTok = feed[feed.length - 1]!; // the pending token (anchorHidden's token)
@@ -88,22 +98,22 @@ export class AssistantSource implements DraftSource {
88
98
  let emb: MlxArray | null = null; // hoisted so a mid-loop throw still frees it
89
99
  try {
90
100
  for (let k = 0; k < n; k++) {
91
- emb = this.target.embedScaled(dTok);
92
- const step = this.drafter.forward(emb, dHid, shared, position + k);
101
+ using ids = ops.fromInt32([dTok], [1, 1]);
102
+ emb = this.target.embed(ids);
103
+ const step = this.drafter.forwardRows(emb, dHid, shared, position + k);
104
+ ownedHiddens.push(step.nextHidden);
105
+ let token: number;
106
+ try { token = ops.itemUint32(step.tokens); } finally { step.tokens.dispose(); }
93
107
  emb.dispose();
94
108
  emb = null;
95
- drafts.push(step.token);
96
- ownedHiddens.push(step.nextHidden);
97
- dTok = step.token;
109
+ drafts.push(token);
110
+ dTok = token;
98
111
  dHid = step.nextHidden;
99
112
  }
100
113
  } finally {
101
114
  emb?.dispose();
102
115
  for (const a of ownedHiddens) a.dispose();
103
- for (const [k, v] of [shared.sliding, shared.full]) {
104
- k.dispose();
105
- v.dispose();
106
- }
116
+ shared.dispose();
107
117
  }
108
118
  return drafts;
109
119
  }
@@ -0,0 +1,22 @@
1
+ import type { GenerateOptions } from "../generate";
2
+
3
+ /** Snapshot numerical configuration when the request is bound. Caller-owned
4
+ * arrays must not change the graph or persistence identity after grouping. */
5
+ export function captureSpeculativeOptions(options: GenerateOptions): GenerateOptions {
6
+ return { ...options,
7
+ ...(options.kvConfig ? { kvConfig: options.kvConfig.map(({ layerIdx, bits, groupSize }) =>
8
+ ({ layerIdx, bits, groupSize })).sort((a, b) => a.layerIdx - b.layerIdx) } : {}),
9
+ ...(options.turboQuant ? { turboQuant: { ...options.turboQuant } } : {}),
10
+ };
11
+ }
12
+
13
+ /** Existing namespaces remain byte-identical when no per-layer scheme is
14
+ * supplied. Execution and persistence consume the same captured settings. */
15
+ export function speculativePrefixNamespace(method: string | undefined, adapters: string | string[],
16
+ options: GenerateOptions): string {
17
+ return JSON.stringify({ method, adapters, kvBits: options.kvBits ?? null,
18
+ kvGroupSize: options.kvGroupSize ?? 64, quantizedKvStart: options.quantizedKvStart ?? null,
19
+ turboQuant: options.turboQuant ?? null,
20
+ ...(options.kvConfig?.length ? { kvConfig: options.kvConfig } : {}),
21
+ });
22
+ }
@@ -0,0 +1,29 @@
1
+ import { projectedDraftGroups } from "./projected-draft-rows";
2
+ import type { GroupedDraftProvider, TargetView } from "./source";
3
+ import type { DeepspecDrafter } from "./dspark/deepspec-module";
4
+
5
+ /** Existing pairing requirements are checked once when the provider opens. */
6
+ export function bindDeepspecTarget(target: TargetView, drafter: Pick<DeepspecDrafter, "cfg">): void {
7
+ if (!target.gemmaTaps) throw new Error("DeepSpec drafter requires a Gemma4 target");
8
+ const layers = drafter.cfg.num_target_layers;
9
+ if (target.gemmaTaps.layerCount !== layers) throw new Error(
10
+ `DeepSpec drafter was trained for a ${layers}-layer target; ` +
11
+ `this model has ${target.gemmaTaps.layerCount} layers — wrong (target, drafter) pairing`);
12
+ }
13
+
14
+ export function deepspecGroups(drafter: DeepspecDrafter, namespace: string): GroupedDraftProvider {
15
+ return projectedDraftGroups(target => {
16
+ bindDeepspecTarget(target, drafter);
17
+ return {
18
+ namespace, schema: "deepspec-context-v1",
19
+ layers: drafter.cfg.num_hidden_layers, tapLayers: drafter.tapLayers,
20
+ project(hidden, positions) {
21
+ using projected = drafter.projectContext(hidden);
22
+ return drafter.projectContextKVRows(projected, positions);
23
+ },
24
+ draft(context, pending, positions, depth) {
25
+ return drafter.draftRows(context, pending, positions).tokens.map(row => row.slice(0, depth));
26
+ },
27
+ };
28
+ });
29
+ }