mlx-bun 0.0.8 → 0.0.9

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 (133) hide show
  1. package/README.md +112 -20
  2. package/docs/design/batching-v2-plan.md +515 -0
  3. package/docs/design/bucketing-stage.md +196 -0
  4. package/docs/design/compat-cli-surface-design.md +11 -11
  5. package/docs/design/docs-reorg-plan.md +1 -1
  6. package/docs/design/dreaming-nightly-pipeline.md +291 -0
  7. package/docs/design/dspark-speculative-decoding.md +106 -0
  8. package/docs/design/generic-model-support.md +381 -0
  9. package/docs/design/memory-inference-path.md +422 -0
  10. package/docs/design/memory-synthesis.md +287 -0
  11. package/docs/design/minicpm5-decode-megakernel.md +484 -0
  12. package/docs/design/mlx-lm-tool-parity-plan.md +535 -0
  13. package/docs/design/orpo-dynamic-lambda.md +41 -0
  14. package/docs/design/orpo-training.md +35 -5
  15. package/docs/design/parallel-slots.md +12 -1
  16. package/docs/design/parity-tier-dag.md +13 -5
  17. package/docs/design/spec-decode-larger-targets.md +229 -0
  18. package/docs/design/tauri-desktop-app.md +2 -2
  19. package/docs/design/the-dreaming-handoff.md +141 -0
  20. package/docs/design/the-dreaming-master-plan.md +446 -0
  21. package/docs/design/write-pipeline-entity.md +468 -0
  22. package/docs/investigations/curve-bisector-routing.md +1 -1
  23. package/docs/investigations/decode-roofline-lookagain.md +262 -0
  24. package/docs/investigations/dspark-handoff.md +58 -0
  25. package/docs/investigations/kernel-perf-review-2026-07.md +92 -0
  26. package/docs/planning/PRODUCT_ROADMAP.md +2 -2
  27. package/docs/planning/curve-sampler-research-plan.md +331 -0
  28. package/docs/planning/memory-docs-and-dag-plan.md +185 -0
  29. package/docs/planning/pi-integration-review.md +178 -0
  30. package/docs/planning/release-notes-v0.0.9.md +122 -0
  31. package/docs/planning/web-ui-pass-plan.md +213 -0
  32. package/docs/planning/website-readme-pass-plan.md +224 -0
  33. package/docs/reference/cli.md +255 -0
  34. package/docs/reference/embedding.md +7 -2
  35. package/docs/reference/library-api.md +22 -3
  36. package/docs/reference/memory.md +5 -5
  37. package/docs/reference/models.md +137 -0
  38. package/docs/reference/orpo-quickstart.md +6 -0
  39. package/docs/reference/server-api.md +187 -9
  40. package/docs/reference/server-config.md +40 -9
  41. package/docs/reference/training.md +25 -11
  42. package/package.json +1 -1
  43. package/src/anthropic.ts +22 -0
  44. package/src/assets/curve-designer.html +1 -1
  45. package/src/chat-template.ts +25 -7
  46. package/src/cli.ts +1029 -86
  47. package/src/config.ts +4 -1
  48. package/src/download.ts +99 -8
  49. package/src/embed.ts +77 -0
  50. package/src/eval/perplexity.ts +164 -0
  51. package/src/eval/runner.ts +15 -6
  52. package/src/eval/tasks/bfcl.ts +1 -1
  53. package/src/generate.ts +141 -18
  54. package/src/harness-pi.ts +43 -17
  55. package/src/index.ts +2 -0
  56. package/src/lora.ts +2 -2
  57. package/src/memory/article.ts +558 -0
  58. package/src/memory/chunk-validation.ts +150 -0
  59. package/src/memory/chunk.ts +305 -12
  60. package/src/memory/cluster.ts +344 -17
  61. package/src/memory/coverage.ts +134 -0
  62. package/src/memory/crosslink.ts +0 -0
  63. package/src/memory/db.ts +184 -45
  64. package/src/memory/entity.ts +203 -0
  65. package/src/memory/gate.ts +187 -0
  66. package/src/memory/history.ts +152 -0
  67. package/src/memory/ingest.ts +324 -11
  68. package/src/memory/model.ts +321 -0
  69. package/src/memory/normalize.ts +671 -0
  70. package/src/memory/parse.ts +22 -0
  71. package/src/memory/pipeline.ts +242 -30
  72. package/src/memory/prompts.ts +42 -0
  73. package/src/memory/query.ts +334 -0
  74. package/src/memory/reconcile.ts +734 -0
  75. package/src/memory/redirect.ts +108 -0
  76. package/src/memory/reindex.ts +325 -0
  77. package/src/memory/resolve.ts +307 -0
  78. package/src/memory/route.ts +272 -0
  79. package/src/memory/schedule.ts +6 -4
  80. package/src/memory/stages.ts +774 -0
  81. package/src/memory/synthesize.ts +1629 -20
  82. package/src/memory/tools.ts +446 -96
  83. package/src/memory/vault.ts +462 -35
  84. package/src/memory/wikify.ts +1057 -16
  85. package/src/mlx/ffi.ts +3 -0
  86. package/src/mlx/ops.ts +46 -0
  87. package/src/model/compiled-decode.ts +43 -8
  88. package/src/model/factory.ts +29 -8
  89. package/src/model/flash-attention.ts +4 -2
  90. package/src/model/fused-decode-kernel.ts +14 -7
  91. package/src/model/fused-mlp-kernel.ts +169 -0
  92. package/src/model/fused-swiglu-kernel.ts +240 -0
  93. package/src/model/gemma4-base.ts +62 -6
  94. package/src/model/gemma4.ts +33 -2
  95. package/src/model/minicpm5.ts +106 -8
  96. package/src/model/qwen3.ts +276 -0
  97. package/src/model/steel-linear-kernel.ts +96 -0
  98. package/src/model/support.ts +38 -8
  99. package/src/model/universal/archs.ts +332 -0
  100. package/src/model/universal/dense.ts +576 -0
  101. package/src/model/universal/modules.ts +163 -0
  102. package/src/model/universal/rope.ts +366 -0
  103. package/src/pi-provider.ts +1 -1
  104. package/src/pi-terminal.ts +29 -8
  105. package/src/pi-web.ts +19 -2
  106. package/src/quantize/config-writer.ts +1 -1
  107. package/src/registry.ts +215 -7
  108. package/src/responses.ts +23 -0
  109. package/src/sampler.ts +220 -9
  110. package/src/serve/batch-scheduler.ts +272 -80
  111. package/src/serve/generation-gateway.ts +86 -18
  112. package/src/server.ts +612 -49
  113. package/src/spec/drafter.ts +81 -49
  114. package/src/spec/dspark/data-dflash.ts +156 -0
  115. package/src/spec/dspark/data.ts +201 -0
  116. package/src/spec/dspark/generate-dflash.ts +193 -0
  117. package/src/spec/dspark/generate.ts +290 -0
  118. package/src/spec/dspark/loss.ts +213 -0
  119. package/src/spec/dspark/module-dflash.ts +361 -0
  120. package/src/spec/dspark/module.ts +540 -0
  121. package/src/spec/dspark/sample.ts +112 -0
  122. package/src/spec/generate.ts +40 -19
  123. package/src/status-page.html +1 -1
  124. package/src/tokenizer.ts +7 -0
  125. package/src/train/flash-cce.ts +70 -15
  126. package/src/train/fuse.ts +243 -0
  127. package/src/train/index.ts +3 -0
  128. package/src/train/job.ts +8 -0
  129. package/src/train/loss.ts +289 -30
  130. package/src/train/prefix-shared.ts +33 -5
  131. package/src/train/segmented.ts +67 -11
  132. package/src/train/trainer.ts +92 -28
  133. package/src/web/skills/memory/SKILL.md +40 -17
