Skip to content

cuda: implement real per-(layer,expert) LRU for --ssd-streaming-cache-experts - #647

Open
nexus-cw wants to merge 4 commits into
antirez:mainfrom
nexus-cw:cuda-expert-lru
Open

cuda: implement real per-(layer,expert) LRU for --ssd-streaming-cache-experts#647
nexus-cw wants to merge 4 commits into
antirez:mainfrom
nexus-cw:cuda-expert-lru

Conversation

@nexus-cw

@nexus-cw nexus-cw commented Aug 1, 2026

Copy link
Copy Markdown

Fixes #639.

Problem

--ssd-streaming-cache-experts has never had any effect on the CUDA backend: cuda_stream_selected_cache_begin_load() unconditionally invalidates and re-fetches every selected expert's gate/up/down bytes from the mapped model file on every call, and ds4_gpu_set_streaming_expert_cache_budget()/_expert_bytes() were no-op stubs while ds4_gpu_stream_expert_cache_configured_count() hard-returned 0 -- at any budget, on any model. Metal's GLM streaming implementation, by contrast, has always had a real per-(layer,expert) LRU (g_stream_expert_cache) with genuine hit/miss tracking.

Design

Metal-LRU parity. Ports Metal's design to CUDA: a persistent, device-resident (layer,expert)-keyed table (cuda_stream_expert_cache_entry, a fixed 80x384 array) is consulted by cuda_stream_expert_cache_peek() before cuda_stream_selected_cache_begin_load() falls back to its existing mapped-file fetch. A hit is served with a device-to-device cudaMemcpy into the existing per-call packed staging buffer that the downstream decode kernels already read from, so the kernel-facing interface is unchanged; a miss still pays the existing fetch and then additionally installs those bytes into the persistent cache (cuda_stream_expert_cache_install()) so the entry survives past this call. Eviction is global least-recently-used across the whole table.

Budget semantics per CLI contract. N in --ssd-streaming-cache-experts N (or the NGB form, already converted to a plain expert count upstream of the GPU backend by ds4.c, identically for Metal and CUDA) is the entry-count budget, clamped to the table's fixed bound -- no CUDA-specific CLI or budget-math changes were needed.

Device-resident entries. Unlike Metal's single-size-class slab allocator (which excludes mixed-precision "boosted" layers off its uniform size class from caching), CUDA's per-entry cudaMalloc has no such uniform-size requirement, so all routed layers -- including any boosted ones -- participate in one cache here.

Pooled allocator. cuda_stream_expert_cache_install()/_clear_entry() originally called cudaMalloc/cudaFree directly on every miss/eviction, which thrashes badly at a too-small budget (install, evict, install, evict, every call, with no offsetting hit-rate benefit) -- a real regression below the no-cache baseline. A size-keyed device-buffer pool (cuda_stream_expert_pool_class_for/_alloc/_free/_release_all) turns install/evict into pool pop/push, with real cudaMalloc only on genuine growth; every existing whole-cache-reset call site (model swap, streaming-mode toggle, budget change) still releases the pool back to the driver, so real teardown is unchanged.

Env-gated counters. Adds DS4_CUDA_STREAM_STATS=1: four host-side counters (fetch_calls, expert_fetches, cache hits/misses, bytes_from_file/cache) at the fetch decision point, printed via a new ds4_gpu_print_cuda_stream_stats() called from the CLI's single-shot generation path (ds4_cli.c). A no-op stub is added to ds4_metal.m for build symmetry -- Metal already has its own richer instrumentation via --expert-profile. This closes the observability gap that made the original no-op possible to ship unnoticed, and gives reviewers a way to directly observe hit rate on their own hardware.

Commits

  1. cuda: add DS4_CUDA_STREAM_STATS=1 diagnostic counters for the SSD streaming expert fetch path -- observability scaffolding, structurally-zero hits/bytes_from_cache at this point (no cache exists yet).
  2. cuda: implement a real per-(layer,expert) LRU expert cache for --ssd-streaming-cache-experts -- the fix itself.
  3. cuda: pool the expert LRU's device buffers to avoid cudaMalloc/cudaFree thrash at small budgets -- correctness-adjacent quality fix so a too-small budget doesn't regress below the no-cache baseline.

Measurements (from the linked issue, IQ2_XXS DeepSeek V4 Flash artifact, single GB10-class GPU)

Short-bench decode throughput at a 100 GB cache budget: 1.03 -> 2.96 t/s (measured hit rate ~81%). A long multi-prompt session converges to a ~96-98% hit rate as the working set of routed experts stabilizes. In this PR's own testing (20 GB budget, single process, an 8-question multi-topic prompt over ~220 generated tokens): hit_rate=0.515, decode 1.62 -> 4.41 t/s, cache saturating at its configured 2522-entry budget.

