Skip to content

metal: pre-M5 decode + prefill speedups — 46 t/s decode, +4% prefill on M3 Ultra (bit-exact) - #954

Open
ivanfioravanti wants to merge 16 commits into
antirez:mainfrom
ivanfioravanti:perf/metal-pre-m5-decode-prefill
Open

metal: pre-M5 decode + prefill speedups — 46 t/s decode, +4% prefill on M3 Ultra (bit-exact)#954
ivanfioravanti wants to merge 16 commits into
antirez:mainfrom
ivanfioravanti:perf/metal-pre-m5-decode-prefill

Conversation

@ivanfioravanti

Copy link
Copy Markdown
Contributor

Metal performance work for pre-M5 GPUs (M1–M4 resident path) that was benchmarked during the Aug decode campaign but never landed on main. All changes are guarded by rollback env switches and produce bit-identical output.

Main pieces:

  • Greedy chain decode (metal_graph_greedy_chain): GPU argmax feeds the next token via a device ring, removes the per-token waitUntilCompleted + 517 KiB logits readback + CPU argmax boundary.
    Also wired into the session API (ds4-eval).
  • Raw-layer gathered attention the path cost ~65 µs vs ~32 µs per layer.
  • Prefill: indexer-query pruning (pre-M5 + M5 port), staged indexed prefill attention rows (RB4 / heads16-dual), long-prefill acceleration, batch Q/KV finalizer overlap, MoE sum6→HC4 and Q8 attn-out→HC4 epilogue fusions.
  • Tooling: commit-only GPU stage-counter profiler; DSpark decode-campaign measurements and handoff docs in speed-bench/.

Performance (branch vs main, fresh builds, interleaved runs)

Hardware: Apple M3 Ultra, 512 GiB · Model:
DeepSeek-V4-Flash-MXFP4…-chat-v2-mxfp4-0731.gguf

Greedy decode

./ds4 -m <mxfp4.gguf> -p "Write a short story about a lighthouse keeper." -c 8192 -n 128 --temp 0

build run 1 (t/s) run 2 (t/s)
main 42.49 43.93
this branch 46.14 46.14

+5.0–8.6% tokens/s. Both builds and both runs produced transcript md5 db0c504c8203618552e685bd2c701e4f — the campaign oracle hash, confirming bit-exactness end to end.

Prefill

./ds4-bench -m <mxfp4.gguf> --prompt-file speed-bench/promessi_sposi.txt --ctx-start 2048 --ctx-max 8192 --step-incr 2048 --gen-tokens 128

ctx tokens main tok/s branch tok/s Δ
2048 638.4 646.9 +1.3%
4096 578.6 604.3 +4.4%
6144 571.0 595.9 +4.4%
8192 566.4 589.1 +4.0%

First-token latency at ctx 2048: 28.1 ms → 24.3 ms. Steady-state generation at longer contexts is equal or slightly better (39.33 → 39.47 t/s at 8K).

Correctness evidence

  • Transcript md5 oracle (db0c504c…) matches on both builds (above).
  • Per-kernel A/B campaigns on this branch compared millions of full-vocabulary logits bit-identical against rollbacks, plus forced direct-kernel oracles (details in speed-bench/README.md on this branch).
  • make test 44/44 at campaign close; prefix-to-decode checks matched all selected token IDs.

Rollback switches

Every optimization is individually disableable, e.g. DS4_DISABLE_GREEDY_CHAIN=1,
DS4_METAL_DISABLE_DECODE_RAW_GATHERED_ATTN,
DS4_METAL_DISABLE_DECODE_RAW_PACKED32,
DS4_METAL_DISABLE_DECODE_MOE_HC_FUSION,
DS4_METAL_DISABLE_PRE_M5_INDEXED_ATTN_PREFILL_RB4,
DS4_METAL_DISABLE_PRE_M5_INDEXED_ATTN_PREFILL_HEADS16_DUAL_RB4,
DS4_METAL_DISABLE_PRE_M5_BATCH_MOE_SUM6_HC_FUSION,
DS4_METAL_DISABLE_PRE_M5_BATCH_ATTN_OUT_HC_FUSION.

ivanfioravanti and others added 16 commits September 1, 2026 14:07
The end-and-wait decode stage profiler serializes the token, so its stage
times mix in per-boundary CPU waits and disable the concurrent shared-expert
overlap.  Add a diagnostic that keeps the production schedule: each stage
boundary commits the open batch command buffer without waiting and records
it, and after the token every stage's GPU busy span (GPUEndTime minus
GPUStartTime) is printed.  Only the serializing profiler now disqualifies
concurrent dispatch, so DS4_METAL_STAGE_COUNTERS=1 measures the real
schedule; per-token total-cb-busy matches production GPU-busy time and
generated tokens are unchanged.  Document the attribution it produced on
M3 Ultra: dense Q8_0 matvecs already stream at the memory wall, while the
per-layer HC pre/post kernels, the Q/KV norm-RoPE-store dispatch, and the
routed-MoE window hold the remaining slack.
Non-strict DSpark peaks at 37.8 t/s on M3 Ultra (vs 43.7 plain): the batch
verify costs 46-60 ms against a ~29-33 ms two-row floor (uniform 1.5-2.5x
per-stage excess, decomposed with the commit-only stage counters), plain
decode inside a DSpark session costs ~51 ms/token from hidden-state capture,
and the draft proposes on 54-85% of cycles.  Scheduler env tuning was swept
and documented; making speculation profitable needs the N<=6 microbatch
verifier, capture-light plain decode, and a cheaper propose chain.
Token-level profiling inside a DSpark session shows plain decode at a
normal ~23.3 ms; the earlier ~51 ms figure misattributed post-accept
first-token evals.  The real per-cycle costs are the confidence-gated
propose (3-8 ms, declining 45-75% of cycles) and ~4-5 ms of bookkeeping.
Best swept configuration: 39.5 t/s at --dspark-confidence 0.75 with
NO_DRAFT_SKIP=0, still below the 43.7 t/s plain equilibrium.
Per-row routed MoE via the single-token static kernels (bit-identical,
verify_layer unchanged) and per-row HC pre via the decode fused producer
(valid non-strict output, 39.3 vs 40.2 t/s) both confirm the batch-verify
excess is inside the batch kernels' execution rather than dispatch
routing.  The microbatch verifier needs genuine small-N batched kernels.
The N=2 dual-row producer was bit-exact under the --dspark-strict oracle
(after fixing a kernel-argument misbinding the oracle caught) but measured
no per-verify gain and collapsed non-strict drafting on the test prompt.
The strict-mode oracle is documented as the validation tool for future
verify-kernel work; the HC-pre excess was overestimated by serialized
stage decomposition.
Per-row MoE, per-row HC pre, and a strict-oracle-verified dual-row HC-pre
kernel each recovered nothing measurable, and the batch MoE already runs at
its distinct-expert floor.  The perfect-sharing verify floor (~29-33 ms)
looks undeliverable on this GPU: the N=2 verify near 50 ms is close to its
real floor, so speculation is unlikely to beat plain decode on M3 Ultra.
Cold-restart kit: verified baselines with reproduction commands, the
22.6 ms token ledger, the closed-avenue list (8 kernel variants, knob
sweeps, 3 microbatch increments), tool usage (commit-only stage profiler,
strict-mode oracle, balanced A/B harness), hard-won gotchas, and the
go/grind/accept/new-hardware decision that must be made first.
Round 2 (carried in working tree): raw-layer gathered attention parity
(+2.7%) and CLI greedy chain decode (+1.75%), 43.46 -> 45.51 t/s,
bit-exact.

Round 3: ds4-eval decodes in session chain bursts
(ds4_session_eval_chain_greedy) outside the think-close window,
44.5 -> 45.2 t/s, traces bit-identical; DS4_DISABLE_GREEDY_CHAIN=1
covers both paths. MoE down-sum6+HC4 tail fusion landed bit-exact,
speed-neutral (rollback DS4_METAL_DISABLE_DECODE_MOE_HC_FUSION).
KV-staging direct read landed bit-exact but -7% (per-head F32
re-read/re-convert amplification), gated off (opt-in
DS4_METAL_ENABLE_DECODE_RAW_DIRECT_KV).

make test 44/44; CLI transcript md5 db0c504c... unchanged.
ds4_gpu_router_select_tensor_devtoken still passed 20 arguments after
ds4_gpu_encode_router_select gained visual_bias, single_token, vocab_size,
and mixed_visual parameters, breaking the build. Pass nil/0/NULL/0/false
for the non-visual dev-token path, matching the other three call sites.
Swap the pre-M5 device gate in metal_graph_encode_layer_attention_batch for
the shared M5 port helper so resident single-device M5 graphs also skip the
four dead indexer-query dispatches on zero-prefix ratio-4 batches while the
compressed cache stays at or below top-k.  Rollback on M5 is
DS4_METAL_DISABLE_M5_BATCH_INDEXER_QUERY_PRUNE; pre-M5 keeps its existing
per-feature and aggregate rollbacks.

Balanced M5 Max A/B (IQ2_XXS/Q2_K ds4flash.gguf): 753.62/732.83 tok/s at
2048 tokens (+2.84%), 8/8 runs bit-identical (1,034,240 logits).  The
standard sweep's first frontier measured +8.7% prefill with decode and all
ineligible frontiers unchanged.

Also measured and rejected on M5 Max (see speed-bench/README.md): indexed
prefill RB4 kernels (-0.27..-1.56% vs the MPP dual-heads default) and the
Q2 2/32 decode split (-0.6..-0.8% vs the 4/none default).
The line is printed on every session creation, and it reports a build
and weights decision the caller did not make and cannot act on. An
embedder that creates a session per turn (or per aside, or per
sub-agent) has it land in the middle of the user's screen each time.

Gated behind DS4_DSPARK_VERBOSE, matching the other DS4_* diagnostic
switches. The failure path beside it stays unconditional: that one is
news.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants