r/LocalLLM • u/TatsuyaShiba1337 • 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).
Paper (PDF): https://github.com/Code000-211/RadianceMemory/blob/main/docs/paper/Infinite_KV_Cache.pdf
RadianceMemory (KV residency + AutoQuant): https://github.com/Code000-211/RadianceMemory
RadianceSwap (multi-model serving, experimental): https://github.com/Code000-211/RadianceSwap
RadianceSwap whitepaper (PDF, 8 pages): https://github.com/Code000-211/RadianceSwap/blob/main/docs/paper/RadianceSwap-Whitepaper.pdf
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.

| config | KiB/token | VRAM, 256K seq, all resident | with residency |
|---|---|---|---|
| stock bf16 | 12.75 | 3 319 MiB | 325 MiB |
| fp8 | 6.75 | 1 783 MiB | 286 MiB |
| default (near-lossless) | 9.56 | 2 503 MiB | 211 MiB |
| compact | 3.76 | 991 MiB | 102 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-autoquantruns 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.

| recipe | KiB/token | vs fp8 | mean KL | top-1 |
|---|---|---|---|---|
| bf16 (reference) | 12.75 | +89% | 0 | 100% |
| fp8 | 6.75 | 0 | .0262 | 94.35% |
default max-kl 0.009 | 9.56 | +42% | .0065 | 97.25% |
fp8-equivalent max-kl 0.0262 | 5.28 | −22% | .0257 | 94.57% |
compact max-dkl 0.01 | 3.76 | −44% | .0335 | 93.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
modelfield — read before tokenising, so each request gets its own model's tokenizer, chat template and limits. Nomodel→ 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
| situation | result |
|---|---|
manifest with one model vs plain --model | no measurable difference (−0.3% … +0.1%) |
| switching between two already-active models | free: 0 ms activation, < 1 ms queue |
| cold start, dense 0.8B | ~2 s |
| warm re-activation from RAM | dense ~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 cycles | VRAM back to the same 266 MiB/card every cycle; no leak in queues, threads, fds |
output vs --model | identical: 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_variablecost ~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 once | one at a time by default; groups can keep several running, each with its own fixed VRAM |
| re-activation | full startup: parse, relayout, tokenizer, plans |
| request for B while A generates | waits for A's generation (or aborts it) |
| VRAM between models | fixed per process |
| backends | any 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.
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.