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.
Files changed (210) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +362 -0
  3. package/THIRD_PARTY_LICENSES.md +41 -0
  4. package/bin/mlx-bun.mjs +32 -0
  5. package/docs/curve-designer.html +303 -0
  6. package/docs/design/adapters-end-to-end.md +201 -0
  7. package/docs/design/compat-cli-surface-design.md +227 -0
  8. package/docs/design/docs-reorg-plan.md +152 -0
  9. package/docs/design/hlg-sampling.md +340 -0
  10. package/docs/design/memory-system.md +339 -0
  11. package/docs/design/optimization_plan.md +344 -0
  12. package/docs/design/parallel-slots.md +515 -0
  13. package/docs/design/runtime-isolation.md +247 -0
  14. package/docs/design/segmented-backward-training.md +508 -0
  15. package/docs/investigations/chunk-finetune-experiment.md +84 -0
  16. package/docs/investigations/curve-bisector-routing.md +123 -0
  17. package/docs/investigations/curve-runs/bisector-route.json +98 -0
  18. package/docs/investigations/curve-runs/bisector-trace.json +1393 -0
  19. package/docs/investigations/curve-runs/curve-terrain.json +1179 -0
  20. package/docs/investigations/curve-runs/curve-terrain.svg +42 -0
  21. package/docs/investigations/curve-terrain.html +105 -0
  22. package/docs/investigations/expert-offload-single-user-moe.md +292 -0
  23. package/docs/investigations/hlg-ab.html +34 -0
  24. package/docs/investigations/hlg-explorer.html +238 -0
  25. package/docs/investigations/hlg-figs/cliff.svg +1 -0
  26. package/docs/investigations/hlg-figs/cross.svg +1 -0
  27. package/docs/investigations/hlg-figs/edges.svg +1 -0
  28. package/docs/investigations/hlg-figs/k20-grids.svg +1 -0
  29. package/docs/investigations/hlg-figs/k20-peaks.svg +1 -0
  30. package/docs/investigations/hlg-figs/lw-curve.svg +1 -0
  31. package/docs/investigations/hlg-figs/terrain.svg +1 -0
  32. package/docs/investigations/hlg-report.html +262 -0
  33. package/docs/investigations/hlg-runs/hlg-ab.json +149 -0
  34. package/docs/investigations/hlg-runs/hlg-blind-prompts.md +87 -0
  35. package/docs/investigations/hlg-runs/hlg-blind.json +107 -0
  36. package/docs/investigations/hlg-runs/hlg-cliff.json +197 -0
  37. package/docs/investigations/hlg-runs/hlg-cross.json +505 -0
  38. package/docs/investigations/hlg-runs/hlg-edge.json +388 -0
  39. package/docs/investigations/hlg-runs/hlg-eval1.json +25 -0
  40. package/docs/investigations/hlg-runs/hlg-frontier.json +86 -0
  41. package/docs/investigations/hlg-runs/hlg-grid.json +242 -0
  42. package/docs/investigations/hlg-runs/hlg-grid2.json +343 -0
  43. package/docs/investigations/hlg-runs/hlg-lw-coarse.json +110 -0
  44. package/docs/investigations/hlg-runs/hlg-map.json +383 -0
  45. package/docs/investigations/hlg-runs/run0-original.log +159 -0
  46. package/docs/investigations/hlg-runs/runA-recommended.log +131 -0
  47. package/docs/investigations/hlg-runs/runAA-cliff-cpm5.log +26 -0
  48. package/docs/investigations/hlg-runs/runAB-grid2-e4b.log +18 -0
  49. package/docs/investigations/hlg-runs/runAC-peak-e4b-partial.log +25 -0
  50. package/docs/investigations/hlg-runs/runAD-ab-samples.log +8 -0
  51. package/docs/investigations/hlg-runs/runAE-edge-e4b.log +51 -0
  52. package/docs/investigations/hlg-runs/runAF-logit-calibration.log +10 -0
  53. package/docs/investigations/hlg-runs/runAG-eval-config2.log +16 -0
  54. package/docs/investigations/hlg-runs/runB-gain-variants.log +192 -0
  55. package/docs/investigations/hlg-runs/runC-pivots.log +162 -0
  56. package/docs/investigations/hlg-runs/runD-oetf.log +132 -0
  57. package/docs/investigations/hlg-runs/runE-eotf.log +162 -0
  58. package/docs/investigations/hlg-runs/runF-shaper-working.log +86 -0
  59. package/docs/investigations/hlg-runs/runG-shaper-sweep.log +101 -0
  60. package/docs/investigations/hlg-runs/runH-wsweep.log +176 -0
  61. package/docs/investigations/hlg-runs/runI-asweep.log +176 -0
  62. package/docs/investigations/hlg-runs/runJ-knobmap.log +49 -0
  63. package/docs/investigations/hlg-runs/runK-frontier.log +19 -0
  64. package/docs/investigations/hlg-runs/runL-widemap.log +65 -0
  65. package/docs/investigations/hlg-runs/runM-frontier-emb.log +15 -0
  66. package/docs/investigations/hlg-runs/runN-verify.log +116 -0
  67. package/docs/investigations/hlg-runs/runO-frontier-seed5000.log +15 -0
  68. package/docs/investigations/hlg-runs/runP-grid-AxSm.log +15 -0
  69. package/docs/investigations/hlg-runs/runQ-grid-tgW.log +15 -0
  70. package/docs/investigations/hlg-runs/runR-blind-transcript.log +79 -0
  71. package/docs/investigations/hlg-runs/runS-cross-coherence.log +33 -0
  72. package/docs/investigations/hlg-runs/runT-cross-cpm5.log +33 -0
  73. package/docs/investigations/hlg-runs/runU-cross-12b.log +33 -0
  74. package/docs/investigations/hlg-runs/runV-lw-e4b.log +22 -0
  75. package/docs/investigations/hlg-runs/runW-lw-12b.log +22 -0
  76. package/docs/investigations/hlg-runs/runX-lw-cpm5.log +22 -0
  77. package/docs/investigations/hlg-runs/runY-cliff-e4b.log +26 -0
  78. package/docs/investigations/hlg-runs/runZ-cliff-12b.log +26 -0
  79. package/docs/investigations/hlg-sampling-investigation.md +970 -0
  80. package/docs/investigations/lab-build-journal.md +157 -0
  81. package/docs/investigations/pi-builtin-investigation.md +308 -0
  82. package/docs/investigations/starter-model-port-handoff.md +323 -0
  83. package/docs/planning/IDEAS.md +26 -0
  84. package/docs/planning/PRODUCT_ROADMAP.md +426 -0
  85. package/docs/planning/ResearchTopics.md +119 -0
  86. package/docs/planning/chunk-task-roadmap.md +77 -0
  87. package/docs/planning/journal.md +155 -0
  88. package/docs/reference/distribution.md +121 -0
  89. package/docs/reference/embedding.md +168 -0
  90. package/docs/reference/library-api.md +153 -0
  91. package/docs/reference/server-api.md +224 -0
  92. package/docs/reference/server-config.md +243 -0
  93. package/docs/segmented-backward-handoff.md +148 -0
  94. package/package.json +68 -0
  95. package/src/anthropic.ts +491 -0
  96. package/src/chat-template.ts +186 -0
  97. package/src/cli.ts +1075 -0
  98. package/src/config.ts +229 -0
  99. package/src/curve-sampler.ts +122 -0
  100. package/src/dataset/generators.ts +1087 -0
  101. package/src/dataset/index.ts +12 -0
  102. package/src/dataset/job.ts +81 -0
  103. package/src/dataset/llm.ts +71 -0
  104. package/src/dataset/registry.ts +528 -0
  105. package/src/download.ts +283 -0
  106. package/src/eval/README.md +86 -0
  107. package/src/eval/kl-prompts.ts +29 -0
  108. package/src/eval/kl.ts +294 -0
  109. package/src/eval/quality-db.ts +92 -0
  110. package/src/eval/runner.ts +102 -0
  111. package/src/eval/score.ts +49 -0
  112. package/src/eval/tasks/bfcl.ts +500 -0
  113. package/src/eval/tasks/gsm8k.ts +85 -0
  114. package/src/eval/tasks/hashhop.ts +109 -0
  115. package/src/eval/tasks/humaneval.ts +252 -0
  116. package/src/eval/tasks/ifeval.ts +354 -0
  117. package/src/eval/tasks/mmlu.ts +145 -0
  118. package/src/evaldb.ts +199 -0
  119. package/src/expert-offload-build.ts +77 -0
  120. package/src/expert-offload.ts +61 -0
  121. package/src/expert-trace.ts +67 -0
  122. package/src/fit.ts +254 -0
  123. package/src/generate.ts +451 -0
  124. package/src/harness-pi.ts +177 -0
  125. package/src/hf-push.ts +628 -0
  126. package/src/index.ts +22 -0
  127. package/src/jobs/db.ts +139 -0
  128. package/src/jobs/index.ts +29 -0
  129. package/src/jobs/job-entry.ts +106 -0
  130. package/src/jobs/runner.ts +304 -0
  131. package/src/jobs/sse.ts +114 -0
  132. package/src/jobs/types.ts +58 -0
  133. package/src/kv-store.ts +185 -0
  134. package/src/lora.ts +314 -0
  135. package/src/md-modules.d.ts +7 -0
  136. package/src/mlx/array.ts +237 -0
  137. package/src/mlx/autograd.ts +267 -0
  138. package/src/mlx/checkpoint.ts +116 -0
  139. package/src/mlx/compile.ts +158 -0
  140. package/src/mlx/custom-vjp.ts +140 -0
  141. package/src/mlx/ffi.ts +433 -0
  142. package/src/mlx/metal-kernel.ts +130 -0
  143. package/src/mlx/ops.ts +707 -0
  144. package/src/mmap.ts +90 -0
  145. package/src/model/batched-mask.ts +204 -0
  146. package/src/model/batched-rotating.ts +300 -0
  147. package/src/model/compiled-decode.ts +681 -0
  148. package/src/model/factory.ts +27 -0
  149. package/src/model/fingerprint.ts +33 -0
  150. package/src/model/flash-attention.ts +674 -0
  151. package/src/model/fused-decode-kernel.ts +305 -0
  152. package/src/model/fused-geglu-kernel.ts +87 -0
  153. package/src/model/gemma4-base.ts +1616 -0
  154. package/src/model/gemma4.ts +1012 -0
  155. package/src/model/generated/gemma4-12b.ts +526 -0
  156. package/src/model/generated/gemma4-26b.ts +500 -0
  157. package/src/model/generated/gemma4-e4b.ts +757 -0
  158. package/src/model/generated/index.ts +18 -0
  159. package/src/model/minicpm5.ts +278 -0
  160. package/src/model/qwen3-delta.ts +224 -0
  161. package/src/model/qwen3_5.ts +429 -0
  162. package/src/model/support.ts +46 -0
  163. package/src/native-pack.ts +152 -0
  164. package/src/pi-provider.ts +95 -0
  165. package/src/pi-terminal.ts +264 -0
  166. package/src/pi-web.ts +924 -0
  167. package/src/preflight.ts +123 -0
  168. package/src/prompt-cache.ts +113 -0
  169. package/src/quantize/allocator.ts +627 -0
  170. package/src/quantize/calibration.ts +180 -0
  171. package/src/quantize/config-writer.ts +132 -0
  172. package/src/quantize/index.ts +57 -0
  173. package/src/quantize/job.ts +136 -0
  174. package/src/quantize/quantizer.ts +462 -0
  175. package/src/quantize/safetensors-writer.ts +171 -0
  176. package/src/quantize/sensitivity.ts +234 -0
  177. package/src/registry.ts +336 -0
  178. package/src/responses.ts +774 -0
  179. package/src/safetensors.ts +158 -0
  180. package/src/sampler.ts +517 -0
  181. package/src/serve/batch-scheduler.ts +362 -0
  182. package/src/serve/generation-gateway.ts +160 -0
  183. package/src/server.ts +1891 -0
  184. package/src/spec/drafter.ts +261 -0
  185. package/src/spec/generate.ts +221 -0
  186. package/src/status-page.html +497 -0
  187. package/src/tokenizer.ts +38 -0
  188. package/src/tool-call.ts +242 -0
  189. package/src/train/dataset.ts +461 -0
  190. package/src/train/export.ts +32 -0
  191. package/src/train/forward.ts +229 -0
  192. package/src/train/index.ts +43 -0
  193. package/src/train/job.ts +148 -0
  194. package/src/train/lora-params.ts +194 -0
  195. package/src/train/loss.ts +342 -0
  196. package/src/train/merge.ts +195 -0
  197. package/src/train/optimizer.ts +176 -0
  198. package/src/train/rank.ts +101 -0
  199. package/src/train/segmented.ts +520 -0
  200. package/src/train/trainer.ts +600 -0
  201. package/src/tui.ts +161 -0
  202. package/src/vision/embedder.ts +168 -0
  203. package/src/vision/preprocess.ts +247 -0
  204. package/src/vision/prompt.ts +158 -0
  205. package/src/vision/siglip.ts +599 -0
  206. package/src/web/app.html +2471 -0
  207. package/src/web/skills/web-research/SKILL.md +44 -0
  208. package/src/web/skills.ts +59 -0
  209. package/src/web-tools.ts +426 -0
  210. package/src/weights.ts +91 -0
@@ -0,0 +1,451 @@
1
+ // Streaming generation — port of mlx-lm's generate_step:
2
+ // - prefill in chunks; cache state evaluated per chunk (bounded transient
3
+ // memory), logits never computed for non-final prefill positions
4
+ // - decode pipelining via mx.async_eval: step n+1's graph is built and
5
+ // dispatched before step n's token is read back, so the GPU never idles
6
+ // on the JS round-trip
7
+ // - sampling stays on-device; only the chosen token id crosses to JS
8
+
9
+ import { MlxArray, gpuStream } from "./mlx/array";
10
+ import {
11
+ clearCache,
12
+ maxRecommendedWorkingSetSize,
13
+ setWiredLimit,
14
+ synchronize,
15
+ } from "./mlx/ffi";
16
+ import * as ops from "./mlx/ops";
17
+ import { CompiledDecode } from "./model/compiled-decode";
18
+ import { Gemma4Model, KVCache, RotatingKVCache, type Cache } from "./model/gemma4";
19
+ import type { RuntimeModel } from "./model/factory";
20
+ import type { KvQuantSpec } from "./config";
21
+ import {
22
+ makeLogitsProcessors, makeSampler,
23
+ type LogitsProcessorOptions, type SamplerOptions, toLogprobs,
24
+ } from "./sampler";
25
+
26
+ export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions {
27
+ maxTokens?: number;
28
+ eosTokenIds?: number[];
29
+ prefillChunkSize?: number;
30
+ /** Pre-warmed KV caches (e.g. from the prompt cache). cache[0].offset
31
+ * prompt tokens are treated as already prefilled; only the suffix is
32
+ * forwarded. Caller keeps ownership — generate() will not dispose. */
33
+ cache?: Cache[];
34
+ /** Vision path: pre-merged (unscaled) input embeddings [1, L, hidden]
35
+ * covering the whole prompt; prefilled in one shot (no chunking).
36
+ * Caller keeps ownership. */
37
+ promptEmbeddings?: MlxArray;
38
+ /** bool [L] marking image tokens (bidirectional attention among them). */
39
+ imageMask?: MlxArray;
40
+ /** Quantize full-attention KV caches to this many bits (4 or 8).
41
+ * Rotating (sliding-window) caches stay bf16 — they're window-capped
42
+ * and upstream rotating-cache quantization is NYI. */
43
+ kvBits?: number;
44
+ kvGroupSize?: number;
45
+ /** Per-layer mixed-precision KV from kv_config.json (config.kvQuant).
46
+ * Overrides kvBits, like optiq serve's --kv-config. layerIdx indexes
47
+ * the cache list (== layer index for the donor prefix); entries for
48
+ * rotating/sliding caches are skipped until Phase 9. */
49
+ kvConfig?: KvQuantSpec[];
50
+ /** Convert once a cache's offset reaches this (uniform-kvBits default
51
+ * 5000 = mlx-lm; kvConfig default 0 = optiq serve). */
52
+ quantizedKvStart?: number;
53
+ /** Mounted LoRA adapter ids to apply (resolved/validated by
54
+ * AdapterManager.resolveSpec). Residuals sum in order. Set on the
55
+ * model's LoraState for exactly the duration of this generation —
56
+ * a plain field, safe because the generation queue is serialized. */
57
+ adapters?: string[];
58
+ }
59
+
60
+ /** Port of mlx-lm maybe_quantize_kv_cache + optiq serve's per-layer
61
+ * patched variant (incl. patch_rotating_to_quantized: rotating caches
62
+ * convert too — Phase 9). kvConfig overrides kvBits, matching optiq's
63
+ * --kv-config precedence; shipped kv_config.json files list
64
+ * full-attention layers only, so rotating quantization engages through
65
+ * uniform kvBits (like optiq --kv-bits) or a config that names sliding
66
+ * layers. */
67
+ export function maybeQuantizeKv(cache: Cache[], options: GenerateOptions): void {
68
+ const { kvBits, kvConfig } = options;
69
+ if (!kvBits && !kvConfig?.length) return;
70
+ const start = options.quantizedKvStart ?? (kvConfig?.length ? 0 : 5000);
71
+ const byLayer = kvConfig?.length
72
+ ? new Map(kvConfig.map((e) => [e.layerIdx, e]))
73
+ : null;
74
+ for (let i = 0; i < cache.length; i++) {
75
+ const c = cache[i]!;
76
+ if (!(c instanceof KVCache || c instanceof RotatingKVCache) || c.offset < start) continue;
77
+ // OptiQ's mixed-KV hook skips empty caches: the first prompt prefill
78
+ // runs bf16, then the populated cache is quantized before decode.
79
+ // Converting empty caches at start=0 makes prefill itself quantized
80
+ // and diverges from the oracle path.
81
+ if (c.offset === 0) continue;
82
+ if (byLayer) {
83
+ const e = byLayer.get(i);
84
+ if (e) cache[i] = c.toQuantized(e.groupSize, e.bits);
85
+ } else {
86
+ cache[i] = c.toQuantized(options.kvGroupSize ?? 64, kvBits!);
87
+ }
88
+ }
89
+ }
90
+
91
+ export interface GenerateStats {
92
+ promptTokens: number;
93
+ /** Prompt tokens skipped via a pre-warmed cache. */
94
+ cachedTokens: number;
95
+ generatedTokens: number;
96
+ prefillTps: number;
97
+ decodeTps: number;
98
+ prefillMs: number;
99
+ decodeMs: number;
100
+ /** Exact token sequence whose KV now lives in the cache (prompt + every
101
+ * decoded token that was forwarded, including a trailing EOS the
102
+ * pipeline forwarded before reading it). For PromptCache.put(). */
103
+ cacheTokens: number[];
104
+ }
105
+
106
+ export interface GeneratedToken {
107
+ token: number;
108
+ index: number;
109
+ }
110
+
111
+ export class Generation implements AsyncIterable<GeneratedToken> {
112
+ stats: GenerateStats | null = null;
113
+ readonly #iter: AsyncGenerator<GeneratedToken, GenerateStats>;
114
+
115
+ constructor(iter: AsyncGenerator<GeneratedToken, GenerateStats>) {
116
+ this.#iter = iter;
117
+ }
118
+
119
+ async *[Symbol.asyncIterator](): AsyncGenerator<GeneratedToken> {
120
+ try {
121
+ while (true) {
122
+ const r = await this.#iter.next();
123
+ if (r.done) {
124
+ this.stats = r.value;
125
+ return;
126
+ }
127
+ yield r.value;
128
+ }
129
+ } finally {
130
+ // Consumer broke early (e.g. a decoded-text stop sequence fired):
131
+ // drive the inner generator's shutdown so its finallys run (array
132
+ // disposal, wired/adapter scopes) and capture the stats its
133
+ // early-return path still reports.
134
+ if (this.stats === null) {
135
+ const r = await this.#iter.return(undefined as unknown as GenerateStats);
136
+ if (r.done && r.value) this.stats = r.value;
137
+ }
138
+ }
139
+ }
140
+ }
141
+
142
+ // Scoped wired limit, raised only for near-ceiling models. mlx-lm's
143
+ // wired_limit context wires unconditionally per generation; we deviate
144
+ // with a measured justification (PLAN Phase 6 verification findings):
145
+ // - 26B-A4B (16.4 GB = 92% of the 17.8 GiB working set) NEEDS wiring —
146
+ // 8.6 tok/s without, 32.3 with (Metal evicts weight buffers per token).
147
+ // - 12B/e4b (≤47%) hit reference parity WITHOUT wiring, and wiring in a
148
+ // multi-model process (the test suite) pins memory the OTHER resident
149
+ // models need — async GPU exec OOM, which is uncatchable (the mlx
150
+ // completion-handler throw terminates the process).
151
+ // Scope semantics match the reference: set → generate → synchronize →
152
+ // restore; nothing stays pinned between generations. Re-entrant: only
153
+ // the outermost wiring scope touches the limit.
154
+ const WIRE_THRESHOLD = 0.75;
155
+ let wiredScopeDepth = 0;
156
+ let wiredOldLimit = 0;
157
+ function enterWiredScope(): void {
158
+ if (wiredScopeDepth++ === 0)
159
+ wiredOldLimit = setWiredLimit(maxRecommendedWorkingSetSize());
160
+ }
161
+ function exitWiredScope(): void {
162
+ if (--wiredScopeDepth === 0) {
163
+ synchronize(gpuStream);
164
+ setWiredLimit(wiredOldLimit);
165
+ }
166
+ }
167
+
168
+ export function generate(
169
+ model: RuntimeModel,
170
+ promptTokens: number[],
171
+ options: GenerateOptions = {},
172
+ ): Generation {
173
+ let inner = generateInner(model, promptTokens, options);
174
+ if (options.adapters?.length) inner = adapterScoped(model, options.adapters, inner);
175
+ const wire =
176
+ process.env.MLX_BUN_FORCE_WIRE === "1" ||
177
+ model.weightsBytes > WIRE_THRESHOLD * maxRecommendedWorkingSetSize();
178
+ return new Generation(wire ? wiredScoped(inner) : inner);
179
+ }
180
+
181
+ /** Hold the model's active-adapter list for exactly this generation. */
182
+ async function* adapterScoped(
183
+ model: RuntimeModel,
184
+ adapters: string[],
185
+ inner: AsyncGenerator<GeneratedToken, GenerateStats>,
186
+ ): AsyncGenerator<GeneratedToken, GenerateStats> {
187
+ model.loraState.active = adapters;
188
+ try {
189
+ return yield* inner;
190
+ } finally {
191
+ model.loraState.active = [];
192
+ }
193
+ }
194
+
195
+ /** Wrap the generator so the wired limit is held exactly while it runs
196
+ * (incl. early break/return/throw — finally fires on .return()). */
197
+ async function* wiredScoped(
198
+ inner: AsyncGenerator<GeneratedToken, GenerateStats>,
199
+ ): AsyncGenerator<GeneratedToken, GenerateStats> {
200
+ enterWiredScope();
201
+ try {
202
+ return yield* inner;
203
+ } finally {
204
+ exitWiredScope();
205
+ }
206
+ }
207
+
208
+ async function* generateInner(
209
+ model: RuntimeModel,
210
+ promptTokens: number[],
211
+ options: GenerateOptions,
212
+ ): AsyncGenerator<GeneratedToken, GenerateStats> {
213
+ const {
214
+ maxTokens = 512,
215
+ eosTokenIds = model.config.eosTokenIds,
216
+ prefillChunkSize = 2048,
217
+ } = options;
218
+ const sampler = makeSampler(options);
219
+ const processors = makeLogitsProcessors(options);
220
+ const needsTokenHistory = processors.length > 0;
221
+
222
+ const ownsCache = !options.cache;
223
+ const cache = options.cache ?? model.makeCache();
224
+ const cachedTokens = cache[0]!.offset;
225
+ if (cachedTokens >= promptTokens.length)
226
+ throw new Error(
227
+ `pre-warmed cache (${cachedTokens} tokens) must be a strict prefix of the prompt (${promptTokens.length})`,
228
+ );
229
+ /** device-side token history (only maintained when processors need it) */
230
+ let history: MlxArray | null = null;
231
+
232
+ // logits [1,1,V] → sampled token array [1] (all on-device)
233
+ const sampleStep = (logits3d: MlxArray, step: number): MlxArray => {
234
+ const V = logits3d.shape[2]!;
235
+ let logits = ops.reshape(logits3d, [1, V]);
236
+ for (const p of processors) logits = disposing(logits, p(history, logits));
237
+ const lp = toLogprobs(logits);
238
+ logits.dispose();
239
+ const tok = sampler(lp, step);
240
+ lp.dispose();
241
+ return tok;
242
+ };
243
+
244
+ const pushHistory = (tok: MlxArray) => {
245
+ if (!needsTokenHistory) return;
246
+ if (!history) {
247
+ history = ops.reshape(tok, [1]);
248
+ } else {
249
+ const prev = history;
250
+ history = ops.concatAxis([prev, tok], 0);
251
+ prev.dispose();
252
+ }
253
+ };
254
+
255
+ // Decode-loop state lives at function scope so the finally can still
256
+ // report stats and dispose in-flight arrays when the consumer
257
+ // terminates the generator early (break on a stop sequence — the
258
+ // forced .return() resumes at the yield and runs the finally).
259
+ let prefillMs = 0;
260
+ let tDecode = 0;
261
+ let decodeMs = 0;
262
+ let generated = 0;
263
+ const forwarded: number[] = [];
264
+ let pending: MlxArray | null = null;
265
+ let nextPending: MlxArray | null = null;
266
+ let finished = false;
267
+ let threw = false;
268
+ const makeStats = (): GenerateStats => ({
269
+ promptTokens: promptTokens.length,
270
+ cachedTokens,
271
+ generatedTokens: generated,
272
+ prefillMs,
273
+ decodeMs,
274
+ prefillTps: ((promptTokens.length - cachedTokens) / prefillMs) * 1000,
275
+ decodeTps: (generated / decodeMs) * 1000,
276
+ cacheTokens: [...promptTokens, ...forwarded],
277
+ });
278
+
279
+ try {
280
+ // ---- prefill ----
281
+ maybeQuantizeKv(cache, options);
282
+ const tPrefill = performance.now();
283
+ let h0: MlxArray;
284
+ if (options.promptEmbeddings) {
285
+ if (cachedTokens !== 0)
286
+ throw new Error("promptEmbeddings cannot be combined with a pre-warmed cache");
287
+ if (needsTokenHistory)
288
+ history = ops.fromInt32(promptTokens, [promptTokens.length]);
289
+ // e2b/e4b need the spliced token ids to build per-layer inputs
290
+ // (image positions zeroed inside forwardEmbeddings).
291
+ const embedIds = ops.fromInt32(promptTokens, [1, promptTokens.length]);
292
+ h0 = model.forwardEmbeddings(
293
+ options.promptEmbeddings, cache, options.imageMask ?? null, embedIds,
294
+ );
295
+ embedIds.dispose();
296
+ } else {
297
+ let pos = cachedTokens;
298
+ while (promptTokens.length - pos > prefillChunkSize) {
299
+ const chunk = promptTokens.slice(pos, pos + prefillChunkSize);
300
+ const ids = ops.fromInt32(chunk, [1, chunk.length]);
301
+ const h = model.forwardHidden(ids, cache);
302
+ ids.dispose();
303
+ h.dispose(); // logits never computed for non-final chunks
304
+ ops.evalAll(cache.flatMap((c) => c.state()));
305
+ maybeQuantizeKv(cache, options);
306
+ // mlx-lm _prefill clears the allocator cache after every chunk;
307
+ // without this, prefill transients pile up in the buffer cache
308
+ // and the first decode step pays a one-shot reclaim stall that
309
+ // scales with prompt length (~800 ms after an 8k prefill —
310
+ // measured, scripts/decode-split.ts; the context-scaling decode
311
+ // gap's main term).
312
+ clearCache();
313
+ pos += prefillChunkSize;
314
+ }
315
+ if (needsTokenHistory) {
316
+ history = ops.fromInt32(promptTokens, [promptTokens.length]);
317
+ }
318
+ const lastChunk = promptTokens.slice(pos);
319
+ const ids0 = ops.fromInt32(lastChunk, [1, lastChunk.length]);
320
+ h0 = model.forwardHidden(ids0, cache);
321
+ ids0.dispose();
322
+ }
323
+ const [, L0, H] = h0.shape as [number, number, number];
324
+ const hLast = h0.slice([0, L0 - 1, 0], [1, L0, H]);
325
+ h0.dispose();
326
+ const logits0 = model.logitsFromHidden(hLast);
327
+ hLast.dispose();
328
+ pending = sampleStep(logits0, 0); // token array [1]
329
+ logits0.dispose();
330
+ // mirror mlx-lm generate_step: async-dispatch the first token's
331
+ // compute; the prefill clock keeps running until the token ARRIVES
332
+ // (first itemUint32 below). mlx-lm stops its prompt clock at the
333
+ // first yielded token, which bills the prefill→decode boundary
334
+ // (allocator reclaim of prefill transients + first-step dispatch)
335
+ // to prompt_time, not decode — replicated so cross-stack decode
336
+ // tok/s measure the same quantity. The boundary cost is real and
337
+ // scales with prompt length; it belongs to "having prefilled".
338
+ ops.asyncEvalAll([pending]);
339
+
340
+ // ---- decode (pipelined) ----
341
+ // Compiled decode (docs/design/optimization_plan.md Phase A): replay the per-step
342
+ // graph in C++ instead of rebuilding it through bun:ffi every token.
343
+ // Bit-exact with the uncompiled path (tests/compiled-decode.test.ts);
344
+ // MLX_BUN_COMPILED_DECODE=0 is the kill switch / A-B lever. LoRA
345
+ // generations stay uncompiled (adapter weights would bake into the
346
+ // trace as constants). Any unsupported cache state falls back for
347
+ // the rest of the generation.
348
+ // MoE models stay uncompiled: GatherQMM lacks output_shapes in mlx
349
+ // 0.6.0, and shapeless replay re-infers the whole tape whenever the
350
+ // growing attention windows change shape (= every step). Remove this
351
+ // when upstream implements GatherQMM::output_shapes.
352
+ let compiled =
353
+ process.env.MLX_BUN_COMPILED_DECODE !== "0" &&
354
+ !options.adapters?.length &&
355
+ model.config.modelType.startsWith("gemma4") &&
356
+ !model.config.text.enableMoeBlock
357
+ ? CompiledDecode.for(model as Gemma4Model)
358
+ : null;
359
+ let stop = false;
360
+ while (!stop) {
361
+ const cur = pending!;
362
+ // build step n+1's graph from the *unread* pending token
363
+ nextPending = null;
364
+ if (generated + 1 < maxTokens) {
365
+ maybeQuantizeKv(cache, options);
366
+ pushHistory(cur);
367
+ let logits: MlxArray | null = null;
368
+ let evalWith: MlxArray[] = [];
369
+ if (compiled && CompiledDecode.supports(cache)) {
370
+ try {
371
+ const r = compiled.step(cur, cache);
372
+ logits = r.logits;
373
+ evalWith = r.evalWith;
374
+ } catch (e) {
375
+ // growth done by a partial prepare is benign for the
376
+ // uncompiled path (updateAndFetch re-checks capacity)
377
+ compiled = null;
378
+ console.warn(`compiled decode disabled for this generation: ${e}`);
379
+ }
380
+ }
381
+ if (!logits) {
382
+ const ids = ops.reshape(cur, [1, 1]);
383
+ const h = model.forwardHidden(ids, cache);
384
+ ids.dispose();
385
+ logits = model.logitsFromHidden(h);
386
+ h.dispose();
387
+ }
388
+ nextPending = sampleStep(logits, generated + 1);
389
+ logits.dispose();
390
+ ops.asyncEvalAll([nextPending, ...evalWith]);
391
+ }
392
+
393
+ // sync-read step n's token while n+1 computes
394
+ const token = ops.itemUint32(cur);
395
+ if (generated === 0) {
396
+ // first token arrived: prompt clock stops, decode clock starts
397
+ // (mlx-lm stream_generate's n==0 clock swap; the first token is
398
+ // "free" on the decode clock there too)
399
+ prefillMs = performance.now() - tPrefill;
400
+ tDecode = performance.now();
401
+ }
402
+ cur.dispose();
403
+ pending = null;
404
+ generated++;
405
+ // if a next-step graph was built, this token's KV entered the cache
406
+ if (nextPending !== null) forwarded.push(token);
407
+
408
+ if (eosTokenIds.includes(token)) {
409
+ nextPending?.dispose();
410
+ nextPending = null;
411
+ stop = true;
412
+ } else {
413
+ yield { token, index: generated - 1 };
414
+ // mlx-lm generate_step: clear_cache after token 0 (drops the
415
+ // remaining prefill transients) and every 256 tokens after
416
+ if ((generated - 1) % 256 === 0) clearCache();
417
+ if (nextPending === null) {
418
+ stop = true;
419
+ } else {
420
+ pending = nextPending;
421
+ nextPending = null;
422
+ }
423
+ }
424
+ }
425
+ decodeMs = performance.now() - tDecode;
426
+ finished = true;
427
+ return makeStats();
428
+ } catch (e) {
429
+ threw = true;
430
+ throw e;
431
+ } finally {
432
+ if (!finished) {
433
+ pending?.dispose();
434
+ nextPending?.dispose();
435
+ }
436
+ if (ownsCache) for (const c of cache) c.dispose();
437
+ history?.dispose();
438
+ if (!finished && !threw) {
439
+ // forced early return (consumer break at a yield): still report
440
+ // stats — `forwarded` only lists tokens whose KV actually entered
441
+ // the cache, so cacheTokens stays exact for PromptCache.put().
442
+ decodeMs = performance.now() - tDecode;
443
+ return makeStats();
444
+ }
445
+ }
446
+ }
447
+
448
+ function disposing(old: MlxArray, next: MlxArray): MlxArray {
449
+ old.dispose();
450
+ return next;
451
+ }
@@ -0,0 +1,177 @@
1
+ // mlx-bun harness pi — register mlx-bun as a provider in the user's pi
2
+ // install (Phase 16 P1, docs/investigations/pi-builtin-investigation.md).
3
+ //
4
+ // Mechanism: a generated extension file in ~/.pi/agent/extensions/ that
5
+ // calls pi.registerProvider() with dynamic discovery — it fetches
6
+ // /v1/models from the running server at pi startup so the model list
7
+ // tracks the server, and falls back to the list baked at install time
8
+ // when the server is down. No models.json surgery; reversible by
9
+ // deleting the one file (or `mlx-bun harness pi --remove`).
10
+
11
+ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { dirname, join } from "node:path";
14
+
15
+ export const PI_EXTENSION_FILENAME = "mlx-bun-provider.ts";
16
+ export const PI_PROVIDER_ID = "mlx-bun";
17
+ /** Stable pi-facing model id. The server runs exactly one model at a time
18
+ * (memory: the 26B alone is 18 GB) and which one varies run-to-run, so
19
+ * advertising the real, changing repo id makes pi's persisted default go
20
+ * stale ("No models match …"). A fixed id resolves to whatever single
21
+ * model is loaded; the real model is carried in the `name` field, and the
22
+ * server still reports the true id in /v1/models and responses. Gives a
23
+ * clean `mlx-bun/local` handle instead of `mlx-bun/mlx-community/…`. */
24
+ export const PI_LOCAL_MODEL_ID = "local";
25
+ export const DEFAULT_BASE_URL = "http://localhost:8090/v1";
26
+
27
+ export function defaultPiExtensionsDir(): string {
28
+ return join(homedir(), ".pi", "agent", "extensions");
29
+ }
30
+
31
+ export interface PiDetection {
32
+ found: boolean;
33
+ binPath?: string;
34
+ version?: string;
35
+ }
36
+
37
+ /** Locate the user's pi install without spawning it (the bin shim may
38
+ * resolve to a node too old for pi-tui's /v-flag regexes). */
39
+ export function detectPi(): PiDetection {
40
+ const candidates = [Bun.which("pi"), join(homedir(), ".bun", "bin", "pi")]
41
+ .filter((p): p is string => !!p && existsSync(p));
42
+ for (const bin of candidates) {
43
+ let version: string | undefined;
44
+ try {
45
+ // bin -> .../node_modules/@earendil-works/pi-coding-agent/dist/cli.js
46
+ let dir = dirname(realpathSync(bin));
47
+ for (let i = 0; i < 4 && dir !== "/"; i++, dir = dirname(dir)) {
48
+ const pkg = join(dir, "package.json");
49
+ if (existsSync(pkg)) {
50
+ const meta = JSON.parse(readFileSync(pkg, "utf8")) as { name?: string; version?: string };
51
+ if (meta.name === "@earendil-works/pi-coding-agent") version = meta.version;
52
+ break;
53
+ }
54
+ }
55
+ } catch {}
56
+ return { found: true, binPath: bin, version };
57
+ }
58
+ return { found: false };
59
+ }
60
+
61
+ export interface ServerModel {
62
+ id: string;
63
+ contextWindow: number;
64
+ maxTokens: number;
65
+ reasoning: boolean;
66
+ }
67
+
68
+ /** Discover models from a running mlx-bun server; [] when unreachable. */
69
+ export async function fetchServerModels(baseUrl: string, timeoutMs = 3000): Promise<ServerModel[]> {
70
+ try {
71
+ const res = await fetch(`${baseUrl.replace(/\/$/, "")}/models`, {
72
+ signal: AbortSignal.timeout(timeoutMs),
73
+ });
74
+ if (!res.ok) return [];
75
+ const body = (await res.json()) as { data?: Array<{ id: string; context_window?: number; reasoning?: boolean }> };
76
+ return (body.data ?? []).map((m) => ({
77
+ id: m.id,
78
+ contextWindow: m.context_window ?? 32768,
79
+ maxTokens: 8192,
80
+ reasoning: !!m.reasoning,
81
+ }));
82
+ } catch {
83
+ return [];
84
+ }
85
+ }
86
+
87
+ /** GET /v1/models with a short timeout; null when no server is up. The
88
+ * `mlx-bun pi` and `serve` commands use this to decide reuse-vs-start. */
89
+ export async function probeServer(baseUrl: string, timeoutMs = 1500): Promise<ServerModel[] | null> {
90
+ const models = await fetchServerModels(baseUrl, timeoutMs);
91
+ return models.length > 0 ? models : null;
92
+ }
93
+
94
+ /** Render the pi extension source. Plain self-contained TS — pi loads it
95
+ * with jiti, so no imports and no dependency on pi's type packages. */
96
+ export function renderPiExtension(baseUrl: string, fallbackModels: ServerModel[]): string {
97
+ const fallback = JSON.stringify(
98
+ fallbackModels.map((m) => modelEntry(m)),
99
+ null,
100
+ 2,
101
+ );
102
+ return `// Generated by \`mlx-bun harness pi\` — safe to delete.
103
+ // Registers the local mlx-bun server as a pi provider. Models are
104
+ // discovered live from the server at pi startup; the baked list below
105
+ // is the fallback when the server isn't running.
106
+ const BASE_URL = ${JSON.stringify(baseUrl)};
107
+ const FALLBACK_MODELS = ${fallback};
108
+
109
+ export default async function (pi: any) {
110
+ let models = FALLBACK_MODELS;
111
+ try {
112
+ const res = await fetch(\`\${BASE_URL}/models\`, { signal: AbortSignal.timeout(2000) });
113
+ if (res.ok) {
114
+ const { data } = await res.json();
115
+ if (Array.isArray(data) && data.length > 0) {
116
+ models = data.map((m: any) => ({
117
+ id: ${JSON.stringify(PI_LOCAL_MODEL_ID)},
118
+ name: \`\${m.id} (mlx-bun local)\`,
119
+ reasoning: !!m.reasoning,
120
+ ...(m.reasoning ? { compat: { thinkingFormat: "qwen-chat-template" } } : {}),
121
+ input: ["text"],
122
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
123
+ contextWindow: m.context_window ?? 32768,
124
+ maxTokens: 8192,
125
+ }));
126
+ }
127
+ }
128
+ } catch {}
129
+ if (models.length === 0) return; // nothing to register
130
+ pi.registerProvider(${JSON.stringify(PI_PROVIDER_ID)}, {
131
+ baseUrl: BASE_URL,
132
+ apiKey: "sk-mlx-bun-local",
133
+ api: "openai-completions",
134
+ models,
135
+ });
136
+ }
137
+ `;
138
+ }
139
+
140
+ function modelEntry(m: ServerModel) {
141
+ return {
142
+ id: PI_LOCAL_MODEL_ID,
143
+ name: `${m.id} (mlx-bun local)`,
144
+ reasoning: !!m.reasoning,
145
+ ...(m.reasoning ? { compat: { thinkingFormat: "qwen-chat-template" } } : {}),
146
+ input: ["text"],
147
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
148
+ contextWindow: m.contextWindow,
149
+ maxTokens: m.maxTokens,
150
+ };
151
+ }
152
+
153
+ export interface InstallResult {
154
+ path: string;
155
+ baseUrl: string;
156
+ bakedModels: string[];
157
+ serverReachable: boolean;
158
+ }
159
+
160
+ export async function installPiExtension(
161
+ baseUrl: string = DEFAULT_BASE_URL,
162
+ extensionsDir: string = defaultPiExtensionsDir(),
163
+ ): Promise<InstallResult> {
164
+ const models = await fetchServerModels(baseUrl);
165
+ mkdirSync(extensionsDir, { recursive: true });
166
+ const path = join(extensionsDir, PI_EXTENSION_FILENAME);
167
+ writeFileSync(path, renderPiExtension(baseUrl, models));
168
+ return { path, baseUrl, bakedModels: models.map((m) => m.id), serverReachable: models.length > 0 };
169
+ }
170
+
171
+ /** Returns true when a file was removed. */
172
+ export function removePiExtension(extensionsDir: string = defaultPiExtensionsDir()): boolean {
173
+ const path = join(extensionsDir, PI_EXTENSION_FILENAME);
174
+ if (!existsSync(path)) return false;
175
+ rmSync(path);
176
+ return true;
177
+ }