Metal (Apple GPU) backend for Kimi K3 - #790
Conversation
β¦ CPU-side MLA KV cache, one-shot banners + throttled stats
|
Two housekeeping notes, then the review β which I want to do properly rather than quickly, because this is 3,800 lines across every engine. Retargeted to #763 is merged, with the default off. Thank you for turning that around without argument β the measurement was never in question, only which way the switch points on a project whose one unconditional promise is semantics. Merge order, so you rebase once instead of twiceThis PR overlaps two others:
So the order is #763 β #787 β #790, which costs you one rebase at the end rather than a rebase after each. The alternative β merging 3,800 lines first and making a 71-line PR rebase around it β would be the wrong way round, and #787 also fixes a leak in I will ping you the moment #787 lands. What I will be reading forNot a rubber stamp β the parts I want to understand before merging:
One question while it waits: on an M5 Max with 128 GB, roughly what expert residency did you have during those runs? K3 is ~1.6 TB, so I want to know whether the prefill numbers came from a warm cache or a cold one β it changes how they generalise to someone with 36 GB. |
|
Residency was low β these were effectively cold runs. ~65 GB resident (β35 GB weights + ~30 GB expert LRU at K3_EXPERT_GB=44), so ~4% of the ~1.6 TB model, ~40β50% routed-expert hit, ~300 GB streamed per 16-token decode. So the prefill/attn numbers aren't from a warm cache. They should still generalize down to 36 GB, because the 1.7Γ/2.4Γ are on the compute-bound phases (KDA attention + projections dispatched to the GPU), which don't depend on expert residency. MoE experts stay on CPU (matmul_mxfp4) on both paths, so residency doesn't enter the Metal-vs-CPU delta at all β it only affects decode wall-clock, which I deliberately didn't claim a speedup on. Two clarifications while you review: (1) against dev the PR touches only Makefile, backend_metal.{mm,h}, kimi_k3.c, test_backend_metal.mm and docs β the compat.h/openai_server.py/test_serve_sentinel.c deltas were from the original against-main diff, not my changes. (2) MoE expert matmuls are CPU, not GPU β the GPU coverage is the KDA token step plus the KDA/MLA/lm_head projections. Standing by for the #787 rebase. |
Metal (Apple GPU) backend for Kimi K3
Summary
Adds an opt-in Metal GPU path for Kimi K3, covering the KDA (Kimi Delta
Attention) token step and the KDA/MLA/lm_head projection matmuls, with a full
CPU fallback at every dispatch site. The MoE expert matmuls stay on CPU
(
matmul_mxfp4), which is why decode β dominated by MoE β remains CPU/I/O-bound;the GPU win is on the attention/projection compute. Enabled at runtime with
K3_METAL=1(build withmake -C c kimi_k3 METAL=1); default is off and the CPU path is unchanged.On an M5 Max / 128 GB this gives ~1.7Γ prefill and ~2.4Γ attention on the
compute-bound phases. Decode remains I/O-bound (routed-expert streaming), so
wall-clock decode is governed by the expert cache, not the backend β see below.
Safety / semantics contract
K3_METAL=1is set at runtime andthe binary was built with
METAL=1. CPU-only builds are untouched.dispatch is guarded by
g_k3_metal && coli_metal_available()and falls throughto the existing CPU code on any failure (self-healing: KDA fused dispatch flips
g_k3_metal=0and continues on CPU).different order than the CPU/AVX2 reference, so logits differ at ~FP32 epsilon
and greedy (
COLI_TEMP=0) can flip a near-tie token. Output stays coherent andlogits track within tolerance; it is not bit-identical. Because the path is
opt-in and default-off, this changes no default behavior. (Same class as the
known Metal-prefill near-tie divergence.)
CPU fallback β every dispatch site
Every
coli_metal_*call inkimi_k3.cis compiled under#ifdef COLI_METALand gated at runtime on
g_k3_metal, so a Metal-enabled binary withK3_METALunset β or on a machine where the device is unavailable β never dispatches.
Site by site (line numbers as of this branch):
coli_metal_initK3_METALenv check; result stored ing_k3_metalcoli_metal_matmul(KDA/MLA/lm_head proj, fmt 0/1/4)if (g_k3_metal && coli_metal_available())(332)coli_metal_matmul(f32 low-rank)if (g_k3_metal && coli_metal_available())(361)coli_metal_kda_fused_tokenif (g_k3_metal)(873); return value checked β on failure setsg_k3_metal = 0and falls through to the CPU path (self-healing)coli_metal_shutdownif (g_k3_metal)MLA KV write/clear are pure CPU β the former Metal calls are gone; only comments
reference them. No
coli_metal_*call is reachable withg_k3_metal == 0, andnone is reachable in a non-
COLI_METALbuild.What runs where (per layer)
w_matmulβcoli_metal_matmulk3_matmul_f32w_matmulβcoli_metal_matmulcoli_metal_matmulmatmul_mxfp4/matmul_mxfp4_i8β not GPU-dispatchedCorrectness
against the CPU reference at the time of authoring (
K3_VALIDATE_LAYER).oh[i]/(vh[i]Β·beta)(=qnΒ·kn) matches CPU to 7significant figures.
logits track CPU within FP tolerance (greedy may diverge on near-ties, above).
c/tests/test_backend_metal.mmexercises the primitives.Performance (M5 Max, 128 GB, unified memory)
Compute-bound phases (prompt prefill, attention), Metal vs CPU at matched expert
hit rate:
time: attn)Decode is I/O-bound (routed experts stream from disk), so wall-clock decode is
set by the expert LRU cache, not the backend.
time: attn/moe/eloadareprinted per run so the split is checkable rather than asserted.
Design notes worth a reviewer's eye
afcalloc). KDA state and conv windows are16 KB-aligned so Metal's
wrap()takes thenewBufferWithBytesNoCopypath βGPU writes land directly in host memory and persist across tokens. Without
alignment the write goes to a throwaway copy and the recurrence never
accumulates (decode degenerates).
afcallocfalls back to plaincallocwhenCOLI_METALis undefined, so non-Metal/Windows builds are unaffected.wrap_persistent). Model-lifetime buffers (state,conv windows, taps) are wrapped once and reused instead of re-wrapping ~13
buffers per token across 69 KDA layers. Keyed by host pointer, valid because
those allocations are never freed/resized during inference.
memory, and the KV write is a trivial per-row rmsnorm+copy (negligible next to
MoE). Keeping write+clear on CPU avoids an unaligned GPU round-trip and keeps
the cache path consistent with the CPU attention that consumes it.
single
MTLCommandBufferper token (~4Γ fewer submits than one-CB-per-kernel).Config
K3_METAL=0|1β enable the Metal path (default 0).K3_EXPERT_GB=Nβ routed-expert LRU budget; the decode lever (I/O-bound).K3_LOAD_THREADS=N,K3_PIPE=1,K3_DIRECT=1β expert I/O (backend-agnostic).OMP_NUM_THREADSβ CPU compute team for the CPU-resident work.Known limitations / follow-ups
kernels.
Files
c/backend_metal.mm,c/backend_metal.h,c/kimi_k3.c,c/Makefile,c/tests/test_backend_metal.mm, and docs (docs/metal_implementation.md,docs/kimi_metal_gap_analysis.md,docs/METAL.txt).