Test evidence

  • Correctness/determinism: greedy-decode output is byte-identical with the cache enabled (20 GB budget) versus a clean pre-cache build (this repo's upstream/main, where no persistent cache exists at all) -- verified on both a single-question prompt ("What is the capital of France?" -> "The capital of France is Paris.") and the 8-question multi-topic prompt above (all 8 answers identical, byte-for-byte, across both binaries).
  • Hit-rate observability: DS4_CUDA_STREAM_STATS=1 on the multi-topic session reports hits=8256 misses=7788 hit_rate=0.515 ... budget=2522 entries=2522 -- a real, populated cache, not the previous structural zero.
  • No regression in default (non-streaming) mode: identical output and comparable throughput (16.23 vs 16.30 t/s) with --ssd-streaming omitted entirely, on branch versus upstream/main.
  • Build: make clean && make cuda-spark -- clean, no warnings.
  • ./ds4_test: this branch shows the same pre-existing-flaky section set as a clean upstream/main run in this environment (tool-call-quality, logprob-vectors, metal-kernels, and metal-tensor-equivalence/think-tool-recovery, which flip between runs on both trees) -- no new failing sections introduced by this change.

🤖 Generated with Claude Code

…eaming expert fetch path

CUDA SSD streaming selected-expert loading
(cuda_stream_selected_cache_begin_load in ds4_cuda.cu) has never had any
per-generation observability: no fetch/hit/miss/byte counters, unlike
ds4_metal.m which already tracks real per-(layer,expert) LRU hit/miss
stats via --expert-profile. Add four host-side counters (fetch_calls,
expert_fetches, cache_hits/misses, bytes_from_file/cache), incremented
at the fetch decision point, and a new ds4_gpu_print_cuda_stream_stats()
that prints them when DS4_CUDA_STREAM_STATS=1 is set in the environment,
called from ds4_cli.c after a sampled generation completes. A no-op stub
is added to ds4_metal.m for build symmetry (Metal already has its own
richer instrumentation). At this point in the series there is still no
persistent cache to hit, so hits/bytes_from_cache stay at zero -- the
next commit makes them real.
…streaming-cache-experts

Fixes antirez#639. --ssd-streaming-cache-experts has never had any effect on
CUDA: cuda_stream_selected_cache_begin_load() unconditionally
invalidates and re-fetches every selected expert's gate/up/down bytes
from the mapped model file on every call, and
ds4_gpu_set_streaming_expert_cache_budget()/_expert_bytes() were no-op
stubs while ds4_gpu_stream_expert_cache_configured_count() hard-returned
0 -- unlike ds4_metal.m's g_stream_expert_cache, a real
per-(layer,expert) LRU with genuine hit/miss tracking.

This ports that design to CUDA: a persistent, device-resident
(layer,expert)-keyed table (cuda_stream_expert_cache_entry, a fixed
80x384 array) consulted by cuda_stream_expert_cache_peek() before
cuda_stream_selected_cache_begin_load() falls back to its existing
mapped-file fetch. A hit is served with a device-to-device cudaMemcpy
into the existing per-call packed staging buffer that the downstream
decode kernels already read from, so the kernel-facing interface is
unchanged; a miss still pays the existing fetch and then additionally
installs those bytes into the persistent cache
(cuda_stream_expert_cache_install()) so the entry survives past this
call. Eviction is global least-recently-used across the whole table
(cuda_stream_expert_cache_prune_global()), enforced against the budget
now stored by the previously-stubbed
ds4_gpu_set_streaming_expert_cache_budget().

Budget semantics match the existing CLI contract: N in
--ssd-streaming-cache-experts N (or the NGB form, already converted to
a plain expert count upstream of the GPU backend by ds4.c, identically
for Metal and CUDA) is the entry-count budget, clamped to the table's
fixed bound.

Unlike Metal's single-size-class slab allocator (which excludes the
Q3_K/Q5_K layers off its uniform size class from caching), CUDA's
per-entry cudaMalloc has no such uniform-size requirement, so all
routed layers -- including those excluded on Metal -- participate in
one cache here; ds4_gpu_set_streaming_expert_cache_expert_bytes() is
kept for CLI/log symmetry but does not gate CUDA caching.

The DS4_CUDA_STREAM_STATS counters added in the previous commit now
measure a real cache: hits/bytes_from_cache are populated on the peek()
hit path instead of staying structurally zero.
…ee thrash at small budgets

cuda_stream_expert_cache_install()/_clear_entry() called cudaMalloc/
cudaFree directly on every miss/eviction. That's fine once the cache
reaches a steady hit rate, but at a too-small budget (relative to the
per-token expert working set) the cache thrashes: install, evict,
install, evict, on every call, with no offsetting hit-rate benefit --
regressing decode throughput below the no-cache baseline.

