Skip to content

Metal — growing the expert cache makes decode SLOWER #379

Description

@monotophic

[Performance]: Metal — growing the expert cache makes decode SLOWER; traced to GPU buffer-residency cost; minimal cache (--cap 1) is the fastest config on a 128 GB M5 Max

Authored in Claude Code (Opus 4.8 -> Fable 5), analysis in partnership with user id @monotophic
Suggested labels: performance, metal, macOS.

Environment

Machine MacBook Pro M5 Max (Mac17,7), 128 GB unified, 18 cores
OS macOS 26.5.2
Engine main @ 72d3d37, make glm METAL=1, COLI_METAL=1, 0 local patches
Model mateogrgic/GLM-5.2-colibri-int4-with-int8-mtp (int8 MTP heads)
SSD internal NVMe, measured 14 GB/s @1 thread / ~20 GB/s @4–8 (F_NOCACHE random 19 MB reads)

Summary

On Metal, every expert-cache mechanism makes decode slower: raising the LRU cap, adding a
pin, or enabling AUTOPIN all reduce tok/s even as hit rate rises. The fastest configuration we
can find on this machine is a minimal cache — MTP=0 CAP_RAISE=0 AUTOPIN=0 --cap 1 — at
~2.0 tok/s, up from ~0.4 at auto defaults (5× from config alone). We ran a chain of
controlled A/Bs to find out why, falsified four mechanisms along the way, and landed on
evidence that the cost is GPU-side buffer residency / page-mapping on large, sparsely-reused
host buffers
registered via newBufferWithBytesNoCopy.

The cross-platform contrast makes the point sharpest. Same engine, same model:

Box Best published config Hit rate decode tok/s
DGX Spark GB10 (#161, Tier A) large LRU + AUTOPIN + DIRECT=1 82% 2.39
M5 Max 128 GB (this report) no cache at all (--cap 1, no pin) 2% ~2.0

The strongest memory subsystem in the tracker gets its best result by refusing to cache
because on Metal, hits cost more than the page-cache misses they replace.

The core measurement (cap isolation; MTP=0 AUTOPIN=0 --ram 90 CAP_RAISE=0, ngen 256, temp 0)

--cap hit expert-matmul GPU blocks kernel stall (gpu-wall − kernel) tok/s
1 2.4% 19.2s 23,036 11.2s 5.8s ~2.0
4 12.5% 21.5s 31,741 13.2s 6.1s 1.98
16 46.6% 49.4s 37,368 12.7s 33.2s 1.52

Identical GPU arithmetic (160,106 experts on GPU, kernel time flat) — the entire regression is
stall, and it scales with how many experts are served from cache. Interleaved same-config
controls bracketed every treatment (thermal drift on this laptop is real; hit-rate/bytes are
the primary metrics, tok/s is drift-corrected color — per the methodology note in #333).

Four hypotheses tested and falsified (so nobody re-runs these dead ends)

  1. Buffer register/unregister churn — dead by code inspection: slab realloc only fires on
    first fill (per-layer expert sizes are uniform); steady-state re-registration ≈ 0.
  2. Hazard tracking — dead by A/B: COLI_METAL_UNTRACKED=1 across cap 1/4/16 leaves the
    slope untouched (49.4s → 48.2s at cap16; noise).
  3. Per-command-buffer fixed cost / two-subset dispatch fragmentation — dead by experiment:
    an env-gated toggle that folds the resident pre-submit into the single post-load Metal command
    buffer ("CB") (one CB per layer, blocks 37,368 → 19,278 as designed) is slightly slower
    (matmul 45.9 → 54.6s at cap16). CB count is not the cost.
  4. (Implicit) "a cheap fix exists" — see below; the surviving mechanism looks driver-level.

The isolating experiment

Instrumenting the three Metal MoE call sites (timers on block_begin / sync moe_block /
block_end) shows the async resident path is nearly free and the cost lives in the sync CB's
commit-to-completion wait. Then the single-CB toggle above accidentally produced the cleanest
isolation we have:

cap1 vs cap16 under the single-CB toggle = identical dispatch structure, identical 19,125
calls, identical 153,000 experts through one CB per layer.
The only difference is where the
weights live
: cap1 serves ~98% from the 64 constantly-reused ws[] staging slabs; cap16
serves ~45% from the ~1,200-slab LRU pool.

cap1: 1.30 ms/call · cap16: 2.86 ms/call — same work, 2.2×, purely buffer locality.

Per-resident-expert penalty ≈ 0.43 ms. (Copying hits into warm staging would cost ~0.6–0.8 ms
per 19 MB expert at measured memcpy bandwidth — routing hits through staging loses too.)

This mechanism also retro-explains COLI_MMAP=1 being catastrophic here (39× slower,
gpu-wall 1405s / kernel 9.2s): registering whole 358 GB shard files with Metal is the same
phenomenon at maximum dose — the GPU demand-faults file-backed pages the pre-touch can't keep
resident when the mapping is 3× RAM.

Questions for maintainers / Metal folks

  1. Is there appetite for an MTLHeap / explicit-residency-set experiment for the expert slab
    pool? (We're happy to run controlled A/Bs on this box; the harness is built.)
  2. If the cost is intrinsic to large sparsely-touched newBufferWithBytesNoCopy sets, should
    the macOS guidance/defaults change? Auto-RAM currently takes 0.88 × available (~100–113 GB
    here → 0.4 tok/s); --cap 1-style minimal-cache is 5× faster on big-RAM Macs.
  3. Cross-ref telemetry: hits conflates pin-tier and ecache hits — split them so eviction-policy changes are measurable #336/telemetry: split expert hit counter into pin vs ecache (LRU) (#336) #367: our data is a concrete case for splitting pin-tier vs LRU-tier hit
    telemetry — on Metal the two tiers have opposite cost profiles.

Also confirmed on this box (supporting, not the headline)

Reproduction

# champion config (~2.0 tok/s on this box)
MTP=0 CAP_RAISE=0 AUTOPIN=0 coli run --ram 90 --cap 1 --ngen 256 --temp 0 "<prompt>"

# the slope (each run: restore .coli_usage first; interleave controls; temp 0)
MTP=0 AUTOPIN=0 CAP_RAISE=0 coli run --ram 90 --cap {1|4|16} --ngen 256 --temp 0 "<prompt>"

Protocol details that matter: --temp 0 + fixed prompt (deterministic routing), restore
<snap>/.coli_usage before every run (every run rewrites it and changes the next AUTOPIN),
pin --ram explicitly (auto resamples MemAvailable and drifts with page-cache state), and
interleave same-config controls (compute throttles; disk-wait doesn't). Full logs, the
instrumentation diff, and per-run outputs available on request.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugDifetto verificato nel codicemetalBackend Metal/Apple

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions