Skip to content

mac: run bloomery on Apple Silicon (native host build, CPU path, Metal backend) #593

Open

Description

@midagedev

Goal

Run bloomery on an Apple Silicon Mac: first as a native build whose host tests run, then as an engine that answers from bloomery-serve, and in the end with a Metal card path.

Today the Mac is an editor only: just mac-check and just mac-lint cross-check for x86_64 Linux, and only the pure crates run native tests.

Where it stands (counted at 8d74220bb)

Layer State Size
Card engine crates/gpu* is cuda-oxide throughout; the only serve::Engine over a real model is GpuModel 386 #[kernel] fns, ~700 PTX entries (tools/ref/ptx-shapes.tsv)
Linux-only host APIs sched_setaffinity/cpu_set_t (threads), RUSAGE_THREAD (gguf, engram, gpu-deepseek41), O_DIRECT (gpu, engram, levers, placement), /proc reads, hugepage advice ~30 sites, most in gpu-gates and the labs
CPU kernels qdot is AVX2; off x86 it runs the scalar mirrors (DGX Spark P0/P1) correct, slow; no NEON

The DGX Spark port already did part of this: aarch64 builds, qdot off x86, and placement on unified memory (PoolOver, pool_card_bytes, docs/research/tierengine.md (h)). Apple Silicon is unified memory too, so that placement work should carry over.

Phases

  • M0 — native host build. The host chain builds and tests on aarch64-apple-darwin. The Linux-only sites go behind one platform module (affinity → no-op or QoS, RUSAGE_THREAD → mach thread_info, O_DIRECT → F_NOCACHE, /proc → sysctl). On x86 Linux nothing moves: just ptx-scan equal.
  • M1 — CPU path. NEON (sdot) for the two generic qdot kernels (Q8K, Q8_2X4), then the hand kernels a host placement uses; the CPU engine (crates/model) behind serve::Engine, so bloomery-serve answers on a Mac.
  • M2 — Metal backend. One device seam in crates/gpu, CUDA and Metal behind it. Start with one fixture family's decode set (Qwen3-MoE), MSL kernels. CUDA graphs map to indirect command buffers, the fault word to a buffer atomic.
  • M3 — Mac gate tier. The Metal path needs gates that run on a Mac; AGENTS.md's "gates need x86_64 Linux and an NVIDIA card" changes with it.

First step

A port map in the form of docs/contrib/dgx-spark.md: every Linux-only site with path:line and its macOS form, the kernel set one Qwen3-MoE decode step reaches, and how Metal kernels get written (hand MSL against the other options).

