mlx-bun 0.0.10 → 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 +107 -32
- package/THIRD_PARTY_LICENSES.md +10 -0
- package/docs/archive/mlx-bun-lab-report.html +275 -0
- package/docs/archive/wwdc-mlx-bun.html +848 -0
- package/docs/design/audio-input-plan.md +356 -0
- package/docs/design/batching-perf-path.md +58 -35
- package/docs/design/batching-v2-plan.md +23 -9
- package/docs/design/bucketing-stage.md +2 -1
- package/docs/design/colibri-glm52-port.md +1479 -0
- package/docs/design/compat-cli-surface-design.md +4 -3
- package/docs/design/decode-speed-program.md +172 -0
- package/docs/design/docs-reorg-plan.md +1 -1
- package/docs/design/dspark-serving-program.md +371 -0
- package/docs/design/dspark-speculative-decoding.md +167 -5
- package/docs/design/faithful-l1-consolidation.md +310 -0
- package/docs/design/generic-model-support.md +8 -5
- package/docs/design/grammar-spec-batching-integration.md +301 -0
- package/docs/design/minicpm5-decode-megakernel.md +16 -11
- package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
- package/docs/design/omlx-adoption-map.md +14 -6
- package/docs/design/orpo-training.md +34 -30
- package/docs/design/paged-kv-cache.md +214 -0
- package/docs/design/parallel-slots.md +82 -49
- package/docs/design/parity-tier-dag.md +43 -33
- package/docs/design/pre-colibri-stabilization.md +329 -0
- package/docs/design/repo-cleanup-plan.md +189 -0
- package/docs/design/runtime-isolation.md +19 -3
- package/docs/design/segmented-backward-training.md +3 -2
- package/docs/design/ssd-kv-cold-tier.md +130 -0
- package/docs/design/structured-output.md +484 -0
- package/docs/design/the-dreaming-master-plan.md +2 -3
- package/docs/design/turboquant-kv.md +235 -0
- package/docs/design/unified-engine-frontier-plan.md +774 -0
- package/docs/design/web-chat-beat-matrix.md +315 -0
- package/docs/design/web-chat-redesign.md +1112 -0
- package/docs/investigations/colibri-oracle-pin.md +1284 -0
- package/docs/investigations/dspark-handoff.md +182 -16
- package/docs/investigations/indexshare-performance-spike.md +357 -0
- package/docs/investigations/lab-build-journal.md +2 -2
- package/docs/investigations/openwiki-evaluation.md +249 -0
- package/docs/planning/IDEAS.md +14 -0
- package/docs/planning/PRODUCT_ROADMAP.md +2 -3
- package/docs/planning/chat-ui-vision-memory-first.md +199 -0
- package/docs/planning/chat-ui-vision.md +50 -0
- package/docs/planning/memory-docs-and-dag-plan.md +15 -6
- package/docs/planning/release-notes-v0.0.11.md +239 -0
- package/docs/planning/release-notes-v0.0.12.md +80 -0
- package/docs/reference/cli.md +63 -18
- package/docs/reference/distribution.md +37 -6
- package/docs/reference/embedding.md +40 -16
- package/docs/reference/features-matrix.md +99 -0
- package/docs/reference/library-api.md +69 -20
- package/docs/reference/memory.md +26 -6
- package/docs/reference/models.md +60 -3
- package/docs/reference/orpo-quickstart.md +3 -4
- package/docs/reference/server-api.md +717 -24
- package/docs/reference/server-config.md +265 -129
- package/docs/reference/training.md +24 -16
- package/package.json +5 -3
- package/src/anthropic.ts +67 -44
- package/src/audio/conformer.ts +647 -0
- package/src/audio/decode.ts +133 -0
- package/src/audio/features.ts +202 -0
- package/src/audio/transcode.ts +67 -0
- package/src/chat-template.ts +155 -10
- package/src/cli.ts +483 -113
- package/src/config.ts +116 -24
- package/src/diffusion/diffusion-generate.ts +14 -8
- package/src/download.ts +143 -12
- package/src/eval/README.md +7 -6
- package/src/eval/ifeval.ts +63 -120
- package/src/eval/kl.ts +59 -3
- package/src/eval/quality-db.ts +2 -2
- package/src/eval/runner.ts +17 -8
- package/src/eval/tasks/ifeval.ts +167 -31
- package/src/expert-io.ts +391 -0
- package/src/expert-offload.ts +15 -2
- package/src/expert-residency.ts +1134 -0
- package/src/expert-usage.ts +415 -0
- package/src/flags.ts +13 -0
- package/src/generate.ts +438 -35
- package/src/grammar.ts +585 -0
- package/src/hf-push.ts +1 -1
- package/src/hub-rest.ts +242 -0
- package/src/index.ts +14 -2
- package/src/jobs/runner.ts +37 -18
- package/src/kv-store.ts +586 -93
- package/src/lora.ts +85 -14
- package/src/md-modules.d.ts +42 -0
- package/src/media-fetch.ts +426 -0
- package/src/memory/rest.ts +245 -0
- package/src/memory/vault.ts +74 -0
- package/src/mlx/array.ts +149 -19
- package/src/mlx/autograd.ts +53 -16
- package/src/mlx/checkpoint.ts +46 -13
- package/src/mlx/custom-vjp.ts +83 -35
- package/src/mlx/ffi.ts +27 -3
- package/src/mlx/metal-kernel.ts +7 -4
- package/src/mlx/ops.ts +153 -23
- package/src/mlx/turboquant-ops.ts +831 -0
- package/src/mlx/turboquant-tables.ts +237 -0
- package/src/model/batched-mask.ts +62 -1
- package/src/model/batched-quant.ts +206 -0
- package/src/model/batched-rotating-quant.ts +346 -0
- package/src/model/batched-rotating.ts +46 -1
- package/src/model/compiled-decode.ts +4 -15
- package/src/model/factory.ts +103 -6
- package/src/model/gemma4-base.ts +395 -39
- package/src/model/gemma4.ts +122 -33
- package/src/model/generated/gemma4-12b.ts +9 -13
- package/src/model/generated/gemma4-26b.ts +10 -5
- package/src/model/generated/gemma4-e4b.ts +14 -9
- 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/minicpm5.ts +58 -102
- package/src/model/paged-kv.ts +253 -0
- package/src/model/qwen3-delta.ts +76 -15
- package/src/model/qwen3-moe.ts +404 -0
- package/src/model/qwen3.ts +9 -8
- package/src/model/qwen3_5.ts +88 -23
- package/src/model/support.ts +19 -1
- package/src/model/universal/dense.ts +40 -5
- package/src/model/universal/rope.ts +36 -21
- package/src/native/expert_io.c +523 -0
- package/src/native-pack.ts +6 -4
- package/src/pi-web.ts +1299 -82
- package/src/preflight.ts +27 -8
- package/src/prompt-cache.ts +293 -33
- package/src/quantize/quantizer.ts +16 -1
- package/src/registry.ts +57 -2
- package/src/responses.ts +62 -49
- package/src/serve/batch-scheduler.ts +1050 -78
- package/src/serve/generation-gateway.ts +252 -24
- package/src/serve/isolate.ts +417 -0
- package/src/serve/lane-registry.ts +44 -0
- package/src/serve/session-search.ts +326 -0
- package/src/server.ts +1701 -175
- package/src/spec/assistant-source.ts +150 -0
- package/src/spec/deepspec-source.ts +143 -0
- package/src/spec/dflash-source.ts +124 -0
- package/src/spec/dspark/ab-stats.ts +136 -0
- package/src/spec/dspark/calibration.ts +112 -0
- package/src/spec/dspark/deepspec-module.ts +801 -0
- package/src/spec/dspark/generate-dflash.ts +72 -20
- package/src/spec/dspark/loader.ts +23 -0
- package/src/spec/dspark/module-dflash.ts +278 -36
- package/src/spec/dspark/quantize-drafter.ts +91 -0
- package/src/spec/generate.ts +59 -25
- package/src/spec/glm52-mtp-source.ts +392 -0
- package/src/spec/ngram-source.ts +132 -0
- package/src/spec/serve-loop.ts +617 -0
- package/src/spec/source.ts +118 -0
- package/src/spec/two-model.ts +171 -0
- package/src/ssd-cache.ts +111 -35
- package/src/tokenizer.ts +288 -2
- package/src/tool-approvals.ts +131 -0
- package/src/tool-call.ts +438 -22
- package/src/train/flash-cce.ts +2 -2
- package/src/train/job.ts +0 -12
- package/src/train/lora-params.ts +17 -6
- package/src/train/trainer.ts +60 -30
- package/src/vision/prompt.ts +217 -48
- package/src/web/app.html +1197 -1791
- package/src/web/app.js +5694 -0
- package/src/web/icon.svg +13 -0
- package/src/web/manifest.webmanifest +24 -0
- package/src/web/src/adapters-panel.ts +288 -0
- package/src/web/src/api.ts +57 -0
- package/src/web/src/assistant.ts +485 -0
- package/src/web/src/chat.ts +1050 -0
- package/src/web/src/composer.ts +959 -0
- package/src/web/src/dataset.ts +132 -0
- package/src/web/src/finetune.ts +267 -0
- package/src/web/src/hub.ts +428 -0
- package/src/web/src/main.ts +98 -0
- package/src/web/src/markdown.ts +0 -0
- package/src/web/src/memory-panel.ts +572 -0
- package/src/web/src/model-picker.ts +159 -0
- package/src/web/src/palette.ts +346 -0
- package/src/web/src/protocol.ts +62 -0
- package/src/web/src/quantize.ts +167 -0
- package/src/web/src/rag.ts +274 -0
- package/src/web/src/sessions.ts +469 -0
- package/src/web/src/shell.ts +804 -0
- package/src/web/src/status.ts +300 -0
- package/src/web/src/ui-catalog.ts +92 -0
- package/src/web/sw.js +71 -0
- package/src/web/vendor/README.md +61 -0
- package/src/web/vendor/hljs-LICENSE +29 -0
- package/src/web/vendor/hljs-theme.css +59 -0
- package/src/web/vendor/hljs.js +26 -0
- package/src/web-tools.ts +54 -2
- package/src/weights.ts +82 -19
- package/src/model/fused-decode-kernel.ts +0 -312
- package/src/model/fused-geglu-kernel.ts +0 -198
- package/src/model/fused-mlp-kernel.ts +0 -169
- package/src/model/fused-swiglu-kernel.ts +0 -240
- package/src/model/steel-linear-kernel.ts +0 -96
- package/src/status-page.html +0 -497
package/src/model/gemma4-base.ts
CHANGED
|
@@ -14,9 +14,7 @@ import { MlxArray } from "../mlx/array";
|
|
|
14
14
|
import { Dtype } from "../mlx/ffi";
|
|
15
15
|
import * as ops from "../mlx/ops";
|
|
16
16
|
import { expertOffloadArray } from "../expert-offload";
|
|
17
|
-
import
|
|
18
|
-
fusedDecodeKernelSupported, fusedDecodeSdpa, perfKernelEnabled,
|
|
19
|
-
} from "./fused-decode-kernel";
|
|
17
|
+
import * as tq from "../mlx/turboquant-ops";
|
|
20
18
|
|
|
21
19
|
export type MaskMode = "" | "causal";
|
|
22
20
|
export interface Mask {
|
|
@@ -206,7 +204,13 @@ export class QuantizedEmbedding {
|
|
|
206
204
|
* position (see src/model/compiled-decode.ts). */
|
|
207
205
|
export type SharedKv =
|
|
208
206
|
| { kind: "plain"; keys: MlxArray; values: MlxArray; offset: number;
|
|
209
|
-
offsetArr?: MlxArray
|
|
207
|
+
offsetArr?: MlxArray;
|
|
208
|
+
/** TurboQuant deferred-V: `values` are still in the rotated (FWHT)
|
|
209
|
+
* domain — every consumer must un-rotate its attention OUTPUT
|
|
210
|
+
* (tq.unrotateValues) after sdpa. Attention is linear in V, so the
|
|
211
|
+
* result is the same decode, just transformed once per query row
|
|
212
|
+
* instead of once per cached token. */
|
|
213
|
+
vRotated?: boolean }
|
|
210
214
|
| { kind: "quant"; keys: ops.QuantizedTensor; values: ops.QuantizedTensor;
|
|
211
215
|
offset: number; groupSize: number; bits: number; offsetArr?: MlxArray };
|
|
212
216
|
|
|
@@ -230,6 +234,38 @@ export interface Cache {
|
|
|
230
234
|
dispose(): void;
|
|
231
235
|
}
|
|
232
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Capability contract for cache layouts that own their dynamic-row batching.
|
|
239
|
+
* The scheduler uses this for non-K/V state families (for example GLM's
|
|
240
|
+
* compressed MLA/DSA state) instead of teaching the scheduler their tensor
|
|
241
|
+
* layout. `mergeRows` writes into an empty cache and does not consume inputs;
|
|
242
|
+
* extracted rows are independent owned caches.
|
|
243
|
+
*/
|
|
244
|
+
export interface BatchableCache extends Cache {
|
|
245
|
+
readonly batchSize: number | null;
|
|
246
|
+
readonly rowOffsets: readonly number[];
|
|
247
|
+
readonly leftPad: readonly number[];
|
|
248
|
+
readonly maxTokens?: number;
|
|
249
|
+
makeEmptyBatch(): BatchableCache;
|
|
250
|
+
mergeRows(rows: readonly Cache[]): void;
|
|
251
|
+
extractRow(row: number): Cache;
|
|
252
|
+
filterRows(keep: readonly number[]): void;
|
|
253
|
+
projectedBytes(tokens: number): number;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function isBatchableCache(cache: Cache): cache is BatchableCache {
|
|
257
|
+
const candidate = cache as Partial<BatchableCache>;
|
|
258
|
+
return (
|
|
259
|
+
typeof candidate.makeEmptyBatch === "function" &&
|
|
260
|
+
typeof candidate.mergeRows === "function" &&
|
|
261
|
+
typeof candidate.extractRow === "function" &&
|
|
262
|
+
typeof candidate.filterRows === "function" &&
|
|
263
|
+
typeof candidate.projectedBytes === "function" &&
|
|
264
|
+
Array.isArray(candidate.rowOffsets) &&
|
|
265
|
+
Array.isArray(candidate.leftPad)
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
233
269
|
/** How one decode step interacts with a cache under compiled decode
|
|
234
270
|
* (returned by prepareDecodeStep, consumed by compiled-decode.ts):
|
|
235
271
|
* - "concat": the compiled graph fetches concat(active prefix, new kv)
|
|
@@ -306,9 +342,16 @@ export class KVCache implements Cache {
|
|
|
306
342
|
|
|
307
343
|
makeMask(N: number, windowSize: number | null): Mask {
|
|
308
344
|
if (N === 1) return { mode: "", arr: null };
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
345
|
+
// Windowless multi-token chunks get the STRING "causal" at ANY offset —
|
|
346
|
+
// mlx-lm cache.py:114-125 create_attention_mask does exactly this, and
|
|
347
|
+
// mx.fast SDPA aligns the string bottom-right, i.e. the same values as
|
|
348
|
+
// our materialized matrix. Shipping the array instead (the old
|
|
349
|
+
// offset>0 branch) forced a materialize-and-add mask path: a bool
|
|
350
|
+
// [N, offset+N] built PER CHUNK and read by every full-attention layer
|
|
351
|
+
// — the e4b 16k prefill gap vs mlx-lm (2026-07-06b, hd=512 layers
|
|
352
|
+
// where the fallback dispatch can't block-skip an array mask).
|
|
353
|
+
if (windowSize === null) return { mode: "causal", arr: null };
|
|
354
|
+
if (this.offset === 0 && N <= windowSize) return { mode: "causal", arr: null };
|
|
312
355
|
return { mode: "array", arr: createCausalMask(N, this.offset, windowSize) };
|
|
313
356
|
}
|
|
314
357
|
|
|
@@ -508,14 +551,13 @@ export class QuantizedKVCache implements Cache {
|
|
|
508
551
|
|
|
509
552
|
makeMask(N: number, windowSize: number | null): Mask {
|
|
510
553
|
if (N === 1) return { mode: "", arr: null };
|
|
511
|
-
|
|
554
|
+
// Same rule as KVCache.makeMask (mlx-lm cache.py:114-125): windowless
|
|
555
|
+
// multi-token chunks are the string "causal" at any offset. The old
|
|
556
|
+
// causalEquivalent escape hatch existed precisely because this case
|
|
557
|
+
// used to ship an array; only windowed continuations materialize now.
|
|
558
|
+
if (windowSize === null || (this.offset === 0 && N <= windowSize))
|
|
512
559
|
return { mode: "causal", arr: null };
|
|
513
|
-
return {
|
|
514
|
-
mode: "array",
|
|
515
|
-
arr: createCausalMask(N, this.offset, windowSize),
|
|
516
|
-
// a windowless continuation mask is exactly mlx-lm's "causal"
|
|
517
|
-
causalEquivalent: windowSize === null,
|
|
518
|
-
};
|
|
560
|
+
return { mode: "array", arr: createCausalMask(N, this.offset, windowSize) };
|
|
519
561
|
}
|
|
520
562
|
|
|
521
563
|
/** Compiled decode: growth half of updateAndFetchQuantized (L=1),
|
|
@@ -599,6 +641,21 @@ export class QuantizedKVCache implements Cache {
|
|
|
599
641
|
this.offset = offset;
|
|
600
642
|
}
|
|
601
643
|
|
|
644
|
+
/** Chronological (K, V) triples sliced to offset — the quantized twin of
|
|
645
|
+
* KVCache.temporalView (batched merge/extend/filter reads). Caller owns
|
|
646
|
+
* the returned views. */
|
|
647
|
+
temporalView(): [ops.QuantizedTensor, ops.QuantizedTensor] {
|
|
648
|
+
if (!this.keys || !this.values) throw new Error("cache is empty");
|
|
649
|
+
const cut = (t: ops.QuantizedTensor): ops.QuantizedTensor => {
|
|
650
|
+
const f = (a: MlxArray): MlxArray => {
|
|
651
|
+
const [b, h, , d] = a.shape as [number, number, number, number];
|
|
652
|
+
return a.slice([0, 0, 0, 0], [b, h, this.offset, d]);
|
|
653
|
+
};
|
|
654
|
+
return { packed: f(t.packed), scales: f(t.scales), biases: f(t.biases) };
|
|
655
|
+
};
|
|
656
|
+
return [cut(this.keys), cut(this.values)];
|
|
657
|
+
}
|
|
658
|
+
|
|
602
659
|
dispose(): void {
|
|
603
660
|
for (const a of this.state()) a.dispose();
|
|
604
661
|
this.keys = this.values = null;
|
|
@@ -1181,6 +1238,25 @@ export class RotatingQuantizedKVCache implements Cache {
|
|
|
1181
1238
|
this.ringIdx = idx;
|
|
1182
1239
|
}
|
|
1183
1240
|
|
|
1241
|
+
/** Chronological (K, V) triples cut to the valid window — the quantized
|
|
1242
|
+
* twin of RotatingKVCache.temporalView (batched merge reads, Phase 3
|
|
1243
|
+
* milestone 2). Caller owns the returned views. */
|
|
1244
|
+
temporalView(): [ops.QuantizedTensor, ops.QuantizedTensor] {
|
|
1245
|
+
if (!this.keys || !this.values) throw new Error("cache is empty");
|
|
1246
|
+
const valid = Math.min(this.offset, this.maxSize);
|
|
1247
|
+
const cutValid = (t: ops.QuantizedTensor): ops.QuantizedTensor =>
|
|
1248
|
+
mapTriple(t, (a) => {
|
|
1249
|
+
const [B, H, , D] = a.shape as [number, number, number, number];
|
|
1250
|
+
return a.slice([0, 0, 0, 0], [B, H, valid, D]);
|
|
1251
|
+
});
|
|
1252
|
+
const tk = this.#temporalOrder(this.keys);
|
|
1253
|
+
const tv = this.#temporalOrder(this.values);
|
|
1254
|
+
const out: [ops.QuantizedTensor, ops.QuantizedTensor] = [cutValid(tk), cutValid(tv)];
|
|
1255
|
+
disposeTriple(tk);
|
|
1256
|
+
disposeTriple(tv);
|
|
1257
|
+
return out;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1184
1260
|
/** Oracle: to_quantized on an already-quantized rotating cache is
|
|
1185
1261
|
* idempotent. */
|
|
1186
1262
|
toQuantized(): RotatingQuantizedKVCache {
|
|
@@ -1279,6 +1355,297 @@ export class RotatingQuantizedKVCache implements Cache {
|
|
|
1279
1355
|
}
|
|
1280
1356
|
}
|
|
1281
1357
|
|
|
1358
|
+
/** One TurboQuant-encoded (K, V) storage tuple — the 5 arrays kv-store.ts
|
|
1359
|
+
* and toQuantized/fromKVCache pass around. Not `ops.QuantizedTensor`
|
|
1360
|
+
* (mlx's affine int4/int8 scheme): this is the rotation + Lloyd-Max
|
|
1361
|
+
* layout (docs/design/turboquant-kv.md), asymmetric-affine for keys,
|
|
1362
|
+
* FWHT+Lloyd-Max for values. Field order is the kv-store.ts tensor-slot
|
|
1363
|
+
* contract — do not reorder without updating snapshotCache/loadKvCache. */
|
|
1364
|
+
export interface TurboQuantTensor {
|
|
1365
|
+
kIdx: MlxArray;
|
|
1366
|
+
kScales: MlxArray;
|
|
1367
|
+
kZeros: MlxArray;
|
|
1368
|
+
vPacked: MlxArray;
|
|
1369
|
+
vScales: MlxArray;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
export const disposeTurboQuant = (t: TurboQuantTensor): void => {
|
|
1373
|
+
t.kIdx.dispose();
|
|
1374
|
+
t.kScales.dispose();
|
|
1375
|
+
t.kZeros.dispose();
|
|
1376
|
+
t.vPacked.dispose();
|
|
1377
|
+
t.vScales.dispose();
|
|
1378
|
+
};
|
|
1379
|
+
|
|
1380
|
+
/** TurboQuant KV cache — v1 (docs/design/turboquant-kv.md): dequantize-
|
|
1381
|
+
* on-fetch. Deliberately does NOT subclass KVCache/RotatingKVCache (or
|
|
1382
|
+
* QuantizedKVCache) — a novel class fails every generated-file
|
|
1383
|
+
* `#matches()` guard and every batching `instanceof` allow-list, so it
|
|
1384
|
+
* gets solo-only routing and monolith fallback for free, the same way
|
|
1385
|
+
* QuantizedKVCache does today. Only full-attention (non-rotating)
|
|
1386
|
+
* layers use this in v1; sliding-window layers stay bf16.
|
|
1387
|
+
*
|
|
1388
|
+
* Storage: 5 arrays per the vllm-metal reference's `turbo_quant_encode`
|
|
1389
|
+
* tuple — kIdx (int8 unpacked at kBits=8, else bit-packed uint8),
|
|
1390
|
+
* kScales/kZeros (fp16, one pair per 32-group), vPacked (bit-packed
|
|
1391
|
+
* uint8, no-op at vBits=8), vScales (fp16, one per 32-group).
|
|
1392
|
+
* updateAndFetch quantizes ONLY the newly appended tokens (unlike
|
|
1393
|
+
* QuantizedKVCache's whole-buffer-toQuantized conversion path — the
|
|
1394
|
+
* streaming append is the whole point of a live cache) and returns the
|
|
1395
|
+
* DEQUANTIZED bf16 active window so ops.sdpa runs unmodified — v1 pays
|
|
1396
|
+
* a full-window dequant every step; the deferred-InvFWHT trick is a
|
|
1397
|
+
* documented non-goal until the quality gate passes. */
|
|
1398
|
+
export class TurboQuantKVCache implements Cache {
|
|
1399
|
+
static readonly STEP = 256;
|
|
1400
|
+
/** Set only by compiled-decode trace adapters (see Cache) — TurboQuant
|
|
1401
|
+
* is not a compiled-decode participant in v1 (novel class, monolith
|
|
1402
|
+
* fallback only), so this is always unset; captured once per step by
|
|
1403
|
+
* construction, never refreshed mid-step (batched-rotating-quant.ts:55-63). */
|
|
1404
|
+
readonly ropeOffsetArr?: MlxArray;
|
|
1405
|
+
#kv: TurboQuantTensor | null = null;
|
|
1406
|
+
offset = 0;
|
|
1407
|
+
/** Validated lazily on first update, once head_dim is known. */
|
|
1408
|
+
#headDim: number | null = null;
|
|
1409
|
+
|
|
1410
|
+
constructor(readonly kBits: number, readonly vBits: number) {}
|
|
1411
|
+
|
|
1412
|
+
get headDim(): number | null {
|
|
1413
|
+
return this.#headDim;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
#validateHeadDim(dim: number): void {
|
|
1417
|
+
if (this.#headDim !== null) return;
|
|
1418
|
+
const supported = [64, 128, 256, 512];
|
|
1419
|
+
if (dim % 32 !== 0 || !supported.includes(dim)) {
|
|
1420
|
+
throw new Error(
|
|
1421
|
+
`TurboQuantKVCache: head_dim ${dim} must be divisible by 32 and in {${supported.join(",")}}`,
|
|
1422
|
+
);
|
|
1423
|
+
}
|
|
1424
|
+
this.#headDim = dim;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
#packedKDim(dim: number): number {
|
|
1428
|
+
return tq.packedDim(dim, this.kBits);
|
|
1429
|
+
}
|
|
1430
|
+
#packedVDim(dim: number): number {
|
|
1431
|
+
return tq.packedDim(dim, this.vBits);
|
|
1432
|
+
}
|
|
1433
|
+
#nGroups(dim: number): number {
|
|
1434
|
+
return dim / tq.TURBOQUANT_BLOCK_SIZE;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
#alloc(B: number, H: number, T: number, dim: number): TurboQuantTensor {
|
|
1438
|
+
const kIdxDtype = this.kBits === 8 ? Dtype.int8 : Dtype.uint8;
|
|
1439
|
+
return {
|
|
1440
|
+
kIdx: ops.zeros([B, H, T, this.#packedKDim(dim)], kIdxDtype),
|
|
1441
|
+
kScales: ops.zeros([B, H, T, this.#nGroups(dim)], Dtype.float16),
|
|
1442
|
+
kZeros: ops.zeros([B, H, T, this.#nGroups(dim)], Dtype.float16),
|
|
1443
|
+
vPacked: ops.zeros([B, H, T, this.#packedVDim(dim)], Dtype.uint8),
|
|
1444
|
+
vScales: ops.zeros([B, H, T, this.#nGroups(dim)], Dtype.float16),
|
|
1445
|
+
};
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
#seqLen(): number {
|
|
1449
|
+
return this.#kv ? this.#kv.kIdx.shape[2]! : 0;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
/** Encode k/v [B,H,L,D] into a fresh TurboQuantTensor (packed at rest). */
|
|
1453
|
+
#encode(k: MlxArray, v: MlxArray): TurboQuantTensor {
|
|
1454
|
+
const signed = this.kBits === 8;
|
|
1455
|
+
const kEnc = tq.encodeKeys(k, this.kBits, signed);
|
|
1456
|
+
const kIdxPacked = this.kBits < 8 ? tq.packBits(kEnc.indices, this.kBits) : kEnc.indices;
|
|
1457
|
+
if (kIdxPacked !== kEnc.indices) kEnc.indices.dispose();
|
|
1458
|
+
const vEnc = tq.encodeValues(v, this.vBits);
|
|
1459
|
+
const vIdxPacked = this.vBits < 8 ? tq.packBits(vEnc.indices, this.vBits) : vEnc.indices;
|
|
1460
|
+
if (vIdxPacked !== vEnc.indices) vEnc.indices.dispose();
|
|
1461
|
+
return {
|
|
1462
|
+
kIdx: kIdxPacked, kScales: kEnc.scales, kZeros: kEnc.zeros,
|
|
1463
|
+
vPacked: vIdxPacked, vScales: vEnc.scales,
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
/** Decode a TurboQuantTensor's active [B,H,upTo,*] window back to
|
|
1468
|
+
* bf16 [B,H,upTo,headDim] (k, v). Caller owns the returned arrays.
|
|
1469
|
+
* deferV leaves V in the rotated domain (decodeValuesRotated) — the
|
|
1470
|
+
* caller un-rotates its attention output instead (tq.unrotateValues). */
|
|
1471
|
+
#decode(t: TurboQuantTensor, upTo: number, headDim: number, deferV = false): [MlxArray, MlxArray] {
|
|
1472
|
+
const cut = (a: MlxArray): MlxArray => {
|
|
1473
|
+
const [B, H, , D] = a.shape as [number, number, number, number];
|
|
1474
|
+
return a.slice([0, 0, 0, 0], [B, H, upTo, D]);
|
|
1475
|
+
};
|
|
1476
|
+
const kIdxCut = cut(t.kIdx);
|
|
1477
|
+
const kIdxUnpacked = this.kBits < 8 ? tq.unpackBits(kIdxCut, this.kBits, headDim) : kIdxCut;
|
|
1478
|
+
if (kIdxUnpacked !== kIdxCut) kIdxCut.dispose();
|
|
1479
|
+
const kScalesCut = cut(t.kScales);
|
|
1480
|
+
const kZerosCut = cut(t.kZeros);
|
|
1481
|
+
const k = tq.decodeKeys(kIdxUnpacked, kScalesCut, kZerosCut);
|
|
1482
|
+
for (const a of [kIdxUnpacked, kScalesCut, kZerosCut]) a.dispose();
|
|
1483
|
+
|
|
1484
|
+
const vPackedCut = cut(t.vPacked);
|
|
1485
|
+
const vIdxUnpacked = this.vBits < 8 ? tq.unpackBits(vPackedCut, this.vBits, headDim) : vPackedCut;
|
|
1486
|
+
if (vIdxUnpacked !== vPackedCut) vPackedCut.dispose();
|
|
1487
|
+
const vScalesCut = cut(t.vScales);
|
|
1488
|
+
const v = deferV
|
|
1489
|
+
? tq.decodeValuesRotated(vIdxUnpacked, vScalesCut, this.vBits)
|
|
1490
|
+
: tq.decodeValues(vIdxUnpacked, vScalesCut, this.vBits);
|
|
1491
|
+
for (const a of [vIdxUnpacked, vScalesCut]) a.dispose();
|
|
1492
|
+
|
|
1493
|
+
return [k, v];
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
/** Quantize the newly-appended k/v, append into growable packed
|
|
1497
|
+
* storage (256-step growth, mirroring KVCache), advance offset, then
|
|
1498
|
+
* dequantize the WHOLE active window (v1 semantics) so the standard
|
|
1499
|
+
* ops.sdpa path runs unmodified. */
|
|
1500
|
+
updateAndFetch(k: MlxArray, v: MlxArray): [MlxArray, MlxArray] {
|
|
1501
|
+
const D = this.#append(k, v);
|
|
1502
|
+
return this.#decode(this.#kv!, this.offset, D);
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
/** Deferred-inverse-FWHT read path: same append, but the returned V
|
|
1506
|
+
* window stays in the ROTATED domain. The caller MUST un-rotate its
|
|
1507
|
+
* attention output with tq.unrotateValues (see SharedKv.vRotated) —
|
|
1508
|
+
* same decode by linearity, O(q·d log d) per step instead of
|
|
1509
|
+
* O(T·d log d). Opt-in per attention site; sites that don't know about
|
|
1510
|
+
* rotation keep calling updateAndFetch and stay correct. */
|
|
1511
|
+
updateAndFetchDeferredV(k: MlxArray, v: MlxArray): [MlxArray, MlxArray] {
|
|
1512
|
+
const D = this.#append(k, v);
|
|
1513
|
+
return this.#decode(this.#kv!, this.offset, D, true);
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
/** Shared append: grow storage, encode the new tokens, slice-write them
|
|
1517
|
+
* at the previous offset, advance offset. Returns head_dim. */
|
|
1518
|
+
#append(k: MlxArray, v: MlxArray): number {
|
|
1519
|
+
const [B, H, L, D] = k.shape as [number, number, number, number];
|
|
1520
|
+
this.#validateHeadDim(D);
|
|
1521
|
+
const prev = this.offset;
|
|
1522
|
+
|
|
1523
|
+
if (!this.#kv || prev + L > this.#seqLen()) {
|
|
1524
|
+
const nSteps = Math.floor((TurboQuantKVCache.STEP + L - 1) / TurboQuantKVCache.STEP);
|
|
1525
|
+
const newSteps = nSteps * TurboQuantKVCache.STEP;
|
|
1526
|
+
if (this.#kv && prev % TurboQuantKVCache.STEP !== 0) {
|
|
1527
|
+
const trimTo = (t: TurboQuantTensor): TurboQuantTensor => {
|
|
1528
|
+
const cut = (a: MlxArray): MlxArray => {
|
|
1529
|
+
const [b, h, , d] = a.shape as [number, number, number, number];
|
|
1530
|
+
const s = a.slice([0, 0, 0, 0], [b, h, prev, d]);
|
|
1531
|
+
a.dispose();
|
|
1532
|
+
return s;
|
|
1533
|
+
};
|
|
1534
|
+
return { kIdx: cut(t.kIdx), kScales: cut(t.kScales), kZeros: cut(t.kZeros),
|
|
1535
|
+
vPacked: cut(t.vPacked), vScales: cut(t.vScales) };
|
|
1536
|
+
};
|
|
1537
|
+
this.#kv = trimTo(this.#kv);
|
|
1538
|
+
}
|
|
1539
|
+
const grown = this.#alloc(B, H, newSteps, D);
|
|
1540
|
+
if (this.#kv) {
|
|
1541
|
+
const cat = (a: MlxArray, b: MlxArray): MlxArray => {
|
|
1542
|
+
const out = ops.concatAxis([a, b], 2);
|
|
1543
|
+
a.dispose();
|
|
1544
|
+
b.dispose();
|
|
1545
|
+
return out;
|
|
1546
|
+
};
|
|
1547
|
+
this.#kv = {
|
|
1548
|
+
kIdx: cat(this.#kv.kIdx, grown.kIdx),
|
|
1549
|
+
kScales: cat(this.#kv.kScales, grown.kScales),
|
|
1550
|
+
kZeros: cat(this.#kv.kZeros, grown.kZeros),
|
|
1551
|
+
vPacked: cat(this.#kv.vPacked, grown.vPacked),
|
|
1552
|
+
vScales: cat(this.#kv.vScales, grown.vScales),
|
|
1553
|
+
};
|
|
1554
|
+
} else {
|
|
1555
|
+
this.#kv = grown;
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
this.offset += L;
|
|
1560
|
+
const enc = this.#encode(k, v);
|
|
1561
|
+
const write = (dst: MlxArray, src: MlxArray): MlxArray => {
|
|
1562
|
+
const [b, h, , d] = dst.shape as [number, number, number, number];
|
|
1563
|
+
const u = ops.sliceUpdate(dst, src, [0, 0, prev, 0], [b, h, this.offset, d]);
|
|
1564
|
+
dst.dispose();
|
|
1565
|
+
src.dispose();
|
|
1566
|
+
return u;
|
|
1567
|
+
};
|
|
1568
|
+
this.#kv = {
|
|
1569
|
+
kIdx: write(this.#kv!.kIdx, enc.kIdx),
|
|
1570
|
+
kScales: write(this.#kv!.kScales, enc.kScales),
|
|
1571
|
+
kZeros: write(this.#kv!.kZeros, enc.kZeros),
|
|
1572
|
+
vPacked: write(this.#kv!.vPacked, enc.vPacked),
|
|
1573
|
+
vScales: write(this.#kv!.vScales, enc.vScales),
|
|
1574
|
+
};
|
|
1575
|
+
|
|
1576
|
+
return D;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
/** Same rule as KVCache/QuantizedKVCache.makeMask (mlx-lm cache.py:114-125):
|
|
1580
|
+
* windowless multi-token chunks are the string "causal" at any offset —
|
|
1581
|
+
* TurboQuant v1 is full-attention-only, never windowed. */
|
|
1582
|
+
makeMask(N: number, windowSize: number | null): Mask {
|
|
1583
|
+
if (N === 1) return { mode: "", arr: null };
|
|
1584
|
+
if (windowSize === null) return { mode: "causal", arr: null };
|
|
1585
|
+
if (this.offset === 0 && N <= windowSize) return { mode: "causal", arr: null };
|
|
1586
|
+
return { mode: "array", arr: createCausalMask(N, this.offset, windowSize) };
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
/** Trimmed-to-offset arrays in documented order: kIdx, kScales, kZeros,
|
|
1590
|
+
* vPacked, vScales (kv-store.ts tensor-slot ordering for kind
|
|
1591
|
+
* "turboquant"). Empty cache → []. */
|
|
1592
|
+
state(): MlxArray[] {
|
|
1593
|
+
if (!this.#kv) return [];
|
|
1594
|
+
const cut = (a: MlxArray): MlxArray => {
|
|
1595
|
+
const [B, H, , D] = a.shape as [number, number, number, number];
|
|
1596
|
+
return a.slice([0, 0, 0, 0], [B, H, this.offset, D]);
|
|
1597
|
+
};
|
|
1598
|
+
return [cut(this.#kv.kIdx), cut(this.#kv.kScales), cut(this.#kv.kZeros),
|
|
1599
|
+
cut(this.#kv.vPacked), cut(this.#kv.vScales)];
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
isTrimmable(): boolean {
|
|
1603
|
+
return true;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
/** Drop the last n tokens (byte-safe: groups run along head_dim, so a
|
|
1607
|
+
* token-axis slice never splits a group). Same front/back convention
|
|
1608
|
+
* as KVCache.trim: offset shrinks, storage is overwritten on next write. */
|
|
1609
|
+
trim(n: number): void {
|
|
1610
|
+
this.offset = Math.max(0, this.offset - n);
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
/** Adopt persisted/converted state (takes ownership of the arrays). */
|
|
1614
|
+
restoreState(t: TurboQuantTensor, offset: number, headDim: number): void {
|
|
1615
|
+
this.dispose();
|
|
1616
|
+
this.#kv = t;
|
|
1617
|
+
this.offset = offset;
|
|
1618
|
+
this.#headDim = headDim;
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
/** Convert an existing bf16 KVCache/RotatingKVCache's live window in one
|
|
1622
|
+
* shot: quantize the whole [.., :offset, :] region, preserve offset,
|
|
1623
|
+
* dispose the source's arrays. Mirrors KVCache.toQuantized/
|
|
1624
|
+
* RotatingKVCache.toQuantized's contract (source cache is consumed). */
|
|
1625
|
+
static fromKVCache(cache: KVCache | RotatingKVCache, kBits: number, vBits: number): TurboQuantKVCache {
|
|
1626
|
+
const q = new TurboQuantKVCache(kBits, vBits);
|
|
1627
|
+
const [k, v] = cache.keys && cache.values ? cache.temporalView() : [null, null];
|
|
1628
|
+
if (k && v) {
|
|
1629
|
+
const D = k.shape[3]!;
|
|
1630
|
+
q.#validateHeadDim(D);
|
|
1631
|
+
const enc = q.#encode(k, v);
|
|
1632
|
+
k.dispose();
|
|
1633
|
+
v.dispose();
|
|
1634
|
+
q.#kv = enc;
|
|
1635
|
+
q.#headDim = D;
|
|
1636
|
+
}
|
|
1637
|
+
q.offset = cache.offset;
|
|
1638
|
+
cache.dispose();
|
|
1639
|
+
return q;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
dispose(): void {
|
|
1643
|
+
if (this.#kv) disposeTurboQuant(this.#kv);
|
|
1644
|
+
this.#kv = null;
|
|
1645
|
+
this.offset = 0;
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1282
1649
|
export const FINFO_MIN: Partial<Record<Dtype, number>> = {
|
|
1283
1650
|
[Dtype.bfloat16]: -3.3895313892515355e38,
|
|
1284
1651
|
[Dtype.float16]: -65504,
|
|
@@ -1338,6 +1705,14 @@ export function quantizedSdpaUnfused(
|
|
|
1338
1705
|
for (const a of [qIdx, kIdx, qCol, kRow]) a.dispose();
|
|
1339
1706
|
} else if (mask.mode === "array") {
|
|
1340
1707
|
maskArr = mask.arr;
|
|
1708
|
+
// Batched padding masks are [B,1,L,S] (buildBatchedDecodeMask). When GQA
|
|
1709
|
+
// reshaped the scores to 5-D [B,KV,nRep,L,S], trailing-dim broadcasting
|
|
1710
|
+
// would misalign the mask's B with KV — insert the unit axis explicitly:
|
|
1711
|
+
// [B,1,1,L,S] broadcasts correctly over (KV, nRep).
|
|
1712
|
+
if (maskArr && maskArr.ndim === 4 && nRep > 1) {
|
|
1713
|
+
maskArr = ops.expandDims(maskArr, 1);
|
|
1714
|
+
ownsMask = true;
|
|
1715
|
+
}
|
|
1341
1716
|
}
|
|
1342
1717
|
if (maskArr) {
|
|
1343
1718
|
const ninf = ops.scalarLike(FINFO_MIN[scores.dtype] ?? -3.4e38, scores);
|
|
@@ -1568,38 +1943,19 @@ function fusedSdpaSupported(q: MlxArray, mask: Mask, groupSize: number, bits: nu
|
|
|
1568
1943
|
/** Quantized-cache SDPA dispatch: L > 1 (prefill/continuation) with a
|
|
1569
1944
|
* supported config goes through the N-tiled fused path; decode (L = 1)
|
|
1570
1945
|
* and unsupported configs stay on the stock unfused port. Exported for
|
|
1571
|
-
* the dispatch-gate tests.
|
|
1572
|
-
*
|
|
1573
|
-
* MLX_BUN_FUSED_DECODE=1 (NEXT UP 1b experiment): tile decode too,
|
|
1574
|
-
* matching optiq's wrapper, which has no L gate — its serving decode
|
|
1575
|
-
* over quantized caches runs N tiles per step, and Phase 15 measured
|
|
1576
|
-
* its kv-mixed decode tax @8k as ~free where ours is ~3%. Off by
|
|
1577
|
-
* default until a cleared-machine A/B shows it pays; directional
|
|
1578
|
-
* numbers via scripts/bench-fused-decode.ts. */
|
|
1946
|
+
* the dispatch-gate tests. */
|
|
1579
1947
|
export function quantizedSdpa(
|
|
1580
1948
|
q: MlxArray, kq: ops.QuantizedTensor, vq: ops.QuantizedTensor,
|
|
1581
1949
|
scale: number, mask: Mask, groupSize: number, bits: number,
|
|
1582
1950
|
): MlxArray {
|
|
1583
|
-
|
|
1584
|
-
// dispatches. NOT bit-exact (online softmax) — gated against the
|
|
1585
|
-
// frozen perf oracle; compat (MLX_BUN_PERF_KERNEL=0) stays the -O0
|
|
1586
|
-
// reference. Never inside a compiled trace: CustomKernel has no
|
|
1587
|
-
// output_shapes, so it cannot live in a (shapeless) closure.
|
|
1588
|
-
if (
|
|
1589
|
-
mask.mode === "" && scale === 1.0 && perfKernelEnabled() &&
|
|
1590
|
-
!isCompiledTrace() && fusedDecodeKernelSupported(q, bits, groupSize)
|
|
1591
|
-
)
|
|
1592
|
-
return fusedDecodeSdpa(q, kq, vq, groupSize, bits);
|
|
1593
|
-
const tile = q.shape[2]! > 1 || process.env.MLX_BUN_FUSED_DECODE === "1";
|
|
1951
|
+
const tile = q.shape[2]! > 1;
|
|
1594
1952
|
if (tile && fusedSdpaSupported(q, mask, groupSize, bits)) {
|
|
1595
1953
|
// The N-tiled loop CANNOT be traced: its tile count and slice extents
|
|
1596
1954
|
// bake at trace-time N, so under shapeless replay a growing (concat-
|
|
1597
|
-
// phase) quantized cache's newest rows are silently never attended
|
|
1598
|
-
//
|
|
1599
|
-
//
|
|
1600
|
-
//
|
|
1601
|
-
// compiled caller — it lands in the trace's transactional fallback
|
|
1602
|
-
// instead of producing silently wrong attention.
|
|
1955
|
+
// phase) quantized cache's newest rows are silently never attended.
|
|
1956
|
+
// This throw is the backstop for any compiled caller — it lands in the
|
|
1957
|
+
// trace's transactional fallback instead of producing silently wrong
|
|
1958
|
+
// attention.
|
|
1603
1959
|
if (isCompiledTrace())
|
|
1604
1960
|
throw new Error(
|
|
1605
1961
|
"quantizedSdpaTiled inside a compiled trace: the tile loop bakes trace-time N " +
|