Purpose-test pass over the audit findings (every verdict re-verified against the file before touching it; three scout findings were hallucinated — a nonexistent filename, my own changelog line, and sanctioned Eras rows): - Observability Provisioning: destroyed the 63-line 'Current delivered state' block — chain topology/relay/delivery restates [[Observability]] and [[Alertmanager]] (those are the owners). Its three sole-custody laws MOVED to Stale Signal Audit as 'Three rules that outlive the incident': down-beside-healthy -> read lastError; Prometheus alert state in process vs Alertmanager's --storage.path persistence; candidate-arm metrics are never fleet state. - RDNA4 MoE VRAM budget: kept the laws (registry over-prices the pool; never reuse a B/token ratio across geometries; pin pool bytes floor-sized), era-labelled the rates, destroyed the measured ladder table for the superseded shape-G/bf16 rungs. - Stale Signal Audit: new rules section + Provisioning Related link. check-okf 109/0, vault links 0 unresolved, docs seams green.
211 lines
11 KiB
Markdown
211 lines
11 KiB
Markdown
---
|
||
type: reference
|
||
title: RDNA4 MoE Expert Offload VRAM Budget
|
||
description: "Expert-offload arithmetic on the RDNA4 fork: movable terms, the utilisation law, graph charge, ceilings, context ladder."
|
||
tags: [vllm, expert-offload, vram, arena, kv-cache, ladder, tcclaviger]
|
||
timestamp: 2026-10-05T00:00:00-07:00
|
||
---
|
||
|
||
# RDNA4 MoE Expert Offload VRAM Budget
|
||
|
||
For VRAM-bound vLLM on the tcclaviger RDNA4 fork. Every boot ledger, arena
|
||
figure and rate below is from [[frank-archon]]'s tcclaviger era (2026-09-29 →
|
||
10-05: Qwen3.8-Flash-Next FP8, TP=2, 2x R9700 at 31.86 GiB each, expert offload
|
||
to host RAM) — that page owns the endpoint's shape, tuning history and era
|
||
record; this page owns only the plan arithmetic, the ladder method and the audit
|
||
procedure. Boot-failure capture lives in
|
||
|
||
## 1. The plan law
|
||
|
||
```
|
||
arena = budget − non_expert − kv_reserve − cuda_graphs − runtime_overhead
|
||
budget = device_total × gpu_memory_utilization # expert_plan.py build_plan
|
||
slots = arena // expert_bytes # 2.344 MiB/slot ⇒ 437 slots/GiB
|
||
```
|
||
|
||
**The arena is DEVICE-resident, so the plan predicts reality:** the fp8
|
||
3-window shape's plan totalled 28.27 GiB and `rocm-smi` measured exactly
|
||
28.27 GiB. `weights + pinned_pool + graphs + arena + non-torch (≈1.45 + 0.57
|
||
driver reserve)` vs 31.86 GiB predicts a boot failure before it happens.
|
||
|
||
Which terms a deploy can move:
|
||
|
||
| term | reachable? | how |
|
||
|---|---|---|
|
||
| budget (util) | **yes, law-capped** | `--gpu-memory-utilization`; +0.01 = +0.32 GiB on 31.86 GiB cards |
|
||
| CUDA graphs | **yes** | container env `VLLM_CUDAGRAPH_MEM_BASE_MB` / `_PER_GRAPH_MB` (or `_ESTIMATE_MB` for an explicit total) |
|
||
| non-expert weights | no | checkpoint census |
|
||
| KV reserve | no | registry constant: 9190 B/token × max_model_len × 1.08 + per-seq mamba pages |
|
||
| runtime overhead | partly | `--expert-precise-memory` swaps default 7.60 GiB for the registry-fitted 5.25 GiB |
|
||
|
||
Anything else is an IMAGE EDIT, not a deploy — say so; do not invent a flag
|
||
(verify with `podman exec <ctr> grep -n getenv …/offloader/expert_activation_registry.py`).
|
||
|
||
**UTIL LAW: 0.982, NEVER 1.00 (measured 2026-10-03).** The CLI field is
|
||
`Field(gt=0, le=1)`, but the worker init demands `free > budget`: at 1.0
|
||
budget == device total and BOTH ranks raise
|
||
`ValueError: Free memory on device cuda:N (31.29/31.86 GiB …)` BEFORE a
|
||
single weight loads — a crash loop (NRestarts ~2 min apart, zero payload
|
||
progress). Not a knife-edge: **unbootable**. Even bootable near-ceiling
|
||
shapes die under real load (0.13 GiB free bf16 boot; `0 bytes free` on a real
|
||
995-token request).
|
||
|
||
## 2. The CUDA-graph term: charge the measured capture
|
||
|
||
Defaults (256 MiB base + 52 MiB/graph) over-charge badly. Measure with the
|
||
engine's own post-capture line `Graph capturing finished … took X GiB` (per
|
||
rank; heavier rank binds). Measured on the no-spec piecewise plan: 0.31/0.17
|
||
GiB, and one boot reported a NEGATIVE −0.06 GiB delta (capture fit inside
|
||
pages the profiler had already counted) — the analytic base pool is pure
|
||
over-charge. Shipped: `BASE_MB=0`, `PER_GRAPH_MB=12` = 0.07 GiB — per-graph,
|
||
so a spec-decode shape (18 graphs) still pays (0.21 GiB). An explicit
|
||
`_ESTIMATE_MB` total does NOT scale with ladder changes; base+per-graph does.
|
||
|
||
**Verify every deploy:** `Estimated CUDA graph memory` line vs the `Graph
|
||
capturing finished` line; if capture exceeds the charge, raise `_PER_GRAPH_MB`
|
||
back toward 52. Check `VLLM_CUDAGRAPH_*` actually present in
|
||
`podman exec … env`.
|
||
|
||
## 3. The pool is invisible to the plan; free VRAM is the estimator gap
|
||
|
||
`--kv-cache-memory-bytes` does NOT appear in the arena arithmetic — the plan
|
||
reserves the registry price, not the actual pool. Raising the pool consumes
|
||
real free VRAM without growing the arena; the registry OVER-PRICES the pool
|
||
(era G measured ~14 % under the planned B/token — the gap is the lesson, not
|
||
the number; **never reuse a B/token ratio across geometries**), and there is a
|
||
~4 MB block-alignment loss to subtract when converting a token target to
|
||
bytes. The ask lands block-aligned: verify against the engine's own
|
||
`GPU KV cache size: N tokens, Maximum concurrency … X.Xx` line at boot.
|
||
|
||
**Driver-reported free VRAM after hours of traffic is NOT allocatable
|
||
arena.** It is the estimator gap: the registry over-prices KV and charges
|
||
5.25 GiB overhead against ~2.5 measured; torch's allocator does not return
|
||
slabs, so it reads as the high-water mark. The only reachable levers are util
|
||
(within the law) and the graph charge; everything else needs a registry edit
|
||
inside the image — the fork's own log line says as much.
|
||
|
||
**MNBT is arena-free up to the anchor:** registry overhead =
|
||
`base + 0.22 GiB × max(0, (nbt−4096)/1024)` anchored at nbt 4096, so rungs
|
||
1024/2048/4096 are plan-identical (the fitted 5.25 GiB was fitted AT 4096).
|
||
MNBT changes re-key the compile range (one recompile, ~24 s at (1,2048) with
|
||
the mounted cache). 512 doubles TTFT at 4064 ctx; 12288 costs ~2.5 % plus
|
||
arena. MNBT does not move the KV pool.
|
||
|
||
## 4. Registry "compile headroom" is not reclaimable slack
|
||
|
||
`--expert-precise-memory` selects the fitted overhead (5.25 GiB, this arch at
|
||
TP=2) over the default (7.60 GiB = fitted + **2.35 GiB of compile and
|
||
graph-capture headroom** the registry documents as "transient VRAM the
|
||
profiler never sees"). It moves exactly one term — the plan's `room` — so the
|
||
arena grows by that 2.35 GiB (~1,025 slots). **That headroom is the budget
|
||
for the phase that allocates LAST**: reclaiming it requires paying elsewhere
|
||
(smaller pinned pool, fewer sessions, shorter context). Measured 2026-10-01:
|
||
bf16 2×262,144 + precise put the total at 31.73 of 31.86 GiB and the boot
|
||
crash-looped at graph capture; dropping the flag restored ~2.4 GiB and booted.
|
||
|
||
## 5. Boot ≠ survival (knife-edge shapes)
|
||
|
||
A shape can pass EVERY boot gate — weights, profile, KV allocation, graph
|
||
capture, `/health` 200 — and die on the first real request. Measured
|
||
2026-10-01: `5 × 262144 @ fp8` booted (KV 1,350,182 tokens / 5.15x) and was
|
||
killed seconds later by a 995-token prompt with `max_tokens=261149`:
|
||
`torch.OutOfMemoryError: Tried to allocate 50.00 MiB … 0 bytes free`. After
|
||
any knife-edge change: send a LARGE-`max_tokens` request before calling it
|
||
done, and read post-boot free VRAM per rank (`rocm-smi --showmeminfo vram`)
|
||
— the survivor had ~3.6 GiB free; the corpse had 0.
|
||
|
||
**KV accounting — pin the pool BYTES, floor-sized:** set
|
||
`--kv-cache-memory-bytes` PER RANK to the contract floor
|
||
(`seqs × MAXLEN × measured B/token`), not the native window — extra pool bytes
|
||
come out of the arena/headroom and buy nothing. The B/token rate is a property
|
||
of the GEOMETRY: era G (3 × 200,704 fp8, vLLM fork) measured **7,891.8**;
|
||
the radiance-era hybrid measured ~7,700 fp8 / ~13,800 bf16 at its own shape.
|
||
Read your live rate from the boot ledger every time; the decomposition (a
|
||
dtype-independent mamba/side stream rides on top of the attention KV) is why
|
||
the ratio moves. Capacity does NOT scale with `SEQS`: the planner's KV reserve
|
||
is for ONE max-model-len request.
|
||
|
||
**Parallelism ceilings you cannot flag past:**
|
||
|
||
- `--decode-context-parallel-size` refused for GQA when `TP ≤ num_kv_heads`:
|
||
*"requires TP (2) to be greater than the model's total number of KV heads
|
||
(2)"* — DCP shards KV along the sequence dimension AFTER KV heads spread
|
||
across TP ranks (it reuses TP ranks, not TP×DCP), so a 2-KV-head model
|
||
needs TP ≥ 4 = four GPUs.
|
||
- EP (`--enable-expert-parallel` + `--all2all-backend`) exists but REPLACES
|
||
the fork's local tiered-offload gather with a per-layer PCIe all-to-all.
|
||
- DP requires a full model copy per replica — on a model larger than the
|
||
card it is impossible, not slow.
|
||
|
||
## 6. The context ladder (find the max bootable window by measurement)
|
||
|
||
"Max possible" = the largest value that BOOTS, measured — never the model's
|
||
spec maximum, never hand arithmetic.
|
||
|
||
1. **Scale the pool with the window.** If KV_BYTES is pinned, it must shrink
|
||
in lockstep with MAXLEN or the rung tests nothing:
|
||
`KV_BYTES = round(KV_BYTES_ref × (seqs×MAXLEN_new) / (seqs×MAXLEN_ref))`
|
||
off a booted reference rung. Verify the rung's capacity still covers
|
||
`seqs × MAXLEN` from the engine's own line.
|
||
2. **Boot under the PRODUCTION environment, not a shell probe** — a rootless
|
||
shell lacks `LimitMEMLOCK=infinity` and can pass where production OOMs.
|
||
Transient unit:
|
||
`sudo systemd-run --uid=<svcuser> --unit=probe-$ML -p LimitMEMLOCK=infinity -p Restart=no env <ENVFILE_OVERRIDE> <serve-script>`
|
||
pointing the serve script at a scratch env file via its `*_ENV_FILE` hook.
|
||
3. **Judge on health + unit liveness:** poll `/health` (200 up / 503 loading
|
||
/ 000 not bound); break early when the unit goes inactive — that is the
|
||
failure signal, not a timeout.
|
||
4. **Capture the engine log per rung BEFORE teardown** (`podman logs` dies
|
||
with `podman rm`; a `podman logs` issued too soon after `podman run -d`
|
||
fails with *no container with that name* — record UNKNOWN, never FAIL).
|
||
5. **Ladder down on FAIL, one bonus step UP on PASS.** Measured steps on this
|
||
box: OOM −16,384 tokens, bonus +4,096. Tiny margins (76 MiB class) mean
|
||
the next rung down almost certainly passes: `Tried to allocate 76.00 MiB …
|
||
0 bytes free … 30.43 GiB allocated by PyTorch` died in graph CAPTION;
|
||
`Tried to allocate 10.28 GiB … 9.46 GiB free` died in KV ALLOCATION.
|
||
6. **Clean between rungs:** stop the transient unit, `podman rm -f`, kill
|
||
orphans by explicit PID
|
||
(`ps aux --sort=-rss | awk '/vllm\/bin\/python3/ && $6 > 50000000 {print $2}'`)
|
||
wait for `MemAvailable` to recover.
|
||
|
||
**The hazard that cost production:** a leftover ladder script whose teardown
|
||
`podman rm -f`s the production container fires against a freshly DEPLOYED
|
||
engine. Teardown runs ONLY on failure, never after a pass; kill the whole
|
||
scratch process tree and verify none remain BEFORE deploying. On PASS: print
|
||
the measured `GPU KV cache size / Maximum concurrency` line, LEAVE IT
|
||
SERVING, and pin the number into the repo (env + seam + harness
|
||
contextWindow) before deploying.
|
||
|
||
Cutover order: ladder → winner passes and serves → repo env + seam + harness
|
||
→ tests → commit+push → verify no scratch scripts remain → stash foreign work
|
||
(`git stash -u`) if the tree is dirty → deploy (one restart) → verify health
|
||
+ engine flags in the log.
|
||
|
||
|
||
## 7. Arena audit procedure (when asked for "more experts")
|
||
|
||
1. Read the live plan before proposing anything — `grep -oE` with a
|
||
digit-anchored tail (plain grep/journalctl truncate these columns to
|
||
`17.`):
|
||
|
||
```
|
||
ssh dankserver 'sudo journalctl CONTAINER_NAME=frank-archon-qwen -b -o cat --no-pager \
|
||
| grep -aoE "(budget \(total x util\) +[0-9.]+|expert cache space +[0-9.]+ GiB +[0-9,]+ slots|Estimated CUDA graph memory.{0,80}|Graph capturing finished.{0,40}|GPU KV cache size.{0,90})"'
|
||
```
|
||
|
||
2. Classify each term (§1 table); take only the two reachable levers.
|
||
3. Verify after deploy: plan lines show new budget/graphs/arena;
|
||
`GPU KV cache size` matches the ask; graph-estimate vs post-capture;
|
||
`rocm-smi` high-water per card = the real margin;
|
||
`systemctl show frank-archon -p NRestarts --value` = 0; a real chat
|
||
completion from a PEER (health-probe via `192.168.0.235:7770/health`, not
|
||
`localhost` inside an ssh session on the host — the latter returns `000`
|
||
and looks like an outage).
|
||
|
||
## Related
|
||
|
||
- the bring-up path and container traps
|
||
- capture and contracts
|
||
- [[frank-archon]] — measured history
|
||
- [[Llama Cpp Serving Sizing]] — the llama.cpp equivalent
|