Activity

  1. added
    enhancementNew feature or request
    hardwarebloomery on a card, CPU or machine we have not run
    no-boxVerifiable without the maintainers' workstation
    trackingA status issue kept open and updated as the work moves
    on Oct 21, 2026
  2. midagedev commented on Oct 21, 2026

    @midagedev
    OwnerAuthor

    Progress, 2026-10-21.

    • M0a is in review: os: the host chain builds natively on macOS; docs/contrib/mac.md, the Mac port map (#593) #600. The host chain builds natively on aarch64-apple-darwin through one new owner, bloomery-os. On x86 nothing moved (canon-check: 990 of 990 entries identical), and the owning gates are green.
    • Port map: docs/contrib/mac.md (in os: the host chain builds natively on macOS; docs/contrib/mac.md, the Mac port map (#593) #600).
    • Survey of the other Mac engines:
      • The published decode rows reach 48–85 % of datasheet bandwidth. A3B-class MoE sits at 54–59 %.
      • No engine replays a captured chain: no Metal ICB or graph.
      • Prefill dequantizes to fp16 and does not use the 8-bit matrix lanes.
    • M2.0 probe (tools/metal-probe, branch mac-probe), development readings on an M1 Pro:
      • A dependent dispatch costs 2.08 µs in one encoder. A command-buffer boundary costs 7.9 µs. ICB did not beat the plain encoder.
      • A 580-dispatch, 1.8 GB token skeleton takes 11.7 ms against a 10.3 ms byte floor.
      • Next: the same sitting on an M4 Pro.
  3. midagedev commented on Oct 21, 2026

    @midagedev
    OwnerAuthor

    M2.0 probe on an M4 Pro (20-core GPU, 48 GB, macOS 27.0.1). Development evidence: the Mac has no timing runner yet, so these are not public numbers. Thermal state nominal at start and end, AC power. Card: docs/cards/mac-m20-dispatch.card.

    term M1 Pro M4 Pro card band (M4 Pro)
    c_d plain, µs/dispatch (fit N=1..580) 2.08 0.84 6.0..8.5
    c_d concurrent 2.80 1.01
    c_d ICB (+ intercept) 1.46 (+121 µs) 0.90 (+26 µs) 0.5..2.0
    c_d, one command buffer per dispatch 7.88 1.80
    f_bw alone (best / datasheet) 176 / 200 = 0.88 262 / 273 = 0.96 0.80..0.85
    chain 580 × 1.8 GB, plain 11.69 ms 8.31 ms
    chain, ICB 12.20 ms 8.31 ms
    chain / floor at in-chain bandwidth 1.14 1.17

    Outcome: decide-below.

    • One dependent dispatch costs under 1 µs on this chip. 580 of them cost about 0.5 ms, and a command-buffer boundary costs about 1 µs. The 2.1–2.5 ms per token that published engines lose is not per-dispatch encoding.
    • ICB is not faster than the plain encoder. M2.3 keeps the plain encoder, so the ICB bet is dropped.
    • In the chain, each dispatch costs 2.08 µs, against 0.84 µs for an empty one. The extra ~1.2 µs is the drain and refill of a bandwidth-bound kernel at each barrier [derived]. The chain reaches 217 GB/s (0.79 of 273). Its byte floor is 6.59 ms at 273 GB/s [derived].
    • M2.3 levers, in order:
      1. Fewer dependent steps (fused kernels).
      2. Independent work placed beside each barrier (concurrent dispatch).
        The chain arm prices both.
  4. midagedev commented on Oct 21, 2026

    @midagedev
    OwnerAuthor

    Decisions on docs/contrib/mac.md Part F, taken by the project's rules (performance first, accuracy opt-in; common code first). Each one can be reopened when new evidence comes in.

    1. Activations on the Metal decode path: f32, as ggml-metal does. No q8_1.
      • The chain is bound by barriers. On an M4 Pro, each dependent step costs about 2 µs beyond its bytes.
      • The q8_1 quantizers would add 2 dispatches a layer, about 96 steps a token. That is about 0.2 ms [derived]: 2–3 % of a token on an M4 Pro, and about 13 % of the 1.5 ms byte floor on an M5 Ultra.
      • Correctness is a band against ik through the f64 referee, as AGENTS.md "Performance first, accuracy opt-in" says.
      • Prefill on M5-class machines may still take int8 tensor ops. That is M2.4's decision.
    2. The first Metal family is the family of the target file (Qwen3.6-35B-A3B). Its fixture is the gate input. The M2.1 design round reads which family that is from the registry.
    3. The seam is the ChainOps cut over the chain walk (C.1). No Metal copy of the chain.
    4. Settled in os: the host chain builds natively on macOS; docs/contrib/mac.md, the Mac port map (#593) #600: bloomery-os.
    5. Minimum macOS: 26.2. The Neural Accelerator path needs it, and our machines run 27.
    6. Bindings: objc2-metal, pinned by version and held by just deny. No Objective-C++ shim. metal-rs is deprecated.
    7. M1 (the CPU/NEON path) stays off the critical path.
    8. Adapting ggml-metal kernels is accepted. Each adapted kernel names its source file and commit, and THIRD_PARTY_NOTICES.md carries the MIT notice.

    Open for the maintainer: 5 (who owns and reaches an M5-class machine), 6 (what reading counts as a Mac timing witness), 12 (who holds the signing and notarization keys).

    M2.0 found the ICB is no faster than the plain encoder, so the ICB is dropped from the plan (mac.md C.5). Next measurement (M2.2a, in flight): f_q, the fraction of the stream bandwidth that a Q4_K / Q6_K mat-vec reaches on Apple GPUs.

  5. midagedev commented on Oct 21, 2026

    @midagedev
    OwnerAuthor

    M0b landed in #611 (dd55bd9). The host crates' tests pass on macOS: base page, host memory, topology, fadvise and peak RSS now go through bloomery-os.

    Found on the Mac: hc_host::tests::hc_pre_f32_digest fails its exp_ik digest. Apple's libm expf gives other bits than glibc's. exp_ours matches. This belongs to M1 (the CPU path), not to the device track.

    Next: M2.1c (device contract + Metal skeleton) and M2.1d (record core lift) are in review. The M2.2a kernel probe runs on the M4 Pro tonight.

  6. 1 remaining item

  7. midagedev commented on Oct 21, 2026

    @midagedev
    OwnerAuthor

    Landed overnight:

    Running:

    • M2.1b2: ChainOps and the qwen3moe walk in bloomery-chain. It is on top of M2.1b1, the plan move, which is not pushed yet.
    • M2.1a: the CUDA side implements the contract, and the contract changes where CUDA needs it. The fault-site table gets one owner.
    • M2.2b: ggml's Q4_K and Q6_K mat-vec in crates/metal, checked against the fixture's tensors. The M4 Pro checks that the port keeps the probe's speed.
  8. midagedev commented on Oct 22, 2026

    @midagedev
    OwnerAuthor

    Decision (2026-10-22): kernels stay in Metal Shading Language. There is no usable layer below Metal. The GPU ISA is unpublished, AIR goes through the same driver compiler, and the Neural Engine is reachable only through CoreML with fixed graphs and shared bandwidth. On the M4 Pro our Q4_K/Q6_K mat-vecs read 229–243 GB/s on large shapes, 87–93 % of the 262 GB/s measured stream. After M2.3a the next levers are inside Metal: M5 Neural Accelerator int8/fp8 lanes for quantized prefill (no engine uses them yet; needs an M5 to measure), and MoE decode fusion.

  9. midagedev commented on Oct 23, 2026

    @midagedev
    OwnerAuthor

    @avlp12 The Metal decode path is on main now (#726). You said you got an M5 Ultra 256 GB. Could you test it?

    What is there:

    • Qwen3.6-35B-A3B (GGUF Q4_K_M) decodes on Metal: generate_metal.
    • bloomery-serve-metal serves it with the llama-server API. A new chat turn reuses the previous turns through checkpoints of the delta-net state. On an M4 Pro, the second turn's time to first token went from 7.0 s to 0.63 s.
    • The prompt still runs one token per step. Batched prefill is the next Metal round, so prompt speed is not good yet.
    • MLX 4-bit files come in the next PR.

    Build and run:

    cargo build --release -p bloomery-metal --bin generate_metal --bin bloomery-serve-metal
    BLOOMERY_REF_MODEL=/path/Qwen3.6-35B-A3B-Q4_K_M.gguf target/release/generate_metal --prompt "Hello" -n 128
    target/release/bloomery-serve-metal -m /path/Qwen3.6-35B-A3B-Q4_K_M.gguf -c 4096
    

    What would help most:

    1. Decode tok/s on the M5 Ultra. The same file through llama.cpp (llama-bench -m ... -p 512 -n 128) and mlx-lm (the 4-bit MLX build) in the same session, to compare.
    2. A two-turn chat against the server. Please send the server log lines for the second turn, especially the "keeps N of M positions" line.
    3. Any error or wrong output, with the full message.

    The Neural Engine: our M4 Pro work says the decode is bound by memory bandwidth, and the ANE is reachable only through Core ML graphs. If you see a way the ANE could help on the M5 Ultra (prefill, for example), we would like to hear it. A PR is welcome too.

    Thank you!

  10. midagedev commented on Oct 24, 2026

    @midagedev
    OwnerAuthor

    MLX 4-bit files now open on Metal (#757). A first comparison on an M4 Pro (48 GB, 20-core GPU). Development evidence: the Mac has no timing runner yet, so these are not public numbers.

    Model: Qwen3.6-35B-A3B. The mlx-community 4-bit folder is converted once with prepare (19.5 GB split, about 2 minutes). The first 40 greedy tokens are the same text as mlx-lm's.

    Four rounds, each arm its own process, arm order rotated every round, AC power, load 2.6–4.9. Decode tok/s, mean ± SD:

    engine file decode tok/s
    bloomery (f2b73ba) MLX 4-bit 102.4 ± 0.65 101.6 ± 0.64
    mlx-lm 0.32.2, 16-token prompt MLX 4-bit 93.1 ± 0.40
    mlx-lm 0.32.2, 512-token prompt MLX 4-bit 92.0 ± 0.41
    bloomery (f2b73ba) Q4_K_M GGUF 87.1 ± 0.67 86.4 ± 0.66
    llama.cpp b11827 tg128 Q4_K_M GGUF 67.1 ± 0.33

    bloomery on the MLX file is +10.0 % +9.2 % over mlx-lm at the same depth (95 % interval ±1.0 %). The prompt still runs one token a step, so prompt speed is far behind mlx-lm. Batched prefill is the next Metal round.

    Correction (2026-10-24): decode_tok_s counted the prompt's first token over 127 decode passes, as @avlp12 found, so our rows read 128/127 high. The struck numbers are corrected in place; the fix is #768.

    @avlp12, the same steps on the M5 Ultra would help a lot:

    cargo build --release -p bloomery-model-hf --bin prepare
    cargo build --release -p bloomery-metal --bin generate_metal
    target/release/prepare <mlx-community Qwen3.6-35B-A3B-4bit folder> <out>/qwen36-mlx4
    BLOOMERY_REF_MODEL=<out>/qwen36-mlx4-00001-of-00001.gguf target/release/generate_metal --prompt "The capital of France is" -n 128 --ctx 4096
    mlx_lm.benchmark --model <same folder> -p 16 -g 128 -n 3
    

    The decode_tok_s= line on stderr is our number.

  11. avlp12 commented on Oct 24, 2026

    @avlp12
    Contributor

    @midagedev Thank you for the Metal path. I ran your three checks on two machines: an M3 Ultra (512 GB, macOS 26.6.2) and an M5 Ultra (256 GB, macOS 27.0). Nothing crashed, every step exited 0, and the output was readable text, not garbage.

    Setup

    • bloomery main at 608c090. I built it once on the M3 Ultra and copied the two binaries to the M5 Ultra. The build had no Metal Toolchain, so the kernels compiled from source at run time.
    • GGUF: lmstudio-community/Qwen3.6-35B-A3B-GGUF, Qwen3.6-35B-A3B-Q4_K_M.gguf. Rev 68a34855, sha256 4ac6a06b…, which matches the Hub.
    • llama.cpp release b11851 (edc35384). I used the same macOS arm64 tarball on both machines. llama-bench picked 24 threads on the M3 Ultra and 12 on the M5 Ultra; the backend was MTL,BLAS.
    • mlx-lm 0.32.2 and mlx 0.32.4, with mlx-community/Qwen3.6-35B-A3B-4bit (rev 38740b84). This is a different quantization from Q4_K_M, so the mlx-lm column is a reference, not a same-weights comparison.
    • I ran one runtime at a time, with one model loaded.

    Commands:

    generate_metal --prompt "Hello" -n 128                 # 3 runs
    generate_metal --prompt "<542-token text>" -n 128      # 2 runs
    llama-bench -m Qwen3.6-35B-A3B-Q4_K_M.gguf -p 512 -n 128 -r 3
    llama-bench -m Qwen3.6-35B-A3B-Q4_K_M.gguf -p 0 -n 128 -d 0,512 -r 3
    mlx_lm.benchmark --model <mlx 4-bit dir> -p 512 -g 128 -n 3
    mlx_lm.benchmark --model <mlx 4-bit dir> -p 32 -g 128 -n 3
    

    1. Decode tok/s

    I give two context depths so that each runtime is compared at about the same depth.

    Machine Depth generate_metal llama.cpp tg128 mlx-lm (4-bit)
    M3 Ultra near 0 150.5 (149.4 / 150.2 / 151.8) 112.35 ± 0.35 123.5 (after 32 tokens)
    M3 Ultra about 512 146.3 (after 542 tokens) 111.77 ± 0.75 (@ d512) 121.3 (after 512 tokens)
    M5 Ultra near 0 219.7 (219.7 / 219.3 / 220.1) 156.84 ± 0.50 165.8 (after 32 tokens)*
    M5 Ultra about 512 211.4 (after 542 tokens) 155.98 ± 0.51 (@ d512)* 166.6 (after 512 tokens)

    Against llama.cpp on the same GGUF, generate_metal is 1.31–1.34x faster on the M3 Ultra and 1.36–1.40x faster on the M5 Ultra. It is also 1.21–1.33x faster than mlx-lm 4-bit.

    Two notes on the numbers:

    • Each value comes from that tool's own timer.
    • In generate_metal, decode_tok_s counts the prompt's first token over 127 decode steps (generate.rs starts the clock after the prompt call). I think that makes it read about 0.8 % high.

    The near-0 llama.cpp numbers come from different runs on the two machines. The M3 Ultra value is from the -d 0,512 run; its -p 512 -n 128 run gave 112.04 ± 1.02. The M5 Ultra value is from the clean -p 512 -n 128 run; its -d 0,512 run gave 158.58 ± 2.73.

    * These two M5 Ultra runs started while a macOS background service (mediaanalysisd) was running at high CPU (474–487 %). Device-wide GPU utilization had a median of 21 % over 9 samples. The clean runs before it gave 156.84 (llama.cpp tg128) and 166.6 (mlx-lm), so I think the effect is small.

    Prompt processing

    Machine generate_metal (542 tokens) llama.cpp pp512 mlx-lm pp512
    M3 Ultra 128.1 tok/s (7.8 ms a token) 2411.53 ± 9.49 2661.7
    M5 Ultra 183.9 tok/s (5.4 ms a token) 4905.54 ± 33.06 5900.1

    One thing I noticed: each prompt token takes 14–20 % longer than a decode token. Decode takes 6.6–6.8 ms a token on the M3 Ultra and 4.6–4.7 ms on the M5 Ultra; the low end is near depth 0 and the high end is after 542 tokens. In the source, the prompt schedule steps each id with StepMode::HostFed (crates/metal/src/arch/qwen3moe/target.rs), which waits for each token. Decode uses StepMode::Chain, which commits the next token before it waits. If that is the cause, the prompt path could get some of this time back before batched prefill lands. I have not tested this.

    2. Two-turn chat against bloomery-serve-metal

    I ran -c 8192 with greedy sampling and max_tokens 1024, and streamed the replies.

    • Turn 1 was a 913-token prompt.
    • Turn 2 put turn 1's answer in the history and added a short question, for 983 tokens.
    • TTFT is client-side: the first streamed token, reasoning included. prompt_ms is the server's timing.
    Machine Turn 1 prompt_ms (913 tokens) Turn 1 TTFT Turn 2 prompt_ms (75 run, 908 reused) Turn 2 TTFT
    M3 Ultra 7197 7.27 s 739 0.77 s
    M5 Ultra 4967 4.97 s 491 0.51 s*

    * The M5 Ultra chat run started while mediaanalysisd was running. The device-wide GPU utilization median was 25 %; the first attempt, which I stopped, showed mediaanalysisd at 469 % CPU. An earlier clean run with max_tokens 64 gave a turn 1 prompt_ms of 5000 and a turn 2 of 219 ms for 23 tokens.

    Server log for turn 1's prompt split, then turn 2. The lines are the same on both machines:

    bloomery-serve: prefill split first=0 end=912 at=[11, 908]
    bloomery-serve: reuse common=911 ask=911 kept=908 held=1569 reason=metal qwen35moe session keeps 908 of 911 positions: checkpoint
    bloomery-serve: prefill split first=908 end=982 at=[965, 978]
    

    How I read it:

    • 908 is where turn 1's prompt was split, at its last message start (the assistant header).
    • The two prompts share 911 positions. The delta-net state can only go back to a checkpoint, so 3 header tokens ran again, plus 72 new tokens.
    • held=1569 is 913 + 657 − 1. None of turn 1's generated tokens were reused. I think the chat template drops turn 1's reasoning from the history, so the two prompts part right after the assistant header. I did not decode the ids at 908–912 to confirm this.

    Turn 2's prompt time also has a fixed cost on top of the per-token time. I fitted it from two turn-2 runs (23 and 75 tokens; the two runs used different -c, so this is rough):

    Machine Fixed cost Per token
    M3 Ultra about 150 ms 7.8 ms
    M5 Ultra about 100 ms 5.2 ms

    The server reported cache_ms of 21–32 ms for the restore, so most of that fixed cost goes somewhere else.

    3. Errors and output

    There were no errors. Two things about the output:

    • Turn 1's answer text was the same on both machines, and so was the reasoning length (2418 characters). Turn 2 reached max_tokens 1024 while it was still reasoning (3553 characters on both machines), so turn 2 returned an empty answer. In a first try with max_tokens 64, turn 1 spent all 64 tokens on reasoning. I don't think either of these is a bloomery problem, but I mention them in case you want to compare against your own runs.
    • I did not look at the Neural Engine in this round.
  12. midagedev commented on Oct 24, 2026

    @midagedev
    OwnerAuthor

    @avlp12 Thank you for the M3 Ultra and M5 Ultra runs. They are very useful.

    • decode_tok_s: you are right. It counted the prompt's first token over 127 passes. metal: generate_metal's decode rate counts the loop's passes #768 fixes it, and I corrected our M4 Pro rows above.
    • Prompt path: your reading is right too. metal: run a prompt ahead with known ids, skip the head on inner rows #762 (merged today) runs each prompt id ahead while the previous one runs, and skips the output head on inner rows. On our M4 Pro it cut about 1.3 ms an id. Batched prefill is the real fix; its GEMM kernel is the current Metal round.
    • Chat reuse: your split reading matches the server log. We will look at the turn 2 fixed cost (100–150 ms beyond cache_ms).
    • Turn 2's empty answer at max_tokens 1024 is the model still reasoning, not a bloomery error.
  13. avlp12 commented on Oct 24, 2026

    @avlp12
    Contributor

    @midagedev Thank you for the quick fixes. I re-ran the bloomery arms on both machines at main 0c46340.

    What changed and what did not

    M3 Ultra (before → now) M5 Ultra (before → now)
    Prompt, 542 tokens 128.1 → 150.2 tok/s 183.9 → 219.9 tok/s
    Prompt, per id 7.81 → 6.66 ms 5.44 → 4.55 ms
    Decode, "Hello" -n 128 (3 runs) 150.5 → 146.9 (146.0 / 146.8 / 147.9) 219.7 → 216.5 (215.3 / 218.3 / 215.8)
    Decode after the 542-token prompt (2 runs) 146.3 → 143.3 211.4 → 211.8
    Chat turn 1 prompt_ms (913 tokens) 7197 → 6223 4967 → 4149
    Chat turn 2 prompt_ms (75 tokens) / TTFT 739 → 663 / 0.77 → 0.70 s 491 → 430 / 0.51 → 0.45 s

    Prompt. The prompt is now faster by 1.15 ms an id on the M3 Ultra and 0.89 ms on the M5 Ultra. That is close to the 1.3 ms you saw on the M4 Pro. A prompt id now costs about the same as a decode step: 6.66 against 6.81 ms, and 4.55 against 4.62 ms.

    Decode on the M3 Ultra. Decode is 2.0–2.4 % lower than in my first run, and the two sets of three runs do not overlap. The server's decode on the M3 Ultra fell by about 3.4 % in both chat turns: from 138.7 to 134.0, and from 136.4 to 131.8. So I think the drop is real and not only noise, but I have not found the cause. On the M5 Ultra, decode is 1.5 % lower near depth 0 and unchanged after 542 tokens. I would not use the M5 Ultra server numbers here, because the earlier M5 chat run was the one with mediaanalysisd running. The prompt's first token is still in the decode count (no #768), but that bias is the same in both runs, so it cancels in these deltas. If it helps, I can run generate_metal --step-times on the M3 Ultra.

    Turn 2 fixed cost. I estimated it the same way for both runs: turn 2's prompt_ms minus 75 ids at the generate_metal prompt rate. That gives 153 → 164 ms on the M3 Ultra and 83 → 89 ms on the M5 Ultra. The prompt rate comes from depths 0 to 542, but turn 2 runs at depths 908 to 983. So these figures are rough, by about ±20 ms, and the cost looks about unchanged.

    Reuse and output. The reuse log line (keeps 908 of 911 positions: checkpoint) is the same as before on both machines. The greedy output is the same too: turn 1's answer and the reasoning lengths match.

    The M5 Ultra chat runs. Both had mediaanalysisd in the background:

    • The earlier chat run started at a device-wide GPU median of 25 %.
    • For this run, I waited until the median was 14 %.

    So the M5 chat before/after is not strictly like for like.

    I can run the same set again when #768 or batched prefill lands.

  14. midagedev commented on Oct 24, 2026

    @midagedev
    OwnerAuthor

    @avlp12 Batched prompt prefill is merged in #773 (main b135ce1). On an M4 Pro the prompt rate went from about 100 tok/s to 760–855 tok/s (873 to 7985 ids), and decode right after a long prompt is normal again.

    Could you re-run the prompt and chat TTFT numbers on the M3 Ultra and M5 Ultra at current main? Prompts of about 512 and 4096 ids next to mlx_lm.benchmark -p 512 and -p 4096 would help most.

    One change to expect: the open now wires all weights up front, so it takes about 2–3 s longer, and the first prompt no longer pays that cost.

  15. avlp12 commented on Oct 24, 2026

    @avlp12
    Contributor

    @midagedev Congratulations on #773. I re-ran the prompt and chat numbers on both machines at main 6c15ff0, which includes #773 and #768. The setup was the same as in my earlier runs:

    • I built the binaries on the M3 Ultra and copied them to the M5 Ultra.
    • The GGUF, the mlx-lm 0.32.2 4-bit build and llama.cpp b11851 were the same as before.
    • One runtime ran at a time.

    Before every arm, the device-wide GPU utilization median was 0 % on both machines. Two other conditions differ between the machines:

    • On the M5 Ultra, the macOS photo-analysis service (mediaanalysisd) used about 360 % CPU during all arms. On the M3 Ultra it used 0 %.
    • The M5 Ultra runs macOS 27.0 and the M3 Ultra runs 26.6.2.

    All three runtimes ran under those conditions on each machine.

    Prompt tok/s

    I fed generate_metal seeded random ids through --tokens, with -n 128 and --ctx 8192. mlx-lm and llama-bench also use random tokens. Each tool measured in its own way:

    • generate_metal: a fresh process for each run. The 512 value is the second of two runs, and the 4096 value is the mean of two.
    • mlx_lm.benchmark: a warmup, then the mean of 3 trials.
    • llama-bench: the mean ± sd of 3 repetitions.

    mlx-lm uses its 4-bit MLX weights, not this GGUF.

    Machine Prompt ids generate_metal (batch) mlx_lm.benchmark -p (4-bit MLX) llama-bench pp (same GGUF)
    M3 Ultra 512 1775.7 2644.0 2409.27 ± 9.91
    M3 Ultra 4096 2772.0 (2775.2 / 2768.8) 3317.1 2308.09 ± 1.18
    M5 Ultra 512 2216.8 5901.5 4879.73 ± 28.21
    M5 Ultra 4096 3578.9 (3582.8 / 3575.0) 10157.5 4750.81 ± 5.09
    • With --prefill steps on the same 512 ids, generate_metal did 156.0 on the M3 Ultra and 233.2 on the M5 Ultra (one run each). Batch is 11.4x and 9.5x faster.
    • M3 Ultra. At 4096 ids, bloomery is 1.20x llama.cpp on the same GGUF. At 512 ids, it is 0.74x. Against mlx-lm, it is 0.84x and 0.67x.
    • M5 Ultra. The gap is larger. bloomery is 0.75x and 0.45x llama.cpp, and 0.35–0.38x mlx-lm.
    • From the M3 Ultra to the M5 Ultra. bloomery's batch rate grows 1.25–1.29x. llama.cpp's grows 2.0–2.1x, and mlx-lm's grows 2.2–3.1x. I have not looked into why. The other two runtimes reached those rates under the same CPU load on the M5 Ultra.
    • A 4096-id prompt of natural text (bloomery's own docs, through --prompt) ran at about the same rate as the random ids: 2754.7 on the M3 Ultra and 3551.4 on the M5 Ultra.
    • A fixed cost for each prompt call. A two-point fit of generate_metal's prompt_s (512 and 4096 ids) gives a fixed part and a per-id part:
      • M3 Ultra: about 118 ms + 0.33 ms an id.
      • M5 Ultra: about 100 ms + 0.26 ms an id.
    • First run of the session. It had a long open_s: 24.2 s on the M3 Ultra and 9.0 s on the M5 Ultra. Its 512-id prompt was also slower: 1061.1 and 1404.6, against 1775.7 and 2216.8 on the next run. I don't know why the prompt was slower even though the open now wires the weights. I did not purge the page cache, so I can't say whether a disk read caused the long open.
    • Open time. In the later runs, open_s was 2.66 s on the M3 Ultra and 2.54 s on the M5 Ultra. In my earlier runs it was 2.49–2.55 s and 2.39–2.49 s. I saw about 0.05–0.17 s more, not 2–3 s.

    Decode right after a long prompt

    The two random 4096-id runs hit an end-of-generation id after 2 tokens, so I don't use their decode rate. The natural-text 4096 run decoded all 128:

    Machine generate_metal after 4096 ids llama-bench tg128 @ d4096 mlx-lm tg after -p 4096
    M3 Ultra 128.8 104.21 ± 4.01 118.8
    M5 Ultra 188.8 148.06 ± 6.94 163.7

    After 512 ids, decode was 141.0 on the M3 Ultra and 211.5 on the M5 Ultra. My previous build (0c46340, before #768) gave 143.3 and 211.8 after 542 ids. If I take out the first token that #768 stopped counting (127 of 128), those become about 142.2 and 210.1. So decode looks about unchanged.

    Chat TTFT (bloomery-serve-metal -c 8192, greedy, max_tokens 1024, TTFT measured on the client)

    Machine Chat Turn 1 prompt (ids) Turn 1 TTFT Turn 2 ids run / reused Turn 2 prompt_ms Turn 2 TTFT
    M3 Ultra short 913 0.62 s (was 7.27 s, then 6.23 s) 78 / 908 356.1 0.39 s
    M3 Ultra long 3982 1.67 s 88 / 3977 378.7 0.41 s
    M5 Ultra short 913 0.48 s (was 4.97 s, then 4.15 s) 78 / 908 240.2 0.26 s
    M5 Ultra long 3982 1.26 s 88 / 3977 256.8 0.28 s
    • Turn 2 now takes a large share of turn 1's time. In the short chat, 78 ids took 356 ms on the M3 Ultra, against 610 ms for turn 1's 913 ids. On the M5 Ultra it was 240 ms against 451 ms.
    • Both turns are cut in three at the message starts: turn 1 at [11, 908], turn 2 at [968, 981]. In the long chat, turn 2 was cut at [4047, 4060]. If I read target.rs right, the source treats each part the same way:
      • A part of 8 ids or fewer is stepped.
      • A longer part runs as a pass.
      • A checkpoint follows each call.
    • My guess at the cause. Each pass may carry about the 100–120 ms fixed cost from the fit above, and turn 2 has two passes for very few ids. If so, the fixed cost, not the ids, makes up most of turn 2. I have not measured where the time goes.
    • Reuse. It worked in both chats. The long chat's line was keeps 3977 of 3980 positions: checkpoint. The server now reports checkpoints every 2048 (capacity 5).
    • Output.
      • Turn 2 now returns an answer in both chats.
      • The answer text and the reasoning length are the same on the two machines. I did not save the reasoning text, so I can only compare its length.
      • Both differ from my run on the previous build. For example, turn 1's reasoning was 1751 characters, against 2418 before. The batched path's f16 operands and summation order may cause this. I did not compare against --prefill steps at the same commit, so I can't say what changed it.

    If it helps, I can run --pass-times on both machines, or repeat the M5 Ultra runs once mediaanalysisd is idle.

  16. midagedev commented on Oct 24, 2026

    @midagedev
    OwnerAuthor

    @avlp12 Thank you, this is very useful.

    • Open time: you are right. The 2–3 s was on an M4 Pro with 48 GB. Your +0.05–0.17 s is the real number on your machines.
    • metal: routed experts' grouped GEMM on a 64-row tile (prompt pass +16–19 % on M4 Pro) #777 (merged, main 1165fd9) moves the routed experts to a 64-row tile. On the M4 Pro the prompt rate went up 16–19 % (873 ids: 764 → 914 tok/s, 7985 ids: 856 → 990).
    • Two things come next from your numbers. First, the fixed cost of about 100 ms per prompt call, which is most of chat turn 2. Second, the M5 neural accelerators: our GEMMs use only simdgroup 8×8 matrices, so they do not reach them. That likely explains the 1.25× scaling from M3 to M5.

    Yes, --pass-times would help a lot. At main after #777, on both machines:

    generate_metal --prompt "$(cat <a text of at least 2048 ids>)" --ctx 8192 --pass-times 512
    generate_metal --prompt "$(cat <same text>)" --ctx 8192 --pass-times 2048
    

    It times each op of one pass on its own command buffer and prints the time per class (gemm, route, experts, delta, attention). It shows which class does not scale on the M5. The output difference you saw is expected: the batched pass uses f16 operands.

  17. midagedev commented on Oct 24, 2026

    @midagedev
    OwnerAuthor

    @avlp12 Two more PRs are merged, and one needs an M5 run.

    Could you run these on the M5 Ultra, at the mac-nax branch (#796)?

    cargo build --release -p bloomery-metal --bin metal_gemm_time --bin generate_metal
    ./target/release/metal_gemm_time --arm f16 --arm tensor16 --shape 8192:2048:2048 --shape 2048:4096:2048 --shape 512:2048:2048
    ./target/release/metal_gemm_time --routed --rows 512 --rows 2048 --arm ship --arm tensor
    ids=$(python3 -c 'import random;random.seed(0);print(",".join(str(random.randrange(1000,150000)) for _ in range(2048)))')
    BLOOMERY_REF_MODEL=<gguf> ./target/release/generate_metal --tokens "$ids" -n 1 --pass-times 2048
    

    The gemm: header should say matrix-units=true. In the gemm lines, ms of f16 divided by ms of tensor16 is the gain (I expect 2.5–3.5, derived, not measured). The last line prints gemm_path=tensor and the pass time by class; the same line from a main build is the before.

    If you have time, your 512/4096-id prompt numbers at main d854633 would help too.

  18. avlp12 commented on Oct 25, 2026

    @avlp12
    Contributor

    @midagedev Here are the --pass-times runs. By the time I ran them, main had #779, #792 and #796 in it, and on the M5 Ultra it picks the tensor-op GEMMs (gemm_path=tensor). So I measured two builds:

    Setup:

    • I built both on the M3 Ultra and copied the binaries to the M5 Ultra.
    • The model files are the same as in my earlier comments:
      • Qwen3.6-35B-A3B Q4_K_M from lmstudio-community/Qwen3.6-35B-A3B-GGUF, rev 68a34855, sha256 4ac6a06b….
      • For mlx-lm 0.32.2, mlx-community/Qwen3.6-35B-A3B-4bit, rev 38740b84.
    • llama.cpp is b11851.
    • The generate_metal sha256 is 940da3b7… for main and 377060bd… for 1165fd9.
    • Before each phase (bloomery, mlx-lm, llama-bench, each chat), the device-wide GPU utilization median was 0 %.
    • On the M5 Ultra, mediaanalysisd was at 377–390 % CPU at every preflight sample. On the M3 Ultra it was at 0 %.

    I have not run your metal_gemm_time commands from the #796 comment yet. The M5 Ultra is busy with another GPU job right now. I will post those numbers when it is free.

    Pass times (--prompt "$(cat <4096-id text>)" --ctx 8192 --pass-times U; ms, median per op, one buffer at a time)

    Class M3 Ultra 512 M5 Ultra 512, 1165fd9 (simdgroup) M5 Ultra 512, main (tensor) M3 Ultra 2048 M5 Ultra 2048, 1165fd9 (simdgroup) M5 Ultra 2048, main (tensor)
    gemm 70.16 59.85 26.90 237.57 203.51 92.73
    experts 89.89 71.63 58.35 249.23 206.13 150.58
    delta 15.92 12.05 11.26 61.22 51.63 48.69
    attention 3.62 2.31 2.31 26.33 20.50 20.53
    route 7.59 5.33 5.34 13.28 9.60 9.59
    embed_head 0.67 0.42 0.43 0.80 0.44 0.44
    total 187.85 151.58 104.58 588.44 491.81 322.56
    rows/s 2726 3378 4896 3480 4164 6349

    The M3 Ultra columns come from main. On the M3 Ultra, 1165fd9 gives the same times per class, within 0.2 ms (totals 187.77 and 588.39).

    How I read the table:

    • Simdgroup, M3 Ultra → M5 Ultra. The two large classes scale 1.17–1.21x at 2048 rows (gemm 1.17x, experts 1.21x). The small classes scale more: delta 1.19x, attention 1.28x, route 1.38x. The whole pass scales 1.20x.
    • Tensor vs simdgroup on the M5 Ultra, at 2048 rows.
      • gemm drops from 203.51 to 92.73 ms, which is 2.19x. That is below your 2.5–3.5 estimate. This is the whole gemm class in a real pass, not your microbenchmark.
      • experts drop 1.37x.
      • delta drops 6 %.
      • attention and route do not change.
    • The tensor path at 2048 rows. experts are now the largest class at 46.7 %. gemm is 28.7 % and delta 15.1 %.

    Prompt tok/s (generate_metal --tokens <seeded random ids> -n 128 --ctx 8192)

    Machine ids 6c15ff0 (last comment) 1165fd9 (#777) main mlx-lm 4-bit llama-bench (same GGUF)
    M3 Ultra 512 1775.7* 2053.2 2570.4 2659.7 2404.55 ± 9.65
    M3 Ultra 4096 2772.0 3120.2 3300.0 3313.1 2301.82 ± 1.15
    M5 Ultra 512 2216.8* 2492.7 4248.9 5916.9 4891.71 ± 28.71
    M5 Ultra 4096 3578.9 3830.9 5971.8 10162.3 4753.32 ± 2.57

    How the values were taken:

    • The 1165fd9 and main values are the mean of two runs after one unscored run.
    • *The two 6c15ff0 values at 512 ids are the second run alone. The first run was 1061.1 and 1404.6, with no unscored run before it.
    • I ran mlx-lm and llama-bench again in this session, right after the main runs.

    What the numbers show:

    • metal: routed experts' grouped GEMM on a 64-row tile (prompt pass +16–19 % on M4 Pro) #777 is +13–16 % on the M3 Ultra and +7–12 % on the M5 Ultra.
    • M3 Ultra, main vs 1165fd9. It is +25 % at 512 ids and +6 % at 4096, while the pass times are the same.
      • I had predicted main within ±5 % of 1165fd9 here, so that prediction failed.
      • A two-point fit of prompt_s fits a smaller fixed part: about 98 ms on 1165fd9 against 50 ms on main. The per-id part stays about 0.29 ms.
      • --pass-times times the GPU per op, so it cannot see that fixed part.
      • This fits what metal: prompt call fixed costs (one buffer per layer, checkpoints copied once) #779 is meant to do: one command buffer a layer, waited once. With only two lengths per build, I can't say which commit did it.
    • M5 Ultra, main vs 1165fd9. It is 1.70x at 512 ids and 1.56x at 4096. The same fit gives about 82 → 40 ms fixed and 0.24 → 0.16 ms per id.
    • Against llama.cpp. main is 1.07x and 1.43x on the M3 Ultra (512 and 4096 ids), and 0.87x and 1.26x on the M5 Ultra.
    • Against mlx-lm (its own 4-bit weights). main is 0.97x and 1.00x on the M3 Ultra, and 0.72x and 0.59x on the M5 Ultra.
    • M3 Ultra → M5 Ultra. main now scales 1.65x at 512 ids and 1.81x at 4096. My last comment had 1.25–1.29x.
    • Natural text. A 4096-id prompt of natural text ran at 3286.6 on the M3 Ultra and 5929.3 on the M5 Ultra.
    • Decode. 141–142 tok/s after 512 ids on the M3 Ultra and 211–212 on the M5 Ultra, as before.
    • First recorded run of each build. On main it was close to the later runs: 2450.1 against 2570.4 on the M3 Ultra, and 4169.9 against 4248.9 on the M5 Ultra. On 1165fd9, the M5 Ultra's first run was 1550.6 against 2492.7.

    Chat on main (bloomery-serve-metal -c 8192, greedy, max_tokens 1024, TTFT measured on the client)

    Machine Chat Turn 1 ids Turn 1 TTFT Turn 2 ids run / reused Turn 2 prompt_ms Turn 2 TTFT
    M3 Ultra short 913 0.35 s (was 0.62 s) 79 / 908 95.6 (was 356.1) 0.10 s
    M3 Ultra long 3982 1.26 s (was 1.67 s) 80 / 3977 107.9 (was 378.7) 0.12 s
    M5 Ultra short 913 0.23 s (was 0.48 s) 78 / 908 62.7 (was 240.2) 0.07 s
    M5 Ultra long 3982 0.70 s (was 1.26 s) 78 / 3977 69.1 (was 256.8) 0.08 s

    Raw --pass-times output (main, 2048 rows):

    M3 Ultra  gemm_path=simdgroup  open_residency_ms=140.77
    pass_times rows=2048 ops=202 runs=3 (median per op, one buffer at a time)
    pass_times total_ms=588.44 rows_per_s=3480
    pass_wall rows=2048 wall_ms=590.71 ops_gpu_ms=588.44 gap_ms=2.27 gap_per_op_us=11.3
    
    M5 Ultra  gemm_path=tensor  open_residency_ms=116.37
    pass_times rows=2048 ops=202 runs=3 (median per op, one buffer at a time)
    pass_times total_ms=322.56 rows_per_s=6349
    pass_wall rows=2048 wall_ms=322.27 ops_gpu_ms=322.56 gap_ms=-0.29 gap_per_op_us=-1.4
    
  19. midagedev commented on Oct 25, 2026

    @midagedev
    OwnerAuthor

    @avlp12 감사합니다!! 앞으로 써보시면서 아쉬운점 있으면 언제든지 이슈 올려주세요!!

  20. avlp12 commented on Oct 25, 2026

    @avlp12
    Contributor

    @midagedev Thank you! I will open issues as I use it. Here are the metal_gemm_time numbers from the M5 Ultra that I promised for #796.

    Setup

    The header reads gemm: architecture=applegpu_g17d metal4=true matrix-units=true engine-path=tensor.

    Dense (--arm f16 --arm tensor16, gain = ms of f16 / ms of tensor16)

    quant 8192:2048:2048 2048:4096:2048 512:2048:2048
    q4k 2.31x (2.537 → 1.100 ms) 2.20x (1.396 → 0.634) 2.29x (0.243 → 0.106)
    q6k 2.07x (2.552 → 1.235) 2.06x (1.398 → 0.680) 2.31x (0.240 → 0.104)
    a4 2.46x (2.480 → 1.008) 2.33x (1.351 → 0.581) 2.42x (0.235 → 0.097)

    The gain is 2.06–2.46x. That is a little below your 2.5–3.5 estimate. tensor16 reaches 40–68 TFLOPS, where f16 reaches 18–28.

    Routed (--routed --rows 512 --rows 2048 --arm ship --arm tensor, ms of ship / tensor)

    rows pair down layer
    512 1.31x (1.095 → 0.836) 1.21x (0.523 → 0.434) 1.27x (1.618 → 1.270)
    2048 1.57x (3.242 → 2.070) 1.50x (1.522 → 1.012) 1.55x (4.764 → 3.082)

    generate_metal --tokens <seed-0 2048 ids> -n 1 --pass-times 2048

    I ran one process per build. Each value is the tool's median of 3 runs per op.

    class d854633 (no gemm_path line printed) main (gemm_path=tensor) gain
    gemm 201.72 92.76 2.17x
    experts 205.17 148.63 1.38x
    delta 46.14 51.43 0.90x
    attention 20.62 20.64 1.00x
    route 9.62 9.63 1.00x
    embed_head 0.44 0.44 1.00x
    total 483.72 323.52 1.50x

    Delta is 11 % slower on main than on d854633.

    • Between the two commits, crates/metal/kernels/delta.metal and src/kernels/delta.rs are unchanged.
    • The only change I see on the delta path is 6defaca. With it, the delta scratch buffers (delta_partials, delta_prep) are no longer zeroed on the host.
    • With one process per build, this may also be noise.

    Prompt tok/s, d854633. I fed the same seeded random ids as in my earlier runs; each value is the mean of two runs after one unscored run.

    ids d854633 (10:35) main (09:50) ratio
    512 2739.0 4248.9 1.55x
    4096 3953.6 5971.8 1.51x

    These two builds were not run back to back. The ratio also covers the other commits between d854633 and main, not only #796.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requesthardwarebloomery on a card, CPU or machine we have not runno-boxVerifiable without the maintainers' workstationtrackingA status issue kept open and updated as the work moves

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions