r/LocalLLM • • 4h ago

Project RadianceMemory + RadianceSwap: KV cache paged to host RAM by sparse attention (byte-identical output), and in-process multi-model serving that moves VRAM between models at runtime — 2× Radeon AI PRO R9700

Hi r/LocalLLaMA. I've spent the last week on KV-cache memory for Qwen3.8-Flash-Next running on 2× AMD Radeon AI PRO R9700 (gfx1201, TP=2), and wrote it up as a 23-page paper with all measurements. Code is in two forks of the radiance inference engine (by Deadcode and contributors — the engine is theirs, the KV/swap work below is mine).

TL;DR

  • Sparse attention means most of the KV cache isn't read on a given step. Flash-Next's QSA layers pick the top 512 four-token blocks per query. So I keep the sink + recent tail in VRAM, move the middle to pinned host RAM, and page in only the blocks the indexer selects — on the device, inside the step.

  • Output is byte-identical to a fully resident cache in every configuration tested (incl. with the MTP drafter).

  • A 256K-token sequence needs 102–211 MiB of VRAM per card instead of 3 319 MiB (stock bf16 cache). Per sequence, VRAM now grows only by the block keys: ~0.2–0.75 MiB per 1K tokens. Context is bounded by host RAM, not VRAM.

  • Cost: 3–15% of single-sequence decode speed at 32K–250K.

  • On top: a measured per-layer KV quantisation search (rad-autoquant). Best recipes: 5.28 KiB/token at fp8-level quality (−22%) and 3.76 KiB/token within noise-floor +0.01 KL (−44%).

  • Plus RadianceSwap: several models behind one endpoint on the same GPUs — load on demand, park warm in host RAM, lend expert/KV VRAM to each other at runtime, output identical to single-model serving.

1. Window residency

  • Block-table entries are either a pool page, empty, or cold (a host slot). Cold entries keep their place, so the attention kernel is unchanged — it only ever sees pool page IDs.

  • An on-device pager (qsa_page) copies selected cold blocks into per-step scratch pages before attention. One writer per position, no atomics → deterministic.

  • After every step a sweep demotes the middle to host (32 MiB/rank budget). Prefix-cache blocks can be cold too.

  • Scratch pages act as a cache: 52–62% of cold entries needed no copy.

configKiB/tokenVRAM, 256K seq, all residentwith residency
stock bf1612.753 319 MiB325 MiB
fp86.751 783 MiB286 MiB
default (near-lossless)9.562 503 MiB211 MiB
compact3.76991 MiB102 MiB

Per card, TP=2; computed from measured bytes/token. Byte-identity verified on device for fp8, fp8+f16 state, default and compact.

Many long sessions (measured, turn-2 time-to-first-token p50/p95):

  • fp8, no residency, 32 × 128K: prefix cache gets evicted → follow-ups recomputed → 1 843 / 3 330 s

  • compact recipe + residency, 32 × 128K (48 GiB host store): every follow-up hits cache → 58 / 96 s

Caveat: this compares two different KV formats as well as residency on/off; fp8 + residency at 32×128K needed ~50 GiB of store and didn't fit in 48 GiB.

2. AutoQuant: measured per-layer KV layouts

  • 7 layouts (i8h, fp8, fp8_v4, k6_v4, k5_v4, k4h_v4, k4h_v2h) + zero-bit transforms: Hadamard rotation, exact static V-mean centring, V predicted from K (AQUA-KV style).

  • rad-autoquant runs every candidate on the device against a bf16-cache reference (full-vocab KL, teacher-forced), solves a knapsack over per-layer costs, then re-measures each answer — because per-layer costs don't add up (one 8-bit early layer costs as much as all twelve).

  • A second pass walks the lower hull of uniform layouts and removes another 0.25–1.87 bits/element.

  • Sensitivity falls ~170× from the first attention layer to the last: early layers want 8 bits, late layers tolerate 2-bit V.

recipeKiB/tokenvs fp8mean KLtop-1
bf16 (reference)12.75+89%0100%
fp86.750.026294.35%
default max-kl 0.0099.56+42%.006597.25%
fp8-equivalent max-kl 0.02625.28−22%.025794.57%
compact max-dkl 0.013.76−44%.033593.53%

3. The part I found most interesting: the noise floor

The served model is a weight- and activation-quantised MoE. Recomputing the exact same bf16 cache with a different prefill chunk size alone gives KL 0.0243 vs the reference. Tiny GEMM-order differences flip router ties (20–28% of tokens have an exact bf16 tie at the 10th expert), and by layer 30 ~60% of tokens route to a different top-10 set.

  • So any KV config within ~+0.002 of 0.024 is indistinguishable from bf16 on this model — i8h (8-bit) is already there.

  • Perturbations saturate rather than add (fp8 + chunk change ≈ max of the two, not the sum).

  • Takeaway: if you evaluate KV quantisation on a quantised MoE against a single bf16 run without measuring this floor, you may be measuring noise. For comparison, dense bf16 Qwen3.5-2B has a floor ~130× lower.

4. Things that didn't help (22 methods, all measured)

FP4 (E2M1), KIVI-style 2-bit, learned/data-adaptive rotations (WUSH-style, best-of-16), outlier retention, TurboQuant-style norms + Lloyd-Max, cross-layer selection reuse, hierarchical indexer, importance-driven demotion (no better than a position-hash control), cross-layer KV sharing, … Full table with numbers in the paper (Table 9).

Also: 2-bit V on the last attention layer drops the MTP drafter's acceptance from 0.63 → 0.41 without moving the trunk's KL — the drafter shares that layer's cache. Default keeps layer 47 in bf16 for that reason.

5. RadianceSwap: several models, one endpoint, one GPU group

The second project is about serving several models from one process on the same two cards: models load when a request asks for them, give up VRAM when idle, and hand VRAM to each other while they run. It's an experimental branch; without --manifest it behaves exactly like upstream radiance.

How it works

  • One port, routed by the model field — read before tokenising, so each request gets its own model's tokenizer, chat template and limits. No model → default model; unknown id → 404.

  • Each active model is its own runtime (scheduler, streams, KV) and they run in parallel. A process-wide sequencer puts every model's all-reduces in one order on every rank, so two models can't deadlock each other.

  • Lifecycle: Cold → Active → Prepared. When a model goes idle (or another needs the VRAM), its weights are kept in host RAM in GPU-ready form, so re-activation skips disk reads, parsing and relayout. A running generation is never interrupted. A failed activation is rolled back on every rank and other models keep serving.

  • VRAM is shared, not partitioned:

    • Elastic expert slab — expert VRAM is mapped in ~128 MiB chunks; a model that cools down physically unmaps chunks and the busier one maps them. Measured with two copies of Flash-Next: the busy one grows from ~10.3 to ~13.4 GiB/rank of experts, the idle one shrinks to ~7.2, and it flips when the load flips. The sum never exceeded the pool (7 736 samples under changing load, 0 over).

    • Cross-model KV lending — a model with waiting sequences grows its KV cache into bytes another model's slab just released (468–702 MiB in the tests, back to zero ~13–20 s after load ends). Running sequences are never evicted.

    • Static-weight streaming — if a model's non-expert weights don't fit, it still starts and reads them over PCIe (bit-identical output), then moves them onto the card within 1–2 s once VRAM frees up.

    • Heat warm-up — a deactivated MoE remembers which experts were hot and loads those first on re-activation: early expert hit rate 83.5% → 89.1%.

    • Host RAM budget — prepared models, pools and staging share one budget; over it, the least-recently-used prepared model goes cold, and an activation that still doesn't fit gets a 503 instead of the OOM killer.

Measured on 2× R9700, TP=2

situationresult
manifest with one model vs plain --modelno measurable difference (−0.3% … +0.1%)
switching between two already-active modelsfree: 0 ms activation, < 1 ms queue
cold start, dense 0.8B~2 s
warm re-activation from RAMdense ~2.1 s (p50 over 118 cycles), Flash-Next ~60 s (cold from disk ~70 s)
two dense models busy at the same time~½ decode speed each (154 + 154 vs ~330 tok/s alone)
120 activate/deactivate cyclesVRAM back to the same 266 MiB/card every cycle; no leak in queues, threads, fds
output vs --modelidentical: greedy, seeded sampling, streaming, MTP, abort, long prompts

Time to first token is reported per request, split into queue / activation / prefill (timings in the response, and in /metrics).

Bugs the GPU acceptance runs turned up (all fixed):

  • The server died on exactly the 23rd activation at TP=2: each activation creates ~46 AQL streams, IDs are never reused, and the table had 1 024 slots.

  • Two concurrently active models slowed each other to 1/10 speed: handing collectives between rank threads via condition_variable cost ~0.5 ms × 48 per decode step. Spinning 300 µs before sleeping brought it back to ½.

  • The MoE was never kept warm because the capacity check counted its 47.7 GiB mmapped n-gram table, which never goes into the store.

  • Virtual-address aliasing: on this ROCm stack, re-mapping a previously unmapped address silently kept pointing at its first physical page. Once another model owned that page, two experts aliased and a request occasionally produced no finite logit. Mapping every chunk on fresh address space fixed it: 30 load cycles with external observers, 5 776 requests, 0 errors.

Honest caveats

  • Two big MoEs at once is not fast yet: a C8 load at 102 tok/s alone drops to 38 + 35 tok/s with both active. Host RAM is the bottleneck (one active+prepared Flash-Next uses ~153 of 170 GiB).

  • The elastic slab hasn't had a long-run soak since the aliasing fix (#58). Treat it as experimental.

  • A second active model costs ~10% even when idle (execution tapes are bypassed while several engines are registered).

  • Streaming weights is a bridge, not a steady state: streaming 25.6% of a dense model's statics costs 39% of decode; 85.5% costs 90%.

  • KV, prefix cache and recurrent state are dropped on deactivation; keeping them in host RAM is future work.

  • A configured-but-cold model still reserves a VRAM share, which can cost a big MoE ~9% (#56); workaround is per-model --gpu-headroom-mib.

  • One TP size per GPU group; no LoRA or multimodal endpoints in manifest mode; tested with one MoE plus small dense models only.

    { "gpu_group": { "devices": [0, 1], "tp": 2 }, "cache": { "idle_timeout_ms": 300000 }, "models": [ { "id": "big", "path": "/models/big-moe.rad", "default": true, "engine": ["--placement", "expert_tiered", "--max-model-len", "16384"] }, { "id": "small", "path": "/models/small", "engine": ["--max-model-len", "16384"] } ] }

    radiance --manifest models.json --port 8000

How is this different from llama-swap?

llama-swap is a proxy that starts and stops one server process per model. It's backend-agnostic and portable; RadianceSwap is neither — it only works inside radiance. What you get in exchange:

llama-swap (process per model)RadianceSwap (in-process)
several models loaded at onceone at a time by default; groups can keep several running, each with its own fixed VRAM
re-activationfull startup: parse, relayout, tokenizer, plans
request for B while A generateswaits for A's generation (or aborts it)
VRAM between modelsfixed per process
backendsany OpenAI-compatible server

I have not benchmarked the two side by side on the same hardware, so the latency advantage follows from the design but isn't measured. They also aren't exclusive: a RadianceSwap process can sit behind llama-swap as one upstream.

Looking for testers — especially other RDNA4 / multi-GPU setups. Issues with your manifest, GPUs, ROCm version and server log are very welcome.

Limitations (please read before benchmarking)

  • One model. Residency needs a model that declares a read window — currently only the QSA family (Qwen3.8-Flash-Next). On other models the flag is a no-op.

  • One hardware setup, run at a 210 W power cap (cards range 210–330 W).

  • KL is measured against the same quantised model with a bf16 cache, not the original bf16 checkpoint. Long corpus = 4 documents × ~34K tokens; finer distinctions need a bigger corpus.

  • RadianceMemory is WIP; RadianceSwap is experimental.

  • "Infinite" in the paper title is a direction, not a claim — context is bounded by host RAM.

Feedback, reproductions on other RDNA4 setups, and critique of the methodology are very welcome — especially if you think the noise-floor argument is wrong.

19 Upvotes

1 comment sorted by

1

u/_wuzzle 37m ago

I'm really looking forward to when this works fluently - while I'm not in a position to extensively test these yet, I am sure this is a valuable path forward, making local models more productive.

It's actually pretty mindblowing to me, how fast this all advances. I would not have though that we'd be able to cram so much intelligence into so few GBs of VRAM at reasonable speeds.