mlx-bun 0.0.11 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +43 -13
  2. package/THIRD_PARTY_LICENSES.md +9 -0
  3. package/docs/design/colibri-glm52-port.md +1479 -0
  4. package/docs/design/pre-colibri-stabilization.md +329 -0
  5. package/docs/investigations/colibri-oracle-pin.md +1284 -0
  6. package/docs/investigations/indexshare-performance-spike.md +357 -0
  7. package/docs/planning/release-notes-v0.0.12.md +80 -0
  8. package/docs/reference/cli.md +28 -2
  9. package/docs/reference/distribution.md +25 -1
  10. package/docs/reference/embedding.md +5 -2
  11. package/docs/reference/features-matrix.md +4 -2
  12. package/docs/reference/library-api.md +41 -13
  13. package/docs/reference/models.md +49 -2
  14. package/docs/reference/server-api.md +90 -9
  15. package/docs/reference/server-config.md +21 -14
  16. package/docs/reference/training.md +10 -1
  17. package/package.json +1 -1
  18. package/src/anthropic.ts +54 -44
  19. package/src/chat-template.ts +155 -10
  20. package/src/cli.ts +127 -8
  21. package/src/config.ts +71 -26
  22. package/src/diffusion/diffusion-generate.ts +14 -8
  23. package/src/download.ts +103 -12
  24. package/src/eval/ifeval.ts +63 -120
  25. package/src/eval/runner.ts +14 -5
  26. package/src/eval/tasks/ifeval.ts +167 -31
  27. package/src/expert-io.ts +391 -0
  28. package/src/expert-residency.ts +1134 -0
  29. package/src/expert-usage.ts +415 -0
  30. package/src/generate.ts +114 -17
  31. package/src/hf-push.ts +1 -1
  32. package/src/index.ts +14 -2
  33. package/src/jobs/runner.ts +37 -18
  34. package/src/kv-store.ts +147 -8
  35. package/src/lora.ts +77 -14
  36. package/src/media-fetch.ts +198 -56
  37. package/src/mlx/array.ts +43 -0
  38. package/src/mlx/autograd.ts +51 -14
  39. package/src/mlx/checkpoint.ts +46 -13
  40. package/src/mlx/custom-vjp.ts +83 -35
  41. package/src/model/factory.ts +96 -5
  42. package/src/model/gemma4-base.ts +32 -0
  43. package/src/model/glm52-atlas.ts +511 -0
  44. package/src/model/glm52-cache.ts +706 -0
  45. package/src/model/glm52-config.ts +262 -0
  46. package/src/model/glm52-container.ts +305 -0
  47. package/src/model/glm52-coupling.ts +388 -0
  48. package/src/model/glm52-dsa.ts +602 -0
  49. package/src/model/glm52-expert-layout.ts +253 -0
  50. package/src/model/glm52-layout.ts +152 -0
  51. package/src/model/glm52-memory.ts +473 -0
  52. package/src/model/glm52-mla.ts +786 -0
  53. package/src/model/glm52-moe.ts +223 -0
  54. package/src/model/glm52-pilot.ts +537 -0
  55. package/src/model/glm52-reference.ts +445 -0
  56. package/src/model/glm52-residency.ts +557 -0
  57. package/src/model/glm52-resident-weights.ts +135 -0
  58. package/src/model/glm52-streamed-experts.ts +453 -0
  59. package/src/model/glm52-streamed-metal.ts +610 -0
  60. package/src/model/glm52-weights.ts +503 -0
  61. package/src/model/glm52.ts +1153 -0
  62. package/src/model/support.ts +8 -1
  63. package/src/native/expert_io.c +523 -0
  64. package/src/native-pack.ts +6 -4
  65. package/src/responses.ts +62 -49
  66. package/src/serve/batch-scheduler.ts +95 -16
  67. package/src/serve/generation-gateway.ts +41 -16
  68. package/src/server.ts +362 -81
  69. package/src/spec/glm52-mtp-source.ts +392 -0
  70. package/src/spec/serve-loop.ts +99 -18
  71. package/src/spec/source.ts +31 -6
  72. package/src/ssd-cache.ts +6 -2
  73. package/src/tool-call.ts +30 -2
  74. package/src/train/job.ts +0 -10
  75. package/src/train/lora-params.ts +17 -6
  76. package/src/train/trainer.ts +59 -26
  77. package/src/web/app.html +13 -2
  78. package/src/web/app.js +30 -3
  79. package/src/web/src/status.ts +51 -3
  80. package/src/web-tools.ts +54 -2
  81. package/src/weights.ts +82 -19
package/src/responses.ts CHANGED
@@ -45,6 +45,7 @@ export class ResponseStore {
45
45
  constructor(
46
46
  readonly ttlMs: number = DEFAULT_TTL_MS,
47
47
  readonly maxBytes: number = DEFAULT_MAX_BYTES,
48
+ private readonly now: () => number = Date.now,
48
49
  ) {}
49
50
 
50
51
  get size(): number {
@@ -67,7 +68,7 @@ export class ResponseStore {
67
68
  input: [...entry.input],
68
69
  output: [...entry.output],
69
70
  instructions: entry.instructions,
70
- createdAt: Date.now(),
71
+ createdAt: this.now(),
71
72
  size,
72
73
  });
73
74
  this.#bytes += size;
@@ -89,9 +90,11 @@ export class ResponseStore {
89
90
  }
90
91
 
91
92
  #evictExpired(): void {
92
- const cutoff = Date.now() - this.ttlMs;
93
+ const cutoff = this.now() - this.ttlMs;
93
94
  for (const [id, entry] of this.#items) {
94
- if (entry.createdAt >= cutoff) break; // oldest-first walk, like the oracle
95
+ // LRU touches reorder the map independently of TTL age, so every entry
96
+ // must be checked rather than stopping at the first fresh one.
97
+ if (entry.createdAt >= cutoff) continue;
95
98
  this.#bytes -= entry.size;
96
99
  this.#items.delete(id);
97
100
  }
@@ -733,8 +736,10 @@ export function translateOpenAiSseToResponses(
733
736
  const dec = new TextDecoder();
734
737
  let buf = "";
735
738
  let finalized = false;
739
+ let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
740
+ let cancelled = false;
736
741
  return new ReadableStream<Uint8Array>({
737
- async start(controller) {
742
+ start(controller) {
738
743
  const emit = (frames: string[]) => {
739
744
  for (const f of frames) controller.enqueue(enc.encode(f));
740
745
  };
@@ -744,54 +749,62 @@ export function translateOpenAiSseToResponses(
744
749
  emit(translator.finalize());
745
750
  onComplete(translator.finalResponse());
746
751
  };
747
- const reader = upstream.getReader();
748
- try {
749
- while (true) {
750
- const { done, value } = await reader.read();
751
- if (done) break;
752
- buf += dec.decode(value, { stream: true });
753
- let sep: number;
754
- while ((sep = buf.indexOf("\n\n")) !== -1) {
755
- const frame = buf.slice(0, sep);
756
- buf = buf.slice(sep + 2);
757
- const data = frame
758
- .split("\n")
759
- .find((l) => l.startsWith("data: "))
760
- ?.slice(6);
761
- if (data == null) continue;
762
- if (data === "[DONE]" || data === '"[DONE]"') {
763
- finish();
764
- continue;
752
+ reader = upstream.getReader();
753
+ void (async () => {
754
+ try {
755
+ while (!cancelled) {
756
+ const { done, value } = await reader!.read();
757
+ if (done) break;
758
+ buf += dec.decode(value, { stream: true });
759
+ let sep: number;
760
+ while ((sep = buf.indexOf("\n\n")) !== -1) {
761
+ const frame = buf.slice(0, sep);
762
+ buf = buf.slice(sep + 2);
763
+ const data = frame
764
+ .split("\n")
765
+ .find((l) => l.startsWith("data: "))
766
+ ?.slice(6);
767
+ if (data == null) continue;
768
+ if (data === "[DONE]" || data === '"[DONE]"') {
769
+ finish();
770
+ continue;
771
+ }
772
+ const parsed = JSON.parse(data);
773
+ if (parsed?.error) {
774
+ emit([
775
+ sse("error", {
776
+ type: "error",
777
+ code: "server_error",
778
+ message: parsed.error.message ?? "generation failed",
779
+ param: null,
780
+ }),
781
+ ]);
782
+ continue;
783
+ }
784
+ emit(translator.addChunk(parsed));
765
785
  }
766
- const parsed = JSON.parse(data);
767
- if (parsed?.error) {
768
- emit([
769
- sse("error", {
770
- type: "error",
771
- code: "server_error",
772
- message: parsed.error.message ?? "generation failed",
773
- param: null,
774
- }),
775
- ]);
776
- continue;
777
- }
778
- emit(translator.addChunk(parsed));
779
786
  }
787
+ if (!cancelled) finish();
788
+ } catch (e) {
789
+ if (!cancelled)
790
+ emit([
791
+ sse("error", {
792
+ type: "error",
793
+ code: "server_error",
794
+ message: (e as Error).message,
795
+ param: null,
796
+ }),
797
+ ]);
798
+ } finally {
799
+ reader?.releaseLock();
800
+ reader = null;
801
+ if (!cancelled) controller.close();
780
802
  }
781
- finish();
782
- } catch (e) {
783
- emit([
784
- sse("error", {
785
- type: "error",
786
- code: "server_error",
787
- message: (e as Error).message,
788
- param: null,
789
- }),
790
- ]);
791
- } finally {
792
- reader.releaseLock();
793
- controller.close();
794
- }
803
+ })();
804
+ },
805
+ async cancel(reason) {
806
+ cancelled = true;
807
+ await reader?.cancel(reason);
795
808
  },
796
809
  });
797
810
  }
@@ -61,7 +61,8 @@ import { flagOn } from "../flags";
61
61
  import { CompiledDecode } from "../model/compiled-decode";
62
62
  import type { Gemma4Model } from "../model/gemma4";
63
63
  import {
64
- KVCache, QuantizedKVCache, RotatingKVCache, RotatingQuantizedKVCache, type Cache,
64
+ KVCache, QuantizedKVCache, RotatingKVCache, RotatingQuantizedKVCache,
65
+ isBatchableCache, type BatchableCache, type Cache,
65
66
  } from "../model/gemma4-base";
66
67
  import { BatchedDecodeMaskCache, mergeKVRows, extendKVRows, extractKVRow, filterKVRows } from "../model/batched-mask";
67
68
  import {
@@ -124,6 +125,10 @@ export interface BatchRequest {
124
125
  * THIS row only (its submit promise rejects; siblings continue). May be
125
126
  * async; keep it cheap — it runs inline in the step loop. */
126
127
  onToken: (token: number) => void | boolean | Promise<void | boolean>;
128
+ /** Client/request lifetime. An aborted pending row is removed before
129
+ * admission; a row in prefill or decode is evicted at the next safe
130
+ * scheduler boundary. */
131
+ signal?: AbortSignal;
127
132
  /** The row's sampler is PLAIN GREEDY (temperature 0, no curve) with no
128
133
  * logits processors and no grammar — set by the gateway when true. Lets
129
134
  * the scheduler take the vectorized sampling fast path (ONE
@@ -269,7 +274,8 @@ type LayerInner =
269
274
  | BatchedRotatingQuantCache // rot layer under a kv_config scheme (milestone 2)
270
275
  | RotatingKVCache // adopted lone-row state only (see #mergeJoiner adopt)
271
276
  | RotatingQuantizedKVCache // adopted lone-row state, quantized rot layer
272
- | SSMCache;
277
+ | SSMCache
278
+ | BatchableCache;
273
279
  type Row1 = { keys: MlxArray; values: MlxArray };
274
280
 
275
281
  export class BatchScheduler {
@@ -300,8 +306,10 @@ export class BatchScheduler {
300
306
  * only a lone adopted row holds un-copied entry caches). Runs after the
301
307
  * adopted caches are disposed, or transfers back on put(). */
302
308
  #adoptedRetain: (() => void) | null = null;
303
- readonly #kinds: ("full" | "rot" | "ssm")[]; // per-layer attention type
309
+ readonly #kinds: ("full" | "rot" | "ssm" | "owned-batch")[];
304
310
  readonly #rotMaxSize: number[]; // per-layer sliding window (rot layers only)
311
+ readonly #compressedProjectors: Array<(tokens: number) => number> | null;
312
+ readonly #batchCacheMaxTokens: number | null;
305
313
  /** layerIdx → mixed-precision spec (Phase 3.1); null = bf16 batch (v1). */
306
314
  readonly #kvByLayer: Map<number, KvQuantSpec> | null;
307
315
  /** Compiled decode runner for the B=1 serial-class case (Phase 3.2) —
@@ -320,8 +328,20 @@ export class BatchScheduler {
320
328
  this.#promptCache = opts.promptCache;
321
329
  const proto = model.makeCache(); // fresh caches hold no buffers
322
330
  this.#kinds = proto.map((c) =>
323
- c instanceof RotatingKVCache ? "rot" : c instanceof SSMCache ? "ssm" : "full",
331
+ isBatchableCache(c)
332
+ ? "owned-batch"
333
+ : c instanceof RotatingKVCache
334
+ ? "rot"
335
+ : c instanceof SSMCache
336
+ ? "ssm"
337
+ : "full",
324
338
  );
339
+ this.#compressedProjectors = proto.every(isBatchableCache)
340
+ ? proto.map((cache) => (tokens: number) => cache.projectedBytes(tokens))
341
+ : null;
342
+ this.#batchCacheMaxTokens = proto.every(isBatchableCache)
343
+ ? Math.min(...proto.map((cache) => cache.maxTokens ?? Number.MAX_SAFE_INTEGER))
344
+ : null;
325
345
  this.#kvByLayer = opts.kvConfig?.length
326
346
  ? new Map(opts.kvConfig.map((e) => [e.layerIdx, e]))
327
347
  : null;
@@ -346,7 +366,19 @@ export class BatchScheduler {
346
366
  /** Projected KV bytes of one row at its worst case (full prompt + full
347
367
  * completion; the sliding-window term is window-capped by kvBytesAt). */
348
368
  #rowKvBytes(row: Row): number {
349
- return kvBytesAt(this.model.config, row.promptTokens + row.req.maxTokens);
369
+ const tokens = row.promptTokens + row.req.maxTokens;
370
+ return this.#compressedProjectors
371
+ ? this.#compressedProjectors.reduce((sum, project) => sum + project(tokens), 0)
372
+ : kvBytesAt(this.model.config, tokens);
373
+ }
374
+
375
+ async #forwardHidden(ids: MlxArray, cache: Cache[]): Promise<MlxArray> {
376
+ const asyncModel = this.model as RuntimeModel & {
377
+ forwardHiddenAsync?: (ids: MlxArray, cache: Cache[]) => Promise<MlxArray>;
378
+ };
379
+ return typeof asyncModel.forwardHiddenAsync === "function"
380
+ ? await asyncModel.forwardHiddenAsync(ids, cache)
381
+ : this.model.forwardHidden(ids, cache);
350
382
  }
351
383
 
352
384
  /** Projected aggregate KV of everything admitted (running + mid-prefill). */
@@ -364,6 +396,18 @@ export class BatchScheduler {
364
396
  * that cannot fit even alone is rejected here (never deadlocks the
365
397
  * queue); one that fits alone but not alongside the current batch waits. */
366
398
  #kvAdmits(candidate: Row): boolean {
399
+ const requestedTokens = candidate.promptTokens + candidate.req.maxTokens;
400
+ if (
401
+ this.#batchCacheMaxTokens !== null &&
402
+ requestedTokens > this.#batchCacheMaxTokens
403
+ ) {
404
+ this.#pending.shift();
405
+ candidate.reject(new RangeError(
406
+ `context limit: prompt ${candidate.promptTokens} + max_tokens ` +
407
+ `${candidate.req.maxTokens} exceeds ${this.#batchCacheMaxTokens}`,
408
+ ));
409
+ return false;
410
+ }
367
411
  if (this.#kvBudgetBytes === undefined) return true;
368
412
  const need = this.#rowKvBytes(candidate);
369
413
  if (need > this.#kvBudgetBytes && this.#running.length === 0 && !this.#prefill) {
@@ -383,12 +427,24 @@ export class BatchScheduler {
383
427
 
384
428
  /** Submit a request; resolves when its row finishes (EOS, stop, or length). */
385
429
  submit(req: BatchRequest): Promise<BatchStats> {
430
+ if (req.signal?.aborted) return Promise.reject(req.signal.reason);
386
431
  return new Promise<BatchStats>((resolve, reject) => {
432
+ let abortListener: (() => void) | null = null;
433
+ const cleanup = () => {
434
+ if (abortListener) req.signal?.removeEventListener("abort", abortListener);
435
+ abortListener = null;
436
+ };
387
437
  this.#pending.push({
388
- req, resolve, reject,
438
+ req,
439
+ resolve: (stats) => { cleanup(); resolve(stats); },
440
+ reject: (error) => { cleanup(); reject(error); },
389
441
  current: 0, generated: 0, sampled: 0, promptTokens: req.promptIds.length,
390
442
  cachedTokens: 0, fed: [], fedTainted: false, merged: false,
391
443
  });
444
+ if (req.signal) {
445
+ abortListener = () => this.kick();
446
+ req.signal.addEventListener("abort", abortListener, { once: true });
447
+ }
392
448
  this.#ensureLoop();
393
449
  });
394
450
  }
@@ -409,6 +465,12 @@ export class BatchScheduler {
409
465
  let release: (() => void) | null = null;
410
466
  try {
411
467
  while (true) {
468
+ for (let i = this.#pending.length - 1; i >= 0; i--) {
469
+ const row = this.#pending[i]!;
470
+ if (!row.req.signal?.aborted) continue;
471
+ this.#pending.splice(i, 1);
472
+ row.reject(row.req.signal.reason);
473
+ }
412
474
  const held = this.#admissionHeld?.() === true;
413
475
  // Work = running rows, an in-flight prefill (finish it even under
414
476
  // drain — the row is already half-admitted), or admissible pending.
@@ -543,6 +605,7 @@ export class BatchScheduler {
543
605
  * The final chunk samples token 0, emits it, and — if the row survives —
544
606
  * merges it into the running batch; returns true (admission complete). */
545
607
  async #prefillChunk(p: PrefillState): Promise<boolean> {
608
+ p.row.req.signal?.throwIfAborted();
546
609
  const prompt = p.row.req.promptIds;
547
610
  // Boundary-snapshot chunking (mirrors generate.ts's snapshotAt split):
548
611
  // chunk edges land EXACTLY on snapAt so the clone is taken while the
@@ -554,7 +617,7 @@ export class BatchScheduler {
554
617
  const end = Math.min(p.pos + this.#prefillChunkSize, p.snapAt);
555
618
  const chunk = prompt.slice(p.pos, end);
556
619
  const ids = ops.fromInt32(chunk, [1, chunk.length]);
557
- const h = this.model.forwardHidden(ids, p.solo);
620
+ const h = await this.#forwardHidden(ids, p.solo);
558
621
  ids.dispose();
559
622
  h.dispose();
560
623
  ops.evalAll(p.solo.flatMap((c) => c.state()));
@@ -584,7 +647,7 @@ export class BatchScheduler {
584
647
  this.#prefillChunkSize) {
585
648
  const chunk = prompt.slice(p.pos, p.pos + this.#prefillChunkSize);
586
649
  const ids = ops.fromInt32(chunk, [1, chunk.length]);
587
- const h = this.model.forwardHidden(ids, p.solo);
650
+ const h = await this.#forwardHidden(ids, p.solo);
588
651
  ids.dispose();
589
652
  h.dispose(); // logits never computed for non-final chunks
590
653
  ops.evalAll(p.solo.flatMap((c) => c.state()));
@@ -602,7 +665,7 @@ export class BatchScheduler {
602
665
  if (tailSplit && p.pos < prompt.length - 1) {
603
666
  const head = prompt.slice(p.pos, prompt.length - 1);
604
667
  const ids = ops.fromInt32(head, [1, head.length]);
605
- const h = this.model.forwardHidden(ids, p.solo);
668
+ const h = await this.#forwardHidden(ids, p.solo);
606
669
  ids.dispose();
607
670
  h.dispose();
608
671
  ops.evalAll(p.solo.flatMap((c) => c.state()));
@@ -618,7 +681,7 @@ export class BatchScheduler {
618
681
  // the last prompt token (L=1).
619
682
  const chunk = prompt.slice(p.pos);
620
683
  const ids = ops.fromInt32(chunk, [1, chunk.length]);
621
- const h = this.model.forwardHidden(ids, p.solo);
684
+ const h = await this.#forwardHidden(ids, p.solo);
622
685
  ids.dispose();
623
686
  const [, Lc, H] = h.shape as [number, number, number];
624
687
  const hLast = h.slice([0, Lc - 1, 0], [1, Lc, H]);
@@ -700,6 +763,17 @@ export class BatchScheduler {
700
763
  const newInners: LayerInner[] = [];
701
764
  let newFullPad = this.#fullLeftPad;
702
765
  for (let layer = 0; layer < this.#kinds.length; layer++) {
766
+ if (this.#kinds[layer] === "owned-batch") {
767
+ const solo = p.solo[layer]!;
768
+ if (!isBatchableCache(solo))
769
+ throw new Error(`batch-capable layer ${layer} lost its cache capability`);
770
+ const merged = solo.makeEmptyBatch();
771
+ const previous = prev?.[layer];
772
+ merged.mergeRows(previous ? [previous, solo] : [solo]);
773
+ newFullPad = [...merged.leftPad];
774
+ newInners.push(merged);
775
+ continue;
776
+ }
703
777
  if (this.#kinds[layer] === "ssm") {
704
778
  // No temporal axis, no left-pad: B-axis concat of the state slots.
705
779
  // mergeRows steals the solo arrays when the batch starts cold, so the
@@ -1005,7 +1079,7 @@ export class BatchScheduler {
1005
1079
  if (!lg) {
1006
1080
  fwd = inners.map((c) =>
1007
1081
  c instanceof BatchedRotatingCache || c instanceof BatchedRotatingQuantCache ||
1008
- c instanceof SSMCache || unpadded
1082
+ c instanceof SSMCache || isBatchableCache(c) || unpadded
1009
1083
  ? c
1010
1084
  : c instanceof QuantizedKVCache
1011
1085
  ? new BatchedQuantDecodeMaskCache(c, B, this.#fullLeftPad)
@@ -1014,7 +1088,7 @@ export class BatchScheduler {
1014
1088
  const ids = this.#pendingToks
1015
1089
  ? ops.reshape(this.#pendingToks, [B, 1]) // feed the unread tokens
1016
1090
  : ops.fromInt32(rows.map((r) => r.current), [B, 1]); // pipeline cold
1017
- const h = this.model.forwardHidden(ids, fwd);
1091
+ const h = await this.#forwardHidden(ids, fwd);
1018
1092
  ids.dispose();
1019
1093
  lg = this.model.logitsFromHidden(h); // [B,1,V]
1020
1094
  h.dispose();
@@ -1180,7 +1254,7 @@ export class BatchScheduler {
1180
1254
  const unpadded = this.#fullLeftPad.every((p) => p === 0);
1181
1255
  const fwd: Cache[] = inners.map((c) =>
1182
1256
  c instanceof BatchedRotatingCache || c instanceof BatchedRotatingQuantCache ||
1183
- c instanceof SSMCache || unpadded
1257
+ c instanceof SSMCache || isBatchableCache(c) || unpadded
1184
1258
  ? c
1185
1259
  : c instanceof QuantizedKVCache
1186
1260
  ? new BatchedQuantDecodeMaskCache(c, B, this.#fullLeftPad)
@@ -1191,7 +1265,7 @@ export class BatchScheduler {
1191
1265
  const ids = prev
1192
1266
  ? ops.reshape(prev, [B, 1]) // feed the unread tokens (device array)
1193
1267
  : ops.fromInt32(rows.map((r) => r.current), [B, 1]); // pipeline cold
1194
- const h = this.model.forwardHidden(ids, fwd);
1268
+ const h = await this.#forwardHidden(ids, fwd);
1195
1269
  ids.dispose();
1196
1270
  const lg = this.model.logitsFromHidden(h); // [B,1,V]
1197
1271
  h.dispose();
@@ -1294,6 +1368,7 @@ export class BatchScheduler {
1294
1368
  * WITHOUT an onToken call; otherwise onToken(token) runs and `false` halts;
1295
1369
  * reaching maxTokens ends with "length". Advances row.current on continue. */
1296
1370
  async #emit(row: Row, token: number): Promise<"continue" | "stop" | "length"> {
1371
+ row.req.signal?.throwIfAborted();
1297
1372
  if (row.req.eosTokenIds.includes(token)) return "stop";
1298
1373
  const cont = await row.req.onToken(token);
1299
1374
  if (cont === false) return "stop";
@@ -1366,7 +1441,8 @@ export class BatchScheduler {
1366
1441
  const out: Cache[] = [];
1367
1442
  for (const inner of this.#inners!) {
1368
1443
  let c: Cache | null;
1369
- if (inner instanceof BatchedRotatingQuantCache) c = inner.extractRow(b);
1444
+ if (isBatchableCache(inner)) c = inner.extractRow(b);
1445
+ else if (inner instanceof BatchedRotatingQuantCache) c = inner.extractRow(b);
1370
1446
  else if (inner instanceof BatchedRotatingCache) c = inner.extractRow(b);
1371
1447
  else if (inner instanceof SSMCache)
1372
1448
  // Coverage gate: recurrent state is UNTRIMMABLE, so an entry is only
@@ -1427,7 +1503,10 @@ export class BatchScheduler {
1427
1503
  }
1428
1504
  const out: LayerInner[] = [];
1429
1505
  for (const inner of inners) {
1430
- if (inner instanceof BatchedRotatingCache) {
1506
+ if (isBatchableCache(inner)) {
1507
+ inner.filterRows(keep);
1508
+ out.push(inner);
1509
+ } else if (inner instanceof BatchedRotatingCache) {
1431
1510
  inner.filter(keep); // in-place (mutates + drops rows + reduces padding)
1432
1511
  out.push(inner);
1433
1512
  } else if (inner instanceof BatchedRotatingQuantCache) {
@@ -20,14 +20,12 @@
20
20
  // curve endpoints, adapter mount/unmount) goes through runExclusive() so there
21
21
  // is exactly ONE mutual-exclusion domain.
22
22
  //
23
- // v1 batchable gate (the rest → serial): batch>1 AND every model cache is a
24
- // dynamic-B-capable type (KVCache | RotatingKVCache — mirrors mlx-lm
25
- // server.py's all-caches-have-merge check; Qwen3.5's SSMCache has no
26
- // merge/filter/temporalView, so hybrid models route serial until the ArraysCache
27
- // port — batching-v2-plan item h) AND bf16 KV (no kv-quant — the batched
28
- // scheduler runs bf16; mixed-precision-KV batching is the novel-combo L2
29
- // follow-up) AND no vision AND no LoRA adapters AND no repetition penalty
30
- // (per-row logits processors are a later refinement) AND no user-fixed seed
23
+ // Batchable gate (the rest → serial): batch>1 AND every model cache is a
24
+ // dynamic-B-capable built-in type or implements BatchableCache (mirrors mlx-lm
25
+ // server.py's all-caches-have-merge check). GLM's capability owns compressed
26
+ // MLA/DSA merge/extract/filter and byte projection; Qwen3.5's SSM cache owns
27
+ // its row state. The remaining request-shape exclusions include vision, LoRA,
28
+ // serial-only KV modes, and user-fixed seed
31
29
  // (reproducibility ⇒ solo, matching mlx-lm's _is_batchable). Temperature /
32
30
  // top-p / top-k DO batch (each row samples with its own seed). Full-attention
33
31
  // AND sliding-window (Gemma) models both batch — the scheduler assembles each
@@ -39,7 +37,7 @@ import { MlxArray } from "../mlx/array";
39
37
  import * as ops from "../mlx/ops";
40
38
  import type { RuntimeModel } from "../model/factory";
41
39
  import { DiffusionGemmaModel } from "../model/diffusion-gemma";
42
- import { KVCache, RotatingKVCache } from "../model/gemma4-base";
40
+ import { KVCache, RotatingKVCache, isBatchableCache } from "../model/gemma4-base";
43
41
  import { SSMCache } from "../model/qwen3-delta";
44
42
  import { UniversalDenseModel } from "../model/universal/dense";
45
43
  import type { GenerateOptions, GenerateStats, TokenLogprobs } from "../generate";
@@ -187,7 +185,13 @@ export class GenerationGateway {
187
185
 
188
186
  /** True if `--batch N` (N>1) is on (batchability is then per-request). */
189
187
  get batchingEnabled(): boolean {
190
- return this.#batch > 1;
188
+ return this.batchMode === "batch";
189
+ }
190
+
191
+ /** Truthful configured/model-capability mode for stats and discovery. */
192
+ get batchMode(): "off" | "serial" | "batch" {
193
+ if (this.#batch <= 1) return "off";
194
+ return this.#modelCachesBatchable() ? "batch" : "serial";
191
195
  }
192
196
 
193
197
  /** Rows currently decoding in the batch (0 if no scheduler / idle). */
@@ -262,6 +266,7 @@ export class GenerationGateway {
262
266
  (c) =>
263
267
  c instanceof KVCache ||
264
268
  c instanceof RotatingKVCache ||
269
+ isBatchableCache(c) ||
265
270
  (ssmOk && c instanceof SSMCache),
266
271
  );
267
272
  for (const c of proto) c.dispose();
@@ -364,7 +369,16 @@ export class GenerationGateway {
364
369
  onToken: OnToken,
365
370
  vision: Vision | undefined,
366
371
  shape: RequestShape,
372
+ signal?: AbortSignal,
367
373
  ): Promise<GenerateStats> {
374
+ try {
375
+ signal?.throwIfAborted();
376
+ } catch (e) {
377
+ // The gateway is the first component that accepts ownership of a
378
+ // compiled controller. An already-aborted request reaches neither lane.
379
+ options.grammar?.dispose();
380
+ throw e;
381
+ }
368
382
  if (!this.willBatch(shape)) {
369
383
  // Serial decode is an unbroken microtask chain (FFI + generator
370
384
  // resumes): without a periodic macrotask hop the event loop starves for
@@ -377,14 +391,26 @@ export class GenerationGateway {
377
391
  let lastHop = performance.now();
378
392
  const hop = (): Promise<void> => new Promise<void>((r) => setImmediate(r));
379
393
  const hoppingOnToken: OnToken = (token, lp) => {
394
+ if (signal?.aborted) return false;
380
395
  const r = onToken(token, lp);
381
396
  if (r === false) return false;
382
397
  if (performance.now() - lastHop < 25) return r;
383
398
  lastHop = performance.now();
384
- if (r instanceof Promise) return r.then((v) => (v === false ? false : hop().then(() => v)));
385
- return hop().then(() => r);
399
+ if (r instanceof Promise)
400
+ return r.then((v) =>
401
+ v === false || signal?.aborted ? false : hop().then(() => signal?.aborted ? false : v),
402
+ );
403
+ return hop().then(() => signal?.aborted ? false : r);
386
404
  };
387
- return this.runExclusive(() => this.serialRun(promptIds, options, hoppingOnToken, vision));
405
+ return this.runExclusive(async () => {
406
+ signal?.throwIfAborted();
407
+ const stats = await this.serialRun(promptIds, options, hoppingOnToken, vision);
408
+ signal?.throwIfAborted();
409
+ return stats;
410
+ })
411
+ // Covers a serial waiter aborted before serialRun takes ownership.
412
+ // generate() also disposes defensively; the operation is idempotent.
413
+ .finally(() => options.grammar?.dispose());
388
414
  }
389
415
 
390
416
  // Per-row sampler, mirroring generate()'s sampleStep: logits processors
@@ -447,6 +473,7 @@ export class GenerationGateway {
447
473
  processors.length === 0 &&
448
474
  !options.grammar,
449
475
  onToken,
476
+ ...(signal ? { signal } : {}),
450
477
  // B1: pass the per-row grammar controller through. The scheduler drives
451
478
  // accept/ready/terminate; this gateway OWNS disposal (finally below)
452
479
  // across resolve, reject, eviction, and the whole-batch-drop error path.
@@ -458,9 +485,7 @@ export class GenerationGateway {
458
485
  } finally {
459
486
  history?.dispose();
460
487
  history = null;
461
- // The controller's WASM matcher/compiled-grammar are per-request; free
462
- // them on every exit path. (generate()'s finally does this on the serial
463
- // lane; the batch lane owns it here since the scheduler never does.)
488
+ // The scheduler never owns per-row grammar state.
464
489
  options.grammar?.dispose();
465
490
  }
466
491