mlx-bun 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -32
- package/docs/README.md +55 -42
- package/docs/archive/investigations/benchmark-ledger-through-v0.4.0.md +3617 -0
- package/docs/archive/planning/release-notes-v0.5.0.md +37 -0
- package/docs/design/batching.md +220 -7
- package/docs/design/decode-speed-program.md +121 -66
- package/docs/design/generic-model-support.md +116 -0
- package/docs/design/kv-cache.md +380 -211
- package/docs/design/speculative-decoding.md +165 -26
- package/docs/design/turboquant.md +20 -7
- package/docs/design/unified-engine-frontier-plan.md +120 -13
- package/docs/planning/PRODUCT_ROADMAP.md +5 -6
- package/docs/planning/public-readiness-handoff.md +244 -0
- package/docs/planning/public-readiness.md +149 -0
- package/docs/reference/benchmarks.md +3463 -3489
- package/docs/reference/cli.md +62 -1
- package/docs/reference/distribution.md +29 -4
- package/docs/reference/environment.md +104 -8
- package/docs/reference/library-api.md +18 -4
- package/docs/reference/memory.md +1 -1
- package/docs/reference/models.md +17 -5
- package/docs/reference/server-api.md +181 -11
- package/docs/reference/server-config.md +232 -105
- package/package.json +2 -2
- package/src/audio/audiotoolbox.ts +112 -0
- package/src/audio/conformer.ts +8 -0
- package/src/audio/mic-capture.ts +98 -0
- package/src/audio/silero-vad.ts +379 -0
- package/src/audio/whisper-decode.ts +774 -0
- package/src/audio/whisper-fast.ts +732 -0
- package/src/audio/whisper-format.ts +73 -0
- package/src/audio/whisper-mel.ts +185 -0
- package/src/audio/whisper-timing.ts +274 -0
- package/src/audio/whisper-tokenizer.ts +239 -0
- package/src/audio/whisper-transcribe.ts +410 -0
- package/src/backends/mlx/autoregressive.ts +22 -1
- package/src/backends/mlx/batch-group.ts +111 -27
- package/src/backends/mlx/cache-layout.ts +3 -3
- package/src/backends/mlx/continuation-persistence.ts +5 -4
- package/src/backends/mlx/encoder-cache.ts +25 -0
- package/src/backends/mlx/fill-append.ts +21 -8
- package/src/backends/mlx/fill-group.ts +497 -0
- package/src/backends/mlx/gateway-binding.ts +56 -12
- package/src/backends/mlx/kv-maintenance.ts +30 -2
- package/src/backends/mlx/mixed-iteration.ts +62 -0
- package/src/backends/mlx/model-prompt.ts +46 -32
- package/src/backends/mlx/model-serving.ts +4 -3
- package/src/backends/mlx/paged-cache-codec.ts +27 -0
- package/src/backends/mlx/prefill-cohort.ts +15 -4
- package/src/backends/mlx/prefill-policy.ts +26 -0
- package/src/backends/mlx/prefill-rows.ts +57 -13
- package/src/backends/mlx/prompt-input.ts +29 -0
- package/src/backends/mlx/qwen-prompt-input.ts +40 -0
- package/src/backends/mlx/request-state-policy.ts +18 -5
- package/src/backends/mlx/rollback.ts +6 -3
- package/src/backends/mlx/serial-executor.ts +16 -7
- package/src/backends/mlx/speculative-group.ts +105 -26
- package/src/backends/mlx/speculative.ts +24 -7
- package/src/backends/mlx/target-layout-capability.ts +1 -1
- package/src/backends/mlx/transitioning-kv-rows.ts +8 -0
- package/src/cli.ts +322 -0
- package/src/contracts/execution.ts +10 -0
- package/src/contracts/object-cache.ts +8 -0
- package/src/contracts/pi-provider.ts +1 -1
- package/src/contracts/pi.ts +3 -1
- package/src/contracts/prefix-cache.ts +13 -3
- package/src/contracts/scheduling.ts +9 -0
- package/src/engine/execution-plan.ts +23 -12
- package/src/engine/execution-tasks.ts +44 -0
- package/src/engine/namespaced-cache.ts +13 -0
- package/src/engine/scheduler.ts +22 -2
- package/src/fill/fill-session.ts +14 -3
- package/src/generate.ts +28 -22
- package/src/grammar.ts +13 -0
- package/src/inference/prefill.ts +6 -0
- package/src/kv-scheme.ts +22 -0
- package/src/kv-store.ts +115 -79
- package/src/lab/README.md +5 -1
- package/src/lab/paged-kv/paged-attention.ts +117 -0
- package/src/lab/paged-kv/paged-kv-rows.ts +33 -5
- package/src/lab/paged-kv/paged-kv.ts +108 -42
- package/src/md-modules.d.ts +5 -0
- package/src/mlx/array.ts +37 -0
- package/src/mlx/ffi.ts +19 -0
- package/src/mlx/normalized-argmax.ts +116 -0
- package/src/mlx/ops.ts +97 -10
- package/src/model/batched-turboquant-kv.ts +6 -5
- package/src/model/delayed-quantized-kv.ts +3 -3
- package/src/model/delayed-rotating-quantized-kv.ts +1 -1
- package/src/model/delayed-turboquant-kv.ts +6 -4
- package/src/model/factory.ts +26 -0
- package/src/model/fingerprint.ts +12 -0
- package/src/model/full-prefill-row.ts +1 -1
- package/src/model/gemma4-base.ts +25 -6
- package/src/model/gemma4.ts +82 -2
- package/src/model/kv-attention-view.ts +4 -1
- package/src/model/profile.ts +29 -4
- package/src/model/quantized-append-attention.ts +31 -0
- package/src/model/qwen3-mrope.ts +7 -9
- package/src/model/qwen3_5.ts +99 -37
- package/src/model/support.ts +8 -0
- package/src/model/token-groups.ts +66 -0
- package/src/model/trellis-linear.ts +2 -2
- package/src/model/turboquant-codec.ts +6 -0
- package/src/model/whisper.ts +435 -0
- package/src/native/mic_capture.swift +92 -0
- package/src/native-pack.ts +6 -5
- package/src/pi-web.ts +9 -1
- package/src/prompt-cache.ts +265 -43
- package/src/runtime-config.ts +1 -5
- package/src/safetensors.ts +9 -2
- package/src/sampler.ts +5 -4
- package/src/serve/audio-routes.ts +323 -0
- package/src/serve/chat-request.ts +2 -0
- package/src/serve/chat-stage.ts +17 -12
- package/src/serve/completion-executor.ts +9 -1
- package/src/serve/discovery-routes.ts +17 -1
- package/src/serve/generated-token-history.ts +57 -0
- package/src/serve/generation-gateway.ts +52 -11
- package/src/serve/model-binding.ts +7 -1
- package/src/serve/preparation.ts +3 -3
- package/src/serve/prompt-response-trace.ts +14 -0
- package/src/serve/request-plan.ts +2 -0
- package/src/serve/request-prep.ts +38 -21
- package/src/serve/transcription-server.ts +64 -0
- package/src/serve/transcription-service.ts +502 -0
- package/src/server.ts +132 -164
- package/src/spec/assistant-rows.ts +1 -0
- package/src/spec/deepspec-rows.ts +1 -1
- package/src/spec/dflash-source.ts +1 -1
- package/src/spec/glm52-mtp-source.ts +1 -0
- package/src/spec/ngram-source.ts +27 -5
- package/src/spec/projected-draft-rows.ts +2 -2
- package/src/spec/qwen-mtp-rows.ts +11 -0
- package/src/spec/qwen-mtp-source.ts +3 -0
- package/src/spec/serve-loop.ts +5 -3
- package/src/spec/source.ts +14 -1
- package/src/spec/two-model-rows.ts +1 -0
- package/src/spec/two-model.ts +2 -2
- package/src/ssd-cache.ts +76 -17
- package/src/ssd-durability.ts +20 -22
- package/src/storage/host-buffer.ts +24 -0
- package/src/storage/kv-writer.ts +83 -0
- package/src/storage/kv-writer.worker.js +264 -0
- package/src/storage/retention-policy.ts +37 -0
- package/src/tiered-prompt-cache.ts +45 -0
- package/src/vision/embedder.ts +8 -0
- package/src/vision/prompt.ts +101 -65
- package/src/vision/qwen3vl-prompt.ts +101 -64
- package/src/vision/qwen3vl-tower.ts +11 -0
- package/src/vision/siglip.ts +8 -0
- package/src/web/app.html +12 -0
- package/src/web/app.js +160 -0
- package/src/web/src/chat.ts +4 -1
- package/src/web/src/composer.ts +3 -0
- package/src/web/src/voice.ts +141 -0
- package/src/web/sw.js +1 -1
- /package/docs/{planning → archive/planning}/release-notes-v0.4.0.md +0 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { MlxArray } from "../../mlx/array";
|
|
2
|
+
import { Dtype } from "../../mlx/ffi";
|
|
3
|
+
import { MetalKernel } from "../../mlx/metal-kernel";
|
|
4
|
+
import * as ops from "../../mlx/ops";
|
|
5
|
+
import type { KvAttentionView, Mask } from "../../model/gemma4-base";
|
|
6
|
+
import { disposeResources } from "../../engine/resources";
|
|
7
|
+
import type { BlockPool } from "./paged-kv";
|
|
8
|
+
|
|
9
|
+
// Each SIMD group reduces one query/head over a bounded sequence partition.
|
|
10
|
+
// Only partial output vectors and normalizers materialize, never gathered KV.
|
|
11
|
+
const partialSpec = { name: "paged_attention_partial", ensureRowContiguous: true,
|
|
12
|
+
inputNames: ["q", "keys", "values", "table", "scale"], outputNames: ["out", "normal"], source: `
|
|
13
|
+
uint lane = thread_position_in_threadgroup.x;
|
|
14
|
+
uint hq = threadgroup_position_in_grid.y % HQ;
|
|
15
|
+
uint query = threadgroup_position_in_grid.y / HQ;
|
|
16
|
+
uint part = threadgroup_position_in_grid.z;
|
|
17
|
+
uint hk = hq / (HQ / HK);
|
|
18
|
+
float accum[(DV + 31) / 32];
|
|
19
|
+
for (uint j = 0; j < (DV + 31) / 32; ++j) accum[j] = 0;
|
|
20
|
+
float maximum = -INFINITY, total = 0;
|
|
21
|
+
uint length = uint(scale[1]);
|
|
22
|
+
uint count = (length + PART - 1) / PART;
|
|
23
|
+
uint end = min(uint(length - N + query + 1), (part + 1) * PART);
|
|
24
|
+
for (uint token = part * PART; token < end; ++token) {
|
|
25
|
+
uint physical = table[token / BS];
|
|
26
|
+
uint keyBase = ((physical * HK + hk) * BS + token % BS) * DK;
|
|
27
|
+
float score = 0;
|
|
28
|
+
for (uint d = lane; d < DK; d += 32)
|
|
29
|
+
score += float(q[(hq * N + query) * DK + d]) * float(keys[keyBase + d]);
|
|
30
|
+
score = simd_sum(score) * scale[0];
|
|
31
|
+
float next = max(maximum, score);
|
|
32
|
+
float previous = exp(maximum - next), weight = exp(score - next);
|
|
33
|
+
uint valueBase = ((physical * HK + hk) * BS + token % BS) * DV;
|
|
34
|
+
for (uint j = 0; j < (DV + 31) / 32; ++j) {
|
|
35
|
+
uint d = j * 32 + lane;
|
|
36
|
+
if (d < DV) accum[j] = accum[j] * previous + weight * float(values[valueBase + d]);
|
|
37
|
+
}
|
|
38
|
+
total = total * previous + weight; maximum = next;
|
|
39
|
+
}
|
|
40
|
+
uint base = ((hq * N + query) * count + part);
|
|
41
|
+
for (uint j = 0; j < (DV + 31) / 32; ++j) {
|
|
42
|
+
uint d = j * 32 + lane;
|
|
43
|
+
if (d < DV) out[base * DV + d] = accum[j];
|
|
44
|
+
}
|
|
45
|
+
if (lane == 0) { normal[base * 2] = maximum; normal[base * 2 + 1] = total; }
|
|
46
|
+
` };
|
|
47
|
+
const partial = new MetalKernel(partialSpec);
|
|
48
|
+
const quantizedPartial = new MetalKernel({ ...partialSpec, name: "paged_attention_affine_partial",
|
|
49
|
+
inputNames: [...partialSpec.inputNames, "ks", "kb", "vs", "vb"],
|
|
50
|
+
source: partialSpec.source
|
|
51
|
+
.replace("float(keys[keyBase + d])", "float(InT(float(ks[(keyBase+d)/GROUP]) * float((keys[(keyBase+d)/PACK] >> (((keyBase+d)%PACK)*BITS)) & ((1u<<BITS)-1u)) + float(kb[(keyBase+d)/GROUP])))")
|
|
52
|
+
.replace("float(values[valueBase + d])", "float(InT(float(vs[(valueBase+d)/GROUP]) * float((values[(valueBase+d)/PACK] >> (((valueBase+d)%PACK)*BITS)) & ((1u<<BITS)-1u)) + float(vb[(valueBase+d)/GROUP])))"),
|
|
53
|
+
});
|
|
54
|
+
const merge = new MetalKernel({ name: "paged_attention_merge", inputNames: ["parts", "normal"], outputNames: ["out"], source: `
|
|
55
|
+
uint d = thread_position_in_grid.x;
|
|
56
|
+
uint row = thread_position_in_grid.y;
|
|
57
|
+
if (d >= DV) return;
|
|
58
|
+
uint count = normal_shape[2];
|
|
59
|
+
float maximum = -INFINITY;
|
|
60
|
+
for (uint part = 0; part < count; ++part) maximum = max(maximum, normal[(row * count + part) * 2]);
|
|
61
|
+
float sum = 0, value = 0;
|
|
62
|
+
for (uint part = 0; part < count; ++part) {
|
|
63
|
+
uint base = row * count + part;
|
|
64
|
+
float weight = exp(normal[base * 2] - maximum);
|
|
65
|
+
sum += normal[base * 2 + 1] * weight;
|
|
66
|
+
value += parts[base * DV + d] * weight;
|
|
67
|
+
}
|
|
68
|
+
out[row * DV + d] = OutT(value / sum);
|
|
69
|
+
` });
|
|
70
|
+
|
|
71
|
+
/** Independent immutable view: later appends or row retirement cannot alter it. */
|
|
72
|
+
export function pagedAttentionView(pool: BlockPool, blockTable: number[], length: number,
|
|
73
|
+
direct: boolean): KvAttentionView {
|
|
74
|
+
const keys = ops.contiguous(pool.keys), values = ops.contiguous(pool.values);
|
|
75
|
+
const table = MlxArray.fromInt32(Int32Array.from(blockTable), [blockTable.length]);
|
|
76
|
+
const bs = pool.blockSize, quantization = pool.quantization;
|
|
77
|
+
const extra = quantization ? [pool.keyScales!, pool.keyBiases!, pool.valueScales!, pool.valueBiases!].map(a => ops.contiguous(a)) : [];
|
|
78
|
+
const dkStored = pool.headDim, dvStored = pool.vHeadDim;
|
|
79
|
+
const dtype = pool.dtype;
|
|
80
|
+
return {
|
|
81
|
+
attend(q: MlxArray, scale: number, mask: Mask): MlxArray {
|
|
82
|
+
const [, hq, n, dk] = q.shape as [number, number, number, number];
|
|
83
|
+
const hk = keys.shape[1]!, dv = dvStored;
|
|
84
|
+
// Long prefill and explicit masks use the established attention oracle.
|
|
85
|
+
// This is shape dispatch within one attention interface, not scheduling.
|
|
86
|
+
if (!direct || n > 8 || mask.mode === "array") {
|
|
87
|
+
const gather = (tensor: MlxArray) => {
|
|
88
|
+
using picked = ops.takeAxis(tensor, table, 0);
|
|
89
|
+
using transposed = ops.transposeAxes(picked, [1, 0, 2, 3]);
|
|
90
|
+
using flat = ops.reshape(transposed, [1, hk, blockTable.length * bs, tensor.shape[3]!]);
|
|
91
|
+
return flat.slice([0, 0, 0, 0], [1, hk, length, tensor.shape[3]!]);
|
|
92
|
+
};
|
|
93
|
+
using k = gather(keys), v = gather(values);
|
|
94
|
+
if (!quantization) return ops.sdpa(q, k, v, scale, mask.mode, mask.arr);
|
|
95
|
+
using ks = gather(extra[0]!), kb = gather(extra[1]!), vs = gather(extra[2]!), vb = gather(extra[3]!);
|
|
96
|
+
using kd = ops.dequantize(k, ks, kb, { ...quantization, mode: "affine" });
|
|
97
|
+
using vd = ops.dequantize(v, vs, vb, { ...quantization, mode: "affine" });
|
|
98
|
+
return ops.sdpa(q, kd, vd, scale, mask.mode, mask.arr);
|
|
99
|
+
}
|
|
100
|
+
const part = 64, parts = Math.ceil(length / part);
|
|
101
|
+
using multiplier = MlxArray.fromFloat32(new Float32Array([scale, length]), [2]);
|
|
102
|
+
const [out, normal] = (quantization ? quantizedPartial : partial).apply([q, keys, values, table, multiplier, ...extra], {
|
|
103
|
+
outputs: [{ shape: [hq, n, parts, dv], dtype: Dtype.float32 }, { shape: [hq, n, parts, 2], dtype: Dtype.float32 }],
|
|
104
|
+
grid: [32, hq * n, parts], threadGroup: [32, 1, 1],
|
|
105
|
+
templateDtypes: quantization ? { InT: dtype } : {},
|
|
106
|
+
templateInts: { HQ: hq, HK: hk, N: n, DK: dkStored, DV: dv, BS: bs, PART: part,
|
|
107
|
+
...(quantization ? { BITS: quantization.bits, GROUP: quantization.groupSize, PACK: 32 / quantization.bits } : {}) },
|
|
108
|
+
});
|
|
109
|
+
try {
|
|
110
|
+
return merge.apply([out!, normal!], { outputs: [{ shape: [1, hq, n, dv], dtype: q.dtype }],
|
|
111
|
+
grid: [Math.ceil(dv / 32) * 32, hq * n, 1], threadGroup: [32, 1, 1],
|
|
112
|
+
templateDtypes: { OutT: q.dtype }, templateInts: { DV: dv } })[0]!;
|
|
113
|
+
} finally { disposeResources([out!, normal!]); }
|
|
114
|
+
},
|
|
115
|
+
dispose() { disposeResources([keys, values, table, ...extra]); },
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import type { BatchableCache, Cache, Mask } from "../../model/gemma4-base";
|
|
1
|
+
import type { BatchableCache, Cache, Mask, KvAttentionView } from "../../model/gemma4-base";
|
|
2
2
|
import { buildBatchedDecodeMask, mergeKVRows } from "../../model/batched-mask";
|
|
3
|
+
import * as ops from "../../mlx/ops";
|
|
4
|
+
import { runtimeFlag } from "../../runtime-config";
|
|
3
5
|
import { MlxArray } from "../../mlx/array";
|
|
4
6
|
import { disposeResources } from "../../engine/resources";
|
|
5
|
-
import { PagedKVCache, poolBlocksFor } from "./paged-kv";
|
|
7
|
+
import { PagedKVCache, poolBlocksFor, type PagedQuantization } from "./paged-kv";
|
|
6
8
|
|
|
7
9
|
/** Block storage owns row membership; attention consumes the same gathered
|
|
8
10
|
* K/V representation as the existing paged cache. No scheduler policy here. */
|
|
@@ -11,7 +13,7 @@ export class PagedKvRows implements BatchableCache {
|
|
|
11
13
|
#rope?: MlxArray;
|
|
12
14
|
#ropeOffset = -1;
|
|
13
15
|
|
|
14
|
-
constructor(readonly capacityTokens: number, readonly blockSize: number) {}
|
|
16
|
+
constructor(readonly capacityTokens: number, readonly blockSize: number, readonly direct = runtimeFlag("MLX_BUN_PAGED_ATTN", false), readonly quantization?: PagedQuantization) {}
|
|
15
17
|
signature(): string { return "kv:paged-rows"; }
|
|
16
18
|
get batchSize(): number { return this.#rows.length; }
|
|
17
19
|
get offset(): number { return Math.max(0, ...this.#rows.map(row => row.offset)); }
|
|
@@ -27,7 +29,7 @@ export class PagedKvRows implements BatchableCache {
|
|
|
27
29
|
return this.#rope;
|
|
28
30
|
}
|
|
29
31
|
releaseRopeArr(): void { this.#rope?.dispose(); this.#rope = undefined; this.#ropeOffset = -1; }
|
|
30
|
-
makeEmptyBatch(): PagedKvRows { return new PagedKvRows(this.capacityTokens, this.blockSize); }
|
|
32
|
+
makeEmptyBatch(): PagedKvRows { return new PagedKvRows(this.capacityTokens, this.blockSize, this.direct, this.quantization); }
|
|
31
33
|
|
|
32
34
|
mergeRows(rows: readonly Cache[]): void {
|
|
33
35
|
const next: PagedKVCache[] = [];
|
|
@@ -51,6 +53,31 @@ export class PagedKvRows implements BatchableCache {
|
|
|
51
53
|
disposeResources(previous.filter((_, row) => !retained.has(row)));
|
|
52
54
|
}
|
|
53
55
|
|
|
56
|
+
get attentionState(): this | undefined { return this.direct || this.quantization ? this : undefined; }
|
|
57
|
+
appendAndFetch(keys: MlxArray, values: MlxArray): KvAttentionView {
|
|
58
|
+
const views: KvAttentionView[] = [];
|
|
59
|
+
try {
|
|
60
|
+
for (const [row, storage] of this.#rows.entries()) {
|
|
61
|
+
using k = keys.slice([row, 0, 0, 0], [row + 1, ...keys.shape.slice(1)]);
|
|
62
|
+
using v = values.slice([row, 0, 0, 0], [row + 1, ...values.shape.slice(1)]);
|
|
63
|
+
views.push(storage.appendAndFetch(k, v));
|
|
64
|
+
}
|
|
65
|
+
} catch (error) { disposeResources(views); throw error; }
|
|
66
|
+
return {
|
|
67
|
+
attend(q, scale, mask) {
|
|
68
|
+
const outputs: MlxArray[] = [];
|
|
69
|
+
try {
|
|
70
|
+
for (const [row, view] of views.entries()) {
|
|
71
|
+
using query = q.slice([row, 0, 0, 0], [row + 1, ...q.shape.slice(1)]);
|
|
72
|
+
outputs.push(view.attend(query, scale, mask));
|
|
73
|
+
}
|
|
74
|
+
return ops.concatAxis(outputs, 0);
|
|
75
|
+
} finally { disposeResources(outputs); }
|
|
76
|
+
},
|
|
77
|
+
dispose() { disposeResources(views); },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
54
81
|
updateAndFetch(keys: MlxArray, values: MlxArray): [MlxArray, MlxArray] {
|
|
55
82
|
if (this.#rows.length === 1) return this.#rows[0]!.updateAndFetch(keys, values);
|
|
56
83
|
const fetched: Array<{ keys: MlxArray; values: MlxArray }> = [];
|
|
@@ -66,6 +93,7 @@ export class PagedKvRows implements BatchableCache {
|
|
|
66
93
|
} finally { disposeResources(fetched.flatMap(row => [row.keys, row.values])); }
|
|
67
94
|
}
|
|
68
95
|
makeMask(tokens: number, window: number | null): Mask {
|
|
96
|
+
if (this.attentionState && window === null) return { mode: tokens === 1 ? "" : "causal", arr: null };
|
|
69
97
|
if (this.#rows.length === 1) return this.#rows[0]!.makeMask(tokens, window);
|
|
70
98
|
return { mode: "array", arr: buildBatchedDecodeMask(this.batchSize, tokens,
|
|
71
99
|
this.offset + tokens, this.leftPad, window) };
|
|
@@ -76,7 +104,7 @@ export class PagedKvRows implements BatchableCache {
|
|
|
76
104
|
projectedBytes(tokens: number): number {
|
|
77
105
|
const pool = this.#rows.find(row => row.pool)?.pool;
|
|
78
106
|
if (!pool) return 0;
|
|
79
|
-
const bytesPerBlock =
|
|
107
|
+
const bytesPerBlock = pool.arrays().reduce((bytes, array) => bytes + array.nbytes, 0) / pool.numBlocks;
|
|
80
108
|
return poolBlocksFor(tokens, this.blockSize) * bytesPerBlock;
|
|
81
109
|
}
|
|
82
110
|
dispose(): void { const rows = this.#rows; this.#rows = []; this.releaseRopeArr(); disposeResources(rows); }
|
|
@@ -1,29 +1,16 @@
|
|
|
1
|
-
// Paged KV
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// Storage: K/V live in fixed-size per-layer pool tensors
|
|
7
|
-
// [numBlocks, H_kv, blockSize, headDim]; a host-side block table maps the
|
|
8
|
-
// sequence's logical block order to physical pool slots. Writes go through
|
|
9
|
-
// ops.sliceUpdate into the tail block; reads gather the occupied blocks
|
|
10
|
-
// back into ONE contiguous [1, H, S, D] pair (ops.takeAxis over the pool's
|
|
11
|
-
// block axis + transpose + reshape) and hand that to the unchanged
|
|
12
|
-
// ops.sdpa call site — identical bytes to what a plain KVCache would have
|
|
13
|
-
// fetched, so the paged path is gated bit-exact, not KL-tolerated
|
|
14
|
-
// (tests/parity/paged-kv-parity.test.ts). The gather IS the cost: a full K/V copy
|
|
15
|
-
// per step, pure bandwidth tax at batch=1 — v1 ships the abstraction for
|
|
16
|
-
// the batched/CoW follow-ups, not a speed win (see the design doc's
|
|
17
|
-
// Motivation, which says this honestly).
|
|
18
|
-
//
|
|
19
|
-
// Deliberately NOT a KVCache subclass (the TurboQuantKVCache reasoning):
|
|
20
|
-
// compiled graph adapters exclude the changing block-list geometry. The
|
|
21
|
-
// cache-layout binding supplies dynamic rows independently of scheduling.
|
|
1
|
+
// Paged KV storage behind the common cache and attention interfaces.
|
|
2
|
+
// Full-attention pools support bf16 and affine KV4/KV8. The established
|
|
3
|
+
// gather + SDPA arm is the numerical control; direct Metal reads are Lab.
|
|
4
|
+
// Published views and SSD snapshots own immutable buffers independently of
|
|
5
|
+
// appends, row membership and RAM eviction.
|
|
22
6
|
|
|
7
|
+
import { disposeResources } from "../../engine/resources";
|
|
8
|
+
import { pagedAttentionView } from "./paged-attention";
|
|
9
|
+
import { runtimeFlag } from "../../runtime-config";
|
|
23
10
|
import { MlxArray } from "../../mlx/array";
|
|
24
11
|
import { Dtype } from "../../mlx/ffi";
|
|
25
12
|
import * as ops from "../../mlx/ops";
|
|
26
|
-
import { createCausalMask, type Cache, type Mask } from "../../model/gemma4-base";
|
|
13
|
+
import { createCausalMask, type Cache, type Mask, type KvAttentionView } from "../../model/gemma4-base";
|
|
27
14
|
|
|
28
15
|
/** Typed pool-exhaustion error: a generation outgrew its pool. Sizing from
|
|
29
16
|
* prompt+maxTokens at construction makes this unreachable in practice;
|
|
@@ -41,10 +28,18 @@ export class PagedPoolExhausted extends Error {
|
|
|
41
28
|
* (ops.zeros — no host-pointer alignment/dtor hazards), shaped
|
|
42
29
|
* [numBlocks, H, blockSize, D]. alloc()/free() manage physical slot
|
|
43
30
|
* indices; the owning PagedKVCache writes/gathers through the pool. */
|
|
31
|
+
export interface PagedQuantization { bits: 4 | 8; groupSize: number }
|
|
32
|
+
|
|
44
33
|
export class BlockPool {
|
|
34
|
+
keyScales?: MlxArray; keyBiases?: MlxArray;
|
|
35
|
+
valueScales?: MlxArray; valueBiases?: MlxArray;
|
|
36
|
+
readonly headDim: number;
|
|
37
|
+
readonly vHeadDim: number;
|
|
38
|
+
readonly dtype: Dtype;
|
|
39
|
+
readonly quantization?: PagedQuantization;
|
|
45
40
|
keys: MlxArray;
|
|
46
41
|
values: MlxArray;
|
|
47
|
-
|
|
42
|
+
numBlocks: number;
|
|
48
43
|
readonly blockSize: number;
|
|
49
44
|
#free: number[];
|
|
50
45
|
|
|
@@ -57,7 +52,10 @@ export class BlockPool {
|
|
|
57
52
|
* reach v1's gemma4 scope, but the pool stays shape-honest). */
|
|
58
53
|
vHeadDim?: number;
|
|
59
54
|
dtype: Dtype;
|
|
55
|
+
quantization?: PagedQuantization;
|
|
60
56
|
}, source?: BlockPool) {
|
|
57
|
+
this.headDim = opts.headDim; this.vHeadDim = opts.vHeadDim ?? opts.headDim;
|
|
58
|
+
this.dtype = opts.dtype; this.quantization = opts.quantization;
|
|
61
59
|
this.numBlocks = opts.numBlocks;
|
|
62
60
|
this.blockSize = opts.blockSize;
|
|
63
61
|
this.keys = source ? ops.contiguous(source.keys) : ops.zeros(
|
|
@@ -65,6 +63,16 @@ export class BlockPool {
|
|
|
65
63
|
this.values = source ? ops.contiguous(source.values) : ops.zeros(
|
|
66
64
|
[opts.numBlocks, opts.numKvHeads, opts.blockSize, opts.vHeadDim ?? opts.headDim],
|
|
67
65
|
opts.dtype);
|
|
66
|
+
if (source?.quantization) {
|
|
67
|
+
this.keyScales = ops.contiguous(source.keyScales!); this.keyBiases = ops.contiguous(source.keyBiases!);
|
|
68
|
+
this.valueScales = ops.contiguous(source.valueScales!); this.valueBiases = ops.contiguous(source.valueBiases!);
|
|
69
|
+
} else if (this.quantization) {
|
|
70
|
+
const { bits, groupSize } = this.quantization;
|
|
71
|
+
const k = ops.quantize(this.keys, groupSize, bits), v = ops.quantize(this.values, groupSize, bits);
|
|
72
|
+
this.keys.dispose(); this.values.dispose();
|
|
73
|
+
this.keys = k.packed; this.keyScales = k.scales; this.keyBiases = k.biases;
|
|
74
|
+
this.values = v.packed; this.valueScales = v.scales; this.valueBiases = v.biases;
|
|
75
|
+
}
|
|
68
76
|
// LIFO free list, low indices first — deterministic layout for tests.
|
|
69
77
|
this.#free = source ? [...source.#free] : Array.from({ length: opts.numBlocks }, (_, i) => opts.numBlocks - 1 - i);
|
|
70
78
|
}
|
|
@@ -73,8 +81,31 @@ export class BlockPool {
|
|
|
73
81
|
* Functional writes detach storage while another snapshot retains it. */
|
|
74
82
|
clone(): BlockPool {
|
|
75
83
|
return new BlockPool({ numBlocks: this.numBlocks, blockSize: this.blockSize,
|
|
76
|
-
numKvHeads: this.keys.shape[1]!, headDim: this.
|
|
77
|
-
vHeadDim: this.
|
|
84
|
+
numKvHeads: this.keys.shape[1]!, headDim: this.headDim,
|
|
85
|
+
vHeadDim: this.vHeadDim, dtype: this.dtype, quantization: this.quantization }, this);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
restoreArrays(arrays: MlxArray[], occupied: readonly number[]): void {
|
|
89
|
+
disposeResources(this.arrays());
|
|
90
|
+
[this.keys, this.values] = [arrays[0]!, arrays[1]!];
|
|
91
|
+
[this.keyScales, this.keyBiases, this.valueScales, this.valueBiases] = arrays.slice(2);
|
|
92
|
+
const used = new Set(occupied);
|
|
93
|
+
this.#free = Array.from({ length: this.numBlocks }, (_, i) => this.numBlocks - 1 - i).filter(i => !used.has(i));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
grow(blocks: number): void {
|
|
97
|
+
if (blocks <= this.numBlocks) return;
|
|
98
|
+
const grow = (array: MlxArray) => {
|
|
99
|
+
using zeros = ops.zeros([blocks - this.numBlocks, ...array.shape.slice(1)], array.dtype);
|
|
100
|
+
const next = ops.concatAxis([array, zeros], 0); array.dispose(); return next;
|
|
101
|
+
};
|
|
102
|
+
this.keys = grow(this.keys); this.values = grow(this.values);
|
|
103
|
+
if (this.quantization) {
|
|
104
|
+
this.keyScales = grow(this.keyScales!); this.keyBiases = grow(this.keyBiases!);
|
|
105
|
+
this.valueScales = grow(this.valueScales!); this.valueBiases = grow(this.valueBiases!);
|
|
106
|
+
}
|
|
107
|
+
for (let i = blocks - 1; i >= this.numBlocks; i--) this.#free.push(i);
|
|
108
|
+
this.numBlocks = blocks;
|
|
78
109
|
}
|
|
79
110
|
|
|
80
111
|
get freeBlocks(): number {
|
|
@@ -96,6 +127,20 @@ export class BlockPool {
|
|
|
96
127
|
* the pool tensors are single-referenced here, so mlx donates the
|
|
97
128
|
* buffer and the write is in-place in the steady state. */
|
|
98
129
|
writeBlock(physIdx: number, k: MlxArray, v: MlxArray, within: number): void {
|
|
130
|
+
if (this.quantization) {
|
|
131
|
+
const { bits, groupSize } = this.quantization;
|
|
132
|
+
const kq = ops.quantize(k, groupSize, bits), vq = ops.quantize(v, groupSize, bits);
|
|
133
|
+
const replace = (target: MlxArray, input: MlxArray): MlxArray => {
|
|
134
|
+
const result = ops.sliceUpdate(target, input, [physIdx, 0, within, 0],
|
|
135
|
+
[physIdx + 1, input.shape[1]!, within + input.shape[2]!, input.shape[3]!]);
|
|
136
|
+
target.dispose(); return result;
|
|
137
|
+
};
|
|
138
|
+
try {
|
|
139
|
+
this.keys = replace(this.keys, kq.packed); this.keyScales = replace(this.keyScales!, kq.scales); this.keyBiases = replace(this.keyBiases!, kq.biases);
|
|
140
|
+
this.values = replace(this.values, vq.packed); this.valueScales = replace(this.valueScales!, vq.scales); this.valueBiases = replace(this.valueBiases!, vq.biases);
|
|
141
|
+
} finally { disposeResources([kq.packed, kq.scales, kq.biases, vq.packed, vq.scales, vq.biases]); }
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
99
144
|
const l = k.shape[2]!;
|
|
100
145
|
const [, H, , kD] = this.keys.shape as [number, number, number, number];
|
|
101
146
|
const vD = this.values.shape[3]!;
|
|
@@ -122,13 +167,23 @@ export class BlockPool {
|
|
|
122
167
|
t.dispose();
|
|
123
168
|
return out;
|
|
124
169
|
};
|
|
125
|
-
const
|
|
126
|
-
|
|
170
|
+
const read = (data: MlxArray, scales?: MlxArray, biases?: MlxArray) => {
|
|
171
|
+
const packed = pick(data);
|
|
172
|
+
if (!this.quantization) return packed;
|
|
173
|
+
using owned = packed; using s = pick(scales!), b = pick(biases!);
|
|
174
|
+
return ops.dequantize(owned, s, b, { ...this.quantization, mode: "affine" });
|
|
175
|
+
};
|
|
176
|
+
const k = read(this.keys, this.keyScales, this.keyBiases);
|
|
177
|
+
const v = read(this.values, this.valueScales, this.valueBiases);
|
|
127
178
|
idx.dispose();
|
|
128
179
|
return [k, v];
|
|
129
180
|
}
|
|
130
181
|
|
|
182
|
+
arrays(): MlxArray[] {
|
|
183
|
+
return [this.keys, this.values, this.keyScales, this.keyBiases, this.valueScales, this.valueBiases].filter((a): a is MlxArray => !!a);
|
|
184
|
+
}
|
|
131
185
|
dispose(): void {
|
|
186
|
+
disposeResources([this.keyScales, this.keyBiases, this.valueScales, this.valueBiases].filter((a): a is MlxArray => !!a));
|
|
132
187
|
this.keys.dispose();
|
|
133
188
|
this.values.dispose();
|
|
134
189
|
this.#free = [];
|
|
@@ -159,10 +214,12 @@ export class PagedKVCache implements Cache {
|
|
|
159
214
|
/** Tokens this cache must be able to hold (prompt + maxTokens). */
|
|
160
215
|
readonly capacityTokens: number,
|
|
161
216
|
readonly blockSize: number,
|
|
217
|
+
readonly direct = runtimeFlag("MLX_BUN_PAGED_ATTN", false),
|
|
218
|
+
readonly quantization?: PagedQuantization,
|
|
162
219
|
) {}
|
|
163
220
|
|
|
164
221
|
clone(): PagedKVCache {
|
|
165
|
-
const copy = new PagedKVCache(this.capacityTokens, this.blockSize);
|
|
222
|
+
const copy = new PagedKVCache(this.capacityTokens, this.blockSize, this.direct, this.quantization);
|
|
166
223
|
copy.offset = this.offset;
|
|
167
224
|
copy.blockTable = [...this.blockTable];
|
|
168
225
|
copy.pool = this.pool?.clone() ?? null;
|
|
@@ -178,7 +235,23 @@ export class PagedKVCache implements Cache {
|
|
|
178
235
|
return Math.ceil(n / this.#blockSize);
|
|
179
236
|
}
|
|
180
237
|
|
|
238
|
+
get attentionState(): this | undefined { return this.direct || this.quantization ? this : undefined; }
|
|
239
|
+
appendAndFetch(k: MlxArray, v: MlxArray): KvAttentionView {
|
|
240
|
+
this.append(k, v);
|
|
241
|
+
return pagedAttentionView(this.pool!, [...this.blockTable], this.offset, this.direct);
|
|
242
|
+
}
|
|
243
|
+
|
|
181
244
|
updateAndFetch(k: MlxArray, v: MlxArray): [MlxArray, MlxArray] {
|
|
245
|
+
this.append(k, v);
|
|
246
|
+
const H = this.pool!.keys.shape[1]!, kD = this.pool!.headDim, vD = this.pool!.vHeadDim;
|
|
247
|
+
const [gk, gv] = this.pool!.gather(this.blockTable);
|
|
248
|
+
const keys = gk.slice([0, 0, 0, 0], [1, H, this.offset, kD]);
|
|
249
|
+
const values = gv.slice([0, 0, 0, 0], [1, H, this.offset, vD]);
|
|
250
|
+
gk.dispose(); gv.dispose();
|
|
251
|
+
return [keys, values];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
append(k: MlxArray, v: MlxArray): void {
|
|
182
255
|
const L = k.shape[2]!;
|
|
183
256
|
const [, H, , kD] = k.shape as [number, number, number, number];
|
|
184
257
|
const vD = v.shape[3]!;
|
|
@@ -189,7 +262,7 @@ export class PagedKVCache implements Cache {
|
|
|
189
262
|
numKvHeads: H,
|
|
190
263
|
headDim: kD,
|
|
191
264
|
vHeadDim: vD,
|
|
192
|
-
dtype: k.dtype,
|
|
265
|
+
dtype: k.dtype, quantization: this.quantization,
|
|
193
266
|
});
|
|
194
267
|
// Write, splitting the incoming L along block boundaries. Blocks past
|
|
195
268
|
// the current tail allocate from the free list as they're reached.
|
|
@@ -198,7 +271,10 @@ export class PagedKVCache implements Cache {
|
|
|
198
271
|
const pos = this.offset + written;
|
|
199
272
|
const bi = Math.floor(pos / this.#blockSize);
|
|
200
273
|
const within = pos % this.#blockSize;
|
|
201
|
-
while (this.blockTable.length <= bi)
|
|
274
|
+
while (this.blockTable.length <= bi) {
|
|
275
|
+
if (!this.pool.freeBlocks) this.pool.grow(Math.max(this.pool.numBlocks + 1, Math.ceil(this.pool.numBlocks * 1.5)));
|
|
276
|
+
this.blockTable.push(this.pool.alloc());
|
|
277
|
+
}
|
|
202
278
|
const l = Math.min(this.#blockSize - within, L - written);
|
|
203
279
|
// Full-range pieces skip the slice (fresh-view slice would be a
|
|
204
280
|
// gratuitous op); partial pieces slice the [written, written+l) rows.
|
|
@@ -215,14 +291,6 @@ export class PagedKVCache implements Cache {
|
|
|
215
291
|
}
|
|
216
292
|
this.offset += L;
|
|
217
293
|
|
|
218
|
-
// Fetch: gather occupied blocks contiguous, slice to the live prefix.
|
|
219
|
-
const nb = this.#blocksFor(this.offset);
|
|
220
|
-
const [gk, gv] = this.pool.gather(this.blockTable.slice(0, nb));
|
|
221
|
-
const keys = gk.slice([0, 0, 0, 0], [1, H, this.offset, kD]);
|
|
222
|
-
const values = gv.slice([0, 0, 0, 0], [1, H, this.offset, vD]);
|
|
223
|
-
gk.dispose();
|
|
224
|
-
gv.dispose();
|
|
225
|
-
return [keys, values];
|
|
226
294
|
}
|
|
227
295
|
|
|
228
296
|
/** Same mask policy as KVCache.makeMask (it reads only offset). */
|
|
@@ -233,11 +301,9 @@ export class PagedKVCache implements Cache {
|
|
|
233
301
|
return { mode: "array", arr: createCausalMask(N, this.offset, windowSize) };
|
|
234
302
|
}
|
|
235
303
|
|
|
236
|
-
/**
|
|
237
|
-
* NOT prompt-cache-compatible in v1 (cloneKvCaches never sees paged
|
|
238
|
-
* caches; the serve lane bypasses take/put for paged requests). */
|
|
304
|
+
/** Owned planes materialized at prefill and immutable publication boundaries. */
|
|
239
305
|
state(): MlxArray[] {
|
|
240
|
-
return this.pool ?
|
|
306
|
+
return this.pool ? this.pool.arrays() : [];
|
|
241
307
|
}
|
|
242
308
|
|
|
243
309
|
isTrimmable(): boolean {
|
package/src/md-modules.d.ts
CHANGED
|
@@ -6,6 +6,11 @@ declare module "*.md" {
|
|
|
6
6
|
export default content;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
declare module "*.worker.js" {
|
|
10
|
+
const content: string;
|
|
11
|
+
export default content;
|
|
12
|
+
}
|
|
13
|
+
|
|
9
14
|
// Same deal for vendored text-imported assets under src/web/vendor/ (see
|
|
10
15
|
// that dir's README) — served as raw strings via `with { type: "text" }`,
|
|
11
16
|
// but bun-types has no ambient declaration for .js/.css read as text.
|
package/src/mlx/array.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Explicit .dispose() is the contract; a FinalizationRegistry backstop
|
|
3
3
|
// frees leaked handles on GC (verified in lab/spikes/phase0-memory.ts).
|
|
4
4
|
|
|
5
|
+
import { hostBufferDestructor, type HostBuffer } from "../storage/host-buffer";
|
|
5
6
|
import { dlopen, ptr, toArrayBuffer } from "bun:ffi";
|
|
6
7
|
import { C, Dtype, DTYPE_NAMES, type MlxHandle, optInt, outArray, takeMlxError } from "./ffi";
|
|
7
8
|
import type { SafetensorsDtype } from "../safetensors";
|
|
@@ -130,6 +131,18 @@ export class MlxArray {
|
|
|
130
131
|
return new MlxArray(handle);
|
|
131
132
|
}
|
|
132
133
|
|
|
134
|
+
/** Adopt a page-aligned CPU restore allocation without a host copy.
|
|
135
|
+
* The native destructor owns it after this call, including GPU references. */
|
|
136
|
+
static adoptHostBuffer(buffer: HostBuffer, shape: number[], dtype: Dtype): MlxArray {
|
|
137
|
+
const sb = shapeBuf(shape);
|
|
138
|
+
const handle = C.mlx_array_new_data_managed_payload(
|
|
139
|
+
buffer.pointer, ptr(sb), shape.length, dtype, buffer.pointer, hostBufferDestructor,
|
|
140
|
+
);
|
|
141
|
+
const array = new MlxArray(handle);
|
|
142
|
+
buffer.transfer();
|
|
143
|
+
return array;
|
|
144
|
+
}
|
|
145
|
+
|
|
133
146
|
/** Copying constructor for small host data. */
|
|
134
147
|
static fromFloat32(data: Float32Array, shape: number[]): MlxArray {
|
|
135
148
|
const sb = shapeBuf(shape);
|
|
@@ -237,6 +250,30 @@ export class MlxArray {
|
|
|
237
250
|
return this.rawBytesView().slice();
|
|
238
251
|
}
|
|
239
252
|
|
|
253
|
+
/** Publish an evaluated immutable buffer to a CPU reader. Strides are in
|
|
254
|
+
* elements, so padding, transposes and slices need no GPU packing copy.
|
|
255
|
+
* The caller retains this array until the reader reports completion. */
|
|
256
|
+
storageView(): import("../storage/kv-writer").StoredTensorView {
|
|
257
|
+
this.eval();
|
|
258
|
+
const shape = this.shape;
|
|
259
|
+
const stridePtr = C.mlx_array_strides(this.handle);
|
|
260
|
+
const strides = shape.length
|
|
261
|
+
? [...new BigInt64Array(toArrayBuffer(stridePtr!, 0, shape.length * 8))].map(Number)
|
|
262
|
+
: [];
|
|
263
|
+
const dt = this.dtype;
|
|
264
|
+
const pointer =
|
|
265
|
+
dt === Dtype.float32 ? C.mlx_array_data_float32(this.handle)
|
|
266
|
+
: dt === Dtype.float16 ? C.mlx_array_data_float16(this.handle)
|
|
267
|
+
: dt === Dtype.bfloat16 ? C.mlx_array_data_bfloat16(this.handle)
|
|
268
|
+
: dt === Dtype.uint32 ? C.mlx_array_data_uint32(this.handle)
|
|
269
|
+
: dt === Dtype.int32 ? C.mlx_array_data_int32(this.handle)
|
|
270
|
+
: dt === Dtype.uint8 ? C.mlx_array_data_uint8(this.handle)
|
|
271
|
+
: dt === Dtype.int8 ? C.mlx_array_data_int8(this.handle)
|
|
272
|
+
: null;
|
|
273
|
+
if (pointer === null && this.size) throw new Error(`storageView: unsupported or unavailable ${this.dtypeName}`);
|
|
274
|
+
return { pointer: Number(pointer), shape, strides, itemSize: Number(C.mlx_array_itemsize(this.handle)) };
|
|
275
|
+
}
|
|
276
|
+
|
|
240
277
|
/** ZERO-COPY view of the evaluated array's bytes — aliases the mlx
|
|
241
278
|
* buffer directly, no JS-heap copy. Valid only while THIS array is
|
|
242
279
|
* alive and unmutated: do not retain past dispose(). Built for
|
package/src/mlx/ffi.ts
CHANGED
|
@@ -53,6 +53,8 @@ export const C = dlopen(LIBMLXC_PATH, {
|
|
|
53
53
|
// array metadata
|
|
54
54
|
mlx_array_ndim: { args: [u64], returns: u64 },
|
|
55
55
|
mlx_array_shape: { args: [u64], returns: P },
|
|
56
|
+
mlx_array_strides: { args: [u64], returns: P },
|
|
57
|
+
mlx_array_itemsize: { args: [u64], returns: u64 },
|
|
56
58
|
mlx_array_dim: { args: [u64, i32], returns: i32 },
|
|
57
59
|
mlx_array_dtype: { args: [u64], returns: i32 },
|
|
58
60
|
mlx_array_size: { args: [u64], returns: u64 },
|
|
@@ -181,6 +183,23 @@ export const C = dlopen(LIBMLXC_PATH, {
|
|
|
181
183
|
// (res, input, weight, stride, padding, dilation, groups, stream) — depthwise
|
|
182
184
|
// causal conv1d for Qwen3.5 gated-DeltaNet (groups == channels, padding 0).
|
|
183
185
|
mlx_conv1d: { args: [P, u64, u64, i32, i32, i32, i32, u64], returns: i32 },
|
|
186
|
+
// --- Whisper (src/model/whisper.ts, src/audio/whisper-mel.ts) -----------
|
|
187
|
+
// (res, a, n, axis, norm, stream) — mlx_fft_norm enum: 0 backward (numpy default).
|
|
188
|
+
mlx_fft_rfft: { args: [P, u64, i32, i32, i32, u64], returns: i32 },
|
|
189
|
+
// (res, a, shape*, shape_num, strides*(int64), strides_num, offset, stream)
|
|
190
|
+
mlx_as_strided: { args: [P, u64, P, u64, P, u64, u64, u64], returns: i32 },
|
|
191
|
+
// (res, a, axes*, n, low*, n, high*, n, pad_value, mode, stream)
|
|
192
|
+
mlx_pad: { args: [P, u64, P, u64, P, u64, P, u64, u64, cstring, u64], returns: i32 },
|
|
193
|
+
mlx_log10: { args: [P, u64, u64], returns: i32 },
|
|
194
|
+
// full reduce: (res, a, keepdims, stream)
|
|
195
|
+
mlx_max: { args: [P, u64, FFIType.bool, u64], returns: i32 },
|
|
196
|
+
mlx_broadcast_to: { args: [P, u64, P, u64, u64], returns: i32 },
|
|
197
|
+
mlx_topk_axis: { args: [P, u64, i32, i32, u64], returns: i32 },
|
|
198
|
+
mlx_var_axis: { args: [P, u64, i32, FFIType.bool, i32, u64], returns: i32 },
|
|
199
|
+
mlx_stack_axis: { args: [P, u64, i32, u64], returns: i32 },
|
|
200
|
+
mlx_minimum: { args: [P, u64, u64, u64], returns: i32 },
|
|
201
|
+
// flat take: (res, a, indices, stream)
|
|
202
|
+
mlx_take: { args: [P, u64, u64, u64], returns: i32 },
|
|
184
203
|
// Natural mlx-c signatures. Bun 1.4 fixed the macOS arm64 stack packing
|
|
185
204
|
// bug recorded in lab/repro/bun-ffi-stack-args, so these must match the
|
|
186
205
|
// header exactly. mlx-bun now requires Bun 1.4 or newer.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Greedy selection preserves dtype-rounded normalized scores, including ties.
|
|
2
|
+
// Native logsumexp retains the reference reduction. Tiled selection avoids
|
|
3
|
+
// writing the vocabulary-sized normalized array. Inputs remain borrowed.
|
|
4
|
+
import { MlxArray } from "./array";
|
|
5
|
+
import { Dtype } from "./ffi";
|
|
6
|
+
import * as ops from "./ops";
|
|
7
|
+
import { CompiledFunction, isShapelessTracing } from "./compile";
|
|
8
|
+
import { MetalKernel } from "./metal-kernel";
|
|
9
|
+
|
|
10
|
+
const header = `
|
|
11
|
+
inline bool better(float x, uint xi, float y, uint yi) {
|
|
12
|
+
return x > y || (x == y && xi < yi);
|
|
13
|
+
}`;
|
|
14
|
+
|
|
15
|
+
// Four simdgroups reduce score/index pairs. Lowest token ID wins every tie.
|
|
16
|
+
// The caller finishes the simd==0 block by writing its output from lane zero.
|
|
17
|
+
const reduction = `
|
|
18
|
+
for (uint distance = 16; distance > 0; distance >>= 1) {
|
|
19
|
+
float other = simd_shuffle_down(best, distance);
|
|
20
|
+
uint oi = simd_shuffle_down(bestId, distance);
|
|
21
|
+
if (lane + distance < 32 && better(other, oi, best, bestId)) {
|
|
22
|
+
best = other; bestId = oi;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
threadgroup float scores[4];
|
|
26
|
+
threadgroup uint ids[4];
|
|
27
|
+
if (lane == 0) { scores[simd] = best; ids[simd] = bestId; }
|
|
28
|
+
threadgroup_barrier(mem_flags::mem_threadgroup);
|
|
29
|
+
if (simd == 0) {
|
|
30
|
+
best = lane < 4 ? scores[lane] : -INFINITY;
|
|
31
|
+
bestId = lane < 4 ? ids[lane] : 0xffffffffu;
|
|
32
|
+
for (uint distance = 2; distance > 0; distance >>= 1) {
|
|
33
|
+
float other = simd_shuffle_down(best, distance);
|
|
34
|
+
uint oi = simd_shuffle_down(bestId, distance);
|
|
35
|
+
if (lane + distance < 4 && better(other, oi, best, bestId)) {
|
|
36
|
+
best = other; bestId = oi;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
const partialSource = `
|
|
42
|
+
const uint tid = thread_position_in_threadgroup.x;
|
|
43
|
+
const uint lane = tid % 32, simd = tid / 32;
|
|
44
|
+
const uint row = threadgroup_position_in_grid.y;
|
|
45
|
+
const uint tile = threadgroup_position_in_grid.x;
|
|
46
|
+
float best = -INFINITY;
|
|
47
|
+
uint bestId = 0xffffffffu;
|
|
48
|
+
for (uint i = tile * 1024 + tid; i < min(uint(V), (tile + 1) * 1024); i += 128) {
|
|
49
|
+
float value = float(T(logits[row * V + i] - lse[row]));
|
|
50
|
+
// A nonfinite logsumexp produces only NaN/-inf normalized scores.
|
|
51
|
+
// Native argmax selects token zero for this degenerate row.
|
|
52
|
+
value = isnan(value) ? -INFINITY : value;
|
|
53
|
+
if (better(value, i, best, bestId)) { best = value; bestId = i; }
|
|
54
|
+
}
|
|
55
|
+
${reduction}
|
|
56
|
+
if (lane == 0) { values[row * P + tile] = best; indices[row * P + tile] = bestId; }
|
|
57
|
+
}
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
const finishSource = `
|
|
61
|
+
const uint tid = thread_position_in_threadgroup.x;
|
|
62
|
+
const uint lane = tid % 32, simd = tid / 32;
|
|
63
|
+
const uint row = threadgroup_position_in_grid.y;
|
|
64
|
+
float best = -INFINITY;
|
|
65
|
+
uint bestId = 0xffffffffu;
|
|
66
|
+
for (uint i = tid; i < P; i += 128) {
|
|
67
|
+
float value = values[row * P + i];
|
|
68
|
+
uint id = indices[row * P + i];
|
|
69
|
+
if (better(value, id, best, bestId)) { best = value; bestId = id; }
|
|
70
|
+
}
|
|
71
|
+
${reduction}
|
|
72
|
+
if (lane == 0) out[row] = bestId;
|
|
73
|
+
}
|
|
74
|
+
`;
|
|
75
|
+
|
|
76
|
+
let partial: MetalKernel | undefined;
|
|
77
|
+
let finish: MetalKernel | undefined;
|
|
78
|
+
let compiled: CompiledFunction | undefined;
|
|
79
|
+
|
|
80
|
+
function kernelGraph(logits: MlxArray): MlxArray {
|
|
81
|
+
const shape = logits.shape;
|
|
82
|
+
const vocab = shape.at(-1)!, rows = logits.size / vocab, tiles = Math.ceil(vocab / 1024);
|
|
83
|
+
partial ??= new MetalKernel({ name: "normalized_argmax_partial", header,
|
|
84
|
+
inputNames: ["logits", "lse"], outputNames: ["values", "indices"], source: partialSource });
|
|
85
|
+
finish ??= new MetalKernel({ name: "normalized_argmax_finish", header,
|
|
86
|
+
inputNames: ["values", "indices"], outputNames: ["out"], source: finishSource });
|
|
87
|
+
using lse = ops.logsumexpAxis(logits, -1, true);
|
|
88
|
+
const [values, indices] = partial.apply([logits, lse], {
|
|
89
|
+
outputs: [{ shape: [rows, tiles], dtype: Dtype.float32 }, { shape: [rows, tiles], dtype: Dtype.uint32 }],
|
|
90
|
+
grid: [tiles * 128, rows, 1], threadGroup: [128, 1, 1],
|
|
91
|
+
templateInts: { V: vocab, P: tiles }, templateDtypes: { T: logits.dtype },
|
|
92
|
+
});
|
|
93
|
+
try {
|
|
94
|
+
using tokens = finish.apply([values!, indices!], {
|
|
95
|
+
outputs: [{ shape: [rows], dtype: Dtype.uint32 }],
|
|
96
|
+
grid: [128, rows, 1], threadGroup: [128, 1, 1], templateInts: { P: tiles },
|
|
97
|
+
})[0]!;
|
|
98
|
+
return ops.reshape(tokens, shape.slice(0, -1));
|
|
99
|
+
} finally { values!.dispose(); indices!.dispose(); }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Return owned greedy IDs with the same shape/rounding as argmax(logprobs).
|
|
103
|
+
* Fixed-shape compilation caches graph/FFI work for repeated row geometries.
|
|
104
|
+
* Shapeless enclosing graphs retain native operations, which support symbolic
|
|
105
|
+
* sizes; integer/empty inputs also retain the original operation semantics. */
|
|
106
|
+
export function normalizedArgmax(logits: MlxArray): MlxArray {
|
|
107
|
+
const dtype = logits.dtype;
|
|
108
|
+
if (isShapelessTracing() || logits.size === 0 ||
|
|
109
|
+
(dtype !== Dtype.float32 && dtype !== Dtype.float16 && dtype !== Dtype.bfloat16)) {
|
|
110
|
+
using lse = ops.logsumexpAxis(logits, -1, true);
|
|
111
|
+
using scores = ops.sub(logits, lse);
|
|
112
|
+
return ops.argmaxAxis(scores, -1);
|
|
113
|
+
}
|
|
114
|
+
compiled ??= new CompiledFunction(([input]) => [kernelGraph(input!)], false);
|
|
115
|
+
return compiled.apply([logits])[0]!;
|
|
116
|
+
}
|