package/src/mlx/ffi.ts CHANGED
@@ -119,6 +119,9 @@ export const C = dlopen(LIBMLXC_PATH, {
119
119
  mlx_divide: { args: [P, u64, u64, u64], returns: i32 },
120
120
  mlx_tanh: { args: [P, u64, u64], returns: i32 },
121
121
  mlx_sigmoid: { args: [P, u64, u64], returns: i32 },
122
+ mlx_erf: { args: [P, u64, u64], returns: i32 },
123
+ // (res, c, a, b, alpha, beta, stream) — alpha·(a@b) + beta·c (nn.Linear bias path)
124
+ mlx_addmm: { args: [P, u64, u64, u64, f32, f32, u64], returns: i32 },
122
125
  mlx_power: { args: [P, u64, u64, u64], returns: i32 },
123
126
  mlx_take_axis: { args: [P, u64, u64, i32, u64], returns: i32 },
124
127
  mlx_reshape: { args: [P, u64, P, u64, u64], returns: i32 },
package/src/mlx/ops.ts CHANGED
@@ -111,6 +111,23 @@ export function rope(
111
111
  );
112
112
  }
113
113
 
114
+ /** fast::rope with every knob exposed (traditional + scale), for the
115
+ * universal rope factory (rope_utils.initialize_rope port). The plain
116
+ * `rope` above stays byte-identical for existing call sites. */
117
+ export function ropeScaled(
118
+ x: MlxArray, dims: number, traditional: boolean, base: number | null,
119
+ scale: number, offset: number, freqs: MlxArray | null, s: S = gpuStream,
120
+ ): MlxArray {
121
+ return new MlxArray(
122
+ outArray("fast_rope", (o) =>
123
+ C.mlx_fast_rope(
124
+ o, x.handle, dims, traditional, optFloat(base), scale, offset,
125
+ freqs?.handle ?? 0n, s,
126
+ ),
127
+ ),
128
+ );
129
+ }
130
+
114
131
  /** fast::rope with the position offset as an ARRAY (int32 scalar) instead
115
132
  * of a baked int — required inside compiled decode graphs, where the
116
133
  * offset changes every step but the graph must not. Same kernel as
@@ -267,6 +284,35 @@ export function geluApprox(x: MlxArray, s: S = gpuStream): MlxArray {
267
284
  return out;
268
285
  }
269
286
 
287
+ export function erf(a: MlxArray, s: S = gpuStream): MlxArray {
288
+ return new MlxArray(outArray("erf", (o) => C.mlx_erf(o, a.handle, s)));
289
+ }
290
+
291
+ /** nn.gelu (PRECISE, erf-based): x · (1 + erf(x/√2)) / 2 — composed exactly
292
+ * as mlx's python source (weak scalars promote to x's dtype). Used by the
293
+ * gemma-1 gated MLP and starcoder2's plain MLP; distinct from geluApprox
294
+ * (gelu_pytorch_tanh). */
295
+ export function geluPrecise(x: MlxArray, s: S = gpuStream): MlxArray {
296
+ const sqrt2 = scalarLike(Math.sqrt(2), x);
297
+ const xs = div(x, sqrt2, s); // x / math.sqrt(2) — division, not mul-by-reciprocal
298
+ const e = erf(xs, s);
299
+ const one = scalarLike(1, x);
300
+ const e1 = add(one, e, s);
301
+ const xe = mul(x, e1, s);
302
+ const two = scalarLike(2, x);
303
+ const out = div(xe, two, s); // (...) / 2
304
+ for (const a of [sqrt2, xs, e, one, e1, xe, two]) a.dispose();
305
+ return out;
306
+ }
307
+
308
+ /** mx.addmm(c, a, b): a@b + c fused (α=β=1) — nn.Linear's bias path
309
+ * (`mx.addmm(bias, x, weight.T)`). */
310
+ export function addmm(c: MlxArray, a: MlxArray, b: MlxArray, s: S = gpuStream): MlxArray {
311
+ return new MlxArray(
312
+ outArray("addmm", (o) => C.mlx_addmm(o, c.handle, a.handle, b.handle, 1.0, 1.0, s)),
313
+ );
314
+ }
315
+
270
316
  export function exp(a: MlxArray, s: S = gpuStream): MlxArray {
271
317
  return new MlxArray(outArray("exp", (o) => C.mlx_exp(o, a.handle, s)));
272
318
  }
@@ -590,6 +590,29 @@ export class CompiledDecode {
590
590
  const evalWith: MlxArray[] = [];
591
591
  let carried = cur; // seg 0 consumes the token array; later, hidden state
592
592
  let carriedOwned = false; // cur belongs to the caller
593
+ // Mid-step failure safety (a later segment's closure.apply can throw at
594
+ // first trace AFTER earlier layers already advanced): the step must be
595
+ // TRANSACTIONAL, or the caller's uncompiled re-forward of the same token
596
+ // double-writes the committed layers (+1 offset skew forever, silently
597
+ // corrupted attention). Two mechanisms, chosen by what each phase allows:
598
+ // - ring segments: adoptDecodeStep is a pure host-side handle swap with
599
+ // no reader between here and step end (each cache is read only by its
600
+ // own layer, inside the closure that produced the update), so the
601
+ // adopts are STAGED and committed only once every segment and js layer
602
+ // has succeeded. On failure the staged buffers are disposed and the
603
+ // ring caches are exactly at their pre-step state (prepareDecodeStep's
604
+ // growth/trim/rotate bookkeeping is benign for the uncompiled path —
605
+ // the same invariant #stepWhole and generate.ts's fallback rely on).
606
+ // - js (concat) layers: layer.forward commits via the cache's real
607
+ // updateAndFetch mid-loop, so on failure the commit is ROLLED BACK by
608
+ // reverting offset (and ring index) — trim(1) semantics. The written
609
+ // KV row beyond the reverted offset is dead data the re-forward
610
+ // overwrites at the same position (updateAndFetch writes at offset/
611
+ // ringIdx), so post-recovery state equals a single clean write. This
612
+ // holds for the post-wrap quantized-under-perf-kernel case too: the
613
+ // in-place S=1 write slot is re-derived from the reverted ringIdx.
614
+ const stagedAdopts: { c: AnyCache; bufs: MlxArray[] }[] = [];
615
+ const committedJs: { c: AnyCache; offsetBefore: number }[] = [];
593
616
  try {
594
617
  for (let s = 0; s < segs.length; s++) {
595
618
  const { from, to } = segs[s]!;
@@ -636,17 +659,14 @@ export class CompiledDecode {
636
659
  carried = outs[0]!;
637
660
  carriedOwned = true;
638
661
  // ring updates are ancestors of the carried hidden state (the
639
- // in-graph fetch reads the updated buffer): no eval roots
662
+ // in-graph fetch reads the updated buffer): no eval roots. The
663
+ // host-side adoption is deferred to the end of the step (see above).
640
664
  let oi = 1;
641
665
  for (let i = from; i < to; i++) {
642
666
  const c = anyCaches[i]!;
643
- if (c instanceof RotatingQuantizedKVCache) {
644
- c.adoptDecodeStep(outs.slice(oi, oi + 6));
645
- oi += 6;
646
- } else {
647
- (c as RotatingKVCache).adoptDecodeStep(outs[oi]!, outs[oi + 1]!);
648
- oi += 2;
649
- }
667
+ const width = c instanceof RotatingQuantizedKVCache ? 6 : 2;
668
+ stagedAdopts.push({ c, bufs: outs.slice(oi, oi + width) });
669
+ oi += width;
650
670
  }
651
671
  }
652
672
  if (s < jsLayers.length) {
@@ -655,6 +675,7 @@ export class CompiledDecode {
655
675
  const c = anyCaches[li]!;
656
676
  const window = layer.layerType === "sliding_attention" ? this.model.windowSize : null;
657
677
  const mask = c.makeMask(1, window); // N=1 → mode ""
678
+ committedJs.push({ c, offsetBefore: c.offset });
658
679
  const { h: next, shared } = layer.forward(carried, mask, c, null, null);
659
680
  if (carriedOwned) carried.dispose();
660
681
  carried = next;
@@ -663,6 +684,20 @@ export class CompiledDecode {
663
684
  mask.arr?.dispose();
664
685
  }
665
686
  }
687
+ // every segment and js layer succeeded — commit the staged ring adopts
688
+ for (const { c, bufs } of stagedAdopts) {
689
+ if (c instanceof RotatingQuantizedKVCache) c.adoptDecodeStep(bufs);
690
+ else (c as RotatingKVCache).adoptDecodeStep(bufs[0]!, bufs[1]!);
691
+ }
692
+ } catch (e) {
693
+ // unwind: free staged (never-adopted) ring buffers and the owned hidden
694
+ // state, and revert the js layers' committed writes, so the caller's
695
+ // uncompiled re-forward of this token starts from the pre-step state.
696
+ for (const { bufs } of stagedAdopts) for (const b of bufs) b.dispose();
697
+ for (const { c, offsetBefore } of committedJs)
698
+ if (c.offset > offsetBefore) c.trim(c.offset - offsetBefore);
699
+ if (carriedOwned) carried.dispose();
700
+ throw e;
666
701
  } finally {
667
702
  for (const t of stepTemps) t.dispose();
668
703
  }
@@ -11,12 +11,21 @@ import { configFingerprint } from "./fingerprint";
11
11
  import { GENERATED } from "./generated";
12
12
  import { MiniCPM5Model } from "./minicpm5";
13
13
  import { Qwen35Model } from "./qwen3_5";
14
+ import { Qwen3Model } from "./qwen3";
14
15
  import { DiffusionGemmaModel } from "./diffusion-gemma";
15
- import { isDiffusionGemmaConfig, isMiniCPM5Config, isQwen35Config } from "./support";
16
+ import { UniversalDenseModel } from "./universal/dense";
17
+ import { genericArgsFor, GENERIC_MODEL_TYPES, remapModelType } from "./universal/archs";
18
+ import { isDiffusionGemmaConfig, isMiniCPM5Config, isQwen35Config, isQwen3Config } from "./support";
16
19
 
17
- export type RuntimeModel = Gemma4Model | MiniCPM5Model | Qwen35Model | DiffusionGemmaModel;
20
+ export type RuntimeModel =
21
+ | Gemma4Model | MiniCPM5Model | Qwen35Model | Qwen3Model | DiffusionGemmaModel
22
+ | UniversalDenseModel;
18
23
 
24
+ /** Dispatch ladder (docs/design/generic-model-support.md §3.3):
25
+ * dedicated → generated/monolith (gemma4*) → generic (Tier-0 universal)
26
+ * → reject with a helpful error. Generic never shadows a dedicated port. */
19
27
  export function createModel(weights: Weights, config: ModelConfig): RuntimeModel {
28
+ // 1. Dedicated classes.
20
29
  // DiffusionGemma is non-autoregressive — generate() detects it and routes to
21
30
  // the denoising engine instead of the AR decode loop. It exposes the shared
22
31
  // RuntimeModel surface (config/weightsBytes/loraState/makeCache) with the
@@ -24,10 +33,22 @@ export function createModel(weights: Weights, config: ModelConfig): RuntimeModel
24
33
  if (isDiffusionGemmaConfig(config)) return new DiffusionGemmaModel(weights, config);
25
34
  if (isMiniCPM5Config(config)) return new MiniCPM5Model(weights, config);
26
35
  if (isQwen35Config(config)) return new Qwen35Model(weights, config);
27
- if (config.modelType === "llama")
28
- throw new Error("unsupported llama config: only MiniCPM5-1B-OptiQ-4bit is wired");
29
- if (config.modelType.startsWith("qwen3_5"))
30
- throw new Error(`unsupported qwen3_5 config (MoE variants are deferred)`);
31
- const cls = GENERATED.get(configFingerprint(config)) ?? Gemma4Model;
32
- return new cls(weights, config);
36
+ if (isQwen3Config(config)) return new Qwen3Model(weights, config);
37
+ // 2. gemma4*: fingerprint-matched generated specialization, else monolith.
38
+ if (config.modelType.startsWith("gemma4")) {
39
+ const cls = GENERATED.get(configFingerprint(config)) ?? Gemma4Model;
40
+ return new cls(weights, config);
41
+ }
42
+ // 3. Generic Tier-0 fallback: the universal-dense descriptor table
43
+ // (replaces the old hard "llama = MiniCPM5 only" / unknown-type throws).
44
+ const generic = genericArgsFor(config);
45
+ if (generic) return new UniversalDenseModel(weights, config, generic);
46
+ // 4. Reject, naming the arch (post-remap) and the declared surface.
47
+ const arch = remapModelType(config.modelType);
48
+ throw new Error(
49
+ `unsupported model_type "${config.modelType}"` +
50
+ (arch !== config.modelType ? ` (mlx-lm remaps it to "${arch}")` : "") +
51
+ ` — targeted: gemma4*, diffusion_gemma, qwen3_5, qwen3, MiniCPM5;` +
52
+ ` generic (Tier-0): ${[...GENERIC_MODEL_TYPES].sort().join(", ")}`,
53
+ );
33
54
  }
@@ -649,8 +649,10 @@ export function flashSupported(q: MlxArray): boolean {
649
649
  }
650
650
 
651
651
  /** Materialized differentiable attention (matmul → mask → softmax → matmul),
652
- * GQA-aware. The correct-vjp fallback for masks flash doesn't take (sliding
653
- * window) and for any case where ops.sdpa's broken dK can't be used. O(L²) —
652
+ * GQA-aware. A correct-vjp fallback for shapes/masks the flash kernel doesn't
653
+ * take. (ops.sdpa's dK vjp is CORRECT — FD-verified, the training default;
654
+ * the old "broken dK" note here predated the flash-kernel root-cause: the
655
+ * bugs were flash's own dK transpose + dQ barrier, both fixed.) O(L²) —
654
656
  * pair with gradient checkpointing. */
655
657
  export function manualSdpa(q: MlxArray, k: MlxArray, v: MlxArray, scale: number, mask: Mask): MlxArray {
656
658
  const [B, Hq, T, Dh] = q.shape as [number, number, number, number];
@@ -26,13 +26,20 @@ import { Dtype } from "../mlx/ffi";
26
26
  import { MetalKernel } from "../mlx/metal-kernel";
27
27
  import * as ops from "../mlx/ops";
28
28
 
29
- /** Perf-mode lever: DEFAULT ON (2026-06-11 cleared-machine pass, ~1.02x @8k).
30
- * It matches optiq's fused-KV decode — which the quantized-KV parity goldens
31
- * track — so it's the production default. It is NOT bit-exact vs the -O0
32
- * reference (online softmax, tier-b), so it's the PERF side of the
33
- * compat-vs-perf A/B: MLX_BUN_PERF_KERNEL=0 selects the compat path we bench
34
- * against the python libs. (B/C-style bit-exact optimizations stay on either
35
- * way — this lever only toggles the parity-breaking decode kernel.) */
29
+ /** Perf-mode lever: DEFAULT ON (2026-06-11 cleared-machine pass, ~1.02x @8k)
30
+ * — but an L3/explicit-only lever, OFF in the bare --l2 tier. This is an
31
+ * mlx-bun ORIGINAL kernel, NOT a port of optiq's fused decode, and the
32
+ * quantized-KV parity goldens do NOT track it: the optiq-golden decode
33
+ * composition is stock UNFUSED L=1 (scripts/regen-kvq-goldens.ts — the fused
34
+ * N-tiled SDPA covers L>1 prefill only, where this kernel never dispatches).
35
+ * It is NOT bit-exact vs the -O0 reference (online softmax, tier-b); its gate
36
+ * is ENVELOPE quality — ≥56/64 teacher-forced argmax agreement vs mlx-bun's
37
+ * own frozen compat trajectory (tests/perf-kernel-oracle.test.ts,
38
+ * scripts/freeze-perf-oracle.ts) plus bounded divergence vs the unfused port
39
+ * (tests/fused-decode-kernel.test.ts). MLX_BUN_PERF_KERNEL=0 selects the
40
+ * compat path we bench against the python libs. (B/C-style bit-exact
41
+ * optimizations stay on either way — this lever only toggles the
42
+ * parity-breaking decode kernel.) */
36
43
  export function perfKernelEnabled(): boolean {
37
44
  return process.env.MLX_BUN_PERF_KERNEL !== "0";
38
45
  }
@@ -0,0 +1,169 @@
1
+ // The WHOLE MLP in ONE dispatch — down = (silu(x·Wgᵀ)·(x·Wuᵀ)) · Wdownᵀ — with no
2
+ // intermediate `hidden` ever exposed to MLX. MLX runs this one kernel; there is no
3
+ // boundary between our kernels for its planner to alias (the corruption only lives
4
+ // at those boundaries). Built from the steel building blocks in steel-qmm-header.ts,
5
+ // structured like the flash-CCE backward: each threadgroup owns one (M-block,I-block),
6
+ // computes silu·up for that I-block ONCE (no recompute), then atomic-accumulates its
7
+ // contribution to every down output — exactly how flash-CCE BWD atomic-adds dh.
8
+ //
9
+ // grid = (TGN, I/BI, M/BM); each tg: i-block i0 = tid.y*BI, m-block m0 = tid.z*BM
10
+ // phase A: gate/up [BM,BI] = x[BM,H] @ {Wg,Wu}[i0,H]ᵀ (reduce H) → silu·up = Hs[BM,BI]
11
+ // phase B: for each Ho-block no: D[BM,BN] = Hs @ Wdown[no,i0]ᵀ; atomic-add D → y[M,Ho]
12
+ //
13
+ // Per-weight quant constants (gate/up/down) are baked literals; H/I/Ho are templateInts;
14
+ // M from x_shape. Output is f32 (atomic accumulation); the caller casts to the model
15
+ // dtype at the MLP boundary. L3 (atomic-add ordering + bf16-rounded silu); gated by
16
+ // teacher-forced KL like the rest of the controlled path.
17
+
18
+ import { MlxArray } from "../mlx/array";
19
+ import { Dtype } from "../mlx/ffi";
20
+ import { MetalKernel } from "../mlx/metal-kernel";
21
+ import { STEEL_QMM_HEADER } from "../train/steel-qmm-header";
22
+ import * as ops from "../mlx/ops";
23
+ import type { QuantSpec } from "../mlx/ops";
24
+
25
+ const BM = 32, BN = 32, BK = 32, WM = 2, WN = 2;
26
+ const TGN = WM * WN * 32; // 128 threads / threadgroup
27
+
28
+ const SOURCE = String.raw`
29
+ const uint M=x_shape[0];
30
+ constexpr int I=I_T, H=H_T, Ho=HO_T;
31
+ constexpr int gsG=GS_G,btsG=BITS_G, gsU=GS_U,btsU=BITS_U, gsD=GS_D,btsD=BITS_D;
32
+ constexpr int pfG=get_pack_factor<btsG,8>(), bppG=get_bytes_per_pack<btsG>();
33
+ constexpr int pfU=get_pack_factor<btsU,8>(), bppU=get_bytes_per_pack<btsU>();
34
+ constexpr int pfD=get_pack_factor<btsD,8>(), bppD=get_bytes_per_pack<btsD>();
35
+ constexpr int BKp=BK_T+16/sizeof(T);
36
+ threadgroup T Xs[BM_T*BKp];
37
+ threadgroup T Wgs[BN_T*BKp];
38
+ threadgroup T Wus[BN_T*BKp];
39
+ threadgroup T Lg[BM_T*BN_T];
40
+ threadgroup T Lu[BM_T*BN_T];
41
+ threadgroup T Hs[BM_T*BKp]; // silu·up for this I-block, padded leading dim BKp
42
+ threadgroup T Wds[BN_T*BKp]; // a down-weight [BN, BI] tile
43
+ using gu_mma=mlx::steel::BlockMMA<T,T,BM_T,BN_T,BK_T,WM_T,WN_T,false,true,BKp,BKp>;
44
+ using lx_t =mlx::steel::BlockLoader<T,BM_T,BK_T,BKp,1,WM_T*WN_T*SIMD_SIZE>;
45
+ using lwg_t =QuantizedBlockLoader<T,BN_T,BK_T,BKp,1,WM_T*WN_T*SIMD_SIZE,gsG,btsG>;
46
+ using lwu_t =QuantizedBlockLoader<T,BN_T,BK_T,BKp,1,WM_T*WN_T*SIMD_SIZE,gsU,btsU>;
47
+ using d_mma =mlx::steel::BlockMMA<T,T,BM_T,BN_T,BK_T,WM_T,WN_T,false,true,BKp,BKp>;
48
+ using lwd_t =QuantizedBlockLoader<T,BN_T,BK_T,BKp,1,WM_T*WN_T*SIMD_SIZE,gsD,btsD>;
49
+ const uint sg=simdgroup_index_in_threadgroup, sl=thread_index_in_simdgroup;
50
+ const uint lid=thread_position_in_threadgroup.x;
51
+ const int Hh=(int)H;
52
+ const int KwG=Hh*bppG/pfG, KgG=Hh/gsG;
53
+ const int KwU=Hh*bppU/pfU, KgU=Hh/gsU;
54
+ const int KwD=I*bppD/pfD, KgD=I/gsD; // down weight rows are length I
55
+ const int m0=(int)threadgroup_position_in_grid.z*BM_T;
56
+ const int i0=(int)threadgroup_position_in_grid.y*BN_T;
57
+ const short num_els=(short)min(BM_T,(int)M-m0);
58
+
59
+ // ---- Phase A: hidden[BM,BI] = silu(x@Wg[i0]ᵀ)·(x@Wu[i0]ᵀ), reduce over H ----
60
+ gu_mma mg(sg,sl), mu(sg,sl);
61
+ lx_t lx (x + (int64_t)m0*Hh, Hh, Xs, sg, sl);
62
+ lwg_t lwg((const device uint8_t*)wg + (int64_t)i0*KwG, sg_w + (int64_t)i0*KgG, bg_w + (int64_t)i0*KgG, Hh, Wgs, sg, sl);
63
+ lwu_t lwu((const device uint8_t*)wu + (int64_t)i0*KwU, su_w + (int64_t)i0*KgU, bu_w + (int64_t)i0*KgU, Hh, Wus, sg, sl);
64
+ for (int k=0;k<Hh;k+=BK_T){
65
+ threadgroup_barrier(mem_flags::mem_threadgroup);
66
+ if (num_els<BM_T) lx.load_safe(short2(BK_T,num_els)); else lx.load_unsafe();
67
+ lwg.load_unsafe(); lwu.load_unsafe();
68
+ threadgroup_barrier(mem_flags::mem_threadgroup);
69
+ mg.mma(Xs,Wgs); mu.mma(Xs,Wus);
70
+ lx.next(); lwg.next(); lwu.next();
71
+ }
72
+ threadgroup_barrier(mem_flags::mem_threadgroup);
73
+ mg.Ctile.template store<T,WM_T,WN_T,BN_T,1>(Lg + mg.sm*BN_T + mg.sn);
74
+ threadgroup_barrier(mem_flags::mem_threadgroup);
75
+ mu.Ctile.template store<T,WM_T,WN_T,BN_T,1>(Lu + mu.sm*BN_T + mu.sn);
76
+ threadgroup_barrier(mem_flags::mem_threadgroup);
77
+ // silu·up into Hs (padded BKp leading dim, so the phase-B BlockMMA can read it as A)
78
+ for (uint idx=lid; idx<(uint)(BM_T*BN_T); idx+=TGN_T){
79
+ int r=(int)idx/BN_T, c=(int)idx%BN_T;
80
+ T gT=Lg[idx], uT=Lu[idx];
81
+ T sigT=(T)(1.0f/(1.0f+metal::precise::exp(-(float)gT)));
82
+ T siluT=(T)((float)gT*(float)sigT);
83
+ Hs[r*BKp + c] = (T)((float)siluT*(float)uT);
84
+ }
85
+ threadgroup_barrier(mem_flags::mem_threadgroup);
86
+
87
+ // ---- Phase B: for each Ho-block, D[BM,BN] = Hs @ Wdown[no,i0]ᵀ; atomic-add → y ----
88
+ for (int no=0; no<Ho; no+=BN_T){
89
+ lwd_t lwd((const device uint8_t*)wd + (int64_t)no*KwD + (int64_t)i0*bppD/pfD,
90
+ sd_w + (int64_t)no*KgD + i0/gsD, bd_w + (int64_t)no*KgD + i0/gsD, I, Wds, sg, sl);
91
+ threadgroup_barrier(mem_flags::mem_threadgroup);
92
+ lwd.load_unsafe(); // one BI-wide tile (BI==BK, single step)
93
+ threadgroup_barrier(mem_flags::mem_threadgroup);
94
+ d_mma D(sg,sl);
95
+ D.mma(Hs, Wds); // D[BM,BN] = Hs[BM,BI] @ Wds[BN,BI]ᵀ
96
+ threadgroup_barrier(mem_flags::mem_threadgroup);
97
+ D.Ctile.template store<T,WM_T,WN_T,BN_T,1>(Lg + D.sm*BN_T + D.sn); // reuse Lg to stage D
98
+ threadgroup_barrier(mem_flags::mem_threadgroup);
99
+ for (uint idx=lid; idx<(uint)(BM_T*BN_T); idx+=TGN_T){
100
+ int r=(int)idx/BN_T, c=(int)idx%BN_T;
101
+ if (m0+r<(int)M){
102
+ const int64_t off=(int64_t)(m0+r)*(int64_t)Ho + (no+c);
103
+ // Absorb the residual: add h exactly once (only the i0==0 threadgroups,
104
+ // which cover every (m,ho) once) so the kernel outputs h + mlp(x) and
105
+ // nothing is left live across it for MLX to plan a buffer around.
106
+ float add = (float)Lg[idx];
107
+ if (i0==0) add += (float)h[off];
108
+ atomic_fetch_add_explicit(&y[off], add, memory_order_relaxed);
109
+ }
110
+ }
111
+ threadgroup_barrier(mem_flags::mem_threadgroup);
112
+ }
113
+ `;
114
+
115
+ const _kernels = new Map<string, MetalKernel>();
116
+ function getKernel(
117
+ gsG: number, btsG: number, gsU: number, btsU: number, gsD: number, btsD: number,
118
+ ): MetalKernel {
119
+ const key = `${gsG}:${btsG}:${gsU}:${btsU}:${gsD}:${btsD}`;
120
+ let k = _kernels.get(key);
121
+ if (!k) {
122
+ k = new MetalKernel({
123
+ name: "mlx_bun_fused_mlp",
124
+ inputNames: ["x", "wg", "sg_w", "bg_w", "wu", "su_w", "bu_w", "wd", "sd_w", "bd_w", "h"],
125
+ outputNames: ["y"],
126
+ source: SOURCE,
127
+ header:
128
+ `${STEEL_QMM_HEADER}\n` +
129
+ `#define GS_G ${gsG}\n#define BITS_G ${btsG}\n#define GS_U ${gsU}\n#define BITS_U ${btsU}\n` +
130
+ `#define GS_D ${gsD}\n#define BITS_D ${btsD}\n` +
131
+ `#define BM_T ${BM}\n#define BN_T ${BN}\n#define BK_T ${BK}\n` +
132
+ `#define WM_T ${WM}\n#define WN_T ${WN}\n#define TGN_T ${TGN}\n`,
133
+ ensureRowContiguous: true,
134
+ atomicOutputs: true,
135
+ });
136
+ _kernels.set(key, k);
137
+ }
138
+ return k;
139
+ }
140
+
141
+ /** The whole MLP, fused, as one dispatch. x is [M, H] (bf16/f16). Returns the MLP
142
+ * output [M, Ho] in x.dtype (cast from the f32 atomic accumulator). gate/up have
143
+ * shape [I, H*bits/32]; down has [Ho, I*bits/32]. Caller owns the result. */
144
+ export function fusedMlp(
145
+ x: MlxArray,
146
+ wg: MlxArray, sg: MlxArray, bg: MlxArray, gateSpec: QuantSpec,
147
+ wu: MlxArray, su: MlxArray, bu: MlxArray, upSpec: QuantSpec,
148
+ wd: MlxArray, sd: MlxArray, bd: MlxArray, downSpec: QuantSpec,
149
+ h: MlxArray, // the residual (layer input) — absorbed: output = h + mlp(x)
150
+ ): MlxArray {
151
+ const M = x.shape[0]!, I = wg.shape[0]!, H = x.shape[1]!, Ho = wd.shape[0]!;
152
+ const [yF] = getKernel(
153
+ gateSpec.groupSize, gateSpec.bits, upSpec.groupSize, upSpec.bits, downSpec.groupSize, downSpec.bits,
154
+ ).apply([x, wg, sg, bg, wu, su, bu, wd, sd, bd, h], {
155
+ // f32 atomic accumulator, zero-initialised; grid tiles (M, I) — every I-block
156
+ // contributes to the output via atomic-add.
157
+ outputShapeFn: (ins) => [{ shape: [ins[0]!.shape[0]!, Ho], dtype: Dtype.float32 }],
158
+ grid: (ins) => [TGN, Math.ceil(I / BN), Math.ceil(ins[0]!.shape[0]! / BM)],
159
+ threadGroup: [TGN, 1, 1],
160
+ templateInts: { I_T: I, H_T: H, HO_T: Ho },
161
+ templateDtypes: { T: x.dtype },
162
+ initValue: 0,
163
+ });
164
+ void M;
165
+ if (yF!.dtype === x.dtype) return yF!;
166
+ const y = yF!.astype(x.dtype);
167
+ yF!.dispose();
168
+ return y;
169
+ }
@@ -0,0 +1,240 @@
1
+ // Fused SwiGLU MLP expansion kernel (cpm5-generate-and-fuse-inference): compute
2
+ // hidden = silu(gate_proj(x)) · up_proj(x)
3
+ // in ONE Metal dispatch, reusing MLX's verbatim steel quantized BlockMMA GEMM
4
+ // (src/train/steel-qmm-header.ts — the same building block the ORPO flash-CCE
5
+ // head uses). Replaces the spelled-out path's two quantized_matmul dispatches +
6
+ // the gate/up/silu/product global-memory materialization (minicpm5.ts LlamaMLP):
7
+ // gate = qmm(x, Wg); up = qmm(x, Wu); silu = gate·σ(gate); hidden = silu·up
8
+ // down_proj is LEFT as a separate qmm so the `hidden` boundary stays bit-exact —
9
+ // the existing minicpm5 L1/L2 parity goldens (maxDiff===0) gate this path directly
10
+ // (their 6-token prompt prefill is an M>1 MLP call).
11
+ //
12
+ // WHERE THE WIN IS: prefill / M>1, where the BlockMMA GEMM is the right tool and
13
+ // the [M, intermediate] materialization is real. At decode (M=1) the matmuls are
14
+ // memory-bound GEMVs (reading Wg/Wu dominates), so the fused kernel only saves the
15
+ // per-token dispatch + a tiny [1,I] round-trip — the caller falls back to unfused
16
+ // below M_MIN. Benchmarks (prefill AND decode) decide the default.
17
+ //
18
+ // PRECISION (two variants, env-flagged — "flag and try both"):
19
+ // - MATCH (default): T = the activation dtype (bf16/f16). The gate/up tiles are
20
+ // stored bf16-rounded out of the float BlockMMA accumulator, exactly as MLX's
21
+ // quantized_matmul casts its float accum to the input dtype; σ/·/· then run
22
+ // op-for-op with ops.sigmoid+ops.mul+ops.mul on bf16. AIM: maxDiff===0 vs the
23
+ // unfused path (like the fused-gelu kernel, kl=0). Only residual: the metal
24
+ // exp() vs MLX's math lib — quantified by the parity test.
25
+ // - F32 (MLX_BUN_FUSED_SWIGLU_F32=1): keep gate/up/silu in f32 through the fusion,
26
+ // round to T only at the final store. More accurate, NOT bit-exact → L3, gated
27
+ // by teacher-forced KL like the fused-decode kernel.
28
+ //
29
+ // Never inside a compiled-decode trace: a CustomKernel has no output_shapes (same
30
+ // rule as fusedDecodeSdpa / fusedGeglu) — the caller guards on !isCompiledTrace().
31
+
32
+ import { MlxArray } from "../mlx/array";
33
+ import { Dtype } from "../mlx/ffi";
34
+ import { MetalKernel } from "../mlx/metal-kernel";
35
+ import { STEEL_QMM_HEADER } from "../train/steel-qmm-header";
36
+ import * as ops from "../mlx/ops";
37
+ import type { QuantSpec } from "../mlx/ops";
38
+
39
+ /** OFF by default until the prefill/decode benchmark + parity delta are in (then
40
+ * flip to on like FUSED_GELU if it lands bit-exact). MLX_BUN_FUSED_SWIGLU=1 opts in. */
41
+ export function fusedSwigluEnabled(): boolean {
42
+ return process.env.MLX_BUN_FUSED_SWIGLU === "1";
43
+ }
44
+
45
+ /** f32-internal variant (NOT bit-exact). Default off → the bit-exact MATCH path. */
46
+ function f32Internal(): boolean {
47
+ return process.env.MLX_BUN_FUSED_SWIGLU_F32 === "1";
48
+ }
49
+
50
+ /** Dispatch counter — a gate test asserts the kernel actually ran. */
51
+ export let fusedSwigluCalls = 0;
52
+
53
+ /** Debug bisect: cap how many fused calls actually run (rest fall back). */
54
+ function maxCalls(): number {
55
+ const v = Number(process.env.MLX_BUN_SWIGLU_MAXCALLS);
56
+ return Number.isFinite(v) && v >= 0 ? v : Infinity;
57
+ }
58
+ export function swigluUnderCap(): boolean {
59
+ return fusedSwigluCalls < maxCalls();
60
+ }
61
+
62
+ // Below this many rows (M) the matmuls are memory-bound GEMVs and the fusion win
63
+ // is just dispatch overhead; the caller keeps the unfused path. Tunable via env
64
+ // for the decode-vs-prefill sweep.
65
+ export function swigluMMin(): number {
66
+ const v = Number(process.env.MLX_BUN_FUSED_SWIGLU_MMIN);
67
+ return Number.isFinite(v) && v > 0 ? v : 2;
68
+ }
69
+
70
+ const BM = 32, BN = 32, BK = 32, WM = 2, WN = 2;
71
+ const TGN = WM * WN * 32; // 128 threads / threadgroup
72
+
73
+ // One BM×BN output tile per threadgroup (grid.y = I-tiles, grid.z = M-tiles), the
74
+ // K=H reduction looped inside. Xs is loaded once and fed to BOTH the gate and up
75
+ // BlockMMA (the whole point of fusing — x is read from global once for two GEMMs).
76
+ const SOURCE = String.raw`
77
+ // M from the x_shape helper (varies per call); I, H BAKED as template literals
78
+ // (model-constant) — no runtime `+"`shape`"+` input array to thread through the
79
+ // lazy graph. The per-loader quant constants (GS_G/BITS_G gate, GS_U/BITS_U up)
80
+ // are baked too — the de-branch from the known per-tensor mixed-precision config,
81
+ // so the gate=4/up=8 layers fuse instead of falling back.
82
+ const uint M=x_shape[0]; constexpr int I=I_T, H=H_T;
83
+ constexpr int gsG=GS_G, btsG=BITS_G, gsU=GS_U, btsU=BITS_U;
84
+ constexpr int pfG=get_pack_factor<btsG,8>(), bppG=get_bytes_per_pack<btsG>();
85
+ constexpr int pfU=get_pack_factor<btsU,8>(), bppU=get_bytes_per_pack<btsU>();
86
+ constexpr int BKp=BK_T+16/sizeof(T);
87
+ threadgroup T Xs[BM_T*BKp];
88
+ threadgroup T Wgs[BN_T*BKp];
89
+ threadgroup T Wus[BN_T*BKp];
90
+ threadgroup T Lg[BM_T*BN_T];
91
+ threadgroup T Lu[BM_T*BN_T];
92
+ using mma_t=mlx::steel::BlockMMA<T,T,BM_T,BN_T,BK_T,WM_T,WN_T,false,true,BKp,BKp>;
93
+ using lx_t=mlx::steel::BlockLoader<T,BM_T,BK_T,BKp,1,WM_T*WN_T*SIMD_SIZE>;
94
+ using lwg_t=QuantizedBlockLoader<T,BN_T,BK_T,BKp,1,WM_T*WN_T*SIMD_SIZE,gsG,btsG>;
95
+ using lwu_t=QuantizedBlockLoader<T,BN_T,BK_T,BKp,1,WM_T*WN_T*SIMD_SIZE,gsU,btsU>;
96
+ const uint sg=simdgroup_index_in_threadgroup, sl=thread_index_in_simdgroup;
97
+ const uint lid=thread_position_in_threadgroup.x;
98
+ const int Hh=(int)H;
99
+ const int K_wG=Hh*bppG/pfG, K_gG=Hh/gsG; // gate: packed-row bytes, group count
100
+ const int K_wU=Hh*bppU/pfU, K_gU=Hh/gsU; // up
101
+ const int m0=(int)threadgroup_position_in_grid.z*BM_T;
102
+ const int n0=(int)threadgroup_position_in_grid.y*BN_T;
103
+ const short num_els=(short)min(BM_T,(int)M-m0);
104
+ const short num_outs=(short)min(BN_T,(int)I-n0);
105
+
106
+ #if SWIGLU_TGZERO
107
+ // Diagnostic: zero threadgroup memory so a back-to-back dispatch can't read the
108
+ // previous kernel's leftover staging (the cross-dispatch hazard hypothesis).
109
+ for (uint z=lid; z<(uint)(BM_T*BKp); z+=TGN_T) Xs[z]=T(0);
110
+ for (uint z=lid; z<(uint)(BN_T*BKp); z+=TGN_T){ Wgs[z]=T(0); Wus[z]=T(0); }
111
+ for (uint z=lid; z<(uint)(BM_T*BN_T); z+=TGN_T){ Lg[z]=T(0); Lu[z]=T(0); }
112
+ threadgroup_barrier(mem_flags::mem_threadgroup);
113
+ #endif
114
+ mma_t mg(sg,sl), mu(sg,sl);
115
+ lx_t lx (x + (int64_t)m0*Hh, Hh, Xs, sg, sl);
116
+ lwg_t lwg((const device uint8_t*)wg + (int64_t)n0*K_wG, sg_w + (int64_t)n0*K_gG, bg_w + (int64_t)n0*K_gG, Hh, Wgs, sg, sl);
117
+ lwu_t lwu((const device uint8_t*)wu + (int64_t)n0*K_wU, su_w + (int64_t)n0*K_gU, bu_w + (int64_t)n0*K_gU, Hh, Wus, sg, sl);
118
+
119
+ for (int k=0; k<Hh; k+=BK_T){
120
+ threadgroup_barrier(mem_flags::mem_threadgroup);
121
+ if (num_els<BM_T) lx.load_safe(short2(BK_T,num_els)); else lx.load_unsafe();
122
+ if (num_outs<BN_T){ lwg.load_safe(short2(BK_T,num_outs)); lwu.load_safe(short2(BK_T,num_outs)); }
123
+ else { lwg.load_unsafe(); lwu.load_unsafe(); }
124
+ threadgroup_barrier(mem_flags::mem_threadgroup);
125
+ mg.mma(Xs,Wgs);
126
+ mu.mma(Xs,Wus);
127
+ lx.next(); lwg.next(); lwu.next();
128
+ }
129
+ threadgroup_barrier(mem_flags::mem_threadgroup);
130
+ // Stage both accumulators to threadgroup. store<T,...> casts the float accum to
131
+ // T (bf16) — the SAME rounding MLX's quantized_matmul applies to its output, so
132
+ // Lg/Lu hold the bf16 gate/up the unfused path would have written to global.
133
+ // Xs/Wgs/Wus alias Lg/Lu only if a kernel reused the same tgmem — they don't here,
134
+ // but the stores write threadgroup that the epilogue reads, so bracket with barriers.
135
+ mg.Ctile.template store<T,WM_T,WN_T,BN_T,1>(Lg + mg.sm*BN_T + mg.sn);
136
+ threadgroup_barrier(mem_flags::mem_threadgroup);
137
+ mu.Ctile.template store<T,WM_T,WN_T,BN_T,1>(Lu + mu.sm*BN_T + mu.sn);
138
+ threadgroup_barrier(mem_flags::mem_threadgroup);
139
+
140
+ for (uint idx=lid; idx<(uint)(BM_T*BN_T); idx+=TGN_T){
141
+ int r=(int)idx/BN_T, c=(int)idx%BN_T;
142
+ if (m0+r>=(int)M || n0+c>=(int)I) continue;
143
+ #if SWIGLU_DEBUG_GATE
144
+ hidden[(int64_t)(m0+r)*(int64_t)I + n0+c] = Lg[idx]; // raw gate proj (debug)
145
+ #elif SWIGLU_F32
146
+ // f32-internal: gate/up stayed bf16 in Lg/Lu (the matmul output is the same),
147
+ // but σ·/· run in f32 with no intermediate rounding → NOT bit-exact.
148
+ float g=(float)Lg[idx], u=(float)Lu[idx];
149
+ float sig=1.0f/(1.0f+metal::precise::exp(-g));
150
+ hidden[(int64_t)(m0+r)*(int64_t)I + n0+c] = (T)(g*sig*u);
151
+ #else
152
+ // MATCH: round σ, silu, product to T op-for-op with ops.sigmoid+mul+mul on bf16.
153
+ T gT=Lg[idx], uT=Lu[idx];
154
+ T sigT=(T)(1.0f/(1.0f+metal::precise::exp(-(float)gT)));
155
+ T siluT=(T)((float)gT*(float)sigT);
156
+ hidden[(int64_t)(m0+r)*(int64_t)I + n0+c] = (T)((float)siluT*(float)uT);
157
+ #endif
158
+ }
159
+ `;
160
+
161
+ const _kernels = new Map<string, MetalKernel>();
162
+ function getKernel(gsG: number, btsG: number, gsU: number, btsU: number, f32: boolean): MetalKernel {
163
+ const key = `${gsG}:${btsG}:${gsU}:${btsU}:${f32 ? 1 : 0}`;
164
+ let k = _kernels.get(key);
165
+ if (!k) {
166
+ k = new MetalKernel({
167
+ name: "mlx_bun_fused_swiglu",
168
+ inputNames: ["x", "wg", "sg_w", "bg_w", "wu", "su_w", "bu_w"],
169
+ outputNames: ["hidden"],
170
+ source: SOURCE,
171
+ header:
172
+ `${STEEL_QMM_HEADER}\n` +
173
+ `#define GS_G ${gsG}\n#define BITS_G ${btsG}\n#define GS_U ${gsU}\n#define BITS_U ${btsU}\n` +
174
+ `#define BM_T ${BM}\n#define BN_T ${BN}\n#define BK_T ${BK}\n` +
175
+ `#define WM_T ${WM}\n#define WN_T ${WN}\n#define TGN_T ${TGN}\n` +
176
+ `#define SWIGLU_F32 ${f32 ? 1 : 0}\n` +
177
+ `#define SWIGLU_TGZERO ${process.env.MLX_BUN_SWIGLU_TGZERO === "1" ? 1 : 0}\n` +
178
+ `#define SWIGLU_DEBUG_GATE ${process.env.MLX_BUN_SWIGLU_DEBUG_GATE === "1" ? 1 : 0}\n`,
179
+ ensureRowContiguous: true,
180
+ });
181
+ _kernels.set(key, k);
182
+ }
183
+ return k;
184
+ }
185
+
186
+ /** Each loader's (bits, groupSize) is baked independently, so gate and up may
187
+ * differ (the per-tensor mixed-precision layers). Requires bf16/f16 activations,
188
+ * affine biases, group size a multiple of BK, and H % BK == 0. */
189
+ export function fusedSwigluSupported(
190
+ x: MlxArray, gateSpec: QuantSpec, upSpec: QuantSpec,
191
+ gateBias: MlxArray | null, upBias: MlxArray | null,
192
+ ): boolean {
193
+ const H = x.shape[x.shape.length - 1]!;
194
+ const specOk = (s: QuantSpec) =>
195
+ (s.bits === 2 || s.bits === 4 || s.bits === 8) && s.groupSize % BK === 0 && BK <= s.groupSize;
196
+ return (
197
+ (x.dtype === Dtype.bfloat16 || x.dtype === Dtype.float16) &&
198
+ gateBias != null && upBias != null &&
199
+ specOk(gateSpec) && specOk(upSpec) &&
200
+ H % BK === 0
201
+ );
202
+ }
203
+
204
+ /** silu(gate_proj(x)) · up_proj(x), fused. `x` is [..., H] (collapsed to [M, H]);
205
+ * wg/wu are the packed quantized weights [I, H*bits/32], with per-group
206
+ * scales/biases [I, H/groupSize]. gate and up may use different quant specs.
207
+ * Returns hidden [M, I] in x.dtype. Caller owns it. */
208
+ export function fusedSwiglu(
209
+ x: MlxArray,
210
+ wg: MlxArray, sg: MlxArray, bg: MlxArray,
211
+ wu: MlxArray, su: MlxArray, bu: MlxArray,
212
+ gateSpec: QuantSpec, upSpec: QuantSpec = gateSpec,
213
+ ): MlxArray {
214
+ fusedSwigluCalls++;
215
+ const H = x.shape[x.shape.length - 1]!;
216
+ const M = x.shape.reduce((a, b) => a * b, 1) / H;
217
+ const I = wg.shape[0]!;
218
+ const x2 = x.shape.length === 2 ? x : ops.reshape(x, [M, H]);
219
+ try {
220
+ const [hidden] = getKernel(gateSpec.groupSize, gateSpec.bits, upSpec.groupSize, upSpec.bits, f32Internal()).apply(
221
+ [x2, wg, sg, bg, wu, su, bu],
222
+ {
223
+ outputShapeFn: (ins) => [{ shape: [ins[0]!.shape[0]!, I], dtype: x.dtype }],
224
+ grid: (ins) => [TGN, Math.ceil(I / BN), Math.ceil(ins[0]!.shape[0]! / BM)],
225
+ threadGroup: [TGN, 1, 1],
226
+ templateInts: { I_T: I, H_T: H },
227
+ templateDtypes: { T: x.dtype },
228
+ },
229
+ );
230
+ // Restore the leading dims the caller passed (e.g. [B, T, I]).
231
+ if (x.shape.length !== 2) {
232
+ const out = ops.reshape(hidden!, [...x.shape.slice(0, -1), I]);
233
+ hidden!.dispose();
234
+ return out;
235
+ }
236
+ return hidden!;
237
+ } finally {
238
+ if (x2 !== x && process.env.MLX_BUN_SWIGLU_NODISPOSE !== "1") x2.dispose();
239
+ }
240
+ }