Metal: MTLResidencySet residency for expert slabs — removes the per-submit useResource walk (#379)#392
Metal: MTLResidencySet residency for expert slabs — removes the per-submit useResource walk (#379)#392monotophic wants to merge 5 commits into
Conversation
…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.
|
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 Same box/discipline as the PR body's tables (M5 Max 128 GB, macOS 26.5, 2026-07-18,
Stock, the matched pin loses to the no-cache baseline (2.30/2.31 on the same session's Framing unchanged from the PR body: this makes residency free, not fast — the 68%-hit |
|
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 CoordinatesMacBook Pro M5 Max ( HeadlineGate 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 Clean static-cache isolation (
|
| 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
- 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).
- Churn elimination — with
CAP_RAISEon (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_WORKERS6→8 ~neutral here (2.40 vs 2.44 at ram96, within drift) — loaders saturate by 6 on this disk.--topp 0.7on 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"
|
Heads-up: |
|
Superseded by PR #426 which aligns this solution with the c/glm.c split. |
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 frominterleaved same-session A/Bs on that box (temp-0, md5-checked,
.coli_usagerestored 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
devstock (this box,MTP=0 CAP_RAISE=0 AUTOPIN=0 --ram 90): at--cap 64the MoE dispatch stalls 53.9s per 256-token decode (gpu-wall69.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 ofre-declaring per command buffer.
COLI_METAL_RESSET=1; default OFF in this PR (byte-identical stock path), with anote below on defaulting it ON.
residency bookkeeping moves. (We also built and measured the MTLHeap alternative; see "Why
not a heap" below.)
g_queueat init;coli_metal_register/unregisteradd/remove theallocation. Register-side commits are deferred and batched (one commit at the next
moe_submitabsorbs an OMP loader burst); unregister commits synchronously (the callerfrees the host memory right after — the remove must land first).
useResource:skip is scoped tomoe_submitonly — 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.
g_resset_mtx, never underthe 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).
@availableguard: on macOS < 15 (or set-creation failure) the stock path runsuntouched, one stderr notice.
METAL-RESSET: flush N.NNsso the commit cost is measurable ratherthan hidden (it would otherwise land outside every existing counter).
fslab-OOM unwind freed
s->slabwithoutcoli_metal_unregister, leaving a stale registryentry 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:
cap curve is flat (2.05–2.12 everywhere) — cache size stops being a footgun.
is the decisive advantage over the heap approach (below).
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=0as kill switch) plusrevisiting #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=1and plainmake glm: clean, zero new warnings (-Wall -Wextradeltavs
devbaseline: none).make test-c: all pass.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).
PIPE=1async-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, verifiedby inspection), and the fslab-OOM unwind path (requires OOM injection; verified by
inspection and compile coverage).
commit(inherent — the caller frees host memory immediately after, so the remove mustland first) is queue-level and O(committed set size), and its cost folds into the existing
expert-wait accounting rather than the
METAL-RESSETline. 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.
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.