Add a small pool of free device buffers keyed by exact byte size
(cuda_stream_expert_pool_class_for/_alloc/_free/_release_all). Gate/up
share one size per layer's size class and down has its own, so there
are only a handful of distinct sizes in play; a short linear-scan array
of size classes is simpler than a hash table at this scale and just as
fast. On a miss/eviction, a freed buffer goes back onto its class's
free list instead of being cudaFree'd; the next install needing that
size pops it back off instead of calling cudaMalloc. Real cudaMalloc
only happens the first time a class needs more buffers than have ever
been freed back to it, i.e. it amortizes to the cache's steady-state
entry count rather than firing once per eviction.

The pool only returns memory to the driver via
cuda_stream_expert_pool_release_all(), called alongside every existing
whole-cache-reset site (model swap, streaming-mode toggle, budget
change) that already calls cuda_stream_expert_cache_clear_all() --
those are rare events, never per-token, so real teardown is unchanged;
only the hot miss/eviction path benefits from pooling.
@nexus-cw

nexus-cw commented Aug 1, 2026

Copy link
Copy Markdown
Author

Noting overlap with #605 (iCreil), which attacks the same missing-CUDA-expert-cache problem and was filed first — apologies for not spotting it before opening this. The approaches look complementary rather than competing: #605 adds a resident cache plus upload pipelining and pageable-copy optimizations (impressive 29-32 tok/s on an RTX PRO 6000), while this PR ports the Metal per-(layer,expert) LRU semantics — budget accounting per the CLI contract, frequency+recency eviction, pooled buffers, and env-gated hit/miss counters. Happy to rebase this on top of #605 if that lands first, or to fold the LRU/budget/counters pieces into #605's structure if iCreil prefers — whichever shape is most useful. Measurements in both PRs agree on the headline: the expert cache is worth 3-14x on streamed decode.

…ry count

The LRU budget was enforced only as an entry count derived from the
dominant slab size class, while CUDA caches every routed layer at its
actual per-expert size -- off-class layers cache entries larger than the
slab figure, so N entries * slab_bytes under-states real device
consumption. The buffer pool's free lists additionally park real
cudaMalloc'd memory that no accounting saw. In production hardening on a
GB10 node this let a nominal 100GB cache budget reach ~121GB of actual
device memory.

Now valid entries are charged at their actual gate+up+down byte sizes,
pool free-list bytes are counted as parked, and installs make room
before allocating -- trimming parked pool buffers first (data-lossless),
then evicting LRU -- so counted + parked never exceeds budget_experts *
expert_bytes, the exact dynamic-cache byte figure ds4.c plans and logs
at startup. The entry-count cap remains as a secondary bound. Parked
bytes reusable by the incoming request are treated as slack rather than
new demand, so at the cap the evict -> park -> pop cycle reuses buffers
with no cudaMalloc/cudaFree traffic (a first cut that double-counted
them measured a ~30 percent decode throughput regression from exactly
the alloc/free churn the pool exists to prevent).

DS4_CUDA_STREAM_STATS=1 now also self-reports the cache's true memory
footprint (budget, counted, pool-parked, device total, persistent
pinned-host staging, entry count) every 4096 fetch calls, and the
end-of-generation stats call in ds4_cli.c is compiled only into CUDA
builds so make cpu links again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@nexus-cw

nexus-cw commented Aug 3, 2026

Copy link
Copy Markdown
Author

Pushed one more commit (ab98473) with a fix for an accounting bug we found while hardening this cache in production.

The bug: the LRU budget was enforced only as an entry count derived from the dominant slab size class, while CUDA caches every routed layer at its actual per-expert size — layers off the slab class cache entries larger than expert_bytes, so N entries * slab_bytes under-states real device consumption. On top of that, the buffer pool's free lists park real cudaMalloc'd memory that no accounting saw. Running a GB10 node with a nominal 100GB cache budget, we observed ~121GB of actual device memory consumed by the cache, which pushed the box into GPU-availability thrash.

The fix:

  • valid entries are charged at their actual gate+up+down byte sizes;
  • pool free-list bytes are counted as parked;
  • installs make room before allocating — trimming parked pool buffers first (data-lossless), then evicting LRU — so counted + parked never exceeds budget_experts * expert_bytes, the exact dynamic-cache byte figure ds4.c plans and logs at startup. The entry-count cap remains as a secondary bound.

One subtlety: parked bytes reusable by the incoming request are treated as slack rather than new demand. A first cut that double-counted them forced a trim-then-malloc cycle on every miss at cap and cost ~30% decode throughput — exactly the alloc/free churn the pool exists to prevent.

DS4_CUDA_STREAM_STATS=1 now also self-reports the cache's true footprint (budget / counted / pool-parked / device total / persistent pinned-host staging / entries) every 4096 fetch calls, and the end-of-generation stats call in ds4_cli.c is now compiled only into CUDA builds, which fixes make cpu linking on this branch.

The fix is production-verified on the same GB10 node at a 70GB budget: actual consumption stays at the nominal figure with no measurable throughput cost versus the pre-fix pooled path.

🤖 Generated with Claude Code

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.

--ssd-streaming-cache-experts is silently inert on CUDA — expert cache never populated, hit rate 0

2 participants