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.
- package/README.md +43 -13
- package/THIRD_PARTY_LICENSES.md +9 -0
- package/docs/design/colibri-glm52-port.md +1479 -0
- package/docs/design/pre-colibri-stabilization.md +329 -0
- package/docs/investigations/colibri-oracle-pin.md +1284 -0
- package/docs/investigations/indexshare-performance-spike.md +357 -0
- package/docs/planning/release-notes-v0.0.12.md +80 -0
- package/docs/reference/cli.md +28 -2
- package/docs/reference/distribution.md +25 -1
- package/docs/reference/embedding.md +5 -2
- package/docs/reference/features-matrix.md +4 -2
- package/docs/reference/library-api.md +41 -13
- package/docs/reference/models.md +49 -2
- package/docs/reference/server-api.md +90 -9
- package/docs/reference/server-config.md +21 -14
- package/docs/reference/training.md +10 -1
- package/package.json +1 -1
- package/src/anthropic.ts +54 -44
- package/src/chat-template.ts +155 -10
- package/src/cli.ts +127 -8
- package/src/config.ts +71 -26
- package/src/diffusion/diffusion-generate.ts +14 -8
- package/src/download.ts +103 -12
- package/src/eval/ifeval.ts +63 -120
- package/src/eval/runner.ts +14 -5
- package/src/eval/tasks/ifeval.ts +167 -31
- package/src/expert-io.ts +391 -0
- package/src/expert-residency.ts +1134 -0
- package/src/expert-usage.ts +415 -0
- package/src/generate.ts +114 -17
- package/src/hf-push.ts +1 -1
- package/src/index.ts +14 -2
- package/src/jobs/runner.ts +37 -18
- package/src/kv-store.ts +147 -8
- package/src/lora.ts +77 -14
- package/src/media-fetch.ts +198 -56
- package/src/mlx/array.ts +43 -0
- package/src/mlx/autograd.ts +51 -14
- package/src/mlx/checkpoint.ts +46 -13
- package/src/mlx/custom-vjp.ts +83 -35
- package/src/model/factory.ts +96 -5
- package/src/model/gemma4-base.ts +32 -0
- package/src/model/glm52-atlas.ts +511 -0
- package/src/model/glm52-cache.ts +706 -0
- package/src/model/glm52-config.ts +262 -0
- package/src/model/glm52-container.ts +305 -0
- package/src/model/glm52-coupling.ts +388 -0
- package/src/model/glm52-dsa.ts +602 -0
- package/src/model/glm52-expert-layout.ts +253 -0
- package/src/model/glm52-layout.ts +152 -0
- package/src/model/glm52-memory.ts +473 -0
- package/src/model/glm52-mla.ts +786 -0
- package/src/model/glm52-moe.ts +223 -0
- package/src/model/glm52-pilot.ts +537 -0
- package/src/model/glm52-reference.ts +445 -0
- package/src/model/glm52-residency.ts +557 -0
- package/src/model/glm52-resident-weights.ts +135 -0
- package/src/model/glm52-streamed-experts.ts +453 -0
- package/src/model/glm52-streamed-metal.ts +610 -0
- package/src/model/glm52-weights.ts +503 -0
- package/src/model/glm52.ts +1153 -0
- package/src/model/support.ts +8 -1
- package/src/native/expert_io.c +523 -0
- package/src/native-pack.ts +6 -4
- package/src/responses.ts +62 -49
- package/src/serve/batch-scheduler.ts +95 -16
- package/src/serve/generation-gateway.ts +41 -16
- package/src/server.ts +362 -81
- package/src/spec/glm52-mtp-source.ts +392 -0
- package/src/spec/serve-loop.ts +99 -18
- package/src/spec/source.ts +31 -6
- package/src/ssd-cache.ts +6 -2
- package/src/tool-call.ts +30 -2
- package/src/train/job.ts +0 -10
- package/src/train/lora-params.ts +17 -6
- package/src/train/trainer.ts +59 -26
- package/src/web/app.html +13 -2
- package/src/web/app.js +30 -3
- package/src/web/src/status.ts +51 -3
- package/src/web-tools.ts +54 -2
- 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:
|
|
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 =
|
|
93
|
+
const cutoff = this.now() - this.ttlMs;
|
|
93
94
|
for (const [id, entry] of this.#items) {
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
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,
|
|
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")[];
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
24
|
-
// dynamic-B-capable type
|
|
25
|
-
// server.py's all-caches-have-merge check
|
|
26
|
-
// merge/filter
|
|
27
|
-
//
|
|
28
|
-
//
|
|
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
|
|
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)
|
|
385
|
-
|
|
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(() =>
|
|
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
|
|
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
|
|