mlx-bun 0.0.4
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/LICENSE +21 -0
- package/README.md +362 -0
- package/THIRD_PARTY_LICENSES.md +41 -0
- package/bin/mlx-bun.mjs +32 -0
- package/docs/curve-designer.html +303 -0
- package/docs/design/adapters-end-to-end.md +201 -0
- package/docs/design/compat-cli-surface-design.md +227 -0
- package/docs/design/docs-reorg-plan.md +152 -0
- package/docs/design/hlg-sampling.md +340 -0
- package/docs/design/memory-system.md +339 -0
- package/docs/design/optimization_plan.md +344 -0
- package/docs/design/parallel-slots.md +515 -0
- package/docs/design/runtime-isolation.md +247 -0
- package/docs/design/segmented-backward-training.md +508 -0
- package/docs/investigations/chunk-finetune-experiment.md +84 -0
- package/docs/investigations/curve-bisector-routing.md +123 -0
- package/docs/investigations/curve-runs/bisector-route.json +98 -0
- package/docs/investigations/curve-runs/bisector-trace.json +1393 -0
- package/docs/investigations/curve-runs/curve-terrain.json +1179 -0
- package/docs/investigations/curve-runs/curve-terrain.svg +42 -0
- package/docs/investigations/curve-terrain.html +105 -0
- package/docs/investigations/expert-offload-single-user-moe.md +292 -0
- package/docs/investigations/hlg-ab.html +34 -0
- package/docs/investigations/hlg-explorer.html +238 -0
- package/docs/investigations/hlg-figs/cliff.svg +1 -0
- package/docs/investigations/hlg-figs/cross.svg +1 -0
- package/docs/investigations/hlg-figs/edges.svg +1 -0
- package/docs/investigations/hlg-figs/k20-grids.svg +1 -0
- package/docs/investigations/hlg-figs/k20-peaks.svg +1 -0
- package/docs/investigations/hlg-figs/lw-curve.svg +1 -0
- package/docs/investigations/hlg-figs/terrain.svg +1 -0
- package/docs/investigations/hlg-report.html +262 -0
- package/docs/investigations/hlg-runs/hlg-ab.json +149 -0
- package/docs/investigations/hlg-runs/hlg-blind-prompts.md +87 -0
- package/docs/investigations/hlg-runs/hlg-blind.json +107 -0
- package/docs/investigations/hlg-runs/hlg-cliff.json +197 -0
- package/docs/investigations/hlg-runs/hlg-cross.json +505 -0
- package/docs/investigations/hlg-runs/hlg-edge.json +388 -0
- package/docs/investigations/hlg-runs/hlg-eval1.json +25 -0
- package/docs/investigations/hlg-runs/hlg-frontier.json +86 -0
- package/docs/investigations/hlg-runs/hlg-grid.json +242 -0
- package/docs/investigations/hlg-runs/hlg-grid2.json +343 -0
- package/docs/investigations/hlg-runs/hlg-lw-coarse.json +110 -0
- package/docs/investigations/hlg-runs/hlg-map.json +383 -0
- package/docs/investigations/hlg-runs/run0-original.log +159 -0
- package/docs/investigations/hlg-runs/runA-recommended.log +131 -0
- package/docs/investigations/hlg-runs/runAA-cliff-cpm5.log +26 -0
- package/docs/investigations/hlg-runs/runAB-grid2-e4b.log +18 -0
- package/docs/investigations/hlg-runs/runAC-peak-e4b-partial.log +25 -0
- package/docs/investigations/hlg-runs/runAD-ab-samples.log +8 -0
- package/docs/investigations/hlg-runs/runAE-edge-e4b.log +51 -0
- package/docs/investigations/hlg-runs/runAF-logit-calibration.log +10 -0
- package/docs/investigations/hlg-runs/runAG-eval-config2.log +16 -0
- package/docs/investigations/hlg-runs/runB-gain-variants.log +192 -0
- package/docs/investigations/hlg-runs/runC-pivots.log +162 -0
- package/docs/investigations/hlg-runs/runD-oetf.log +132 -0
- package/docs/investigations/hlg-runs/runE-eotf.log +162 -0
- package/docs/investigations/hlg-runs/runF-shaper-working.log +86 -0
- package/docs/investigations/hlg-runs/runG-shaper-sweep.log +101 -0
- package/docs/investigations/hlg-runs/runH-wsweep.log +176 -0
- package/docs/investigations/hlg-runs/runI-asweep.log +176 -0
- package/docs/investigations/hlg-runs/runJ-knobmap.log +49 -0
- package/docs/investigations/hlg-runs/runK-frontier.log +19 -0
- package/docs/investigations/hlg-runs/runL-widemap.log +65 -0
- package/docs/investigations/hlg-runs/runM-frontier-emb.log +15 -0
- package/docs/investigations/hlg-runs/runN-verify.log +116 -0
- package/docs/investigations/hlg-runs/runO-frontier-seed5000.log +15 -0
- package/docs/investigations/hlg-runs/runP-grid-AxSm.log +15 -0
- package/docs/investigations/hlg-runs/runQ-grid-tgW.log +15 -0
- package/docs/investigations/hlg-runs/runR-blind-transcript.log +79 -0
- package/docs/investigations/hlg-runs/runS-cross-coherence.log +33 -0
- package/docs/investigations/hlg-runs/runT-cross-cpm5.log +33 -0
- package/docs/investigations/hlg-runs/runU-cross-12b.log +33 -0
- package/docs/investigations/hlg-runs/runV-lw-e4b.log +22 -0
- package/docs/investigations/hlg-runs/runW-lw-12b.log +22 -0
- package/docs/investigations/hlg-runs/runX-lw-cpm5.log +22 -0
- package/docs/investigations/hlg-runs/runY-cliff-e4b.log +26 -0
- package/docs/investigations/hlg-runs/runZ-cliff-12b.log +26 -0
- package/docs/investigations/hlg-sampling-investigation.md +970 -0
- package/docs/investigations/lab-build-journal.md +157 -0
- package/docs/investigations/pi-builtin-investigation.md +308 -0
- package/docs/investigations/starter-model-port-handoff.md +323 -0
- package/docs/planning/IDEAS.md +26 -0
- package/docs/planning/PRODUCT_ROADMAP.md +426 -0
- package/docs/planning/ResearchTopics.md +119 -0
- package/docs/planning/chunk-task-roadmap.md +77 -0
- package/docs/planning/journal.md +155 -0
- package/docs/reference/distribution.md +121 -0
- package/docs/reference/embedding.md +168 -0
- package/docs/reference/library-api.md +153 -0
- package/docs/reference/server-api.md +224 -0
- package/docs/reference/server-config.md +243 -0
- package/docs/segmented-backward-handoff.md +148 -0
- package/package.json +68 -0
- package/src/anthropic.ts +491 -0
- package/src/chat-template.ts +186 -0
- package/src/cli.ts +1075 -0
- package/src/config.ts +229 -0
- package/src/curve-sampler.ts +122 -0
- package/src/dataset/generators.ts +1087 -0
- package/src/dataset/index.ts +12 -0
- package/src/dataset/job.ts +81 -0
- package/src/dataset/llm.ts +71 -0
- package/src/dataset/registry.ts +528 -0
- package/src/download.ts +283 -0
- package/src/eval/README.md +86 -0
- package/src/eval/kl-prompts.ts +29 -0
- package/src/eval/kl.ts +294 -0
- package/src/eval/quality-db.ts +92 -0
- package/src/eval/runner.ts +102 -0
- package/src/eval/score.ts +49 -0
- package/src/eval/tasks/bfcl.ts +500 -0
- package/src/eval/tasks/gsm8k.ts +85 -0
- package/src/eval/tasks/hashhop.ts +109 -0
- package/src/eval/tasks/humaneval.ts +252 -0
- package/src/eval/tasks/ifeval.ts +354 -0
- package/src/eval/tasks/mmlu.ts +145 -0
- package/src/evaldb.ts +199 -0
- package/src/expert-offload-build.ts +77 -0
- package/src/expert-offload.ts +61 -0
- package/src/expert-trace.ts +67 -0
- package/src/fit.ts +254 -0
- package/src/generate.ts +451 -0
- package/src/harness-pi.ts +177 -0
- package/src/hf-push.ts +628 -0
- package/src/index.ts +22 -0
- package/src/jobs/db.ts +139 -0
- package/src/jobs/index.ts +29 -0
- package/src/jobs/job-entry.ts +106 -0
- package/src/jobs/runner.ts +304 -0
- package/src/jobs/sse.ts +114 -0
- package/src/jobs/types.ts +58 -0
- package/src/kv-store.ts +185 -0
- package/src/lora.ts +314 -0
- package/src/md-modules.d.ts +7 -0
- package/src/mlx/array.ts +237 -0
- package/src/mlx/autograd.ts +267 -0
- package/src/mlx/checkpoint.ts +116 -0
- package/src/mlx/compile.ts +158 -0
- package/src/mlx/custom-vjp.ts +140 -0
- package/src/mlx/ffi.ts +433 -0
- package/src/mlx/metal-kernel.ts +130 -0
- package/src/mlx/ops.ts +707 -0
- package/src/mmap.ts +90 -0
- package/src/model/batched-mask.ts +204 -0
- package/src/model/batched-rotating.ts +300 -0
- package/src/model/compiled-decode.ts +681 -0
- package/src/model/factory.ts +27 -0
- package/src/model/fingerprint.ts +33 -0
- package/src/model/flash-attention.ts +674 -0
- package/src/model/fused-decode-kernel.ts +305 -0
- package/src/model/fused-geglu-kernel.ts +87 -0
- package/src/model/gemma4-base.ts +1616 -0
- package/src/model/gemma4.ts +1012 -0
- package/src/model/generated/gemma4-12b.ts +526 -0
- package/src/model/generated/gemma4-26b.ts +500 -0
- package/src/model/generated/gemma4-e4b.ts +757 -0
- package/src/model/generated/index.ts +18 -0
- package/src/model/minicpm5.ts +278 -0
- package/src/model/qwen3-delta.ts +224 -0
- package/src/model/qwen3_5.ts +429 -0
- package/src/model/support.ts +46 -0
- package/src/native-pack.ts +152 -0
- package/src/pi-provider.ts +95 -0
- package/src/pi-terminal.ts +264 -0
- package/src/pi-web.ts +924 -0
- package/src/preflight.ts +123 -0
- package/src/prompt-cache.ts +113 -0
- package/src/quantize/allocator.ts +627 -0
- package/src/quantize/calibration.ts +180 -0
- package/src/quantize/config-writer.ts +132 -0
- package/src/quantize/index.ts +57 -0
- package/src/quantize/job.ts +136 -0
- package/src/quantize/quantizer.ts +462 -0
- package/src/quantize/safetensors-writer.ts +171 -0
- package/src/quantize/sensitivity.ts +234 -0
- package/src/registry.ts +336 -0
- package/src/responses.ts +774 -0
- package/src/safetensors.ts +158 -0
- package/src/sampler.ts +517 -0
- package/src/serve/batch-scheduler.ts +362 -0
- package/src/serve/generation-gateway.ts +160 -0
- package/src/server.ts +1891 -0
- package/src/spec/drafter.ts +261 -0
- package/src/spec/generate.ts +221 -0
- package/src/status-page.html +497 -0
- package/src/tokenizer.ts +38 -0
- package/src/tool-call.ts +242 -0
- package/src/train/dataset.ts +461 -0
- package/src/train/export.ts +32 -0
- package/src/train/forward.ts +229 -0
- package/src/train/index.ts +43 -0
- package/src/train/job.ts +148 -0
- package/src/train/lora-params.ts +194 -0
- package/src/train/loss.ts +342 -0
- package/src/train/merge.ts +195 -0
- package/src/train/optimizer.ts +176 -0
- package/src/train/rank.ts +101 -0
- package/src/train/segmented.ts +520 -0
- package/src/train/trainer.ts +600 -0
- package/src/tui.ts +161 -0
- package/src/vision/embedder.ts +168 -0
- package/src/vision/preprocess.ts +247 -0
- package/src/vision/prompt.ts +158 -0
- package/src/vision/siglip.ts +599 -0
- package/src/web/app.html +2471 -0
- package/src/web/skills/web-research/SKILL.md +44 -0
- package/src/web/skills.ts +59 -0
- package/src/web-tools.ts +426 -0
- package/src/weights.ts +91 -0
|
@@ -0,0 +1,1616 @@
|
|
|
1
|
+
// gemma4-base — the config-INDEPENDENT machinery shared by every
|
|
2
|
+
// architecture variant (docs/design/optimization_plan.md Phase B: pure code
|
|
3
|
+
// movement out of the gemma4.ts monolith; every definition here is
|
|
4
|
+
// verbatim from it). Cache classes, quantized-KV SDPA, masks, the
|
|
5
|
+
// quantized primitives + LoRA machinery, and small graph helpers live
|
|
6
|
+
// here; everything that branches on architecture (Attention, MLP, MoE,
|
|
7
|
+
// DecoderLayer, Gemma4Model) stays in gemma4.ts and becomes the
|
|
8
|
+
// per-model generated code in Phase C.
|
|
9
|
+
|
|
10
|
+
import type { ModelConfig } from "../config";
|
|
11
|
+
import { quantFor } from "../config";
|
|
12
|
+
import type { Weights } from "../weights";
|
|
13
|
+
import { MlxArray } from "../mlx/array";
|
|
14
|
+
import { Dtype } from "../mlx/ffi";
|
|
15
|
+
import * as ops from "../mlx/ops";
|
|
16
|
+
import { expertOffloadArray } from "../expert-offload";
|
|
17
|
+
import {
|
|
18
|
+
fusedDecodeKernelSupported, fusedDecodeSdpa, perfKernelEnabled,
|
|
19
|
+
} from "./fused-decode-kernel";
|
|
20
|
+
|
|
21
|
+
export type MaskMode = "" | "causal";
|
|
22
|
+
export interface Mask {
|
|
23
|
+
mode: MaskMode | "array";
|
|
24
|
+
arr: MlxArray | null;
|
|
25
|
+
/** True when `arr` is exactly the bottom-right-aligned causal matrix
|
|
26
|
+
* (offset continuation, no window) — the case where mlx-lm would
|
|
27
|
+
* have handed the string "causal" instead. Only these array masks
|
|
28
|
+
* are eligible for the fused tiled SDPA: the optiq wrapper falls
|
|
29
|
+
* back to unfused on every array mask, and window/bidir masks must
|
|
30
|
+
* match that to stay scenario-bit-exact (Phase 9 finding). */
|
|
31
|
+
causalEquivalent?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Per-adapter LoRA weights for one linear (mlx-lm LoRALinear shapes:
|
|
35
|
+
* a [in_features, rank], b [rank, out_features], typically f32). */
|
|
36
|
+
export interface LoraWeights {
|
|
37
|
+
a: MlxArray;
|
|
38
|
+
b: MlxArray;
|
|
39
|
+
scale: number;
|
|
40
|
+
rank: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Active-adapter state, shared by every mounted linear of one model.
|
|
44
|
+
* A plain field, NOT a ContextVar port: our generation queue is
|
|
45
|
+
* serialized, so exactly one request's adapters are active at a time
|
|
46
|
+
* (PLAN Phase 8 decision). Set/restored by generate(). */
|
|
47
|
+
export class LoraState {
|
|
48
|
+
active: string[] = [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class QuantizedLinear {
|
|
52
|
+
/** Mounted adapters keyed by id (null until first mount — fast path). */
|
|
53
|
+
adapters: Map<string, LoraWeights> | null = null;
|
|
54
|
+
/** Shared per-model active state (wired by AdapterManager.mount). */
|
|
55
|
+
loraState: LoraState | null = null;
|
|
56
|
+
|
|
57
|
+
constructor(
|
|
58
|
+
readonly w: MlxArray,
|
|
59
|
+
readonly scales: MlxArray,
|
|
60
|
+
readonly biases: MlxArray | null,
|
|
61
|
+
readonly spec: ops.QuantSpec,
|
|
62
|
+
) {}
|
|
63
|
+
|
|
64
|
+
static load(weights: Weights, path: string, config: ModelConfig): QuantizedLinear {
|
|
65
|
+
if (!weights.has(`${path}.scales`))
|
|
66
|
+
throw new Error(`${path}: expected quantized linear (no .scales tensor)`);
|
|
67
|
+
const spec = quantFor(config.quantization, path);
|
|
68
|
+
if (!spec) throw new Error(`${path}: no quant spec`);
|
|
69
|
+
return new QuantizedLinear(
|
|
70
|
+
weights.tensor(`${path}.weight`),
|
|
71
|
+
weights.tensor(`${path}.scales`),
|
|
72
|
+
weights.has(`${path}.biases`) ? weights.tensor(`${path}.biases`) : null,
|
|
73
|
+
spec,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** (in_features, out_features) from the quantized tensors (reference
|
|
78
|
+
* _infer_linear_shape: scales are [out, in/group_size]). */
|
|
79
|
+
get inFeatures(): number {
|
|
80
|
+
return this.scales.shape[1]! * this.spec.groupSize;
|
|
81
|
+
}
|
|
82
|
+
get outFeatures(): number {
|
|
83
|
+
return this.scales.shape[0]!;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
forward(x: MlxArray): MlxArray {
|
|
87
|
+
let out = ops.quantizedMatmul(x, this.w, this.scales, this.biases, this.spec, true);
|
|
88
|
+
// LoRA residual — composition is mlx-lm LoRALinear / optiq apply.py:
|
|
89
|
+
// y + (scale · ((x @ A) @ B)).astype(x.dtype)
|
|
90
|
+
// (optiq mount.py omits the astype, leaking the f32 residual into the
|
|
91
|
+
// bf16 stream — divergence documented in PLAN Phase 8 findings; the
|
|
92
|
+
// cast form is what the adapters were trained behind.)
|
|
93
|
+
const st = this.loraState;
|
|
94
|
+
if (st && st.active.length > 0 && this.adapters && this.adapters.size > 0) {
|
|
95
|
+
for (const id of st.active) {
|
|
96
|
+
const lw = this.adapters.get(id);
|
|
97
|
+
if (!lw) continue;
|
|
98
|
+
const xa = ops.matmul(x, lw.a);
|
|
99
|
+
const z = ops.matmul(xa, lw.b);
|
|
100
|
+
xa.dispose();
|
|
101
|
+
const zs = ops.mulScalar(z, lw.scale);
|
|
102
|
+
z.dispose();
|
|
103
|
+
const zc = zs.astype(x.dtype);
|
|
104
|
+
zs.dispose();
|
|
105
|
+
out = disposing(out, ops.add(out, zc));
|
|
106
|
+
zc.dispose();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export class RMSNorm {
|
|
114
|
+
constructor(readonly weight: MlxArray | null, readonly eps: number) {}
|
|
115
|
+
forward(x: MlxArray): MlxArray {
|
|
116
|
+
return ops.rmsNorm(x, this.weight, this.eps);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export class QuantizedEmbedding {
|
|
121
|
+
constructor(
|
|
122
|
+
readonly w: MlxArray,
|
|
123
|
+
readonly scales: MlxArray,
|
|
124
|
+
readonly biases: MlxArray | null,
|
|
125
|
+
readonly spec: ops.QuantSpec,
|
|
126
|
+
) {}
|
|
127
|
+
|
|
128
|
+
static load(weights: Weights, path: string, config: ModelConfig): QuantizedEmbedding {
|
|
129
|
+
const spec = quantFor(config.quantization, path)!;
|
|
130
|
+
return new QuantizedEmbedding(
|
|
131
|
+
weights.tensor(`${path}.weight`),
|
|
132
|
+
weights.tensor(`${path}.scales`),
|
|
133
|
+
weights.has(`${path}.biases`) ? weights.tensor(`${path}.biases`) : null,
|
|
134
|
+
spec,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** ids [1, L] (int/uint) → embeddings [1, L, hidden]. */
|
|
139
|
+
encode(ids: MlxArray): MlxArray {
|
|
140
|
+
const rows = ops.takeAxis(this.w, ids, 0);
|
|
141
|
+
const scaleRows = ops.takeAxis(this.scales, ids, 0);
|
|
142
|
+
const biasRows = this.biases ? ops.takeAxis(this.biases, ids, 0) : null;
|
|
143
|
+
const out = ops.dequantize(rows, scaleRows, biasRows, this.spec);
|
|
144
|
+
for (const a of [rows, scaleRows]) a.dispose();
|
|
145
|
+
biasRows?.dispose();
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Tied output head: h [1, L, hidden] → logits [1, L, vocab]. */
|
|
150
|
+
asLinear(h: MlxArray): MlxArray {
|
|
151
|
+
return ops.quantizedMatmul(h, this.w, this.scales, this.biases, this.spec, true);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Fetched KV handed from a donor layer to its sharers (and to the
|
|
156
|
+
* speculative drafter). Owned by forwardLayers for the pass.
|
|
157
|
+
* `offsetArr` is set only under compiled decode (trace adapters): the
|
|
158
|
+
* RoPE offset as an array VALUE so the compiled graph replays at any
|
|
159
|
+
* position (see src/model/compiled-decode.ts). */
|
|
160
|
+
export type SharedKv =
|
|
161
|
+
| { kind: "plain"; keys: MlxArray; values: MlxArray; offset: number;
|
|
162
|
+
offsetArr?: MlxArray }
|
|
163
|
+
| { kind: "quant"; keys: ops.QuantizedTensor; values: ops.QuantizedTensor;
|
|
164
|
+
offset: number; groupSize: number; bits: number; offsetArr?: MlxArray };
|
|
165
|
+
|
|
166
|
+
export interface Cache {
|
|
167
|
+
offset: number;
|
|
168
|
+
/** Compiled-decode trace adapters expose the RoPE offset as an int32
|
|
169
|
+
* array input here; real caches leave it unset (static int path). */
|
|
170
|
+
readonly ropeOffsetArr?: MlxArray;
|
|
171
|
+
updateAndFetch(k: MlxArray, v: MlxArray): [MlxArray, MlxArray];
|
|
172
|
+
/** Mask for an N-token step given this cache's state. */
|
|
173
|
+
makeMask(N: number, windowSize: number | null): Mask;
|
|
174
|
+
state(): MlxArray[];
|
|
175
|
+
/** Can `trim(n)` drop the last n tokens? (Ring caches lose trimability
|
|
176
|
+
* once wrapped.) */
|
|
177
|
+
isTrimmable(): boolean;
|
|
178
|
+
/** Drop the last n tokens (future writes overwrite them). */
|
|
179
|
+
trim(n: number): void;
|
|
180
|
+
dispose(): void;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** How one decode step interacts with a cache under compiled decode
|
|
184
|
+
* (returned by prepareDecodeStep, consumed by compiled-decode.ts):
|
|
185
|
+
* - "concat": the compiled graph fetches concat(active prefix, new kv)
|
|
186
|
+
* — same values as today's write-then-slice — and the WRITE happens
|
|
187
|
+
* outside the graph right after (writeDecodeStep), keeping the buffer
|
|
188
|
+
* single-referenced at its slice_update so mlx donates it in place.
|
|
189
|
+
* - "ring": the write happens IN-graph (slice_update_dynamic at
|
|
190
|
+
* writePos) and the fetch is the full updated buffer — the rotating
|
|
191
|
+
* steady state, where attention reads the whole ring in ring order
|
|
192
|
+
* (a concat would permute KV positions and change summation order).
|
|
193
|
+
* The updated buffers come back as closure outputs; adoptDecodeStep
|
|
194
|
+
* swaps them in. */
|
|
195
|
+
export interface DecodeStepPlan {
|
|
196
|
+
fetch: "concat" | "ring";
|
|
197
|
+
/** Write position on axis 2 (== ring index for rotating caches). */
|
|
198
|
+
writePos: number;
|
|
199
|
+
/** Valid prefix length for "concat" fetches (== offset). */
|
|
200
|
+
activeLen: number;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** KV cache — port of mlx-lm cache.py KVCache: preallocated in steps of
|
|
204
|
+
* 256 along the sequence axis, updated in place via slice_update. */
|
|
205
|
+
export class KVCache implements Cache {
|
|
206
|
+
static readonly STEP = 256;
|
|
207
|
+
/** Set only by compiled-decode trace adapters (see Cache). */
|
|
208
|
+
readonly ropeOffsetArr?: MlxArray;
|
|
209
|
+
keys: MlxArray | null = null;
|
|
210
|
+
values: MlxArray | null = null;
|
|
211
|
+
offset = 0;
|
|
212
|
+
|
|
213
|
+
updateAndFetch(k: MlxArray, v: MlxArray): [MlxArray, MlxArray] {
|
|
214
|
+
const prev = this.offset;
|
|
215
|
+
const L = k.shape[2]!;
|
|
216
|
+
if (!this.keys || prev + L > this.keys.shape[2]!) {
|
|
217
|
+
const [B, H, , D] = k.shape as [number, number, number, number];
|
|
218
|
+
const vD = v.shape[3]!;
|
|
219
|
+
const nSteps = Math.floor((KVCache.STEP + L - 1) / KVCache.STEP);
|
|
220
|
+
const newK = ops.zeros([B, H, nSteps * KVCache.STEP, D], k.dtype);
|
|
221
|
+
const newV = ops.zeros([B, H, nSteps * KVCache.STEP, vD], v.dtype);
|
|
222
|
+
if (this.keys && this.values) {
|
|
223
|
+
let oldK = this.keys;
|
|
224
|
+
let oldV = this.values;
|
|
225
|
+
if (prev % KVCache.STEP !== 0) {
|
|
226
|
+
const trimK = oldK.slice([0, 0, 0, 0], [B, H, prev, D]);
|
|
227
|
+
const trimV = oldV.slice([0, 0, 0, 0], [B, H, prev, vD]);
|
|
228
|
+
oldK.dispose();
|
|
229
|
+
oldV.dispose();
|
|
230
|
+
oldK = trimK;
|
|
231
|
+
oldV = trimV;
|
|
232
|
+
}
|
|
233
|
+
this.keys = ops.concatAxis([oldK, newK], 2);
|
|
234
|
+
this.values = ops.concatAxis([oldV, newV], 2);
|
|
235
|
+
for (const a of [oldK, oldV, newK, newV]) a.dispose();
|
|
236
|
+
} else {
|
|
237
|
+
this.keys = newK;
|
|
238
|
+
this.values = newV;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
this.offset += L;
|
|
243
|
+
const [B, H, S, D] = this.keys!.shape as [number, number, number, number];
|
|
244
|
+
const vD = this.values!.shape[3]!;
|
|
245
|
+
const k2 = ops.sliceUpdate(this.keys!, k, [0, 0, prev, 0], [B, H, this.offset, D]);
|
|
246
|
+
const v2 = ops.sliceUpdate(this.values!, v, [0, 0, prev, 0], [B, H, this.offset, vD]);
|
|
247
|
+
this.keys!.dispose();
|
|
248
|
+
this.values!.dispose();
|
|
249
|
+
this.keys = k2;
|
|
250
|
+
this.values = v2;
|
|
251
|
+
return [
|
|
252
|
+
this.keys.slice([0, 0, 0, 0], [B, H, this.offset, D]),
|
|
253
|
+
this.values.slice([0, 0, 0, 0], [B, H, this.offset, vD]),
|
|
254
|
+
];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
makeMask(N: number, windowSize: number | null): Mask {
|
|
258
|
+
if (N === 1) return { mode: "", arr: null };
|
|
259
|
+
if (this.offset === 0 && windowSize === null) return { mode: "causal", arr: null };
|
|
260
|
+
if (this.offset === 0 && windowSize !== null && N <= windowSize)
|
|
261
|
+
return { mode: "causal", arr: null };
|
|
262
|
+
return { mode: "array", arr: createCausalMask(N, this.offset, windowSize) };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Arrays to eval to materialize cache state (prefill chunk boundary). */
|
|
266
|
+
state(): MlxArray[] {
|
|
267
|
+
return this.keys && this.values ? [this.keys, this.values] : [];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
isTrimmable(): boolean {
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
trim(n: number): void {
|
|
275
|
+
this.offset = Math.max(0, this.offset - n);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Chronological (K, V) view sliced to offset (drafter donor read). */
|
|
279
|
+
temporalView(): [MlxArray, MlxArray] {
|
|
280
|
+
if (!this.keys || !this.values) throw new Error("cache is empty");
|
|
281
|
+
const [B, H, , D] = this.keys.shape as [number, number, number, number];
|
|
282
|
+
const vD = this.values.shape[3]!;
|
|
283
|
+
return [
|
|
284
|
+
this.keys.slice([0, 0, 0, 0], [B, H, this.offset, D]),
|
|
285
|
+
this.values.slice([0, 0, 0, 0], [B, H, this.offset, vD]),
|
|
286
|
+
];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Compiled decode: host-side bookkeeping for a 1-token step — the
|
|
290
|
+
* growth/trim half of updateAndFetch (L=1), without the write. */
|
|
291
|
+
prepareDecodeStep(): DecodeStepPlan {
|
|
292
|
+
const prev = this.offset;
|
|
293
|
+
if (!this.keys || !this.values) throw new Error("compiled decode on an empty cache");
|
|
294
|
+
if (prev + 1 > this.keys.shape[2]!) {
|
|
295
|
+
const [B, H, , D] = this.keys.shape as [number, number, number, number];
|
|
296
|
+
const vD = this.values.shape[3]!;
|
|
297
|
+
let oldK = this.keys;
|
|
298
|
+
let oldV = this.values;
|
|
299
|
+
if (prev % KVCache.STEP !== 0) {
|
|
300
|
+
const trimK = oldK.slice([0, 0, 0, 0], [B, H, prev, D]);
|
|
301
|
+
const trimV = oldV.slice([0, 0, 0, 0], [B, H, prev, vD]);
|
|
302
|
+
oldK.dispose();
|
|
303
|
+
oldV.dispose();
|
|
304
|
+
oldK = trimK;
|
|
305
|
+
oldV = trimV;
|
|
306
|
+
}
|
|
307
|
+
const newK = ops.zeros([B, H, KVCache.STEP, D], oldK.dtype);
|
|
308
|
+
const newV = ops.zeros([B, H, KVCache.STEP, vD], oldV.dtype);
|
|
309
|
+
this.keys = ops.concatAxis([oldK, newK], 2);
|
|
310
|
+
this.values = ops.concatAxis([oldV, newV], 2);
|
|
311
|
+
for (const a of [oldK, oldV, newK, newV]) a.dispose();
|
|
312
|
+
}
|
|
313
|
+
return { fetch: "concat", writePos: prev, activeLen: prev };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Compiled decode: the write half (same sliceUpdate as updateAndFetch).
|
|
317
|
+
* Takes ownership of kNew/vNew; returns the arrays to async-eval with
|
|
318
|
+
* the step (the updated buffers). */
|
|
319
|
+
writeDecodeStep(kNew: MlxArray, vNew: MlxArray): MlxArray[] {
|
|
320
|
+
const prev = this.offset;
|
|
321
|
+
const [B, H, , D] = this.keys!.shape as [number, number, number, number];
|
|
322
|
+
const vD = this.values!.shape[3]!;
|
|
323
|
+
const k2 = ops.sliceUpdate(this.keys!, kNew, [0, 0, prev, 0], [B, H, prev + 1, D]);
|
|
324
|
+
const v2 = ops.sliceUpdate(this.values!, vNew, [0, 0, prev, 0], [B, H, prev + 1, vD]);
|
|
325
|
+
this.keys!.dispose();
|
|
326
|
+
this.values!.dispose();
|
|
327
|
+
kNew.dispose();
|
|
328
|
+
vNew.dispose();
|
|
329
|
+
this.keys = k2;
|
|
330
|
+
this.values = v2;
|
|
331
|
+
this.offset = prev + 1;
|
|
332
|
+
return [k2, v2];
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Adopt persisted state (takes ownership of the arrays). */
|
|
336
|
+
restoreState(keys: MlxArray, values: MlxArray, offset: number): void {
|
|
337
|
+
this.dispose();
|
|
338
|
+
this.keys = keys;
|
|
339
|
+
this.values = values;
|
|
340
|
+
this.offset = offset;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Port of mlx-lm KVCache.to_quantized: quantize the whole buffer
|
|
344
|
+
* (padding included — it's overwritten before being read). */
|
|
345
|
+
toQuantized(groupSize: number, bits: number): QuantizedKVCache {
|
|
346
|
+
const q = new QuantizedKVCache(groupSize, bits);
|
|
347
|
+
q.offset = this.offset;
|
|
348
|
+
if (this.keys && this.values) {
|
|
349
|
+
q.keys = ops.quantize(this.keys, groupSize, bits);
|
|
350
|
+
q.values = ops.quantize(this.values, groupSize, bits);
|
|
351
|
+
}
|
|
352
|
+
this.dispose();
|
|
353
|
+
return q;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
dispose(): void {
|
|
357
|
+
this.keys?.dispose();
|
|
358
|
+
this.values?.dispose();
|
|
359
|
+
this.keys = this.values = null;
|
|
360
|
+
this.offset = 0;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Quantized KV cache — port of mlx-lm QuantizedKVCache: keys/values
|
|
365
|
+
* stored as (packed u32, scales, biases) triples, quantized along
|
|
366
|
+
* head_dim. Only full-attention layers convert (mlx-lm's rotating-cache
|
|
367
|
+
* quantization is NYI upstream; sliding layers are window-capped
|
|
368
|
+
* anyway). Attention dispatches to quantizedSdpa for these. */
|
|
369
|
+
export class QuantizedKVCache implements Cache {
|
|
370
|
+
static readonly STEP = 256;
|
|
371
|
+
/** Set only by compiled-decode trace adapters (see Cache). */
|
|
372
|
+
readonly ropeOffsetArr?: MlxArray;
|
|
373
|
+
keys: ops.QuantizedTensor | null = null;
|
|
374
|
+
values: ops.QuantizedTensor | null = null;
|
|
375
|
+
offset = 0;
|
|
376
|
+
|
|
377
|
+
constructor(readonly groupSize: number, readonly bits: number) {}
|
|
378
|
+
|
|
379
|
+
updateAndFetch(): [MlxArray, MlxArray] {
|
|
380
|
+
throw new Error("QuantizedKVCache: use updateAndFetchQuantized");
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
#grow(triple: ops.QuantizedTensor | null, lastDims: number[], B: number, H: number, steps: number, dtype: Dtype): ops.QuantizedTensor {
|
|
384
|
+
const mk = (dim: number, dt: Dtype) => ops.zeros([B, H, steps, dim], dt);
|
|
385
|
+
if (!triple)
|
|
386
|
+
return { packed: mk(lastDims[0]!, Dtype.uint32), scales: mk(lastDims[1]!, dtype), biases: mk(lastDims[2]!, dtype) };
|
|
387
|
+
const ext = (a: MlxArray, dim: number, dt: Dtype): MlxArray => {
|
|
388
|
+
const z = mk(dim, dt);
|
|
389
|
+
const out = ops.concatAxis([a, z], 2);
|
|
390
|
+
a.dispose();
|
|
391
|
+
z.dispose();
|
|
392
|
+
return out;
|
|
393
|
+
};
|
|
394
|
+
return {
|
|
395
|
+
packed: ext(triple.packed, lastDims[0]!, Dtype.uint32),
|
|
396
|
+
scales: ext(triple.scales, lastDims[1]!, dtype),
|
|
397
|
+
biases: ext(triple.biases, lastDims[2]!, dtype),
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Quantize incoming k/v and append; returns quantized views to offset. */
|
|
402
|
+
updateAndFetchQuantized(k: MlxArray, v: MlxArray): [ops.QuantizedTensor, ops.QuantizedTensor] {
|
|
403
|
+
const [B, H, L, kD] = k.shape as [number, number, number, number];
|
|
404
|
+
const vD = v.shape[3]!;
|
|
405
|
+
const prev = this.offset;
|
|
406
|
+
const elPerInt = 32 / this.bits;
|
|
407
|
+
|
|
408
|
+
if (!this.keys || prev + L > this.keys.packed.shape[2]!) {
|
|
409
|
+
const newSteps = Math.floor((QuantizedKVCache.STEP + L - 1) / QuantizedKVCache.STEP) * QuantizedKVCache.STEP;
|
|
410
|
+
if (this.keys && prev % QuantizedKVCache.STEP !== 0) {
|
|
411
|
+
const trimTo = (t: ops.QuantizedTensor): ops.QuantizedTensor => {
|
|
412
|
+
const cut = (a: MlxArray): MlxArray => {
|
|
413
|
+
const [b, h, , d] = a.shape as [number, number, number, number];
|
|
414
|
+
const s = a.slice([0, 0, 0, 0], [b, h, prev, d]);
|
|
415
|
+
a.dispose();
|
|
416
|
+
return s;
|
|
417
|
+
};
|
|
418
|
+
return { packed: cut(t.packed), scales: cut(t.scales), biases: cut(t.biases) };
|
|
419
|
+
};
|
|
420
|
+
this.keys = trimTo(this.keys);
|
|
421
|
+
this.values = trimTo(this.values!);
|
|
422
|
+
}
|
|
423
|
+
const dtype = k.dtype;
|
|
424
|
+
this.keys = this.#grow(this.keys, [kD / elPerInt, kD / this.groupSize, kD / this.groupSize], B, H, newSteps, dtype);
|
|
425
|
+
this.values = this.#grow(this.values, [vD / elPerInt, vD / this.groupSize, vD / this.groupSize], B, H, newSteps, dtype);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
this.offset += L;
|
|
429
|
+
const kq = ops.quantize(k, this.groupSize, this.bits);
|
|
430
|
+
const vq = ops.quantize(v, this.groupSize, this.bits);
|
|
431
|
+
|
|
432
|
+
const writeAll = (dst: ops.QuantizedTensor, src: ops.QuantizedTensor): ops.QuantizedTensor => {
|
|
433
|
+
const w = (d: MlxArray, s: MlxArray): MlxArray => {
|
|
434
|
+
const [b, h, , dd] = d.shape as [number, number, number, number];
|
|
435
|
+
const u = ops.sliceUpdate(d, s, [0, 0, prev, 0], [b, h, this.offset, dd]);
|
|
436
|
+
d.dispose();
|
|
437
|
+
s.dispose();
|
|
438
|
+
return u;
|
|
439
|
+
};
|
|
440
|
+
return {
|
|
441
|
+
packed: w(dst.packed, src.packed),
|
|
442
|
+
scales: w(dst.scales, src.scales),
|
|
443
|
+
biases: w(dst.biases, src.biases),
|
|
444
|
+
};
|
|
445
|
+
};
|
|
446
|
+
this.keys = writeAll(this.keys!, kq);
|
|
447
|
+
this.values = writeAll(this.values!, vq);
|
|
448
|
+
|
|
449
|
+
const fetch = (t: ops.QuantizedTensor): ops.QuantizedTensor => {
|
|
450
|
+
const f = (a: MlxArray): MlxArray => {
|
|
451
|
+
const [b, h, , d] = a.shape as [number, number, number, number];
|
|
452
|
+
return a.slice([0, 0, 0, 0], [b, h, this.offset, d]);
|
|
453
|
+
};
|
|
454
|
+
return { packed: f(t.packed), scales: f(t.scales), biases: f(t.biases) };
|
|
455
|
+
};
|
|
456
|
+
return [fetch(this.keys), fetch(this.values)];
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
makeMask(N: number, windowSize: number | null): Mask {
|
|
460
|
+
if (N === 1) return { mode: "", arr: null };
|
|
461
|
+
if (this.offset === 0 && (windowSize === null || N <= windowSize))
|
|
462
|
+
return { mode: "causal", arr: null };
|
|
463
|
+
return {
|
|
464
|
+
mode: "array",
|
|
465
|
+
arr: createCausalMask(N, this.offset, windowSize),
|
|
466
|
+
// a windowless continuation mask is exactly mlx-lm's "causal"
|
|
467
|
+
causalEquivalent: windowSize === null,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Compiled decode: growth half of updateAndFetchQuantized (L=1),
|
|
472
|
+
* without quantize/write (those live in the compiled graph / after). */
|
|
473
|
+
prepareDecodeStep(): DecodeStepPlan {
|
|
474
|
+
const prev = this.offset;
|
|
475
|
+
if (!this.keys || !this.values) throw new Error("compiled decode on an empty cache");
|
|
476
|
+
if (prev + 1 > this.keys.packed.shape[2]!) {
|
|
477
|
+
if (prev % QuantizedKVCache.STEP !== 0) {
|
|
478
|
+
const trimTo = (t: ops.QuantizedTensor): ops.QuantizedTensor => {
|
|
479
|
+
const cut = (a: MlxArray): MlxArray => {
|
|
480
|
+
const [b, h, , d] = a.shape as [number, number, number, number];
|
|
481
|
+
const s = a.slice([0, 0, 0, 0], [b, h, prev, d]);
|
|
482
|
+
a.dispose();
|
|
483
|
+
return s;
|
|
484
|
+
};
|
|
485
|
+
return { packed: cut(t.packed), scales: cut(t.scales), biases: cut(t.biases) };
|
|
486
|
+
};
|
|
487
|
+
this.keys = trimTo(this.keys);
|
|
488
|
+
this.values = trimTo(this.values);
|
|
489
|
+
}
|
|
490
|
+
const [B, H] = this.keys.packed.shape as [number, number, number, number];
|
|
491
|
+
const elPerInt = 32 / this.bits;
|
|
492
|
+
const kD = this.keys.packed.shape[3]! * elPerInt;
|
|
493
|
+
const vD = this.values.packed.shape[3]! * elPerInt;
|
|
494
|
+
const dtype = this.keys.scales.dtype;
|
|
495
|
+
this.keys = this.#grow(this.keys, [kD / elPerInt, kD / this.groupSize, kD / this.groupSize], B, H, QuantizedKVCache.STEP, dtype);
|
|
496
|
+
this.values = this.#grow(this.values, [vD / elPerInt, vD / this.groupSize, vD / this.groupSize], B, H, QuantizedKVCache.STEP, dtype);
|
|
497
|
+
}
|
|
498
|
+
return { fetch: "concat", writePos: prev, activeLen: prev };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Compiled decode: the write half — six sliceUpdates of the already-
|
|
502
|
+
* quantized step row (quantize ran in-graph). Takes ownership of the
|
|
503
|
+
* rows; returns the updated buffers to async-eval with the step. */
|
|
504
|
+
writeDecodeStep(rows: MlxArray[]): MlxArray[] {
|
|
505
|
+
const prev = this.offset;
|
|
506
|
+
const w = (d: MlxArray, srcRow: MlxArray): MlxArray => {
|
|
507
|
+
const [b, h, , dd] = d.shape as [number, number, number, number];
|
|
508
|
+
const u = ops.sliceUpdate(d, srcRow, [0, 0, prev, 0], [b, h, prev + 1, dd]);
|
|
509
|
+
d.dispose();
|
|
510
|
+
srcRow.dispose();
|
|
511
|
+
return u;
|
|
512
|
+
};
|
|
513
|
+
this.keys = {
|
|
514
|
+
packed: w(this.keys!.packed, rows[0]!),
|
|
515
|
+
scales: w(this.keys!.scales, rows[1]!),
|
|
516
|
+
biases: w(this.keys!.biases, rows[2]!),
|
|
517
|
+
};
|
|
518
|
+
this.values = {
|
|
519
|
+
packed: w(this.values!.packed, rows[3]!),
|
|
520
|
+
scales: w(this.values!.scales, rows[4]!),
|
|
521
|
+
biases: w(this.values!.biases, rows[5]!),
|
|
522
|
+
};
|
|
523
|
+
this.offset = prev + 1;
|
|
524
|
+
return this.state();
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
state(): MlxArray[] {
|
|
528
|
+
if (!this.keys || !this.values) return [];
|
|
529
|
+
return [
|
|
530
|
+
this.keys.packed, this.keys.scales, this.keys.biases,
|
|
531
|
+
this.values.packed, this.values.scales, this.values.biases,
|
|
532
|
+
];
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
isTrimmable(): boolean {
|
|
536
|
+
return true;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
trim(n: number): void {
|
|
540
|
+
this.offset = Math.max(0, this.offset - n);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
dispose(): void {
|
|
544
|
+
for (const a of this.state()) a.dispose();
|
|
545
|
+
this.keys = this.values = null;
|
|
546
|
+
this.offset = 0;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Rotating (sliding-window) KV cache — port of mlx-lm RotatingKVCache
|
|
551
|
+
* with keep=0 (gemma4's configuration): a ring buffer of max_size
|
|
552
|
+
* entries, so decode attends over at most the window. RoPE offsets use
|
|
553
|
+
* the true position; masks use the buffer-clamped offset. */
|
|
554
|
+
export class RotatingKVCache implements Cache {
|
|
555
|
+
static readonly STEP = 256;
|
|
556
|
+
/** Set only by compiled-decode trace adapters (see Cache). */
|
|
557
|
+
readonly ropeOffsetArr?: MlxArray;
|
|
558
|
+
keys: MlxArray | null = null;
|
|
559
|
+
values: MlxArray | null = null;
|
|
560
|
+
offset = 0;
|
|
561
|
+
#idx = 0;
|
|
562
|
+
readonly maxSize: number;
|
|
563
|
+
|
|
564
|
+
constructor(maxSize: number) {
|
|
565
|
+
this.maxSize = maxSize;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** v with ring contents rearranged into temporal order (keep=0). */
|
|
569
|
+
#temporalOrder(v: MlxArray): MlxArray {
|
|
570
|
+
const S = v.shape[2]!;
|
|
571
|
+
const [B, H, , D] = v.shape as [number, number, number, number];
|
|
572
|
+
if (this.#idx === S) return v.slice([0, 0, 0, 0], [B, H, S, D]);
|
|
573
|
+
if (this.#idx < this.offset) {
|
|
574
|
+
const tail = v.slice([0, 0, this.#idx, 0], [B, H, S, D]);
|
|
575
|
+
const head = v.slice([0, 0, 0, 0], [B, H, this.#idx, D]);
|
|
576
|
+
const out = ops.concatAxis([tail, head], 2);
|
|
577
|
+
tail.dispose();
|
|
578
|
+
head.dispose();
|
|
579
|
+
return out;
|
|
580
|
+
}
|
|
581
|
+
return v.slice([0, 0, 0, 0], [B, H, this.#idx, D]);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
#trim(trimSize: number, v: MlxArray, append: MlxArray | null): MlxArray {
|
|
585
|
+
const [B, H, S, D] = v.shape as [number, number, number, number];
|
|
586
|
+
let base: MlxArray;
|
|
587
|
+
if (trimSize > 0) {
|
|
588
|
+
base = v.slice([0, 0, trimSize, 0], [B, H, S, D]);
|
|
589
|
+
} else {
|
|
590
|
+
base = v.slice([0, 0, 0, 0], [B, H, S, D]);
|
|
591
|
+
}
|
|
592
|
+
if (!append) return base;
|
|
593
|
+
const out = ops.concatAxis([base, append], 2);
|
|
594
|
+
base.dispose();
|
|
595
|
+
return out;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
#updateConcat(k: MlxArray, v: MlxArray): [MlxArray, MlxArray] {
|
|
599
|
+
const S = k.shape[2]!;
|
|
600
|
+
if (!this.keys || !this.values) {
|
|
601
|
+
// own copies (full-range slice = cheap view node)
|
|
602
|
+
const [B, H, , D] = k.shape as [number, number, number, number];
|
|
603
|
+
const vD = v.shape[3]!;
|
|
604
|
+
this.keys = k.slice([0, 0, 0, 0], [B, H, S, D]);
|
|
605
|
+
this.values = v.slice([0, 0, 0, 0], [B, H, S, vD]);
|
|
606
|
+
} else {
|
|
607
|
+
const tk = this.#temporalOrder(this.keys);
|
|
608
|
+
const tv = this.#temporalOrder(this.values);
|
|
609
|
+
this.keys.dispose();
|
|
610
|
+
this.values.dispose();
|
|
611
|
+
this.#idx = tk.shape[2]!;
|
|
612
|
+
const trimSize = this.#idx - this.maxSize + 1;
|
|
613
|
+
this.keys = this.#trim(trimSize, tk, k);
|
|
614
|
+
this.values = this.#trim(trimSize, tv, v);
|
|
615
|
+
tk.dispose();
|
|
616
|
+
tv.dispose();
|
|
617
|
+
}
|
|
618
|
+
this.offset += S;
|
|
619
|
+
this.#idx = this.keys.shape[2]!;
|
|
620
|
+
return this.#fetchAll();
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
#updateInPlace(k: MlxArray, v: MlxArray): [MlxArray, MlxArray] {
|
|
624
|
+
const [B, H, S, D] = k.shape as [number, number, number, number];
|
|
625
|
+
const vD = v.shape[3]!;
|
|
626
|
+
const prev = this.offset;
|
|
627
|
+
|
|
628
|
+
if (!this.keys || (prev >= this.keys.shape[2]! && this.keys.shape[2]! < this.maxSize)) {
|
|
629
|
+
const newSize = Math.min(RotatingKVCache.STEP, this.maxSize - prev);
|
|
630
|
+
const newK = ops.zeros([B, H, newSize, D], k.dtype);
|
|
631
|
+
const newV = ops.zeros([B, H, newSize, vD], v.dtype);
|
|
632
|
+
if (this.keys && this.values) {
|
|
633
|
+
const ck = ops.concatAxis([this.keys, newK], 2);
|
|
634
|
+
const cv = ops.concatAxis([this.values, newV], 2);
|
|
635
|
+
this.keys.dispose();
|
|
636
|
+
this.values.dispose();
|
|
637
|
+
newK.dispose();
|
|
638
|
+
newV.dispose();
|
|
639
|
+
this.keys = ck;
|
|
640
|
+
this.values = cv;
|
|
641
|
+
} else {
|
|
642
|
+
this.keys = newK;
|
|
643
|
+
this.values = newV;
|
|
644
|
+
}
|
|
645
|
+
this.#idx = prev;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const trimSize = this.keys!.shape[2]! - this.maxSize;
|
|
649
|
+
if (trimSize > 0) {
|
|
650
|
+
const tk = this.#trim(trimSize, this.keys!, null);
|
|
651
|
+
const tv = this.#trim(trimSize, this.values!, null);
|
|
652
|
+
this.keys!.dispose();
|
|
653
|
+
this.values!.dispose();
|
|
654
|
+
this.keys = tk;
|
|
655
|
+
this.values = tv;
|
|
656
|
+
this.#idx = this.maxSize;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (this.#idx === this.maxSize) this.#idx = 0; // rotate (keep=0)
|
|
660
|
+
|
|
661
|
+
const [, , SK, DK] = this.keys!.shape as [number, number, number, number];
|
|
662
|
+
const k2 = ops.sliceUpdate(this.keys!, k, [0, 0, this.#idx, 0], [B, H, this.#idx + S, DK]);
|
|
663
|
+
const v2 = ops.sliceUpdate(this.values!, v, [0, 0, this.#idx, 0], [B, H, this.#idx + S, vD]);
|
|
664
|
+
this.keys!.dispose();
|
|
665
|
+
this.values!.dispose();
|
|
666
|
+
this.keys = k2;
|
|
667
|
+
this.values = v2;
|
|
668
|
+
this.offset += S;
|
|
669
|
+
this.#idx += S;
|
|
670
|
+
|
|
671
|
+
if (this.offset < this.maxSize) {
|
|
672
|
+
const kOut = this.keys.slice([0, 0, 0, 0], [B, H, this.offset, DK]);
|
|
673
|
+
const vOut = this.values.slice([0, 0, 0, 0], [B, H, this.offset, vD]);
|
|
674
|
+
return [kOut, vOut];
|
|
675
|
+
}
|
|
676
|
+
return this.#fetchAll();
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
#fetchAll(): [MlxArray, MlxArray] {
|
|
680
|
+
const [B, H, S, D] = this.keys!.shape as [number, number, number, number];
|
|
681
|
+
const vD = this.values!.shape[3]!;
|
|
682
|
+
return [
|
|
683
|
+
this.keys!.slice([0, 0, 0, 0], [B, H, S, D]),
|
|
684
|
+
this.values!.slice([0, 0, 0, 0], [B, H, S, vD]),
|
|
685
|
+
];
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
updateAndFetch(k: MlxArray, v: MlxArray): [MlxArray, MlxArray] {
|
|
689
|
+
return k.shape[2]! === 1 ? this.#updateInPlace(k, v) : this.#updateConcat(k, v);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
makeMask(N: number, windowSize: number | null): Mask {
|
|
693
|
+
const window = windowSize ?? this.maxSize;
|
|
694
|
+
if (N > 1) {
|
|
695
|
+
const offset = Math.min(this.maxSize - 1, this.offset);
|
|
696
|
+
if (offset + N > window)
|
|
697
|
+
return { mode: "array", arr: createCausalMask(N, offset, window) };
|
|
698
|
+
return { mode: "causal", arr: null };
|
|
699
|
+
}
|
|
700
|
+
// N == 1: eviction enforces the window (window === maxSize for gemma4)
|
|
701
|
+
return { mode: "", arr: null };
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
state(): MlxArray[] {
|
|
705
|
+
return this.keys && this.values ? [this.keys, this.values] : [];
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** Port of mlx-lm RotatingKVCache.is_trimmable/trim: only valid while
|
|
709
|
+
* the ring has never wrapped (still in temporal order). */
|
|
710
|
+
isTrimmable(): boolean {
|
|
711
|
+
return this.offset < this.maxSize;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
trim(n: number): void {
|
|
715
|
+
const k = Math.min(this.offset, n);
|
|
716
|
+
this.offset -= k;
|
|
717
|
+
this.#idx -= k;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
get ringIdx(): number {
|
|
721
|
+
return this.#idx;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** Compiled decode: host-side bookkeeping of #updateInPlace (L=1) —
|
|
725
|
+
* growth, oversize trim, rotation — without the write. */
|
|
726
|
+
prepareDecodeStep(): DecodeStepPlan {
|
|
727
|
+
const prev = this.offset;
|
|
728
|
+
if (!this.keys || !this.values) throw new Error("compiled decode on an empty cache");
|
|
729
|
+
if (prev >= this.keys.shape[2]! && this.keys.shape[2]! < this.maxSize) {
|
|
730
|
+
const [B, H, , D] = this.keys.shape as [number, number, number, number];
|
|
731
|
+
const vD = this.values.shape[3]!;
|
|
732
|
+
const newSize = Math.min(RotatingKVCache.STEP, this.maxSize - prev);
|
|
733
|
+
const newK = ops.zeros([B, H, newSize, D], this.keys.dtype);
|
|
734
|
+
const newV = ops.zeros([B, H, newSize, vD], this.values.dtype);
|
|
735
|
+
const ck = ops.concatAxis([this.keys, newK], 2);
|
|
736
|
+
const cv = ops.concatAxis([this.values, newV], 2);
|
|
737
|
+
for (const a of [this.keys, this.values, newK, newV]) a.dispose();
|
|
738
|
+
this.keys = ck;
|
|
739
|
+
this.values = cv;
|
|
740
|
+
this.#idx = prev;
|
|
741
|
+
}
|
|
742
|
+
const trimSize = this.keys.shape[2]! - this.maxSize;
|
|
743
|
+
if (trimSize > 0) {
|
|
744
|
+
const tk = this.#trim(trimSize, this.keys, null);
|
|
745
|
+
const tv = this.#trim(trimSize, this.values, null);
|
|
746
|
+
this.keys.dispose();
|
|
747
|
+
this.values.dispose();
|
|
748
|
+
this.keys = tk;
|
|
749
|
+
this.values = tv;
|
|
750
|
+
this.#idx = this.maxSize;
|
|
751
|
+
}
|
|
752
|
+
if (this.#idx === this.maxSize) this.#idx = 0; // rotate (keep=0)
|
|
753
|
+
// Pre-window-fill the attended set is the [0..offset] prefix plus the
|
|
754
|
+
// new row (today's slice of the updated buffer); once writes wrap,
|
|
755
|
+
// it's the whole ring in ring order, which only the in-graph
|
|
756
|
+
// write-then-read-all form reproduces bit-exactly.
|
|
757
|
+
const fetch = prev + 1 < this.maxSize && this.#idx === prev ? "concat" : "ring";
|
|
758
|
+
return { fetch, writePos: this.#idx, activeLen: prev };
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** Compiled decode, concat fetch: the write half. Takes ownership of
|
|
762
|
+
* kNew/vNew; returns the updated buffers to async-eval. */
|
|
763
|
+
writeDecodeStep(kNew: MlxArray, vNew: MlxArray): MlxArray[] {
|
|
764
|
+
const [B, H, , D] = this.keys!.shape as [number, number, number, number];
|
|
765
|
+
const vD = this.values!.shape[3]!;
|
|
766
|
+
const k2 = ops.sliceUpdate(this.keys!, kNew, [0, 0, this.#idx, 0], [B, H, this.#idx + 1, D]);
|
|
767
|
+
const v2 = ops.sliceUpdate(this.values!, vNew, [0, 0, this.#idx, 0], [B, H, this.#idx + 1, vD]);
|
|
768
|
+
this.keys!.dispose();
|
|
769
|
+
this.values!.dispose();
|
|
770
|
+
kNew.dispose();
|
|
771
|
+
vNew.dispose();
|
|
772
|
+
this.keys = k2;
|
|
773
|
+
this.values = v2;
|
|
774
|
+
this.offset += 1;
|
|
775
|
+
this.#idx += 1;
|
|
776
|
+
return [k2, v2];
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** Compiled decode, ring fetch: adopt the in-graph-updated buffers
|
|
780
|
+
* (closure outputs; ownership transfers here). */
|
|
781
|
+
adoptDecodeStep(newKeys: MlxArray, newValues: MlxArray): MlxArray[] {
|
|
782
|
+
this.keys!.dispose();
|
|
783
|
+
this.values!.dispose();
|
|
784
|
+
this.keys = newKeys;
|
|
785
|
+
this.values = newValues;
|
|
786
|
+
this.offset += 1;
|
|
787
|
+
this.#idx += 1;
|
|
788
|
+
return [newKeys, newValues];
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** Chronological (K, V) view, valid length min(offset, maxSize)
|
|
792
|
+
* (port of optiq kv_view._read_cache_temporal). */
|
|
793
|
+
temporalView(): [MlxArray, MlxArray] {
|
|
794
|
+
if (!this.keys || !this.values) throw new Error("cache is empty");
|
|
795
|
+
const tk = this.#temporalOrder(this.keys);
|
|
796
|
+
const tv = this.#temporalOrder(this.values);
|
|
797
|
+
const valid = Math.min(this.offset, this.maxSize);
|
|
798
|
+
const cut = (a: MlxArray): MlxArray => {
|
|
799
|
+
const [B, H, , D] = a.shape as [number, number, number, number];
|
|
800
|
+
const s = a.slice([0, 0, 0, 0], [B, H, valid, D]);
|
|
801
|
+
a.dispose();
|
|
802
|
+
return s;
|
|
803
|
+
};
|
|
804
|
+
return [cut(tk), cut(tv)];
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/** Adopt persisted state (takes ownership of the arrays). */
|
|
808
|
+
restoreState(keys: MlxArray, values: MlxArray, offset: number, idx: number): void {
|
|
809
|
+
this.dispose();
|
|
810
|
+
this.keys = keys;
|
|
811
|
+
this.values = values;
|
|
812
|
+
this.offset = offset;
|
|
813
|
+
this.#idx = idx;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/** Port of optiq rotating.py _replay_into_quantized: quantize the
|
|
817
|
+
* whole buffer AS-LAID-OUT (ring order, not temporal order — correct
|
|
818
|
+
* because ringIdx is preserved with it) into a
|
|
819
|
+
* RotatingQuantizedKVCache. */
|
|
820
|
+
toQuantized(groupSize: number, bits: number): RotatingQuantizedKVCache {
|
|
821
|
+
const q = new RotatingQuantizedKVCache(this.maxSize, groupSize, bits);
|
|
822
|
+
if (this.keys && this.values) {
|
|
823
|
+
q.keys = ops.quantize(this.keys, groupSize, bits);
|
|
824
|
+
q.values = ops.quantize(this.values, groupSize, bits);
|
|
825
|
+
}
|
|
826
|
+
q.offset = this.offset;
|
|
827
|
+
q.ringIdx = this.#idx;
|
|
828
|
+
this.dispose();
|
|
829
|
+
return q;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
dispose(): void {
|
|
833
|
+
this.keys?.dispose();
|
|
834
|
+
this.values?.dispose();
|
|
835
|
+
this.keys = this.values = null;
|
|
836
|
+
this.offset = 0;
|
|
837
|
+
this.#idx = 0;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
export const mapTriple = (
|
|
842
|
+
t: ops.QuantizedTensor, f: (a: MlxArray) => MlxArray,
|
|
843
|
+
): ops.QuantizedTensor => ({ packed: f(t.packed), scales: f(t.scales), biases: f(t.biases) });
|
|
844
|
+
|
|
845
|
+
export const disposeTriple = (t: ops.QuantizedTensor): void => {
|
|
846
|
+
t.packed.dispose();
|
|
847
|
+
t.scales.dispose();
|
|
848
|
+
t.biases.dispose();
|
|
849
|
+
};
|
|
850
|
+
|
|
851
|
+
/** Quantized rotating (sliding-window) KV cache — port of optiq
|
|
852
|
+
* runtime/kv/rotating.py RotatingQuantizedKVCache with keep=0
|
|
853
|
+
* (gemma4's configuration): RotatingKVCache's ring mechanics over
|
|
854
|
+
* (packed, scales, biases) triples, storage convention identical to
|
|
855
|
+
* QuantizedKVCache. Returns ACTIVE QUANTIZED SLICES — the oracle's
|
|
856
|
+
* module docstring claims dequantize-on-read, but its code does not
|
|
857
|
+
* (Phase 9 finding; port follows the code). optiq's producer-registry
|
|
858
|
+
* + SDPA patches are unnecessary here: our SharedKv carries
|
|
859
|
+
* groupSize/bits through the donor→sharer plumbing explicitly. */
|
|
860
|
+
export class RotatingQuantizedKVCache implements Cache {
|
|
861
|
+
static readonly STEP = 256;
|
|
862
|
+
/** Set only by compiled-decode trace adapters (see Cache). */
|
|
863
|
+
readonly ropeOffsetArr?: MlxArray;
|
|
864
|
+
keys: ops.QuantizedTensor | null = null;
|
|
865
|
+
values: ops.QuantizedTensor | null = null;
|
|
866
|
+
offset = 0;
|
|
867
|
+
/** Ring write index (oracle `_idx`); public so toQuantized replay
|
|
868
|
+
* and persistence can carry it. */
|
|
869
|
+
ringIdx = 0;
|
|
870
|
+
readonly maxSize: number;
|
|
871
|
+
|
|
872
|
+
constructor(maxSize: number, readonly groupSize: number, readonly bits: number) {
|
|
873
|
+
this.maxSize = maxSize;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
updateAndFetch(): [MlxArray, MlxArray] {
|
|
877
|
+
throw new Error("RotatingQuantizedKVCache: use updateAndFetchQuantized");
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
#seqLen(): number {
|
|
881
|
+
return this.keys ? this.keys.packed.shape[2]! : 0;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/** Empty (packed, scales, biases) triple of T tokens (oracle _alloc_pair). */
|
|
885
|
+
#allocPair(B: number, H: number, T: number, dim: number, dtype: Dtype): ops.QuantizedTensor {
|
|
886
|
+
const elPerInt = 32 / this.bits;
|
|
887
|
+
return {
|
|
888
|
+
packed: ops.zeros([B, H, T, dim / elPerInt], Dtype.uint32),
|
|
889
|
+
scales: ops.zeros([B, H, T, dim / this.groupSize], dtype),
|
|
890
|
+
biases: ops.zeros([B, H, T, dim / this.groupSize], dtype),
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/** Ring contents rearranged into temporal order, per component (keep=0). */
|
|
895
|
+
#temporalOrder(t: ops.QuantizedTensor): ops.QuantizedTensor {
|
|
896
|
+
const S = t.packed.shape[2]!;
|
|
897
|
+
const cut = (a: MlxArray, from: number, to: number): MlxArray => {
|
|
898
|
+
const [B, H, , D] = a.shape as [number, number, number, number];
|
|
899
|
+
return a.slice([0, 0, from, 0], [B, H, to, D]);
|
|
900
|
+
};
|
|
901
|
+
if (this.ringIdx === S) return mapTriple(t, (a) => cut(a, 0, S));
|
|
902
|
+
if (this.ringIdx < this.offset) {
|
|
903
|
+
return mapTriple(t, (a) => {
|
|
904
|
+
const tail = cut(a, this.ringIdx, S);
|
|
905
|
+
const head = cut(a, 0, this.ringIdx);
|
|
906
|
+
const out = ops.concatAxis([tail, head], 2);
|
|
907
|
+
tail.dispose();
|
|
908
|
+
head.dispose();
|
|
909
|
+
return out;
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
return mapTriple(t, (a) => cut(a, 0, this.ringIdx));
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
#trim(trimSize: number, t: ops.QuantizedTensor, append: ops.QuantizedTensor | null): ops.QuantizedTensor {
|
|
916
|
+
const part = (a: MlxArray, ap: MlxArray | null): MlxArray => {
|
|
917
|
+
const [B, H, S, D] = a.shape as [number, number, number, number];
|
|
918
|
+
const base = a.slice([0, 0, trimSize > 0 ? trimSize : 0, 0], [B, H, S, D]);
|
|
919
|
+
if (!ap) return base;
|
|
920
|
+
const out = ops.concatAxis([base, ap], 2);
|
|
921
|
+
base.dispose();
|
|
922
|
+
return out;
|
|
923
|
+
};
|
|
924
|
+
return {
|
|
925
|
+
packed: part(t.packed, append?.packed ?? null),
|
|
926
|
+
scales: part(t.scales, append?.scales ?? null),
|
|
927
|
+
biases: part(t.biases, append?.biases ?? null),
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
#updateConcat(k: MlxArray, v: MlxArray): [ops.QuantizedTensor, ops.QuantizedTensor] {
|
|
932
|
+
const S = k.shape[2]!;
|
|
933
|
+
const kq = ops.quantize(k, this.groupSize, this.bits);
|
|
934
|
+
const vq = ops.quantize(v, this.groupSize, this.bits);
|
|
935
|
+
if (!this.keys || !this.values) {
|
|
936
|
+
this.keys = kq;
|
|
937
|
+
this.values = vq;
|
|
938
|
+
} else {
|
|
939
|
+
const tk = this.#temporalOrder(this.keys);
|
|
940
|
+
const tv = this.#temporalOrder(this.values);
|
|
941
|
+
disposeTriple(this.keys);
|
|
942
|
+
disposeTriple(this.values);
|
|
943
|
+
this.ringIdx = tk.packed.shape[2]!;
|
|
944
|
+
const trimSize = this.ringIdx - this.maxSize + 1;
|
|
945
|
+
this.keys = this.#trim(trimSize, tk, kq);
|
|
946
|
+
this.values = this.#trim(trimSize, tv, vq);
|
|
947
|
+
for (const t of [tk, tv, kq, vq]) disposeTriple(t);
|
|
948
|
+
}
|
|
949
|
+
this.offset += S;
|
|
950
|
+
this.ringIdx = this.#seqLen();
|
|
951
|
+
return this.#activeSlices();
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
#updateInPlace(k: MlxArray, v: MlxArray): [ops.QuantizedTensor, ops.QuantizedTensor] {
|
|
955
|
+
const [B, H, S, D] = k.shape as [number, number, number, number];
|
|
956
|
+
const vD = v.shape[3]!;
|
|
957
|
+
const prev = this.offset;
|
|
958
|
+
|
|
959
|
+
if (!this.keys || (prev >= this.#seqLen() && this.#seqLen() < this.maxSize)) {
|
|
960
|
+
const newSize = Math.min(RotatingQuantizedKVCache.STEP, this.maxSize - prev);
|
|
961
|
+
const newK = this.#allocPair(B, H, newSize, D, k.dtype);
|
|
962
|
+
const newV = this.#allocPair(B, H, newSize, vD, v.dtype);
|
|
963
|
+
if (this.keys && this.values) {
|
|
964
|
+
const grow = (old: ops.QuantizedTensor, add: ops.QuantizedTensor): ops.QuantizedTensor => {
|
|
965
|
+
const cat = (a: MlxArray, b: MlxArray): MlxArray => {
|
|
966
|
+
const out = ops.concatAxis([a, b], 2);
|
|
967
|
+
a.dispose();
|
|
968
|
+
b.dispose();
|
|
969
|
+
return out;
|
|
970
|
+
};
|
|
971
|
+
return {
|
|
972
|
+
packed: cat(old.packed, add.packed),
|
|
973
|
+
scales: cat(old.scales, add.scales),
|
|
974
|
+
biases: cat(old.biases, add.biases),
|
|
975
|
+
};
|
|
976
|
+
};
|
|
977
|
+
this.keys = grow(this.keys, newK);
|
|
978
|
+
this.values = grow(this.values, newV);
|
|
979
|
+
} else {
|
|
980
|
+
this.keys = newK;
|
|
981
|
+
this.values = newV;
|
|
982
|
+
}
|
|
983
|
+
this.ringIdx = prev;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
const trimSize = this.#seqLen() - this.maxSize;
|
|
987
|
+
if (trimSize > 0) {
|
|
988
|
+
const tk = this.#trim(trimSize, this.keys!, null);
|
|
989
|
+
const tv = this.#trim(trimSize, this.values!, null);
|
|
990
|
+
disposeTriple(this.keys!);
|
|
991
|
+
disposeTriple(this.values!);
|
|
992
|
+
this.keys = tk;
|
|
993
|
+
this.values = tv;
|
|
994
|
+
this.ringIdx = this.maxSize;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
if (this.ringIdx === this.maxSize) this.ringIdx = 0; // rotate (keep=0)
|
|
998
|
+
|
|
999
|
+
const kq = ops.quantize(k, this.groupSize, this.bits);
|
|
1000
|
+
const vq = ops.quantize(v, this.groupSize, this.bits);
|
|
1001
|
+
const writeAt = (dst: ops.QuantizedTensor, src: ops.QuantizedTensor): ops.QuantizedTensor => ({
|
|
1002
|
+
packed: this.#assign(dst.packed, src.packed, S),
|
|
1003
|
+
scales: this.#assign(dst.scales, src.scales, S),
|
|
1004
|
+
biases: this.#assign(dst.biases, src.biases, S),
|
|
1005
|
+
});
|
|
1006
|
+
this.keys = writeAt(this.keys!, kq);
|
|
1007
|
+
this.values = writeAt(this.values!, vq);
|
|
1008
|
+
disposeTriple(kq);
|
|
1009
|
+
disposeTriple(vq);
|
|
1010
|
+
|
|
1011
|
+
this.offset += S;
|
|
1012
|
+
this.ringIdx += S;
|
|
1013
|
+
return this.#activeSlices();
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
#assign(dst: MlxArray, src: MlxArray, S: number): MlxArray {
|
|
1017
|
+
const [B, H, , D] = dst.shape as [number, number, number, number];
|
|
1018
|
+
const out = ops.sliceUpdate(dst, src, [0, 0, this.ringIdx, 0], [B, H, this.ringIdx + S, D]);
|
|
1019
|
+
dst.dispose();
|
|
1020
|
+
return out;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/** Active window as quantized triples (oracle _active_slices; fresh
|
|
1024
|
+
* view handles so callers own what they dispose). */
|
|
1025
|
+
#activeSlices(): [ops.QuantizedTensor, ops.QuantizedTensor] {
|
|
1026
|
+
const upTo = this.offset < this.maxSize ? this.offset : this.#seqLen();
|
|
1027
|
+
const cut = (a: MlxArray): MlxArray => {
|
|
1028
|
+
const [B, H, , D] = a.shape as [number, number, number, number];
|
|
1029
|
+
return a.slice([0, 0, 0, 0], [B, H, upTo, D]);
|
|
1030
|
+
};
|
|
1031
|
+
return [mapTriple(this.keys!, cut), mapTriple(this.values!, cut)];
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/** Quantize incoming k/v and write into the ring; returns active
|
|
1035
|
+
* quantized triples (S=1 in place, S>1 via temporal-order + concat). */
|
|
1036
|
+
updateAndFetchQuantized(k: MlxArray, v: MlxArray): [ops.QuantizedTensor, ops.QuantizedTensor] {
|
|
1037
|
+
return k.shape[2]! === 1 ? this.#updateInPlace(k, v) : this.#updateConcat(k, v);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/** Same mask formula as RotatingKVCache (inherited in the oracle). */
|
|
1041
|
+
makeMask(N: number, windowSize: number | null): Mask {
|
|
1042
|
+
const window = windowSize ?? this.maxSize;
|
|
1043
|
+
if (N > 1) {
|
|
1044
|
+
const offset = Math.min(this.maxSize - 1, this.offset);
|
|
1045
|
+
if (offset + N > window)
|
|
1046
|
+
return { mode: "array", arr: createCausalMask(N, offset, window) };
|
|
1047
|
+
return { mode: "causal", arr: null };
|
|
1048
|
+
}
|
|
1049
|
+
return { mode: "", arr: null };
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
state(): MlxArray[] {
|
|
1053
|
+
if (!this.keys || !this.values) return [];
|
|
1054
|
+
return [
|
|
1055
|
+
this.keys.packed, this.keys.scales, this.keys.biases,
|
|
1056
|
+
this.values.packed, this.values.scales, this.values.biases,
|
|
1057
|
+
];
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/** Ring rule (inherited semantics): trimmable only before wrap. */
|
|
1061
|
+
isTrimmable(): boolean {
|
|
1062
|
+
return this.offset < this.maxSize;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
trim(n: number): void {
|
|
1066
|
+
const k = Math.min(this.offset, n);
|
|
1067
|
+
this.offset -= k;
|
|
1068
|
+
this.ringIdx -= k;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/** Oracle: to_quantized on an already-quantized rotating cache is
|
|
1072
|
+
* idempotent. */
|
|
1073
|
+
toQuantized(): RotatingQuantizedKVCache {
|
|
1074
|
+
return this;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/** Compiled decode: host-side bookkeeping of #updateInPlace (L=1) —
|
|
1078
|
+
* growth, oversize trim, rotation — without quantize/write. */
|
|
1079
|
+
prepareDecodeStep(): DecodeStepPlan {
|
|
1080
|
+
const prev = this.offset;
|
|
1081
|
+
if (!this.keys || !this.values) throw new Error("compiled decode on an empty cache");
|
|
1082
|
+
if (prev >= this.#seqLen() && this.#seqLen() < this.maxSize) {
|
|
1083
|
+
const [B, H, , pD] = this.keys.packed.shape as [number, number, number, number];
|
|
1084
|
+
const elPerInt = 32 / this.bits;
|
|
1085
|
+
const kD = pD * elPerInt;
|
|
1086
|
+
const vD = this.values.packed.shape[3]! * elPerInt;
|
|
1087
|
+
const dtype = this.keys.scales.dtype;
|
|
1088
|
+
const newSize = Math.min(RotatingQuantizedKVCache.STEP, this.maxSize - prev);
|
|
1089
|
+
const newK = this.#allocPair(B, H, newSize, kD, dtype);
|
|
1090
|
+
const newV = this.#allocPair(B, H, newSize, vD, dtype);
|
|
1091
|
+
const grow = (old: ops.QuantizedTensor, add: ops.QuantizedTensor): ops.QuantizedTensor => {
|
|
1092
|
+
const cat = (a: MlxArray, b: MlxArray): MlxArray => {
|
|
1093
|
+
const out = ops.concatAxis([a, b], 2);
|
|
1094
|
+
a.dispose();
|
|
1095
|
+
b.dispose();
|
|
1096
|
+
return out;
|
|
1097
|
+
};
|
|
1098
|
+
return {
|
|
1099
|
+
packed: cat(old.packed, add.packed),
|
|
1100
|
+
scales: cat(old.scales, add.scales),
|
|
1101
|
+
biases: cat(old.biases, add.biases),
|
|
1102
|
+
};
|
|
1103
|
+
};
|
|
1104
|
+
this.keys = grow(this.keys, newK);
|
|
1105
|
+
this.values = grow(this.values, newV);
|
|
1106
|
+
this.ringIdx = prev;
|
|
1107
|
+
}
|
|
1108
|
+
const trimSize = this.#seqLen() - this.maxSize;
|
|
1109
|
+
if (trimSize > 0) {
|
|
1110
|
+
const tk = this.#trim(trimSize, this.keys, null);
|
|
1111
|
+
const tv = this.#trim(trimSize, this.values, null);
|
|
1112
|
+
disposeTriple(this.keys);
|
|
1113
|
+
disposeTriple(this.values);
|
|
1114
|
+
this.keys = tk;
|
|
1115
|
+
this.values = tv;
|
|
1116
|
+
this.ringIdx = this.maxSize;
|
|
1117
|
+
}
|
|
1118
|
+
if (this.ringIdx === this.maxSize) this.ringIdx = 0; // rotate (keep=0)
|
|
1119
|
+
const fetch = prev + 1 < this.maxSize && this.ringIdx === prev ? "concat" : "ring";
|
|
1120
|
+
return { fetch, writePos: this.ringIdx, activeLen: prev };
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/** Compiled decode, concat fetch: six sliceUpdates of the in-graph-
|
|
1124
|
+
* quantized step row. Takes ownership; returns updated buffers. */
|
|
1125
|
+
writeDecodeStep(rows: MlxArray[]): MlxArray[] {
|
|
1126
|
+
const w = (d: MlxArray, srcRow: MlxArray): MlxArray => {
|
|
1127
|
+
const [b, h, , dd] = d.shape as [number, number, number, number];
|
|
1128
|
+
const u = ops.sliceUpdate(d, srcRow, [0, 0, this.ringIdx, 0], [b, h, this.ringIdx + 1, dd]);
|
|
1129
|
+
d.dispose();
|
|
1130
|
+
srcRow.dispose();
|
|
1131
|
+
return u;
|
|
1132
|
+
};
|
|
1133
|
+
this.keys = {
|
|
1134
|
+
packed: w(this.keys!.packed, rows[0]!),
|
|
1135
|
+
scales: w(this.keys!.scales, rows[1]!),
|
|
1136
|
+
biases: w(this.keys!.biases, rows[2]!),
|
|
1137
|
+
};
|
|
1138
|
+
this.values = {
|
|
1139
|
+
packed: w(this.values!.packed, rows[3]!),
|
|
1140
|
+
scales: w(this.values!.scales, rows[4]!),
|
|
1141
|
+
biases: w(this.values!.biases, rows[5]!),
|
|
1142
|
+
};
|
|
1143
|
+
this.offset += 1;
|
|
1144
|
+
this.ringIdx += 1;
|
|
1145
|
+
return this.state();
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/** Compiled decode, ring fetch: adopt the six in-graph-updated buffers
|
|
1149
|
+
* (closure outputs, in state() order; ownership transfers here). */
|
|
1150
|
+
adoptDecodeStep(bufs: MlxArray[]): MlxArray[] {
|
|
1151
|
+
disposeTriple(this.keys!);
|
|
1152
|
+
disposeTriple(this.values!);
|
|
1153
|
+
this.keys = { packed: bufs[0]!, scales: bufs[1]!, biases: bufs[2]! };
|
|
1154
|
+
this.values = { packed: bufs[3]!, scales: bufs[4]!, biases: bufs[5]! };
|
|
1155
|
+
this.offset += 1;
|
|
1156
|
+
this.ringIdx += 1;
|
|
1157
|
+
return bufs;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
dispose(): void {
|
|
1161
|
+
if (this.keys) disposeTriple(this.keys);
|
|
1162
|
+
if (this.values) disposeTriple(this.values);
|
|
1163
|
+
this.keys = this.values = null;
|
|
1164
|
+
this.offset = 0;
|
|
1165
|
+
this.ringIdx = 0;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
export const FINFO_MIN: Partial<Record<Dtype, number>> = {
|
|
1170
|
+
[Dtype.bfloat16]: -3.3895313892515355e38,
|
|
1171
|
+
[Dtype.float16]: -65504,
|
|
1172
|
+
[Dtype.float32]: -3.4028234663852886e38,
|
|
1173
|
+
};
|
|
1174
|
+
|
|
1175
|
+
/** Port of base.py quantized_scaled_dot_product_attention (stock,
|
|
1176
|
+
* non-tiled): scores and output via quantized_matmul against the
|
|
1177
|
+
* quantized KV triples; GQA via a 5-d reshape. Exported for the
|
|
1178
|
+
* fused-vs-unfused parity tests. */
|
|
1179
|
+
export function quantizedSdpaUnfused(
|
|
1180
|
+
q: MlxArray, kq: ops.QuantizedTensor, vq: ops.QuantizedTensor,
|
|
1181
|
+
scale: number, mask: Mask, groupSize: number, bits: number,
|
|
1182
|
+
): MlxArray {
|
|
1183
|
+
const [B, H, L, D] = q.shape as [number, number, number, number];
|
|
1184
|
+
const KV = kq.packed.shape[1]!;
|
|
1185
|
+
const nRep = H / KV;
|
|
1186
|
+
const N = kq.packed.shape[2]!;
|
|
1187
|
+
|
|
1188
|
+
// scale is 1.0 for Gemma4 (q/k are RMS-normed) — skip the identity multiply.
|
|
1189
|
+
let queries = q;
|
|
1190
|
+
const owned: MlxArray[] = [];
|
|
1191
|
+
if (scale !== 1.0) { queries = ops.mulScalar(q, scale); owned.push(queries); }
|
|
1192
|
+
|
|
1193
|
+
let kT = kq;
|
|
1194
|
+
let vT = vq;
|
|
1195
|
+
if (nRep > 1) {
|
|
1196
|
+
const qr = ops.reshape(queries, [B, KV, nRep, L, D]);
|
|
1197
|
+
owned.push(qr);
|
|
1198
|
+
queries = qr;
|
|
1199
|
+
const expand = (t: ops.QuantizedTensor): ops.QuantizedTensor => {
|
|
1200
|
+
const e = (a: MlxArray): MlxArray => {
|
|
1201
|
+
// expand_dims(axis=-3) like the reference — view-preserving;
|
|
1202
|
+
// reshape would copy the strided slice and change kernel paths
|
|
1203
|
+
const r = ops.expandDims(a, -3);
|
|
1204
|
+
owned.push(r);
|
|
1205
|
+
return r;
|
|
1206
|
+
};
|
|
1207
|
+
return { packed: e(t.packed), scales: e(t.scales), biases: e(t.biases) };
|
|
1208
|
+
};
|
|
1209
|
+
kT = expand(kq);
|
|
1210
|
+
vT = expand(vq);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
let scores = ops.quantizedMatmulQT(queries, kT, true, groupSize, bits);
|
|
1214
|
+
owned.push(scores);
|
|
1215
|
+
|
|
1216
|
+
let maskArr: MlxArray | null = null;
|
|
1217
|
+
let ownsMask = false;
|
|
1218
|
+
if (mask.mode === "causal") {
|
|
1219
|
+
const qIdx = ops.arange(N - L, N, 1, Dtype.int32);
|
|
1220
|
+
const kIdx = ops.arange(0, N, 1, Dtype.int32);
|
|
1221
|
+
const qCol = ops.reshape(qIdx, [L, 1]);
|
|
1222
|
+
const kRow = ops.reshape(kIdx, [1, N]);
|
|
1223
|
+
maskArr = ops.greaterEqual(qCol, kRow);
|
|
1224
|
+
ownsMask = true;
|
|
1225
|
+
for (const a of [qIdx, kIdx, qCol, kRow]) a.dispose();
|
|
1226
|
+
} else if (mask.mode === "array") {
|
|
1227
|
+
maskArr = mask.arr;
|
|
1228
|
+
}
|
|
1229
|
+
if (maskArr) {
|
|
1230
|
+
const ninf = ops.scalarLike(FINFO_MIN[scores.dtype] ?? -3.4e38, scores);
|
|
1231
|
+
const masked = ops.where(maskArr, scores, ninf);
|
|
1232
|
+
ninf.dispose();
|
|
1233
|
+
if (ownsMask) maskArr.dispose();
|
|
1234
|
+
owned.push(masked);
|
|
1235
|
+
scores = masked;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
const probs = ops.softmaxAxis(scores, -1, true);
|
|
1239
|
+
owned.push(probs);
|
|
1240
|
+
let out = ops.quantizedMatmulQT(probs, vT, false, groupSize, bits);
|
|
1241
|
+
if (nRep > 1) {
|
|
1242
|
+
const r = ops.reshape(out, [B, H, L, D]);
|
|
1243
|
+
out.dispose();
|
|
1244
|
+
out = r;
|
|
1245
|
+
}
|
|
1246
|
+
for (const a of owned) a.dispose();
|
|
1247
|
+
return out;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/** Tile size for the fused quantized-SDPA prefill path (oracle default). */
|
|
1251
|
+
export const FUSED_N_CHUNK = 512;
|
|
1252
|
+
|
|
1253
|
+
/** Port of optiq fused_quant_sdpa._prefill_flashattn_n_tiled: a
|
|
1254
|
+
* FlashAttention-2 loop over the KV N axis with mx.quantized_matmul as
|
|
1255
|
+
* the inner kernel. Never materializes the full [..., L, N] scores
|
|
1256
|
+
* matrix — the per-tile transient is bounded by FUSED_N_CHUNK, which is
|
|
1257
|
+
* the whole point (stock u4 KV prefill peaks ABOVE fp16 KV at long
|
|
1258
|
+
* context; see the oracle's module docstring). Op composition order
|
|
1259
|
+
* mirrors the oracle exactly: parity is tier a (bit-exact) vs the python
|
|
1260
|
+
* fused path; vs quantizedSdpaUnfused it is tier b by construction
|
|
1261
|
+
* (online softmax ≠ one-shot precise softmax in bf16). */
|
|
1262
|
+
export function quantizedSdpaTiled(
|
|
1263
|
+
q: MlxArray, kq: ops.QuantizedTensor, vq: ops.QuantizedTensor,
|
|
1264
|
+
scale: number, mask: Mask, groupSize: number, bits: number,
|
|
1265
|
+
): MlxArray {
|
|
1266
|
+
const [B, H, L, D] = q.shape as [number, number, number, number];
|
|
1267
|
+
const KV = kq.packed.shape[1]!;
|
|
1268
|
+
const nRep = H / KV;
|
|
1269
|
+
const N = kq.packed.shape[2]!;
|
|
1270
|
+
|
|
1271
|
+
// scale is 1.0 for Gemma4 (q/k are RMS-normed) — skip the identity multiply.
|
|
1272
|
+
let queries = q;
|
|
1273
|
+
let ownsQueries = false;
|
|
1274
|
+
if (scale !== 1.0) { queries = ops.mulScalar(q, scale); ownsQueries = true; }
|
|
1275
|
+
|
|
1276
|
+
let kT = kq;
|
|
1277
|
+
let vT = vq;
|
|
1278
|
+
const expanded: MlxArray[] = [];
|
|
1279
|
+
if (nRep > 1) {
|
|
1280
|
+
const qr = ops.reshape(queries, [B, KV, nRep, L, D]);
|
|
1281
|
+
if (ownsQueries) queries.dispose();
|
|
1282
|
+
queries = qr;
|
|
1283
|
+
ownsQueries = true;
|
|
1284
|
+
const expand = (t: ops.QuantizedTensor): ops.QuantizedTensor => {
|
|
1285
|
+
const e = (a: MlxArray): MlxArray => {
|
|
1286
|
+
const r = ops.expandDims(a, -3);
|
|
1287
|
+
expanded.push(r);
|
|
1288
|
+
return r;
|
|
1289
|
+
};
|
|
1290
|
+
return { packed: e(t.packed), scales: e(t.scales), biases: e(t.biases) };
|
|
1291
|
+
};
|
|
1292
|
+
kT = expand(kq);
|
|
1293
|
+
vT = expand(vq);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// The oracle builds a bottom-right-aligned [L, N] bool causal matrix
|
|
1297
|
+
// and slices columns per tile; our "array" masks (createCausalMask at
|
|
1298
|
+
// offset > 0) are that same matrix already materialized — both slice
|
|
1299
|
+
// identically below.
|
|
1300
|
+
let maskArr: MlxArray | null = null;
|
|
1301
|
+
let ownsMask = false;
|
|
1302
|
+
if (mask.mode === "causal") {
|
|
1303
|
+
const qIdx = ops.arange(N - L, N, 1, Dtype.int32);
|
|
1304
|
+
const kIdx = ops.arange(0, N, 1, Dtype.int32);
|
|
1305
|
+
const qCol = ops.reshape(qIdx, [L, 1]);
|
|
1306
|
+
const kRow = ops.reshape(kIdx, [1, N]);
|
|
1307
|
+
maskArr = ops.greaterEqual(qCol, kRow);
|
|
1308
|
+
ownsMask = true;
|
|
1309
|
+
for (const a of [qIdx, kIdx, qCol, kRow]) a.dispose();
|
|
1310
|
+
} else if (mask.mode === "array") {
|
|
1311
|
+
maskArr = mask.arr;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// Slice [..., n0:n1, :] (KV triples) / [..., n0:n1] (mask chunks).
|
|
1315
|
+
// Under a compiled-decode trace, subrange Slice swaps to DynamicSlice
|
|
1316
|
+
// (identical values; shapeless compile rejects Slice — same pattern as
|
|
1317
|
+
// the per-layer-input and MoE top-k slices).
|
|
1318
|
+
const sliceAxis = (a: MlxArray, axisFromEnd: 1 | 2, n0: number, n1: number): MlxArray => {
|
|
1319
|
+
const dims = a.shape;
|
|
1320
|
+
const axis = dims.length - axisFromEnd;
|
|
1321
|
+
if (isCompiledTrace()) {
|
|
1322
|
+
const start = ops.fromInt32([n0], [1]);
|
|
1323
|
+
const size = [...dims];
|
|
1324
|
+
size[axis] = n1 - n0;
|
|
1325
|
+
const out = ops.sliceDynamic(a, start, [axis], size);
|
|
1326
|
+
start.dispose();
|
|
1327
|
+
return out;
|
|
1328
|
+
}
|
|
1329
|
+
const start = dims.map(() => 0);
|
|
1330
|
+
const stop = [...dims];
|
|
1331
|
+
start[axis] = n0;
|
|
1332
|
+
stop[axis] = n1;
|
|
1333
|
+
return a.slice(start, stop);
|
|
1334
|
+
};
|
|
1335
|
+
|
|
1336
|
+
let oAcc: MlxArray | null = null;
|
|
1337
|
+
let rowMax: MlxArray | null = null;
|
|
1338
|
+
let rowSum: MlxArray | null = null;
|
|
1339
|
+
|
|
1340
|
+
for (let n0 = 0; n0 < N; n0 += FUSED_N_CHUNK) {
|
|
1341
|
+
const n1 = Math.min(n0 + FUSED_N_CHUNK, N);
|
|
1342
|
+
const kChunk: ops.QuantizedTensor = {
|
|
1343
|
+
packed: sliceAxis(kT.packed, 2, n0, n1),
|
|
1344
|
+
scales: sliceAxis(kT.scales, 2, n0, n1),
|
|
1345
|
+
biases: sliceAxis(kT.biases, 2, n0, n1),
|
|
1346
|
+
};
|
|
1347
|
+
const vChunk: ops.QuantizedTensor = {
|
|
1348
|
+
packed: sliceAxis(vT.packed, 2, n0, n1),
|
|
1349
|
+
scales: sliceAxis(vT.scales, 2, n0, n1),
|
|
1350
|
+
biases: sliceAxis(vT.biases, 2, n0, n1),
|
|
1351
|
+
};
|
|
1352
|
+
|
|
1353
|
+
let scores = ops.quantizedMatmulQT(queries, kChunk, true, groupSize, bits);
|
|
1354
|
+
for (const a of [kChunk.packed, kChunk.scales, kChunk.biases]) a.dispose();
|
|
1355
|
+
|
|
1356
|
+
if (maskArr) {
|
|
1357
|
+
const maskChunk = sliceAxis(maskArr, 1, n0, n1);
|
|
1358
|
+
let masked: MlxArray;
|
|
1359
|
+
if (maskChunk.dtype === Dtype.bool) {
|
|
1360
|
+
const ninf = ops.scalarLike(FINFO_MIN[scores.dtype] ?? -3.4e38, scores);
|
|
1361
|
+
masked = ops.where(maskChunk, scores, ninf);
|
|
1362
|
+
ninf.dispose();
|
|
1363
|
+
} else {
|
|
1364
|
+
masked = ops.add(scores, maskChunk);
|
|
1365
|
+
}
|
|
1366
|
+
maskChunk.dispose();
|
|
1367
|
+
scores.dispose();
|
|
1368
|
+
scores = masked;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
const chunkMax = ops.maxAxis(scores, -1, true);
|
|
1372
|
+
if (oAcc === null) {
|
|
1373
|
+
rowMax = chunkMax;
|
|
1374
|
+
const shifted = ops.sub(scores, rowMax);
|
|
1375
|
+
const exps = ops.exp(shifted);
|
|
1376
|
+
shifted.dispose();
|
|
1377
|
+
rowSum = ops.sumAxis(exps, -1, true);
|
|
1378
|
+
oAcc = ops.quantizedMatmulQT(exps, vChunk, false, groupSize, bits);
|
|
1379
|
+
exps.dispose();
|
|
1380
|
+
} else {
|
|
1381
|
+
const newMax = ops.maximum(rowMax!, chunkMax);
|
|
1382
|
+
chunkMax.dispose();
|
|
1383
|
+
const maxDiff = ops.sub(rowMax!, newMax);
|
|
1384
|
+
const factor = ops.exp(maxDiff);
|
|
1385
|
+
maxDiff.dispose();
|
|
1386
|
+
const shifted = ops.sub(scores, newMax);
|
|
1387
|
+
const exps = ops.exp(shifted);
|
|
1388
|
+
shifted.dispose();
|
|
1389
|
+
// new_sum = factor * row_sum + sum(exps) (association order kept)
|
|
1390
|
+
const carried = ops.mul(factor, rowSum!);
|
|
1391
|
+
const sumExps = ops.sumAxis(exps, -1, true);
|
|
1392
|
+
const newSum = ops.add(carried, sumExps);
|
|
1393
|
+
carried.dispose();
|
|
1394
|
+
sumExps.dispose();
|
|
1395
|
+
const deltaOut = ops.quantizedMatmulQT(exps, vChunk, false, groupSize, bits);
|
|
1396
|
+
exps.dispose();
|
|
1397
|
+
// o_acc = o_acc * factor + delta_out
|
|
1398
|
+
const scaledAcc = ops.mul(oAcc!, factor);
|
|
1399
|
+
factor.dispose();
|
|
1400
|
+
const nextAcc = ops.add(scaledAcc, deltaOut);
|
|
1401
|
+
scaledAcc.dispose();
|
|
1402
|
+
deltaOut.dispose();
|
|
1403
|
+
oAcc!.dispose();
|
|
1404
|
+
oAcc = nextAcc;
|
|
1405
|
+
rowMax!.dispose();
|
|
1406
|
+
rowMax = newMax;
|
|
1407
|
+
rowSum!.dispose();
|
|
1408
|
+
rowSum = newSum;
|
|
1409
|
+
}
|
|
1410
|
+
scores.dispose();
|
|
1411
|
+
for (const a of [vChunk.packed, vChunk.scales, vChunk.biases]) a.dispose();
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
let out = ops.div(oAcc!, rowSum!);
|
|
1415
|
+
oAcc!.dispose();
|
|
1416
|
+
rowMax!.dispose();
|
|
1417
|
+
rowSum!.dispose();
|
|
1418
|
+
if (ownsMask) maskArr!.dispose();
|
|
1419
|
+
for (const a of expanded) a.dispose();
|
|
1420
|
+
if (ownsQueries) queries.dispose();
|
|
1421
|
+
if (nRep > 1) {
|
|
1422
|
+
const r = ops.reshape(out, [B, H, L, D]);
|
|
1423
|
+
out.dispose();
|
|
1424
|
+
out = r;
|
|
1425
|
+
}
|
|
1426
|
+
return out;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
/** Gate for the tiled path — port of fused_quant_sdpa._supported plus the
|
|
1430
|
+
* wrapper's mask check, with one documented deviation: the oracle wrapper
|
|
1431
|
+
* falls back to unfused on ARRAY masks because mlx-lm always hands it the
|
|
1432
|
+
* "causal" string for windowless continuations (its make_mask returns
|
|
1433
|
+
* "causal" even at offset > 0); our makeMask materializes the equivalent
|
|
1434
|
+
* bool matrix — so masks flagged causalEquivalent tile too (the oracle's
|
|
1435
|
+
* INNER function handles them with the same column slicing we use).
|
|
1436
|
+
* Window/bidir array masks do NOT tile, matching the reference's
|
|
1437
|
+
* scenario-level dispatch exactly (Phase 9: sliding-layer quantized
|
|
1438
|
+
* prefill is unfused in optiq too). */
|
|
1439
|
+
function fusedSdpaSupported(q: MlxArray, mask: Mask, groupSize: number, bits: number): boolean {
|
|
1440
|
+
// Escape hatch mirroring optiq serve's --no-fused-kv: forces the
|
|
1441
|
+
// stock unfused path everywhere. Also the A/B lever for
|
|
1442
|
+
// scripts/bench-fused-prefill.ts. Read per call (cheap next to the
|
|
1443
|
+
// FFI work) so tests and paired A/B harnesses can flip it in-process.
|
|
1444
|
+
if (process.env.MLX_BUN_NO_FUSED_SDPA === "1") return false;
|
|
1445
|
+
if (bits !== 4 && bits !== 8) return false;
|
|
1446
|
+
if (groupSize !== 32 && groupSize !== 64 && groupSize !== 128) return false;
|
|
1447
|
+
if (q.dtype !== Dtype.bfloat16 && q.dtype !== Dtype.float16) return false;
|
|
1448
|
+
if (mask.mode === "causal" || mask.mode === "") return true; // "" = oracle's mask=None
|
|
1449
|
+
if (mask.mode === "array")
|
|
1450
|
+
return mask.causalEquivalent === true && mask.arr !== null &&
|
|
1451
|
+
mask.arr.shape.length === 2 && mask.arr.dtype === Dtype.bool;
|
|
1452
|
+
return false;
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
/** Quantized-cache SDPA dispatch: L > 1 (prefill/continuation) with a
|
|
1456
|
+
* supported config goes through the N-tiled fused path; decode (L = 1)
|
|
1457
|
+
* and unsupported configs stay on the stock unfused port. Exported for
|
|
1458
|
+
* the dispatch-gate tests.
|
|
1459
|
+
*
|
|
1460
|
+
* MLX_BUN_FUSED_DECODE=1 (NEXT UP 1b experiment): tile decode too,
|
|
1461
|
+
* matching optiq's wrapper, which has no L gate — its serving decode
|
|
1462
|
+
* over quantized caches runs N tiles per step, and Phase 15 measured
|
|
1463
|
+
* its kv-mixed decode tax @8k as ~free where ours is ~3%. Off by
|
|
1464
|
+
* default until a cleared-machine A/B shows it pays; directional
|
|
1465
|
+
* numbers via scripts/bench-fused-decode.ts. */
|
|
1466
|
+
export function quantizedSdpa(
|
|
1467
|
+
q: MlxArray, kq: ops.QuantizedTensor, vq: ops.QuantizedTensor,
|
|
1468
|
+
scale: number, mask: Mask, groupSize: number, bits: number,
|
|
1469
|
+
): MlxArray {
|
|
1470
|
+
// Perf mode (Phase E): the fused decode kernel takes supported L=1
|
|
1471
|
+
// dispatches. NOT bit-exact (online softmax) — gated against the
|
|
1472
|
+
// frozen perf oracle; compat (MLX_BUN_PERF_KERNEL=0) stays the -O0
|
|
1473
|
+
// reference. Never inside a compiled trace: CustomKernel has no
|
|
1474
|
+
// output_shapes, so it cannot live in a (shapeless) closure.
|
|
1475
|
+
if (
|
|
1476
|
+
mask.mode === "" && scale === 1.0 && perfKernelEnabled() &&
|
|
1477
|
+
!isCompiledTrace() && fusedDecodeKernelSupported(q, bits, groupSize)
|
|
1478
|
+
)
|
|
1479
|
+
return fusedDecodeSdpa(q, kq, vq, groupSize, bits);
|
|
1480
|
+
const tile = q.shape[2]! > 1 || process.env.MLX_BUN_FUSED_DECODE === "1";
|
|
1481
|
+
if (tile && fusedSdpaSupported(q, mask, groupSize, bits))
|
|
1482
|
+
return quantizedSdpaTiled(q, kq, vq, scale, mask, groupSize, bits);
|
|
1483
|
+
return quantizedSdpaUnfused(q, kq, vq, scale, mask, groupSize, bits);
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
/** The runtime-only half of fusedSdpaSupported (env flag, dtype, mask
|
|
1487
|
+
* kind) — generated models (Phase D) bake the (bits, group_size) half
|
|
1488
|
+
* as a compile-time constant and call this for the rest. The combined
|
|
1489
|
+
* predicate is exactly fusedSdpaSupported. */
|
|
1490
|
+
export function fusedSdpaRuntimeOk(q: MlxArray, mask: Mask): boolean {
|
|
1491
|
+
if (process.env.MLX_BUN_NO_FUSED_SDPA === "1") return false;
|
|
1492
|
+
if (q.dtype !== Dtype.bfloat16 && q.dtype !== Dtype.float16) return false;
|
|
1493
|
+
if (mask.mode === "causal" || mask.mode === "") return true;
|
|
1494
|
+
if (mask.mode === "array")
|
|
1495
|
+
return mask.causalEquivalent === true && mask.arr !== null &&
|
|
1496
|
+
mask.arr.shape.length === 2 && mask.arr.dtype === Dtype.bool;
|
|
1497
|
+
return false;
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
/** Port of base.py create_causal_mask (bool, [N, offset+N]). */
|
|
1501
|
+
export function createCausalMask(N: number, offset: number, windowSize: number | null): MlxArray {
|
|
1502
|
+
const rinds = ops.arange(0, offset + N, 1, Dtype.int32);
|
|
1503
|
+
const lindsFlat = offset ? ops.arange(offset, offset + N, 1, Dtype.int32) : rinds;
|
|
1504
|
+
const linds = ops.reshape(lindsFlat, [N, 1]);
|
|
1505
|
+
const rindsB = ops.reshape(rinds, [1, offset + N]);
|
|
1506
|
+
let mask = ops.greaterEqual(linds, rindsB);
|
|
1507
|
+
if (windowSize !== null) {
|
|
1508
|
+
const w = ops.fromInt32([windowSize], []);
|
|
1509
|
+
const rPlusW = ops.add(rindsB, w);
|
|
1510
|
+
const inWindow = ops.less(linds, rPlusW);
|
|
1511
|
+
const combined = ops.logicalAnd(mask, inWindow);
|
|
1512
|
+
for (const a of [w, rPlusW, inWindow, mask]) a.dispose();
|
|
1513
|
+
mask = combined;
|
|
1514
|
+
}
|
|
1515
|
+
if (lindsFlat !== rinds) lindsFlat.dispose();
|
|
1516
|
+
rinds.dispose();
|
|
1517
|
+
linds.dispose();
|
|
1518
|
+
rindsB.dispose();
|
|
1519
|
+
return mask;
|
|
1520
|
+
}
|
|
1521
|
+
/** Port of switch_layers.QuantizedSwitchLinear (gather_qmm over stacked
|
|
1522
|
+
* expert weights; rhs_indices selects the expert per row). */
|
|
1523
|
+
export class QuantizedSwitchLinear {
|
|
1524
|
+
constructor(
|
|
1525
|
+
readonly w: MlxArray,
|
|
1526
|
+
readonly scales: MlxArray,
|
|
1527
|
+
readonly biases: MlxArray | null,
|
|
1528
|
+
readonly spec: ops.QuantSpec,
|
|
1529
|
+
) {}
|
|
1530
|
+
|
|
1531
|
+
static load(weights: Weights, path: string, config: ModelConfig): QuantizedSwitchLinear {
|
|
1532
|
+
if (!weights.has(`${path}.scales`))
|
|
1533
|
+
throw new Error(`${path}: expected quantized switch linear (no .scales tensor)`);
|
|
1534
|
+
const spec = quantFor(config.quantization, path);
|
|
1535
|
+
if (!spec) throw new Error(`${path}: no quant spec`);
|
|
1536
|
+
// Expert WEIGHT (the ~94% of expert bytes) comes from the page-aligned
|
|
1537
|
+
// offload mmap when --expert-offload is active (else resident); scales/
|
|
1538
|
+
// biases stay resident (small). Same bytes either way → bit-exact.
|
|
1539
|
+
const wName = `${path}.weight`;
|
|
1540
|
+
return new QuantizedSwitchLinear(
|
|
1541
|
+
expertOffloadArray(wName) ?? weights.tensor(wName),
|
|
1542
|
+
weights.tensor(`${path}.scales`),
|
|
1543
|
+
weights.has(`${path}.biases`) ? weights.tensor(`${path}.biases`) : null,
|
|
1544
|
+
spec,
|
|
1545
|
+
);
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
forward(x: MlxArray, indices: MlxArray, sortedIndices: boolean): MlxArray {
|
|
1549
|
+
return ops.gatherQmm(x, this.w, this.scales, this.biases, indices, this.spec, sortedIndices);
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
/** Dispose `old` and return `next` — for h = disposing(h, op(h)) chains. */
|
|
1553
|
+
export function disposing(old: MlxArray, next: MlxArray): MlxArray {
|
|
1554
|
+
old.dispose();
|
|
1555
|
+
return next;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
// Compiled-decode trace mode (set by compiled-decode.ts around its trace,
|
|
1559
|
+
// which runs synchronously on the serialized generation queue — same
|
|
1560
|
+
// safety argument as LoraState). Inside a shapeless-compiled graph, mlx's
|
|
1561
|
+
// Slice primitive cannot re-infer output shapes, so the two subrange
|
|
1562
|
+
// slices on the decode path (per-layer-input split, MoE top-k) swap to
|
|
1563
|
+
// DynamicSlice — identical values, shapeless-safe — ONLY while tracing.
|
|
1564
|
+
// The uncompiled path keeps the exact oracle op sequence.
|
|
1565
|
+
let compiledTrace = false;
|
|
1566
|
+
export function setCompiledTrace(v: boolean): void {
|
|
1567
|
+
compiledTrace = v;
|
|
1568
|
+
}
|
|
1569
|
+
export function isCompiledTrace(): boolean {
|
|
1570
|
+
return compiledTrace;
|
|
1571
|
+
}
|
|
1572
|
+
/** Causal(+window) mask OR'd with image×image bidirectional attention. */
|
|
1573
|
+
export function bidirMask(L: number, windowSize: number | null, bidir: MlxArray): Mask {
|
|
1574
|
+
const causal = createCausalMask(L, 0, windowSize);
|
|
1575
|
+
const col = ops.reshape(bidir, [L, 1]);
|
|
1576
|
+
const row = ops.reshape(bidir, [1, L]);
|
|
1577
|
+
const outer = ops.logicalAnd(col, row);
|
|
1578
|
+
col.dispose();
|
|
1579
|
+
row.dispose();
|
|
1580
|
+
const allow = ops.logicalOr(causal, outer);
|
|
1581
|
+
causal.dispose();
|
|
1582
|
+
outer.dispose();
|
|
1583
|
+
return { mode: "array", arr: allow };
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
/** tanh(x / cap) * cap with weak-scalar semantics. */
|
|
1587
|
+
export function logitSoftcap(logits: MlxArray, cap: number): MlxArray {
|
|
1588
|
+
const capArr = ops.scalarLike(cap, logits);
|
|
1589
|
+
const scaled = ops.div(logits, capArr);
|
|
1590
|
+
const t = ops.tanh(scaled);
|
|
1591
|
+
scaled.dispose();
|
|
1592
|
+
const out = ops.mul(t, capArr);
|
|
1593
|
+
t.dispose();
|
|
1594
|
+
capArr.dispose();
|
|
1595
|
+
return out;
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
/** argmax over vocab at the last sequence position of [1, L, V]. */
|
|
1599
|
+
export function argmaxLastPosition(logits: MlxArray): number {
|
|
1600
|
+
const [, L, V] = logits.shape as [number, number, number];
|
|
1601
|
+
const last = logits.slice([0, L - 1, 0], [1, L, V]);
|
|
1602
|
+
const am = ops.argmaxAxis(last, -1);
|
|
1603
|
+
const id = ops.itemUint32(am);
|
|
1604
|
+
last.dispose();
|
|
1605
|
+
am.dispose();
|
|
1606
|
+
return id;
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
/** Last-position logits as f32 (for the parity harness). */
|
|
1610
|
+
export function lastPositionLogits(logits: MlxArray): Float32Array {
|
|
1611
|
+
const [, L, V] = logits.shape as [number, number, number];
|
|
1612
|
+
const last = logits.slice([0, L - 1, 0], [1, L, V]);
|
|
1613
|
+
const f32 = last.toFloat32();
|
|
1614
|
+
last.dispose();
|
|
1615
|
+
return f32;
|
|
1616
|
+
}
|