Skip to content

Metal: MTLResidencySet residency for expert slabs — removes the per-submit useResource walk (#379)#392

Closed
monotophic wants to merge 5 commits into
JustVugg:devfrom
monotophic:e5/metal-residency-set
Closed

Metal: MTLResidencySet residency for expert slabs — removes the per-submit useResource walk (#379)#392
monotophic wants to merge 5 commits into
JustVugg:devfrom
monotophic:e5/metal-residency-set

Conversation

@monotophic

Copy link
Copy Markdown
Contributor

Authored by Fable 5 in Claude Code, analysis in partnership with @monotophic

Follow-up to #379 and companion to #386 — this is the residency-mechanism fix the thread
converged on. Coordinates: designed and measured 2026-07-18 on M5 Max (128 GB unified,
macOS 26.5.2), GLM-5.2-int4, branch base dev @ caa49f7; all perf numbers below are from
interleaved same-session A/Bs on that box (temp-0, md5-checked, .coli_usage restored per run).

Problem

#379 established that Metal expert caching is anti-productive: the per-submit useResource:
walk scales with the number of resident buffers, so the bigger the expert cache, the more the
GPU stalls. The measured extreme on dev stock (this box, MTP=0 CAP_RAISE=0 AUTOPIN=0 --ram 90): at --cap 64 the MoE dispatch stalls 53.9s per 256-token decode (gpu-wall
69.3s vs kernel 15.5s) and throughput collapses to 0.89 tok/s at a 68.4% hit rate — worse
than running with almost no cache at all. Users on this platform currently have to tune the
cache down
(cap 1; #386 automates exactly that) to avoid the cliff.

Fix

MTLResidencySet (macOS 15+): declare residency once on the command queue instead of
re-declaring per command buffer.

  • Env-gated COLI_METAL_RESSET=1; default OFF in this PR (byte-identical stock path), with a
    note below on defaulting it ON.
  • Allocation is unchanged — same malloc'd slabs, same per-slab wrapped buffers. Only the
    residency bookkeeping moves. (We also built and measured the MTLHeap alternative; see "Why
    not a heap" below.)
  • One set, attached to g_queue at init; coli_metal_register/unregister add/remove the
    allocation. Register-side commits are deferred and batched (one commit at the next
    moe_submit absorbs an OMP loader burst); unregister commits synchronously (the caller
    frees the host memory right after — the remove must land first).
  • The per-buffer useResource: skip is scoped to moe_submit only — the read-only,
    indirectly-referenced weight/scale slabs that are the actual scaling seam. Every
    write-carrying call site (attention Lb/Rb, gemm, gemv binds) keeps explicit useResource:,
    because residency sets do not provide hazard tracking.
  • Lock discipline: set mutations + commit run under a dedicated g_resset_mtx, never under
    the slab-table lock the OMP loaders contend on (holding a data-structure lock across a live
    Metal call is precisely the mistake that cost the heap prototype +12s of load time before
    its review caught it).
  • Runtime @available guard: on macOS < 15 (or set-creation failure) the stock path runs
    untouched, one stderr notice.
  • New gated stat line METAL-RESSET: flush N.NNs so the commit cost is measurable rather
    than hidden (it would otherwise land outside every existing counter).
  • Second commit carries a pre-existing one-line bug fix this change makes more urgent: the
    fslab-OOM unwind freed s->slab without coli_metal_unregister, leaving a stale registry
    entry over freed memory (under a residency set, a permanent member). Unregister-before-free.

Measured results (all identical-output; prose md5 equal across every run below)

Same-session interleaved A/B, the binary is its own control via the env gate:

config gate tok/s hit MoE stall (gpu-wall − kernel) decode disk-wait flush
cap 16 off 2.05 46.2% 8.7s 73.5s
cap 16 on 2.12 46.2% 2.9s (−67%) 75.1s 0.26s
cap 1 off 2.09 2.4% 7.2s 72.9s
cap 1 on 2.07 2.4% 8.5s 73.5s 0.11s
cap 64 off 0.89 68.4% 53.9s 153.4s
cap 64 on 2.06 / 2.09 (rep) 68.4% 2.8 / 2.3s (−95%) 78.8s 0.77s
  • The cliff is gone. cap 64 goes 0.89 → 2.06: a +131% rescue. With the set active the
    cap curve is flat (2.05–2.12 everywhere) — cache size stops being a footgun.
  • No load-path tax. Gate-on disk-wait stays within +0.6–1.6s of stock at every cap. This
    is the decisive advantage over the heap approach (below).
  • No harm where there's nothing to fix (cap 1: −1%, noise).
  • Commit overhead is real but small and now visible: 0.11–0.77s per 256-token run.

Why not a heap

We first built the MTLHeap version (per the #379 discussion). Same-session comparison at
cap 16: the heap also cuts the stall (to 6.1s vs the set's 2.9s) but adds +16s of
expert-disk wait
(89.6s vs 73.5s stock) on the load path — replicated, and consistent with
the +13s previously reported in #379 (suspected unified-memory ownership migration on CPU
writes into heap-backed pages). Keeping malloc pages and moving only the residency
declaration gets the stall kill without that tax. The heap branch is retired.

Honest framing — what this is and isn't

This is a robustness and scalability fix, not a throughput lever at moderate configs.
On this box, decode throughput plateaus at ~2 tok/s regardless of hit rate (2.4% → 68.4%
all land within noise of 2.05–2.12) because on unified memory the engine cache and the OS
page cache are the same physical tier: raising the engine hit rate converts page-cache hits
into engine-cache hits, zero-sum. The felt disk-wait (~73–79s per 256 tokens here) belongs
to the cold expert stream, which no cache can serve. What this PR buys is: (1) the
catastrophic large-cache regime no longer exists, (2) cache sizing stops requiring
platform-specific tuning, and (3) future residency-heavy work (pinning tiers, bigger boxes,
smaller models where the working set fits) stops paying a per-submit walk that scales with
residency. The throughput ceiling itself is prefetch/overlap territory, out of scope here.

Interaction with #386

#386's platform default (cap 1 on Metal + fast SSD) carries a CURRENT-STATE CALIBRATION
marker naming this exact work as its revisit trigger. Today, without this PR, the default is
still protective (the 0.89 collapse above is stock dev). If this PR lands and defaults ON,
the cap default becomes nearly indifferent on fast-SSD boxes — cap 1 remains a fine
conservative choice, but the cliff it guards against disappears. We'd suggest landing this
env-gated first, and flipping the default (COLI_METAL_RESSET=0 as kill switch) plus
revisiting #386's constant in a follow-up once it has soaked on more Metal hardware than ours.

Testing

  • make metal-test: 16/16 gate-off (byte-identical to baseline), 16/16 gate-on
    ([METAL] residency-set: on), numerically identical errors both ways.
  • make glm METAL=1 and plain make glm: clean, zero new warnings (-Wall -Wextra delta
    vs dev baseline: none). make test-c: all pass.
  • Real-model validation: the six-run battery + three-run high-residency probe above, all on
    the real 358 GB GLM-5.2 container; temp-0 prose byte-identical across gate × cap × repeat
    (five runs share one md5; the cap-64 runs share it too — same prompt, same tokens).
  • Not exercised: PIPE=1 async-loader overlap with the set active (our configs run PIPE=0 —
    though the ordering was traced through the pipe_wait drain invariants and holds by
    inspection), macOS < 15 fallback (no such hardware here — guarded by @available, verified
    by inspection), and the fslab-OOM unwind path (requires OOM injection; verified by
    inspection and compile coverage).
  • Known, disclosed cost not surfaced by a counter: the synchronous per-unregister
    commit (inherent — the caller frees host memory immediately after, so the remove must
    land first) is queue-level and O(committed set size), and its cost folds into the existing
    expert-wait accounting rather than the METAL-RESSET line. Our cap sweep (1/16/64 at
    --ram 90) sits in regimes where it was not visible; a large-resident-set,
    eviction-heavy workload could surface it, and a second accumulator is a trivial follow-up
    if a report suggests it.
  • A read-only hazard audit of the final diff (ObjC/ARC lifetimes, overflow/UB, lock
    discipline, concurrency seams incl. multi-queue coverage, gate-off cost) preceded
    submission; its one finding — a latent set-membership leak on a re-register path with no
    in-tree trigger — is fixed in this branch (defensive: set membership exactly mirrors the
    slab table).

Independent line-by-line validation was performed against the diff (lock discipline,
gate-off no-op proof by binary diff of test output, hazard-tracking reasoning traced through
the drain invariants in glm.c) before submission. Raw logs available on request.

…hutdown)

Env-gated (COLI_METAL_RESSET=1) persistent MTLResidencySet attached to g_queue
(macOS 15+, @available-guarded with a one-line stderr fallback). Adds
resset_add/resset_remove/resset_flush helpers, wires them into the existing
coli_metal_register/coli_metal_unregister/coli_metal_shutdown bodies -- no new
functions in backend_metal.h, no glm.c changes, every existing call site keeps
its current signature and behavior.

register() defers the commit (resset_add just marks dirty, under the same
g_slab_mtx that already serializes parallel OMP loader threads) so a loader
burst doesn't pay a commit per slab; unregister() commits synchronously and
immediately, because the caller frees the underlying host memory right after
it returns and a deferred removal would leave the set referencing freed
memory. Nothing yet reads g_resset_enabled to change dispatch behavior --
this commit is bookkeeping only, gate on or off, so it does not change what
any command buffer does (verified by inspection: no useResource:/useHeap:
call site is touched here).

Gate off (default) is byte-for-byte the stock path: g_resset_enabled starts
false and nothing sets it outside the COLI_METAL_RESSET branch in
coli_metal_init, so every new helper is a no-op.

See SUMMARY.md (next commit) for the full design and UNCERTAINTIES.
…esource:

The one seam the mechanism history actually implicates: moe_submit's `use`
vector (resolved expert weight/scale slabs) is the only useResource: loop
whose length scales with LRU cache size. With COLI_METAL_RESSET=1, skip that
loop entirely -- the queue-attached MTLResidencySet already guarantees those
buffers are resident -- after resset_flush() commits any pending adds from a
loader burst. Every other useResource: call site (bind_gemv's weight/scale
buffers, attn_decode/layer_decode's Lb/Rb/kvbW/kvbS/inB/pnB/rwB/rbB,
coli_metal_gemm's wb/sb) is left unconditionally unchanged: none of them
scale with cache size, and Lb/Rb carry real GPU-side write traffic ordered by
existing explicit memoryBarrierWithScope: calls, not by useResource:'s hazard
tracking -- narrowing the blast radius rather than removing useResource:
uniformly. Full reasoning, the hazard-tracking tradeoff (residency sets don't
support hazard tracking, per Apple's MTLResidencySet developer documentation
and residency-set adoption guide; the SDK header itself is silent on the
topic), and every judgment call in UNCERTAINTIES: see SUMMARY.md.

Gate off is unaffected: g_resset_enabled is false, so the useResource: loop
runs exactly as before.
…tations)

1. REQUIRED (backend_metal.mm): no Metal call runs under g_slab_mtx anymore.
   The set mutations (addAllocation/removeAllocation/commit) and the dirty
   flag moved to a dedicated g_resset_mtx; coli_metal_register/unregister do
   their g_slabs bookkeeping under g_slab_mtx exactly as stock, then call
   resset_add/resset_remove after dropping it (still before returning, so
   unregister's remove+commit still lands before the caller frees the host
   memory). The original shape -- commit under the slab lock the parallel OMP
   loader threads contend on -- was structurally identical to the mutex-over-
   live-Metal-call bug E4's audit round 2 fixed, the leading suspect for its
   replicated +12s expert-disk regression. The register->flush->resolve
   happens-before argument survives the split: resset_add completes inside
   coli_metal_register before it returns, the engine cannot dispatch an
   expert before its load returns, and add/flush are serialized by
   g_resset_mtx (comment at resset_add). The two mutexes are never held
   together, so no lock-order hazard exists.

2. REQUIRED (backend_metal.mm, backend_metal.h, glm.c): the resset_flush
   cost in moe_submit -- deliberately outside the g_t_setup window so the
   A/B harness counters keep their meaning -- was invisible. New
   g_t_resset_flush accumulator timed around the flush, exported via
   coli_metal_resset_stats(), printed by profile_print as a separate
   gate-on-only "METAL-RESSET: flush" line (mirrors E4's METAL-HEAP line;
   the METAL: line the harness parses keeps its exact format; stock output
   byte-identical -- the stats call returns 0 with the gate off and the
   whole block sits in the pre-existing #ifdef COLI_METAL arm). Register-
   side add/remove costs land in the existing t_ewait window; comment at
   resset_add says so instead of a second counter.

3. REQUIRED (SUMMARY.md; the moe_submit commit message was already reworded
   in place, pre-push): corrected two false attributions -- the hazard-
   tracking and thread-safety statements were credited to the SDK header,
   which is silent on both; the actual source is Apple's online
   MTLResidencySet class reference and residency-set adoption guide
   (fetched 2026-07-18). Note: the scaffolding commit's message was checked
   and contains no such claim, so it was left untouched.

4. DOC (SUMMARY.md): the pre-existing fslab OOM-unwind bug (glm.c ~1868-73,
   frees s->slab without unregister) has a strictly longer-lived exposure
   under E5 (permanent set member vs stock's transient per-CB declaration).
   Out of scope here; the upstream PR built from E5 must carry the one-line
   fix (reference: E4 branch commit 6753225).
expert_load's fslab-OOM unwind freed s->slab via compat_aligned_free without
coli_metal_unregister -- a pre-existing gap on main/dev (validator-confirmed
during E4) that leaves a stale g_slabs entry over freed memory, letting
resolve() hand the GPU a dangling pointer. Under COLI_METAL_RESSET=1 the
exposure is strictly longer-lived: the wrapped buffer would stay a permanent
residency-set member over the freed pages instead of stock's transient
last-command-buffer window. Ported from e4/metal-heap validator fix 6753225,
adapted to dev's non-heap code shape (no coli_metal_heap_free wrapper --
plain unregister-before-free).

The uring_load_add analog (E4 audit round-2 insurance) is deliberately not
carried: that arm is #ifdef __linux__-gated while COLI_METAL is macOS-only,
so it is dead code on every real build target, and unlike E4 this branch has
no allocation-path reason to touch the function.

Reachable only through allocation failure mid-load; verified by inspection
and clean builds (no OOM-injection harness in tree). SUMMARY.md item 10
updated to "carried on this branch".
…x ref)

1. DEFENSIVE (backend_metal.mm, coli_metal_register): re-registering a live
   base overwrote s.buf and resset_add'ed the new wrapper without removing
   the OLD one from the residency set -- ARC drops our reference but the set
   retains the object and keeps its pages resident: a leak and a
   set/g_slabs divergence. The found branch now stashes the replaced
   wrapper under g_slab_mtx and, after dropping the lock (round-1 invariant:
   no Metal call under the slab lock), resset_remove(old)s it before
   resset_add(b); identical old==b early-outs both set operations.
   Invariant defended, stated in the comment: residency-set membership
   mirrors g_slabs exactly. No in-tree caller re-registers a live base
   today (audited) -- closed defensively.

2. DOC (SUMMARY.md): the moe_submit lifecycle bullet still said
   resset_flush commits "under g_slab_mtx" -- stale text from before the
   round-1 mutex split; the code takes g_resset_mtx and never touches the
   slab lock there. Parenthetical corrected; register bullet updated to
   describe the re-register hygiene from item 1.
@monotophic

Copy link
Copy Markdown
Contributor Author

Authored by Fable 5 in Claude Code, analysis in partnership with @monotophic.

Post-submission datapoint strengthening the scalability claim in the PR body ("payoff grows
with residency"): the residency set also opens the pin tier, not just the LRU.

Same box/discipline as the PR body's tables (M5 Max 128 GB, macOS 26.5, 2026-07-18,
interleaved controls ±0.4%, temp 0, --ngen 256, this branch @ a9ab279). Config: a
workload-matched autopin profile (~2,100 pinned experts, 68% total hit — grown from a fresh
.coli_usage in six warmup runs on the benchmark prompt from #103/#87; deliberately a
saturation probe — single-prompt repetition establishes the pin-path ceiling, and realistic
mixed workloads will sit below it), --ram 96 --cap 16:

tok/s MoE stall (gpu-wall − kernel)
gate off (stock dev) 1.97 16.0s
gate on 2.30 3.1s
gate on + PIPE=1 2.39 3.1s

Stock, the matched pin loses to the no-cache baseline (2.30/2.31 on the same session's
interleaved controls) — ~2,100 pinned buffers pay the per-submit walk, so today the engine's
pin tier is anti-productive on Metal exactly like the LRU was. Gate on, the same config
reaches parity, and with PIPE=1 it's the first configuration to beat the no-cache baseline
on this box. The gate-off 1.97 independently replicates #87's number (same prompt, same
config class, different machine) — so this isn't a quirk of our setup.

Framing unchanged from the PR body: this makes residency free, not fast — the 68%-hit
config beats the 2.5%-hit baseline by only +4% because the remaining wait belongs to the
cold-expert stream (overlap territory, out of scope here). But it means the --auto-pin
machinery and .coli_usage profiles become useful on Metal for the first time, which is
additional review-relevant surface: the pin tier was exercised end-to-end under the set
(register-once mlocked slabs, LRU churn alongside, PIPE=1 loaders concurrent with flushes)
with output invariance holding on fixed pin state.

@RDouglasSharp

RDouglasSharp commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Authored by Claude (Opus 4.8) in Cowork, analysis in partnership with @RDouglasSharp — measurements & hardware by @RDouglasSharp, analysis & drafting by Claude.

Independent M5 Max 128 GB validation of this PR — and the sweep pinned down a mechanism worth stating outright: the gate-off "cliff" is CAP_RAISE growth churn, not cache size, and the residency set removes it.

Coordinates

MacBook Pro M5 Max (Mac17,7, 128 GB unified, 6P+12E, internal Apple NVMe), macOS 26.5.2, this branch (e5/metal-residency-set, base dev), make glm METAL=1, model mateogrgic/GLM-5.2-colibri-int4-with-int8-mtp. All runs: AUTOPIN=1 COLI_METAL=1 DIRECT=1 MTP=0 PIPE=1 PIPE_WORKERS=8 --ram 113 --cap … --temp 0 --ngen 256, prompt "Compare the myths of Lucifer and Prometheus", coli_usage restored from a frozen ~40%-pin snapshot before every run (matched pin across the gate). temp-0; RESSET changes residency bookkeeping, not routing, so output is invariant by construction (md5-identical across the gate is reported in the PR body; not independently re-verified here). Single box, 1–2 runs/cell; same-config drift measured ±2.3%, so tok/s is drift-corrected color, stall/hit are primary (per #333).

Headline

Gate on, decode is flat and near-optimal across every cache config I tried (2.60–2.69 tok/s, MoE stall ~1.4s). Gate off, throughput is a fragile function of cache dynamics, cratering to 1.61 when CAP_RAISE grows the cache into a big RAM budget. The set turns cache sizing from a footgun into a non-issue.

Clean static-cache isolation (CAP_RAISE=0, --cap 34, --ram 113)

Only COLI_METAL_RESSET differs:

gate tok/s MoE stall (gpu-wall − kernel) flush
off 2.49 8.6s (17.52 − 8.90)
on 2.69 1.4s (11.23 − 9.78) 0.29s

+8% at a stable cache — the fixed per-submit useResource: walk, removed. Disk-wait (59.0 vs 60.0s) and attention invariant, as expected for a residency-only change.

The real lever: growth churn, not cache size

Gate-off at ram113, sweeping the cache:

cache config tok/s MoE stall
cap16, CAP_RAISE=1 (grows) 1.61 25.7s
cap32, CAP_RAISE=1 (grows) 1.66 25.6s
cap64 → clamped 34 (static) 2.51 8.4s
cap34, CAP_RAISE=0 (static, control) 2.49 8.6s

Same ~100 GB RSS, same steady-state cap (~34) in all four — 3× stall gap. The only difference is whether the cache is growing during decode: CAP_RAISE registering/reshuffling slabs mid-run, each add extending the walk. Retraction for the record: I first read the ram96→ram113 slowdown as "stall scales with resident-set size." The control kills that — with CAP_RAISE=0, ram113 gate-off is fine (2.49). RAM only looked causal because a bigger budget lets CAP_RAISE grow the cache more.

Two distinct effects

  1. Static/low-churn walk cost — ~+7–8% (2.49→2.69 static at ram113; 2.29→2.44 at ram96, where the small budget limits growth).
  2. Churn elimination — with CAP_RAISE on (the shipping default), gate-off craters to 1.61; the set makes growth free → 2.60. +61% at cap16/ram113. The gap between +8% and +61% is the churn cost.

Gate-on is insensitive to all of it: cap 16/32/64/34 all land 2.60–2.69, stall ~1.4s, CAP_RAISE on or off.

Corroborates the #386 interaction on an independent box

The cliff exists only while CAP_RAISE grows the cache. #386 defaults CAP_RAISE=0 on Metal to dodge this by config; #392 removes its cost mechanically. That's the PR body's "#392 makes #386's cap default nearly indifferent" — demonstrated: with the set on, the growing-cache regime #386 guards against is no longer a cliff.

Honest framing

Robustness/scalability fix, not a moderate-config throughput lever: decode plateaus ~2.6–2.7 tok/s regardless of hit rate (unified memory — engine cache and OS page cache are one tier; the residual ~59s disk-wait is the cold-expert stream, overlap territory). What the set buys is that the catastrophic growing-cache regime stops existing and cache/RAM sizing stops needing platform-specific care. PIPE=1 + flush shows no load-path tax (flush 0.29s; disk-wait flat vs gate-off).

Asides (not part of the isolation)

  • PIPE_WORKERS 6→8 ~neutral here (2.40 vs 2.44 at ram96, within drift) — loaders saturate by 6 on this disk.
  • --topp 0.7 on gate-on reaches 3.44 tok/s (routed experts 7.97 → 4.83/layer). Composes cleanly — but it changes routing, so it's not identical-output and isn't comparable to the numbers above.

Raw logs available on request.

And I should mention, all runs were done with "sudo sysctl iogpu.wired_limit_mb=120832"

@JustVugg

Copy link
Copy Markdown
Owner

Heads-up: c/glm.c was just split into c/colibri.c + header modules (#391, now merged into dev). This PR touches the old glm.c, so it will need a rebase onto current dev with its changes moved into colibri.c (or the relevant extracted header: quant.h, sample.h, kv_persist.h, telemetry.h, grammar.h). The make glm target still works (alias of make colibri), so no build scripts break. Apologies for the churn — ping me if the rebase gets thorny and I can help place the hunks.

@monotophic

Copy link
Copy Markdown
Contributor Author

Superseded by PR #426 which aligns this solution with the c/glm.c split.

@monotophic monotophic closed this Jul 19, 2026
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.

3 participants