mlx-bun 0.0.13 → 0.2.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 (224) hide show
  1. package/README.md +31 -21
  2. package/bin/mlx-bun.mjs +1 -1
  3. package/docs/{design → archive}/hlg-sampling.md +3 -3
  4. package/docs/{investigations → archive/investigations}/hlg-sampling-investigation.md +1 -1
  5. package/docs/archive/investigations/pareto-specialized-runtime-findings.md +92 -0
  6. package/docs/{planning → archive/planning}/chat-ui-vision.md +1 -1
  7. package/docs/{planning → archive/planning}/release-notes-v0.0.9.md +1 -1
  8. package/docs/{planning → archive/planning}/website-readme-pass-plan.md +1 -1
  9. package/docs/design/colibri-glm52-port.md +1 -1
  10. package/docs/design/compat-cli-surface-design.md +1 -1
  11. package/docs/design/decode-speed-program.md +1 -1
  12. package/docs/design/docs-reorg-plan.md +1 -1
  13. package/docs/design/dspark-serving-program.md +1 -1
  14. package/docs/design/dspark-speculative-decoding.md +53 -3
  15. package/docs/design/grammar-spec-batching-integration.md +1 -1
  16. package/docs/design/mlx-lm-serving-execution-seam.md +81 -0
  17. package/docs/design/orpo-dynamic-lambda.md +1 -1
  18. package/docs/design/orpo-future-enhancements.md +1 -1
  19. package/docs/design/orpo-training.md +2 -2
  20. package/docs/design/parity-tier-dag.md +1 -1
  21. package/docs/design/repo-cleanup-plan.md +46 -4
  22. package/docs/design/segmented-backward-training.md +2 -2
  23. package/docs/design/ssd-kv-cold-tier.md +127 -0
  24. package/docs/design/turboquant-weights.md +467 -0
  25. package/docs/design/w4a16-compute-precision-spike.md +1 -1
  26. package/docs/design/web-chat-redesign.md +5 -5
  27. package/docs/planning/chunk-task-roadmap.md +1 -1
  28. package/docs/planning/curve-sampler-research-plan.md +4 -4
  29. package/docs/planning/memory-docs-and-dag-plan.md +1 -1
  30. package/docs/planning/release-notes-v0.2.0.md +49 -0
  31. package/docs/planning/video-series.md +421 -0
  32. package/docs/reference/cli.md +15 -4
  33. package/docs/reference/distribution.md +1 -0
  34. package/docs/reference/features-matrix.md +3 -2
  35. package/docs/reference/library-api.md +41 -1
  36. package/docs/reference/models.md +33 -0
  37. package/docs/reference/server-api.md +69 -14
  38. package/docs/reference/server-config.md +57 -33
  39. package/package.json +2 -2
  40. package/src/anthropic.ts +92 -3
  41. package/src/cli.ts +131 -36
  42. package/src/eval/kl.ts +27 -20
  43. package/src/eval/runner.ts +23 -4
  44. package/src/eval/tasks/bfcl.ts +2 -1
  45. package/src/eval/tasks/gsm8k.ts +2 -1
  46. package/src/eval/tasks/hashhop.ts +2 -1
  47. package/src/eval/tasks/humaneval.ts +5 -4
  48. package/src/eval/tasks/ifeval.ts +2 -1
  49. package/src/eval/tasks/mmlu.ts +2 -1
  50. package/src/expert-io.ts +3 -1
  51. package/src/expert-offload.ts +3 -1
  52. package/src/expert-trace.ts +3 -1
  53. package/src/fit.ts +24 -43
  54. package/src/flags.ts +7 -9
  55. package/src/generate.ts +107 -92
  56. package/src/grammar.ts +2 -1
  57. package/src/harness-pi.ts +1 -1
  58. package/src/index.ts +20 -0
  59. package/src/jobs/job-entry.ts +3 -2
  60. package/src/kv-scheme.ts +199 -0
  61. package/src/kv-store.ts +370 -205
  62. package/src/media-fetch.ts +19 -3
  63. package/src/memory/model.ts +2 -1
  64. package/src/memory/vault.ts +2 -1
  65. package/src/memory-plan.ts +18 -0
  66. package/src/mlx/ffi.ts +8 -11
  67. package/src/mlx/ops.ts +31 -6
  68. package/src/model/batched-mask.ts +1 -1
  69. package/src/model/batched-quant.ts +1 -1
  70. package/src/model/batched-rotating-quant.ts +43 -97
  71. package/src/model/batched-rotating-state.ts +73 -0
  72. package/src/model/batched-rotating.ts +56 -105
  73. package/src/model/batched-row-storage.ts +127 -0
  74. package/src/model/compiled-decode.ts +3 -2
  75. package/src/model/factory.ts +44 -49
  76. package/src/model/gemma4-base.ts +163 -10
  77. package/src/model/generated/gemma4-12b.ts +50 -146
  78. package/src/model/generated/gemma4-26b.ts +32 -92
  79. package/src/model/generated/gemma4-e4b.ts +26 -74
  80. package/src/model/glm52-cache.ts +19 -9
  81. package/src/model/glm52-memory.ts +28 -1
  82. package/src/model/minicpm5.ts +2 -1
  83. package/src/model/profile.ts +433 -0
  84. package/src/model/qwen3-delta.ts +98 -2
  85. package/src/model/qwen3-mrope.ts +252 -0
  86. package/src/model/qwen3_5.ts +217 -25
  87. package/src/native/frame_extract.swift +140 -0
  88. package/src/native-pack.ts +13 -6
  89. package/src/pi-web.ts +6 -5
  90. package/src/prompt-cache.ts +2 -2
  91. package/src/quantize/atomic-output.ts +38 -0
  92. package/src/quantize/calibration.ts +2 -1
  93. package/src/quantize/config-writer.ts +6 -0
  94. package/src/quantize/index.ts +28 -1
  95. package/src/quantize/job.ts +11 -0
  96. package/src/quantize/quantizer.ts +265 -100
  97. package/src/quantize/rotate.ts +603 -0
  98. package/src/quantize/safetensors-writer.ts +79 -1
  99. package/src/quantize/sensitivity.ts +41 -59
  100. package/src/quantize/weight-transform.ts +292 -0
  101. package/src/responses.ts +102 -2
  102. package/src/runtime-config.ts +67 -0
  103. package/src/sampler.ts +272 -0
  104. package/src/serve/admin-routes.ts +184 -0
  105. package/src/serve/aux-routes.ts +132 -0
  106. package/src/serve/batch-scheduler.ts +200 -87
  107. package/src/serve/completion-executor.ts +346 -0
  108. package/src/serve/completion-sink.ts +157 -0
  109. package/src/serve/discovery-routes.ts +205 -0
  110. package/src/serve/generation-gateway.ts +116 -109
  111. package/src/serve/kv-budget.ts +14 -0
  112. package/src/serve/lab-routes.ts +338 -0
  113. package/src/serve/model-admin-routes.ts +153 -0
  114. package/src/serve/prompt-response-trace.ts +147 -0
  115. package/src/serve/request-plan.ts +158 -0
  116. package/src/serve/static-routes.ts +101 -0
  117. package/src/server.ts +704 -1400
  118. package/src/spec/dflash-source.ts +2 -1
  119. package/src/spec/dspark/module-dflash.ts +1 -1
  120. package/src/spec/glm52-mtp-source.ts +8 -0
  121. package/src/spec/qwen-mtp-source.ts +431 -0
  122. package/src/spec/serve-loop.ts +36 -68
  123. package/src/ssd-cache.ts +17 -14
  124. package/src/ssd-durability.ts +195 -0
  125. package/src/train/flash-cce.ts +12 -11
  126. package/src/train/forward.ts +32 -1
  127. package/src/train/loss.ts +2 -1
  128. package/src/train/segmented.ts +4 -3
  129. package/src/train/trainer.ts +3 -2
  130. package/src/vision/preprocess.ts +1 -1
  131. package/src/vision/prompt.ts +47 -1
  132. package/src/vision/qwen3vl-preprocess.ts +409 -0
  133. package/src/vision/qwen3vl-prompt.ts +196 -0
  134. package/src/vision/qwen3vl-tower.ts +513 -0
  135. package/src/vision/video-frames.ts +148 -0
  136. package/src/web/app.js +10 -3
  137. package/src/web/src/chat.ts +12 -5
  138. package/src/weights.ts +81 -26
  139. /package/docs/{investigations → archive/investigations}/chunk-finetune-experiment.md +0 -0
  140. /package/docs/{investigations → archive/investigations}/colibri-oracle-pin.md +0 -0
  141. /package/docs/{investigations → archive/investigations}/curve-bisector-routing.md +0 -0
  142. /package/docs/{investigations → archive/investigations}/curve-runs/bisector-route.json +0 -0
  143. /package/docs/{investigations → archive/investigations}/curve-runs/bisector-trace.json +0 -0
  144. /package/docs/{investigations → archive/investigations}/curve-runs/curve-terrain.json +0 -0
  145. /package/docs/{investigations → archive/investigations}/curve-runs/curve-terrain.svg +0 -0
  146. /package/docs/{investigations → archive/investigations}/curve-terrain.html +0 -0
  147. /package/docs/{investigations → archive/investigations}/decode-roofline-lookagain.md +0 -0
  148. /package/docs/{investigations → archive/investigations}/dspark-handoff.md +0 -0
  149. /package/docs/{investigations → archive/investigations}/expert-offload-single-user-moe.md +0 -0
  150. /package/docs/{investigations → archive/investigations}/hlg-ab.html +0 -0
  151. /package/docs/{investigations → archive/investigations}/hlg-explorer.html +0 -0
  152. /package/docs/{investigations → archive/investigations}/hlg-figs/cliff.svg +0 -0
  153. /package/docs/{investigations → archive/investigations}/hlg-figs/cross.svg +0 -0
  154. /package/docs/{investigations → archive/investigations}/hlg-figs/edges.svg +0 -0
  155. /package/docs/{investigations → archive/investigations}/hlg-figs/k20-grids.svg +0 -0
  156. /package/docs/{investigations → archive/investigations}/hlg-figs/k20-peaks.svg +0 -0
  157. /package/docs/{investigations → archive/investigations}/hlg-figs/lw-curve.svg +0 -0
  158. /package/docs/{investigations → archive/investigations}/hlg-figs/terrain.svg +0 -0
  159. /package/docs/{investigations → archive/investigations}/hlg-report.html +0 -0
  160. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-ab.json +0 -0
  161. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-blind-prompts.md +0 -0
  162. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-blind.json +0 -0
  163. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-cliff.json +0 -0
  164. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-cross.json +0 -0
  165. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-edge.json +0 -0
  166. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-eval1.json +0 -0
  167. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-frontier.json +0 -0
  168. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-grid.json +0 -0
  169. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-grid2.json +0 -0
  170. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-lw-coarse.json +0 -0
  171. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-map.json +0 -0
  172. /package/docs/{investigations → archive/investigations}/hlg-runs/run0-original.log +0 -0
  173. /package/docs/{investigations → archive/investigations}/hlg-runs/runA-recommended.log +0 -0
  174. /package/docs/{investigations → archive/investigations}/hlg-runs/runAA-cliff-cpm5.log +0 -0
  175. /package/docs/{investigations → archive/investigations}/hlg-runs/runAB-grid2-e4b.log +0 -0
  176. /package/docs/{investigations → archive/investigations}/hlg-runs/runAC-peak-e4b-partial.log +0 -0
  177. /package/docs/{investigations → archive/investigations}/hlg-runs/runAD-ab-samples.log +0 -0
  178. /package/docs/{investigations → archive/investigations}/hlg-runs/runAE-edge-e4b.log +0 -0
  179. /package/docs/{investigations → archive/investigations}/hlg-runs/runAF-logit-calibration.log +0 -0
  180. /package/docs/{investigations → archive/investigations}/hlg-runs/runAG-eval-config2.log +0 -0
  181. /package/docs/{investigations → archive/investigations}/hlg-runs/runB-gain-variants.log +0 -0
  182. /package/docs/{investigations → archive/investigations}/hlg-runs/runC-pivots.log +0 -0
  183. /package/docs/{investigations → archive/investigations}/hlg-runs/runD-oetf.log +0 -0
  184. /package/docs/{investigations → archive/investigations}/hlg-runs/runE-eotf.log +0 -0
  185. /package/docs/{investigations → archive/investigations}/hlg-runs/runF-shaper-working.log +0 -0
  186. /package/docs/{investigations → archive/investigations}/hlg-runs/runG-shaper-sweep.log +0 -0
  187. /package/docs/{investigations → archive/investigations}/hlg-runs/runH-wsweep.log +0 -0
  188. /package/docs/{investigations → archive/investigations}/hlg-runs/runI-asweep.log +0 -0
  189. /package/docs/{investigations → archive/investigations}/hlg-runs/runJ-knobmap.log +0 -0
  190. /package/docs/{investigations → archive/investigations}/hlg-runs/runK-frontier.log +0 -0
  191. /package/docs/{investigations → archive/investigations}/hlg-runs/runL-widemap.log +0 -0
  192. /package/docs/{investigations → archive/investigations}/hlg-runs/runM-frontier-emb.log +0 -0
  193. /package/docs/{investigations → archive/investigations}/hlg-runs/runN-verify.log +0 -0
  194. /package/docs/{investigations → archive/investigations}/hlg-runs/runO-frontier-seed5000.log +0 -0
  195. /package/docs/{investigations → archive/investigations}/hlg-runs/runP-grid-AxSm.log +0 -0
  196. /package/docs/{investigations → archive/investigations}/hlg-runs/runQ-grid-tgW.log +0 -0
  197. /package/docs/{investigations → archive/investigations}/hlg-runs/runR-blind-transcript.log +0 -0
  198. /package/docs/{investigations → archive/investigations}/hlg-runs/runS-cross-coherence.log +0 -0
  199. /package/docs/{investigations → archive/investigations}/hlg-runs/runT-cross-cpm5.log +0 -0
  200. /package/docs/{investigations → archive/investigations}/hlg-runs/runU-cross-12b.log +0 -0
  201. /package/docs/{investigations → archive/investigations}/hlg-runs/runV-lw-e4b.log +0 -0
  202. /package/docs/{investigations → archive/investigations}/hlg-runs/runW-lw-12b.log +0 -0
  203. /package/docs/{investigations → archive/investigations}/hlg-runs/runX-lw-cpm5.log +0 -0
  204. /package/docs/{investigations → archive/investigations}/hlg-runs/runY-cliff-e4b.log +0 -0
  205. /package/docs/{investigations → archive/investigations}/hlg-runs/runZ-cliff-12b.log +0 -0
  206. /package/docs/{investigations → archive/investigations}/indexshare-performance-spike.md +0 -0
  207. /package/docs/{investigations → archive/investigations}/kernel-perf-review-2026-07.md +0 -0
  208. /package/docs/{investigations → archive/investigations}/lab-build-journal.md +0 -0
  209. /package/docs/{investigations → archive/investigations}/openwiki-evaluation.md +0 -0
  210. /package/docs/{investigations → archive/investigations}/orpo-base-uf-experiment-and-directions.md +0 -0
  211. /package/docs/{investigations → archive/investigations}/orpo-flash-cce-pin-leak.md +0 -0
  212. /package/docs/{investigations → archive/investigations}/orpo-uf-testing-handoff.md +0 -0
  213. /package/docs/{investigations → archive/investigations}/pi-builtin-investigation.md +0 -0
  214. /package/docs/{investigations → archive/investigations}/segmented-backward-handoff.md +0 -0
  215. /package/docs/{investigations → archive/investigations}/starter-model-port-handoff.md +0 -0
  216. /package/docs/{investigations → archive/investigations}/steel-flash-cce-handoff.md +0 -0
  217. /package/docs/{investigations → archive/investigations}/trainer-validation-experiment.md +0 -0
  218. /package/docs/{planning → archive/planning}/chat-ui-vision-memory-first.md +0 -0
  219. /package/docs/{planning → archive/planning}/journal.md +0 -0
  220. /package/docs/{planning → archive/planning}/release-notes-v0.0.10.md +0 -0
  221. /package/docs/{planning → archive/planning}/release-notes-v0.0.11.md +0 -0
  222. /package/docs/{planning → archive/planning}/release-notes-v0.0.12.md +0 -0
  223. /package/docs/{planning → archive/planning}/release-notes-v0.0.13.md +0 -0
  224. /package/docs/{planning → archive/planning}/web-ui-pass-plan.md +0 -0
@@ -21,6 +21,7 @@
21
21
  // runs model-free.
22
22
 
23
23
  import { lookup } from "node:dns/promises";
24
+ import { runtimeValue } from "./runtime-config";
24
25
 
25
26
  export interface MediaFetchPolicy {
26
27
  /** Permit private/loopback/link-local destinations (LAN hosts). */
@@ -71,7 +72,7 @@ export interface RestrictedFetchResult {
71
72
 
72
73
  export function defaultMediaFetchPolicy(): MediaFetchPolicy {
73
74
  return {
74
- allowPrivate: process.env.MLX_BUN_ALLOW_PRIVATE_MEDIA === "1",
75
+ allowPrivate: runtimeValue("MLX_BUN_ALLOW_PRIVATE_MEDIA") === "1",
75
76
  // 10 s / 64 MB: well above what a 30 s audio clip (~5 MB of 16 kHz
76
77
  // WAV, less compressed) or any supported image needs.
77
78
  timeoutMs: 10_000,
@@ -405,10 +406,19 @@ export async function fetchRestrictedHttpBytes(
405
406
  * redirect hop re-validated, one wall-clock timeout, and a streaming
406
407
  * size cap. Throws Error with a client-presentable message — callers
407
408
  * surface it as a 400 (the prompt-build catch in server.ts). */
409
+ /** Video clips are legitimately larger than images/audio: same SSRF guard
410
+ * and timeout discipline, quadruple the body cap (a 30 s 1080p H.264 clip
411
+ * runs tens of MB; frame sampling truncates long clips server-side). */
412
+ export function videoMediaFetchPolicy(): MediaFetchPolicy {
413
+ return { ...defaultMediaFetchPolicy(), maxBytes: 256 * 1024 * 1024 };
414
+ }
415
+
408
416
  export async function fetchMediaBytes(
409
417
  url: string,
410
- kind: "image" | "audio",
411
- policy: MediaFetchPolicy = defaultMediaFetchPolicy(),
418
+ kind: "image" | "audio" | "video",
419
+ policy: MediaFetchPolicy = kind === "video"
420
+ ? videoMediaFetchPolicy()
421
+ : defaultMediaFetchPolicy(),
412
422
  ): Promise<Uint8Array> {
413
423
  if (url.startsWith("data:")) {
414
424
  const comma = url.indexOf(",");
@@ -416,6 +426,12 @@ export async function fetchMediaBytes(
416
426
  const meta = url.slice(0, comma);
417
427
  const body = url.slice(comma + 1);
418
428
  if (!meta.includes("base64")) throw new Error("data: URL must be base64");
429
+ // The same body cap as the HTTP path — inline payloads must not become
430
+ // the uncapped route (base64 inflates 4/3, so check pre-decode too).
431
+ if (body.length * 0.75 > policy.maxBytes)
432
+ throw new Error(
433
+ `${kind} data: URL exceeds the ${Math.round(policy.maxBytes / 1024 / 1024)} MB cap`,
434
+ );
419
435
  return Uint8Array.from(Buffer.from(body, "base64"));
420
436
  }
421
437
  return (
@@ -30,6 +30,7 @@ import type { LoadedTokenizer } from "../tokenizer";
30
30
  import { BatchScheduler, type RowSampler } from "../serve/batch-scheduler";
31
31
  import * as ops from "../mlx/ops";
32
32
  import type { MlxArray } from "../mlx/array";
33
+ import { runtimeValue } from "../runtime-config";
33
34
 
34
35
  const HF_HUB = `${process.env.HOME}/.cache/huggingface/hub`;
35
36
  const E4B_REPO = "models--mlx-community--gemma-4-e4b-it-OptiQ-4bit";
@@ -166,7 +167,7 @@ interface MemoryRuntime {
166
167
  * Opt back in with MLX_BUN_MEMORY_BATCH=8 if a length-bucketed scheduler
167
168
  * lands. (Decision: Josh, 2026-07-01.) */
168
169
  export function memoryBatchSize(): number {
169
- const n = Number(process.env.MLX_BUN_MEMORY_BATCH ?? "1");
170
+ const n = Number(runtimeValue("MLX_BUN_MEMORY_BATCH") ?? "1");
170
171
  return Number.isFinite(n) && n >= 1 ? Math.floor(n) : 1;
171
172
  }
172
173
 
@@ -19,12 +19,13 @@ import { homedir } from "node:os";
19
19
  import { isAbsolute, join } from "node:path";
20
20
  import { spawn } from "node:child_process";
21
21
  import { isFenceLine } from "./article";
22
+ import { runtimeValue } from "../runtime-config";
22
23
 
23
24
  // ---- paths -----------------------------------------------------------
24
25
 
25
26
  /** Root of the memory vault: ~/.mlx-bun/wiki (override with MLX_BUN_WIKI). */
26
27
  export function vaultRoot(): string {
27
- const override = process.env.MLX_BUN_WIKI?.trim();
28
+ const override = runtimeValue("MLX_BUN_WIKI")?.trim();
28
29
  if (override) return override.replace(/^~(?=$|[/\\])/, homedir());
29
30
  return join(homedir(), ".mlx-bun", "wiki");
30
31
  }
@@ -0,0 +1,18 @@
1
+ /** Common admission contract for every runtime. Representation-specific
2
+ * planners may expose richer fields, but serving consumes only this shape. */
3
+ export interface MemoryPlan {
4
+ readonly schemaVersion: 1;
5
+ readonly strategy: "generic-kv" | "glm52-colibri";
6
+ readonly fits: boolean;
7
+ readonly contextTokens: number;
8
+ readonly maxSafeContext: number;
9
+ readonly weightsBytes: number;
10
+ readonly kvBytes: number;
11
+ readonly transientBytes: number;
12
+ readonly reserveBytes: number;
13
+ readonly totalBytes: number;
14
+ readonly usableBytes: number;
15
+ readonly predictedDecodeTps: number | null;
16
+ /** mlx allocator-cache limit, distinct from the process admission limit. */
17
+ readonly allocatorLimitBytes?: number;
18
+ }
package/src/mlx/ffi.ts CHANGED
@@ -14,6 +14,7 @@ import { dlopen, FFIType, ptr, read } from "bun:ffi";
14
14
  import { existsSync } from "node:fs";
15
15
  import { dirname, join } from "node:path";
16
16
  import { nativePackDir } from "../native-pack";
17
+ import { runtimeValue } from "../runtime-config";
17
18
 
18
19
  const { ptr: P, i32, u64, f32, f64, cstring } = FFIType;
19
20
 
@@ -24,7 +25,7 @@ const { ptr: P, i32, u64, f32, f64, cstring } = FFIType;
24
25
  * (src/native-pack.ts; populated on first run by the CLI) → homebrew
25
26
  * (arm64, then Intel prefix). Keep in sync with nativeRuntimeDir(). */
26
27
  function resolveLibmlxc(): string {
27
- const env = process.env.MLX_BUN_LIBMLXC;
28
+ const env = runtimeValue("MLX_BUN_LIBMLXC");
28
29
  if (env) return env;
29
30
  const beside = join(dirname(process.execPath), "libmlxc.dylib");
30
31
  if (existsSync(beside)) return beside;
@@ -99,6 +100,7 @@ export const C = dlopen(LIBMLXC_PATH, {
99
100
  mlx_fast_layer_norm: { args: [P, u64, u64, u64, f32, u64], returns: i32 },
100
101
  mlx_matmul: { args: [P, u64, u64, u64], returns: i32 },
101
102
  mlx_contiguous: { args: [P, u64, FFIType.bool, u64], returns: i32 },
103
+ mlx_copy: { args: [P, u64, u64], returns: i32 },
102
104
  // (res: mlx_vector_array*, w, opt group_size, opt bits, mode, global_scale, stream)
103
105
  mlx_quantize: { args: [P, u64, u64, u64, cstring, u64, u64], returns: i32 },
104
106
  mlx_vector_array_get: { args: [P, u64, u64], returns: i32 },
@@ -173,16 +175,11 @@ export const C = dlopen(LIBMLXC_PATH, {
173
175
  // (res, input, weight, stride, padding, dilation, groups, stream) — depthwise
174
176
  // causal conv1d for Qwen3.5 gated-DeltaNet (groups == channels, padding 0).
175
177
  mlx_conv1d: { args: [P, u64, u64, i32, i32, i32, i32, u64], returns: i32 },
176
- // C signature: (res, input, weight, stride_0, stride_1, padding_0,
177
- // padding_1, dilation_0, dilation_1, groups, stream) — NHWC conv2d for the
178
- // gemma-4 audio SSCP subsampler.
179
- // HAZARD (lab/repro/bun-ffi-stack-args): Apple arm64 packs sub-8-byte STACK
180
- // args at natural size (dilation_1@sp+0, groups@sp+4, stream@sp+8) but
181
- // bun:ffi 1.3.14 writes one 8-byte slot per arg — the callee reads shifted
182
- // garbage and mlx segfaults on a bogus stream. Workaround: declare the two
183
- // stack ints as ONE u64 (dilation_1 | groups<<32) so Bun's slot layout
184
- // byte-matches the Apple ABI. ops.conv2d does the packing.
185
- mlx_conv2d: { args: [P, u64, u64, i32, i32, i32, i32, i32, u64, u64], returns: i32 },
178
+ // Natural mlx-c signatures. Bun 1.4 fixed the macOS arm64 stack packing
179
+ // bug recorded in lab/repro/bun-ffi-stack-args, so these must match the
180
+ // header exactly. mlx-bun now requires Bun 1.4 or newer.
181
+ mlx_conv2d: { args: [P, u64, u64, i32, i32, i32, i32, i32, i32, i32, u64], returns: i32 },
182
+ mlx_conv3d: { args: [P, u64, u64, i32, i32, i32, i32, i32, i32, i32, i32, i32, i32, u64], returns: i32 },
186
183
  // --- training: autograd (value_and_grad) — proven in lab/spikes/phase-train-vag.ts.
187
184
  // mlx_value_and_grad(res: mlx_closure_value_and_grad*, fun: mlx_closure,
188
185
  // argnums: int*, n) → builds a value+grad closure differentiating the
package/src/mlx/ops.ts CHANGED
@@ -113,6 +113,15 @@ export function matmul(a: MlxArray, b: MlxArray, s: S = gpuStream): MlxArray {
113
113
  return new MlxArray(outArray("matmul", (o) => C.mlx_matmul(o, a.handle, b.handle, s)));
114
114
  }
115
115
 
116
+ /** Bit-identical copy through a FRESH primitive node (mx.copy). Unlike
117
+ * `contiguous`, this always creates a new graph node even for an already-
118
+ * contiguous input — the tool for DETACHING an array from a multi-output
119
+ * primitive's sibling set (a retained sibling output pins every co-output's
120
+ * buffer natively; see gatedDeltaUpdate's state detach, 2026-08-20). */
121
+ export function copyOf(a: MlxArray, s: S = gpuStream): MlxArray {
122
+ return new MlxArray(outArray("copy", (o) => C.mlx_copy(o, a.handle, s)));
123
+ }
124
+
116
125
  /** Materialize a row-major contiguous copy (needed before rawBytes on views). */
117
126
  export function contiguous(a: MlxArray, s: S = gpuStream): MlxArray {
118
127
  return new MlxArray(
@@ -880,20 +889,36 @@ export function conv1d(
880
889
 
881
890
  /** nn.Conv2d: input [B, H, W, C_in] (NHWC), weight [C_out, kH, kW, C_in/groups]
882
891
  * → [B, H', W', C_out]. mlx weight layout — the oracle transposes PyTorch's
883
- * [C_out, C_in, kH, kW] at load. For the gemma-4 audio SSCP subsampler.
884
- * dilation_1 and groups ride in one packed u64 — see the mlx_conv2d hazard
885
- * note in ffi.ts (bun:ffi stack-arg ABI bug). */
892
+ * [C_out, C_in, kH, kW] at load. For the gemma-4 audio SSCP subsampler. */
886
893
  export function conv2d(
887
894
  input: MlxArray, weight: MlxArray, stride: [number, number] = [1, 1],
888
895
  padding: [number, number] = [0, 0], dilation: [number, number] = [1, 1],
889
896
  groups = 1, s: S = gpuStream,
890
897
  ): MlxArray {
891
- const dilation1AndGroups =
892
- (BigInt(dilation[1] >>> 0) & 0xffffffffn) | (BigInt(groups >>> 0) << 32n);
893
898
  return new MlxArray(
894
899
  outArray("conv2d", (o) =>
895
900
  C.mlx_conv2d(o, input.handle, weight.handle, stride[0], stride[1],
896
- padding[0], padding[1], dilation[0], dilation1AndGroups, s),
901
+ padding[0], padding[1], dilation[0], dilation[1], groups, s),
902
+ ),
903
+ );
904
+ }
905
+
906
+ /** nn.Conv3d: input [B, D, H, W, C_in] (NDHWC), weight [C_out, kD, kH, kW,
907
+ * C_in/groups] → [B, D', H', W', C_out]. The Qwen3-VL vision patch embed
908
+ * (kernel == stride) — the conv KERNEL's accumulation order is the parity-
909
+ * relevant arithmetic; a value-equivalent GEMM differs by bf16 ulp. */
910
+ export function conv3d(
911
+ input: MlxArray, weight: MlxArray,
912
+ stride: [number, number, number] = [1, 1, 1],
913
+ padding: [number, number, number] = [0, 0, 0],
914
+ dilation: [number, number, number] = [1, 1, 1],
915
+ groups = 1, s: S = gpuStream,
916
+ ): MlxArray {
917
+ return new MlxArray(
918
+ outArray("conv3d", (o) =>
919
+ C.mlx_conv3d(o, input.handle, weight.handle, stride[0], stride[1],
920
+ stride[2], padding[0], padding[1], padding[2], dilation[0],
921
+ dilation[1], dilation[2], groups, s),
897
922
  ),
898
923
  );
899
924
  }
@@ -242,7 +242,7 @@ export function extractKVRow(cache: KVCache, leftPad: number, i: number): KVCach
242
242
  const cut = (a: MlxArray): MlxArray => {
243
243
  const [, H, , D] = a.shape as [number, number, number, number];
244
244
  const view = a.slice([i, 0, leftPad, 0], [i + 1, H, S, D]);
245
- const own = ops.contiguous(view);
245
+ const own = ops.copyOf(view); // TRUE copy: contiguous(view) is a no-op VIEW when already contiguous — pins the source buffer (2026-08-20 DeltaNet conv leak class)
246
246
  view.dispose();
247
247
  return own;
248
248
  };
@@ -122,7 +122,7 @@ export function extractQuantRow(
122
122
  tripleMap(t, (a) => {
123
123
  const [, H, , D] = a.shape as [number, number, number, number];
124
124
  const view = a.slice([i, 0, leftPad, 0], [i + 1, H, S, D]);
125
- const own = ops.contiguous(view);
125
+ const own = ops.copyOf(view); // TRUE copy: contiguous(view) is a no-op VIEW when already contiguous — pins the source buffer (2026-08-20 DeltaNet conv leak class)
126
126
  view.dispose();
127
127
  return own;
128
128
  });
@@ -32,6 +32,12 @@ import { Dtype } from "../mlx/ffi";
32
32
  import { RotatingQuantizedKVCache, type Mask } from "./gemma4-base";
33
33
  import { buildBatchedRotatingMask } from "./batched-rotating";
34
34
  import type { QuantRow } from "./batched-quant";
35
+ import { BatchedRotatingState } from "./batched-rotating-state";
36
+ import {
37
+ mergeStorageRows,
38
+ quantizedRowStorage,
39
+ temporalStorageView,
40
+ } from "./batched-row-storage";
35
41
 
36
42
  const STEP = 256; // shared with RotatingQuantizedKVCache.STEP and the bf16 twin
37
43
 
@@ -49,9 +55,7 @@ const mapTriple = (
49
55
  * storage. Scalar ring state (`ringIdx`/`offset` reuse the base fields);
50
56
  * per-row `offsetArr` (absolute positions → RoPE) and `leftPad`. */
51
57
  export class BatchedRotatingQuantCache extends RotatingQuantizedKVCache {
52
- offsetArr: number[];
53
- leftPad: number[];
54
- #rotated = false;
58
+ readonly #rows: BatchedRotatingState;
55
59
  /** Per-row RoPE positions. STABLE ACROSS A STEP — refreshed only at
56
60
  * releaseRopeArr() (the scheduler's post-dispatch hook), never inside
57
61
  * the update: the monolith CAPTURES it pre-update and ropes Q after
@@ -67,13 +71,16 @@ export class BatchedRotatingQuantCache extends RotatingQuantizedKVCache {
67
71
  maxSize: number, groupSize: number, bits: number, leftPad: number[], offsets: number[],
68
72
  ) {
69
73
  super(maxSize, groupSize, bits);
70
- this.leftPad = [...leftPad];
71
- this.offsetArr = [...offsets];
74
+ this.#rows = new BatchedRotatingState(maxSize, leftPad, offsets);
72
75
  this.ropeOffsetArr = MlxArray.fromInt32(Int32Array.from(offsets), [offsets.length]);
73
76
  }
74
77
 
78
+ get offsetArr(): number[] { return this.#rows.offsets; }
79
+ get leftPad(): number[] { return this.#rows.leftPad; }
80
+ get batchSize(): number { return this.#rows.batchSize; }
81
+
75
82
  get #B(): number {
76
- return this.leftPad.length;
83
+ return this.#rows.batchSize;
77
84
  }
78
85
 
79
86
  #seqLen2(): number {
@@ -104,7 +111,7 @@ export class BatchedRotatingQuantCache extends RotatingQuantizedKVCache {
104
111
  mode: "array",
105
112
  arr: buildBatchedRotatingMask(
106
113
  this.#B, N, this.leftPad, this.maxSize, window,
107
- this.ringIdx, this.offset, this.#rotated,
114
+ this.#rows.ringIndex, this.#rows.totalOffset, this.#rows.rotated,
108
115
  ),
109
116
  };
110
117
  }
@@ -117,7 +124,7 @@ export class BatchedRotatingQuantCache extends RotatingQuantizedKVCache {
117
124
  const vD = v.shape[3]!;
118
125
  if (S !== 1)
119
126
  throw new Error("BatchedRotatingQuantCache supports N=1 decode updates only (solo-prefill then merge)");
120
- const prev = this.offset;
127
+ const prev = this.#rows.totalOffset;
121
128
 
122
129
  // Grow the buffer (in STEP chunks) until it reaches maxSize.
123
130
  if (!this.keys || (prev >= this.#seqLen2() && this.#seqLen2() < this.maxSize)) {
@@ -144,7 +151,8 @@ export class BatchedRotatingQuantCache extends RotatingQuantizedKVCache {
144
151
  this.keys = newK;
145
152
  this.values = newV;
146
153
  }
147
- this.ringIdx = prev;
154
+ this.#rows.markGrown(prev);
155
+ this.ringIdx = this.#rows.ringIndex;
148
156
  }
149
157
 
150
158
  // Trim any overshoot past maxSize (decrements left padding persistently).
@@ -159,16 +167,12 @@ export class BatchedRotatingQuantCache extends RotatingQuantizedKVCache {
159
167
  });
160
168
  this.keys = cutFrom(this.keys!);
161
169
  this.values = cutFrom(this.values!);
162
- this.ringIdx = this.maxSize;
163
- this.leftPad = this.leftPad.map((x) => x - trimSize);
170
+ this.#rows.trimOvershoot(trimSize);
171
+ this.ringIdx = this.#rows.ringIndex;
164
172
  }
165
173
 
166
174
  // Rotate when the write head reaches the end of the ring.
167
- if (this.ringIdx === this.maxSize) {
168
- this.#rotated = true;
169
- this.ringIdx = 0;
170
- }
171
- if (this.#rotated) this.leftPad = this.leftPad.map((x) => x - S);
175
+ this.ringIdx = this.#rows.beginWrite(S);
172
176
 
173
177
  // Quantize + write the new K/V at the ring head (six sliceUpdates).
174
178
  const kq = ops.quantize(k, this.groupSize, this.bits);
@@ -184,9 +188,9 @@ export class BatchedRotatingQuantCache extends RotatingQuantizedKVCache {
184
188
  disposeTriple(kq);
185
189
  disposeTriple(vq);
186
190
 
187
- this.offset += S;
188
- this.offsetArr = this.offsetArr.map((x) => x + S);
189
- this.ringIdx += S;
191
+ this.#rows.commitWrite(S);
192
+ this.offset = this.#rows.totalOffset;
193
+ this.ringIdx = this.#rows.ringIndex;
190
194
 
191
195
  // Return the populated prefix (ring not yet full) or the whole buffer.
192
196
  const upTo = this.offset < this.maxSize ? this.offset : this.#seqLen2();
@@ -209,27 +213,10 @@ export class BatchedRotatingQuantCache extends RotatingQuantizedKVCache {
209
213
  * overrides the base to use the BATCHED ring state. */
210
214
  override temporalView(): [ops.QuantizedTensor, ops.QuantizedTensor] {
211
215
  if (!this.keys || !this.values) throw new Error("cache is empty");
212
- const valid = Math.min(this.offset, this.maxSize);
213
- const order = (t: ops.QuantizedTensor): ops.QuantizedTensor =>
214
- mapTriple(t, (a) => {
215
- const [B, H, Sbuf, D] = a.shape as [number, number, number, number];
216
- let tt: MlxArray;
217
- if (this.ringIdx === Sbuf) {
218
- tt = a.slice([0, 0, 0, 0], [B, H, Sbuf, D]);
219
- } else if (this.ringIdx < this.offset) {
220
- const tail = a.slice([0, 0, this.ringIdx, 0], [B, H, Sbuf, D]);
221
- const head = a.slice([0, 0, 0, 0], [B, H, this.ringIdx, D]);
222
- tt = ops.concatAxis([tail, head], 2);
223
- tail.dispose();
224
- head.dispose();
225
- } else {
226
- tt = a.slice([0, 0, 0, 0], [B, H, this.ringIdx, D]);
227
- }
228
- const cut = tt.slice([0, 0, 0, 0], [B, H, valid, D]);
229
- tt.dispose();
230
- return cut;
231
- });
232
- return [order(this.keys), order(this.values)];
216
+ return [
217
+ temporalStorageView(quantizedRowStorage, this.keys, this.#rows),
218
+ temporalStorageView(quantizedRowStorage, this.values, this.#rows),
219
+ ];
233
220
  }
234
221
 
235
222
  /** Quantized twin of BatchedRotatingCache.extractRow (mlx-lm
@@ -244,34 +231,13 @@ export class BatchedRotatingQuantCache extends RotatingQuantizedKVCache {
244
231
  extractRow(i: number): RotatingQuantizedKVCache | null {
245
232
  if (!this.keys || !this.values) return null;
246
233
  const pad = Math.max(0, this.leftPad[i]!);
247
- const valid = Math.min(this.offset, this.maxSize);
248
- const cut = (t: ops.QuantizedTensor): ops.QuantizedTensor =>
249
- mapTriple(t, (a) => {
250
- const [, H, Sbuf, D] = a.shape as [number, number, number, number];
251
- const row = a.slice([i, 0, 0, 0], [i + 1, H, Sbuf, D]);
252
- let tt: MlxArray;
253
- if (this.ringIdx === Sbuf) {
254
- tt = row;
255
- } else if (this.ringIdx < this.offset) {
256
- const tail = row.slice([0, 0, this.ringIdx, 0], [1, H, Sbuf, D]);
257
- const head = row.slice([0, 0, 0, 0], [1, H, this.ringIdx, D]);
258
- tt = ops.concatAxis([tail, head], 2);
259
- tail.dispose();
260
- head.dispose();
261
- row.dispose();
262
- } else {
263
- tt = row.slice([0, 0, 0, 0], [1, H, this.ringIdx, D]);
264
- row.dispose();
265
- }
266
- const cutV = tt.slice([0, 0, pad, 0], [1, H, valid, D]);
267
- tt.dispose();
268
- const own = ops.contiguous(cutV);
269
- cutV.dispose();
270
- return own;
271
- });
272
234
  const c = new RotatingQuantizedKVCache(this.maxSize, this.groupSize, this.bits);
273
- const k = cut(this.keys);
274
- const v = cut(this.values);
235
+ const k = temporalStorageView(quantizedRowStorage, this.keys, this.#rows, {
236
+ row: i, from: pad, copy: true,
237
+ });
238
+ const v = temporalStorageView(quantizedRowStorage, this.values, this.#rows, {
239
+ row: i, from: pad, copy: true,
240
+ });
275
241
  c.restoreState(k, v, this.offsetArr[i]!, k.packed.shape[2]!);
276
242
  return c;
277
243
  }
@@ -279,22 +245,19 @@ export class BatchedRotatingQuantCache extends RotatingQuantizedKVCache {
279
245
  /** Keep only `keep` rows along the batch axis (eviction), in place. */
280
246
  filter(keep: number[]): void {
281
247
  if (this.keys && this.values) {
282
- const idxArr = MlxArray.fromInt32(Int32Array.from(keep), [keep.length]);
283
- const take = (t: ops.QuantizedTensor): ops.QuantizedTensor =>
284
- mapTriple(t, (a) => ops.takeAxis(a, idxArr, 0));
285
- const k = take(this.keys);
286
- const v = take(this.values);
287
- idxArr.dispose();
248
+ const k = quantizedRowStorage.takeRows(this.keys, keep);
249
+ const v = quantizedRowStorage.takeRows(this.values, keep);
288
250
  disposeTriple(this.keys);
289
251
  disposeTriple(this.values);
290
252
  this.keys = k;
291
253
  this.values = v;
292
254
  }
293
- this.offsetArr = keep.map((i) => this.offsetArr[i]!);
294
- this.leftPad = keep.map((i) => this.leftPad[i]!);
255
+ this.#rows.filter(keep);
295
256
  this.releaseRopeArr(); // safe here: filter runs between steps
296
257
  }
297
258
 
259
+ filterRows(keep: readonly number[]): void { this.filter([...keep]); }
260
+
298
261
  override isTrimmable(): boolean {
299
262
  return false; // batched rows never re-enter the prompt cache directly
300
263
  }
@@ -317,30 +280,13 @@ export class BatchedRotatingQuantCache extends RotatingQuantizedKVCache {
317
280
  const width = Math.max(...lens, 0);
318
281
  const leftPad = lens.map((l) => width - l);
319
282
  const cache = new BatchedRotatingQuantCache(maxSize, groupSize, bits, leftPad, offsets);
283
+ cache.#rows.restoreMerged(width, offsets);
284
+ cache.offset = cache.#rows.totalOffset;
285
+ cache.ringIdx = cache.#rows.ringIndex;
320
286
  if (width === 0) return cache;
321
287
 
322
- const padTo = (a: MlxArray, pad: number): MlxArray => {
323
- if (pad === 0) return a.slice([0, 0, 0, 0], a.shape as number[]);
324
- const [B, H, , D] = a.shape as [number, number, number, number];
325
- const z = ops.zeros([B, H, pad, D], a.dtype);
326
- const out = ops.concatAxis([z, a], 2);
327
- z.dispose();
328
- return out;
329
- };
330
- const padded = rows.map((r, i) => ({
331
- keys: mapTriple(r.keys, (a) => padTo(a, leftPad[i]!)),
332
- values: mapTriple(r.values, (a) => padTo(a, leftPad[i]!)),
333
- }));
334
- const cat = (pick: (r: QuantRow) => ops.QuantizedTensor): ops.QuantizedTensor => ({
335
- packed: ops.concatAxis(padded.map((r) => pick(r).packed), 0),
336
- scales: ops.concatAxis(padded.map((r) => pick(r).scales), 0),
337
- biases: ops.concatAxis(padded.map((r) => pick(r).biases), 0),
338
- });
339
- cache.keys = cat((r) => r.keys);
340
- cache.values = cat((r) => r.values);
341
- for (const r of padded) { disposeTriple(r.keys); disposeTriple(r.values); }
342
- cache.ringIdx = width;
343
- cache.offset = width;
288
+ cache.keys = mergeStorageRows(quantizedRowStorage, rows.map((row) => row.keys), leftPad);
289
+ cache.values = mergeStorageRows(quantizedRowStorage, rows.map((row) => row.values), leftPad);
344
290
  return cache;
345
291
  }
346
292
  }
@@ -0,0 +1,73 @@
1
+ /** Host-side row and ring bookkeeping shared by plain and quantized storage. */
2
+ export class BatchedRotatingState {
3
+ readonly maxSize: number;
4
+ offsets: number[];
5
+ leftPad: number[];
6
+ ringIndex = 0;
7
+ totalOffset = 0;
8
+ rotated = false;
9
+
10
+ constructor(maxSize: number, leftPad: readonly number[], offsets?: readonly number[]) {
11
+ this.maxSize = maxSize;
12
+ this.leftPad = [...leftPad];
13
+ this.offsets = offsets ? [...offsets] : leftPad.map((pad) => -pad);
14
+ }
15
+
16
+ get batchSize(): number { return this.leftPad.length; }
17
+ get validLength(): number { return Math.min(this.totalOffset, this.maxSize); }
18
+ get trimmable(): boolean { return this.totalOffset < this.maxSize; }
19
+
20
+ markGrown(previousOffset: number): void {
21
+ this.ringIndex = previousOffset;
22
+ }
23
+
24
+ trimOvershoot(tokens: number): void {
25
+ if (tokens <= 0) return;
26
+ this.ringIndex = this.maxSize;
27
+ this.leftPad = this.leftPad.map((pad) => pad - tokens);
28
+ }
29
+
30
+ /** Prepare an N-token write and return its physical ring column. */
31
+ beginWrite(tokens: number): number {
32
+ if (this.ringIndex === this.maxSize) {
33
+ this.rotated = true;
34
+ this.ringIndex = 0;
35
+ }
36
+ if (this.rotated) this.leftPad = this.leftPad.map((pad) => pad - tokens);
37
+ return this.ringIndex;
38
+ }
39
+
40
+ commitWrite(tokens: number): void {
41
+ this.totalOffset += tokens;
42
+ this.offsets = this.offsets.map((offset) => offset + tokens);
43
+ this.ringIndex += tokens;
44
+ }
45
+
46
+ filter(keep: readonly number[]): void {
47
+ this.offsets = keep.map((row) => this.offsets[row]!);
48
+ this.leftPad = keep.map((row) => this.leftPad[row]!);
49
+ }
50
+
51
+ trim(tokens: number): number {
52
+ const amount = Math.min(this.totalOffset, tokens);
53
+ this.totalOffset -= amount;
54
+ this.ringIndex -= amount;
55
+ this.offsets = this.offsets.map((offset) => offset - amount);
56
+ return amount;
57
+ }
58
+
59
+ restoreMerged(width: number, offsets: readonly number[]): void {
60
+ this.totalOffset = width;
61
+ this.ringIndex = width;
62
+ this.offsets = [...offsets];
63
+ this.rotated = false;
64
+ }
65
+
66
+ /** Physical ranges that expose a ring buffer in temporal order. */
67
+ temporalRanges(bufferLength: number): readonly [number, number][] {
68
+ if (this.ringIndex === bufferLength) return [[0, bufferLength]];
69
+ if (this.ringIndex < this.totalOffset)
70
+ return [[this.ringIndex, bufferLength], [0, this.ringIndex]];
71
+ return [[0, this.ringIndex]];
72
+ }
73
+ }