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.
Files changed (158) hide show
  1. package/README.md +57 -32
  2. package/docs/README.md +55 -42
  3. package/docs/archive/investigations/benchmark-ledger-through-v0.4.0.md +3617 -0
  4. package/docs/archive/planning/release-notes-v0.5.0.md +37 -0
  5. package/docs/design/batching.md +220 -7
  6. package/docs/design/decode-speed-program.md +121 -66
  7. package/docs/design/generic-model-support.md +116 -0
  8. package/docs/design/kv-cache.md +380 -211
  9. package/docs/design/speculative-decoding.md +165 -26
  10. package/docs/design/turboquant.md +20 -7
  11. package/docs/design/unified-engine-frontier-plan.md +120 -13
  12. package/docs/planning/PRODUCT_ROADMAP.md +5 -6
  13. package/docs/planning/public-readiness-handoff.md +244 -0
  14. package/docs/planning/public-readiness.md +149 -0
  15. package/docs/reference/benchmarks.md +3463 -3489
  16. package/docs/reference/cli.md +62 -1
  17. package/docs/reference/distribution.md +29 -4
  18. package/docs/reference/environment.md +104 -8
  19. package/docs/reference/library-api.md +18 -4
  20. package/docs/reference/memory.md +1 -1
  21. package/docs/reference/models.md +17 -5
  22. package/docs/reference/server-api.md +181 -11
  23. package/docs/reference/server-config.md +232 -105
  24. package/package.json +2 -2
  25. package/src/audio/audiotoolbox.ts +112 -0
  26. package/src/audio/conformer.ts +8 -0
  27. package/src/audio/mic-capture.ts +98 -0
  28. package/src/audio/silero-vad.ts +379 -0
  29. package/src/audio/whisper-decode.ts +774 -0
  30. package/src/audio/whisper-fast.ts +732 -0
  31. package/src/audio/whisper-format.ts +73 -0
  32. package/src/audio/whisper-mel.ts +185 -0
  33. package/src/audio/whisper-timing.ts +274 -0
  34. package/src/audio/whisper-tokenizer.ts +239 -0
  35. package/src/audio/whisper-transcribe.ts +410 -0
  36. package/src/backends/mlx/autoregressive.ts +22 -1
  37. package/src/backends/mlx/batch-group.ts +111 -27
  38. package/src/backends/mlx/cache-layout.ts +3 -3
  39. package/src/backends/mlx/continuation-persistence.ts +5 -4
  40. package/src/backends/mlx/encoder-cache.ts +25 -0
  41. package/src/backends/mlx/fill-append.ts +21 -8
  42. package/src/backends/mlx/fill-group.ts +497 -0
  43. package/src/backends/mlx/gateway-binding.ts +56 -12
  44. package/src/backends/mlx/kv-maintenance.ts +30 -2
  45. package/src/backends/mlx/mixed-iteration.ts +62 -0
  46. package/src/backends/mlx/model-prompt.ts +46 -32
  47. package/src/backends/mlx/model-serving.ts +4 -3
  48. package/src/backends/mlx/paged-cache-codec.ts +27 -0
  49. package/src/backends/mlx/prefill-cohort.ts +15 -4
  50. package/src/backends/mlx/prefill-policy.ts +26 -0
  51. package/src/backends/mlx/prefill-rows.ts +57 -13
  52. package/src/backends/mlx/prompt-input.ts +29 -0
  53. package/src/backends/mlx/qwen-prompt-input.ts +40 -0
  54. package/src/backends/mlx/request-state-policy.ts +18 -5
  55. package/src/backends/mlx/rollback.ts +6 -3
  56. package/src/backends/mlx/serial-executor.ts +16 -7
  57. package/src/backends/mlx/speculative-group.ts +105 -26
  58. package/src/backends/mlx/speculative.ts +24 -7
  59. package/src/backends/mlx/target-layout-capability.ts +1 -1
  60. package/src/backends/mlx/transitioning-kv-rows.ts +8 -0
  61. package/src/cli.ts +322 -0
  62. package/src/contracts/execution.ts +10 -0
  63. package/src/contracts/object-cache.ts +8 -0
  64. package/src/contracts/pi-provider.ts +1 -1
  65. package/src/contracts/pi.ts +3 -1
  66. package/src/contracts/prefix-cache.ts +13 -3
  67. package/src/contracts/scheduling.ts +9 -0
  68. package/src/engine/execution-plan.ts +23 -12
  69. package/src/engine/execution-tasks.ts +44 -0
  70. package/src/engine/namespaced-cache.ts +13 -0
  71. package/src/engine/scheduler.ts +22 -2
  72. package/src/fill/fill-session.ts +14 -3
  73. package/src/generate.ts +28 -22
  74. package/src/grammar.ts +13 -0
  75. package/src/inference/prefill.ts +6 -0
  76. package/src/kv-scheme.ts +22 -0
  77. package/src/kv-store.ts +115 -79
  78. package/src/lab/README.md +5 -1
  79. package/src/lab/paged-kv/paged-attention.ts +117 -0
  80. package/src/lab/paged-kv/paged-kv-rows.ts +33 -5
  81. package/src/lab/paged-kv/paged-kv.ts +108 -42
  82. package/src/md-modules.d.ts +5 -0
  83. package/src/mlx/array.ts +37 -0
  84. package/src/mlx/ffi.ts +19 -0
  85. package/src/mlx/normalized-argmax.ts +116 -0
  86. package/src/mlx/ops.ts +97 -10
  87. package/src/model/batched-turboquant-kv.ts +6 -5
  88. package/src/model/delayed-quantized-kv.ts +3 -3
  89. package/src/model/delayed-rotating-quantized-kv.ts +1 -1
  90. package/src/model/delayed-turboquant-kv.ts +6 -4
  91. package/src/model/factory.ts +26 -0
  92. package/src/model/fingerprint.ts +12 -0
  93. package/src/model/full-prefill-row.ts +1 -1
  94. package/src/model/gemma4-base.ts +25 -6
  95. package/src/model/gemma4.ts +82 -2
  96. package/src/model/kv-attention-view.ts +4 -1
  97. package/src/model/profile.ts +29 -4
  98. package/src/model/quantized-append-attention.ts +31 -0
  99. package/src/model/qwen3-mrope.ts +7 -9
  100. package/src/model/qwen3_5.ts +99 -37
  101. package/src/model/support.ts +8 -0
  102. package/src/model/token-groups.ts +66 -0
  103. package/src/model/trellis-linear.ts +2 -2
  104. package/src/model/turboquant-codec.ts +6 -0
  105. package/src/model/whisper.ts +435 -0
  106. package/src/native/mic_capture.swift +92 -0
  107. package/src/native-pack.ts +6 -5
  108. package/src/pi-web.ts +9 -1
  109. package/src/prompt-cache.ts +265 -43
  110. package/src/runtime-config.ts +1 -5
  111. package/src/safetensors.ts +9 -2
  112. package/src/sampler.ts +5 -4
  113. package/src/serve/audio-routes.ts +323 -0
  114. package/src/serve/chat-request.ts +2 -0
  115. package/src/serve/chat-stage.ts +17 -12
  116. package/src/serve/completion-executor.ts +9 -1
  117. package/src/serve/discovery-routes.ts +17 -1
  118. package/src/serve/generated-token-history.ts +57 -0
  119. package/src/serve/generation-gateway.ts +52 -11
  120. package/src/serve/model-binding.ts +7 -1
  121. package/src/serve/preparation.ts +3 -3
  122. package/src/serve/prompt-response-trace.ts +14 -0
  123. package/src/serve/request-plan.ts +2 -0
  124. package/src/serve/request-prep.ts +38 -21
  125. package/src/serve/transcription-server.ts +64 -0
  126. package/src/serve/transcription-service.ts +502 -0
  127. package/src/server.ts +132 -164
  128. package/src/spec/assistant-rows.ts +1 -0
  129. package/src/spec/deepspec-rows.ts +1 -1
  130. package/src/spec/dflash-source.ts +1 -1
  131. package/src/spec/glm52-mtp-source.ts +1 -0
  132. package/src/spec/ngram-source.ts +27 -5
  133. package/src/spec/projected-draft-rows.ts +2 -2
  134. package/src/spec/qwen-mtp-rows.ts +11 -0
  135. package/src/spec/qwen-mtp-source.ts +3 -0
  136. package/src/spec/serve-loop.ts +5 -3
  137. package/src/spec/source.ts +14 -1
  138. package/src/spec/two-model-rows.ts +1 -0
  139. package/src/spec/two-model.ts +2 -2
  140. package/src/ssd-cache.ts +76 -17
  141. package/src/ssd-durability.ts +20 -22
  142. package/src/storage/host-buffer.ts +24 -0
  143. package/src/storage/kv-writer.ts +83 -0
  144. package/src/storage/kv-writer.worker.js +264 -0
  145. package/src/storage/retention-policy.ts +37 -0
  146. package/src/tiered-prompt-cache.ts +45 -0
  147. package/src/vision/embedder.ts +8 -0
  148. package/src/vision/prompt.ts +101 -65
  149. package/src/vision/qwen3vl-prompt.ts +101 -64
  150. package/src/vision/qwen3vl-tower.ts +11 -0
  151. package/src/vision/siglip.ts +8 -0
  152. package/src/web/app.html +12 -0
  153. package/src/web/app.js +160 -0
  154. package/src/web/src/chat.ts +4 -1
  155. package/src/web/src/composer.ts +3 -0
  156. package/src/web/src/voice.ts +141 -0
  157. package/src/web/sw.js +1 -1
  158. /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 = (pool.keys.nbytes + pool.values.nbytes) / pool.numBlocks;
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 cache — vLLM-style block-pool storage behind the standard Cache
2
- // interface (docs/design/kv-cache.md). OPTIONAL and default-off
3
- // (`--paged-kv` / GenerateOptions.pagedKv); Gemma4-family plain full-attention
4
- // layers, bf16 only. PagedKvRows composes independent pools at B1/B>1.
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
- readonly numBlocks: number;
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.keys.shape[3]!,
77
- vHeadDim: this.values.shape[3]!, dtype: this.keys.dtype }, 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 k = pick(this.keys);
126
- const v = pick(this.values);
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) this.blockTable.push(this.pool.alloc());
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
- /** Pool tensors — what a prefill-chunk boundary must materialize.
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 ? [this.pool.keys, this.pool.values] : [];
306
+ return this.pool ? this.pool.arrays() : [];
241
307
  }
242
308
 
243
309
  isTrimmable(): boolean {
@@ -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
+ }