Every payload swap a live endpoint has ever run is now one row in that endpoint's Eras table, with its rollback path in the same row. No per-attempt page, no per-swap ledger, nowhere else. - Added: LLM/frank-archon.md (radiance, 2x R9700 TP=2), LLM/frank-dragoon.md (halogen flash-server, 19 eras), LLM/frank-zealot.md (llama.cpp PrismML fork on the 3090). Each carries Current shape, Eras, Laws, wire contract, files. - Deleted: 45 per-payload runbooks -- the frank-dragoon line (11 including the pre-name halogen/kingjones/kairic/CIRU/eemin/agentionai/dflash2 pages and the benchmark page), the frank-zealot line (llama.cpp/vLLM/ik_llama/SGLang eras), the frank-archon line (vLLM/FreeToken/R9V/llama.cpp/radiance eras), plus frank-medium, frank-npu and the ubatch/PCIe one-offs that owned nothing. - Rewired: every wikilink and markdown-path reference to a folded page points at the endpoint page instead; the three Research see-also lists and the ubatch sweep's runbook link included. No anchored links existed in the set. - Regenerated both index pages as pure locators -- one row, one clause, no numbers -- so the docs_completeness locator law holds for all 153 pages. tests/docs_completeness.bats green: index links every page, no unresolved links. The pages moved to .AGENTTRASH/20261018-doc-dedupe/LLM/ (gitignored, and git history holds every byte).
13 KiB
type, title, description, tags, timestamp
| type | title | description | tags | timestamp | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| reference | RDNA4 Container Serving | Bringing a model larger than VRAM onto Dankserver's 2x R9700 (gfx1201) with the tcclaviger vLLM stack — quant-class gating, fetch verification, container GPU-init triage, memory budgeting and the expert-offload plan. |
|
2026-10-05T00:00:00-07:00 |
RDNA4 Container Serving
Bring-up path for Dankserver (2x Radeon AI PRO R9700, gfx1201, 31.86 GiB each =
63.7 GiB real VRAM, 246 GiB RAM, 7700X) with
docker.io/tcclaviger/vllm (vLLM 0.29.0.dev, GLM/Qwen-specialised RDNA4 build
with native MoE quant kernels and a first-class expert offload planner). The
tuning history and current deployed shape live in
frank-archon; this page is the bring-up law.
0. Know the box — and its phantom third GPU
torch.cuda.device_count() returns 3; summing total_memory overstates
the box as 186.7 GiB. The third device is the 7700X's integrated Vega
(gfx1036): torch reports ~123 GiB for it, rocm-smi reports 0.50 GiB,
allocating on it moves system MemAvailable 1:1 (it is GTT over DDR5, not
dedicated VRAM), and it dies with hipErrorInvalidKernelFile when the image
ships no code for that arch. Never sum device total_memory; never count
the iGPU as headroom or a workload.
1. Preflight: is the quant class EXECUTABLE, not merely present?
Expert offload is mandatory when weights exceed VRAM, and it requires the
checkpoint's experts class to have BOTH a compiled kernel AND an
offload_recipe ("a pinned expert has no host copy, so there is no
read-through fallback" — startup aborts otherwise). Check BEFORE downloading
anything:
podman run --rm --device /dev/kfd --device /dev/dri --device /dev/mem \
-e HIP_VISIBLE_DEVICES=0,1 --entrypoint bash $IMAGE -c '
nm -D /app/r4dhip/r4d.so | grep -oE "gemm_moe_[a-z0-9_]+" | sort -u
grep -rl offload_recipe /opt/vllm/lib/python*/site-packages/vllm/model_executor/layers/fused_moe/'
| quant | kernel in r4d.so |
experts class | verdict |
|---|---|---|---|
| FP8 | yes | Fp8HipMoEExperts |
serves |
| MXFP4 / DSFP4 | yes (gemm_moe_mxfp4a8_* / dsfp4a8_*) |
R4dMxfp4MoEExperts / R4dDsfp4MoEExperts |
serves |
| W4A16 | no gemm_moe_w4a16_* |
R4dW4a16MoEExperts exists |
silently falls back to generic TritonWNA16Experts, no recipe → startup halts |
| RFA | none | no class at all | out |
Also confirm the architecture routes natively with NO overlay
(ModelRegistry.get_supported_archs() for Qwen4Exp/Glm5). The stock image
natively carries Qwen3.8/Qwen4Exp and GLM-5.x including MTP heads. Do not
apply vendor overlays against a fast-moving pre-release image — the breakage
misleads (a missing symbol in the overlay's OWN copy of a module masks the
real cause, then moves to another symbol each patch;
cannot import name 'checkpoint_has_lm_head' = overlay clobbered
transformers_utils/config.py). The image ships quantizers for
rfa|rfi|mxfp4|mxfp4ocp|paro5 under /app/tools/quantize.py
(--fp8-attn --quant-only --dry-run first) if a re-quant is needed — note it
can run MXFP4/DSFP4/FP8 but only PRODUCE rfa/rfi/mxfp4/paro.
2. Fetch with verification, always
Gated repos: token never in argv (pgrep -a curl prints command lines).
Build a mode-600 curl config once:
{ printf 'header = "Authorization: Bearer '; cat ~/.cache/huggingface/token; printf '"\n'; } \
> ~/.cache/huggingface/curl.conf && chmod 600 ~/.cache/huggingface/curl.conf
curl -K ~/.cache/huggingface/curl.conf -f -sS -L -C - -o "$out" "$url"
--fail is MANDATORY — curl -o FILE URL exits 0 on HTTP 401/403 and writes
the error body as a "successful artifact" (a gated repo once yielded three
153-byte shards with identical sha256s and a cheerful DONE banner). -C -
resumes torn shards; HTTP/2 cancels leave silently truncated files, so
re-verify EVERY shard's byte count against the Hub manifest (and the LFS
sha256) before serving. The AGENTS.md Secret Rules carry the token-hygiene
law; frank-medium-model-swap has the same detail for GGUFs and
.partNofM fragments.
3. Serve — the known-good invocation and its traps
sudo sh -c 'sync; echo 3 > /proc/sys/vm/drop_caches'
podman run -d --name <ctr> --ipc=host --ulimit memlock=-1:-1 \
--cap-add SYS_PTRACE \
--device /dev/kfd --device /dev/dri --device /dev/mem \
-e HIP_VISIBLE_DEVICES=0,1 -e VLLM_ROCM_USE_AITER=0 \
-v /home/dank/models/<model>:/models:ro \
$IMAGE /models --served-model-name <name> --port 7770 \
--tensor-parallel-size 2 --max-model-len 65536 \
--kv-cache-memory-bytes 2147483648 \
--max-num-seqs 4 --gpu-memory-utilization 0.95 \
--enable-prefix-caching --trust-remote-code
- Entrypoint already execs
vllm serve "$@"— pass the model path as the first argument and do NOT passserve(podman then tries to pullserveas an image name).-e VAR=1must come BEFORE the image name. - Mount the whole
/dev/dri, never individual render nodes (§4). --ipc=hostand--shm-sizeare mutually exclusive in podman.podman run -dwithout-asends container stdout nowhere — readpodman logs <ctr>; and vLLM writesApplication startup completeto stderr, so apodman logs X 2>/dev/null | grep -qreadiness gate never fires.- Start at 65536, not the native ceiling; prove the plan, then scale.
- Load takes ~5 min with
/healthrefusing the whole time — do not kill the process (deploy.sh waits 20 min).
4. Container GPU-init triage (ordered)
- Enumerate-vs-context trap (most common): individual
renderD*nodes give HIP the render nodes WITHOUT the primarycard*nodes — it enumerates agents (device_count()returns N) and then refuses every context:RuntimeError: No CUDA GPUs are availableon the first real op. A/B-confirmed 2026-09-19. Rule:--device /dev/kfd --device /dev/dri --device /dev/mem; restrict GPUs withHIP_VISIBLE_DEVICES, never with device nodes. device_count()is not evidence. Prove usability with a real op ((a@a).sum().item()on a freshtorch.randn), then repeat viamp.spawn,mp.fork, andsubprocess.run([sys.executable, ...]), and cross-checkrocm-smi --showmeminfo vram.- memlock rlimit → a MISLEADING CUDA OOM. Host default 8192 KB; a pin
larger than the limit fails as
torch.AcceleratorError: CUDA error: out of memoryraised frompin_memory()— it names the accelerator but the fault is host pinned memory. It cannot be raised from inside a container or even from root's own shell (Operation not permitted); only PAM applies it at login, so fixes only affect NEW sessions:/etc/security/limits.d/99-memlock.confwith* - memlock unlimited+root - memlock unlimited; the container needs--ulimit memlock=-1:-1to inherit. Discriminating test after the fix: the container reportsunlimitedAND small pins succeed; if pins still fail, memlock was never the cause. /proc/meminfoTAB trap: the key/value separator is a TAB, sogrep MemAvailable /proc/meminfo | tr -s " " | cut -d" " -f2returns EMPTY and a following awk prints0.0— a false "host out of memory" on a host with hundreds of GiB free, which invites aborting something healthy. Tab-safe:awk -v k='MemAvailable:' '$1==k {printf "%.1f", $2/1048576; exit}' /proc/meminfo, cross-check<= MemTotal, refuse to print a zero. Usescripts/dankstatus.shinstead of hand-rolling.- Silent-parse traps:
grep -c PATTERN file || echo 0yields TWO lines (grep prints 0 AND exits 1) —[: 0\n0: integer expected; use| head -1or|| true.systemctl is-enabledcan emit multiple lines.rocm-smi --showmeminfo vram --csvcolumn order isdevice, VRAM Total Memory (B), VRAM Total Used Memory (B)— reading $3 as total/$4 as used yields 0.00; filter to^card[0-9]+$. - Guard the box: long loads with co-tenants can drive RAM to 0 — abort
before the OOM killer (
MemAvailable < 4 GiB → podman rm -f, exit 1). - Drop page cache before a large load (
echo 3 > /proc/sys/vm/drop_caches) and confirm the drop was actually in effect for the run before treating a failure as a capacity wall — a stale "it doesn't fit" from a contaminated run is expensive to unwind.
5. Memory budgeting: the levers are not where they look
The planner builds budget = total × util, subtracts a KV reserve, and gives
the REMAINDER to the expert arena. Consequences, all measured:
--gpu-memory-utilizationgrows the ARENA, not the KV pool (KV stayed 0.64 GiB at util 0.95 and 0.98). The arena is the only elastic term.- Lowering
--max-model-lenis the WRONG lever and backfires: a smaller context shrinks the reserve, handing the space to the arena — measured 65536 → 0.64 GiB KV, 32768 → 0.34 GiB KV. - Hybrid-attention undercount: the planner's per-token KV figure (9190
B/token for Qwen4Exp) misses the large fixed per-sequence Mamba state
(~2x), so the reserve is always short and the arena silently covers it.
Fix by pinning the pool:
--kv-cache-memory-bytes <BYTES>(2 GiB → 113,595 tokens / 3.47x concurrency at 32k). For long context,--kv-offloading-size(GiB) spills KV to host. --expert-offload-memcaps the HOST side, not the VRAM arena (cap = int(x * GIB)againsthost_memory_available()). Do not reach for it to fix a VRAM problem; to grow the arena, shrink the KV pin or raise util (see frank-archon for the util=1.0 unbootability law).
Read the plan — it is the real acceptance test:
expert offload: AUTO-ENABLED, weights 62.54 GiB per rank exceed the 30.27 GiB budget (TP2)
budget (total x util) 30.27 GiB
non-expert weights 6.28 GiB
KV reserve 0.36 GiB <- sanity-check against the model's own per-token figure
CUDA graphs 0.66 GiB
runtime overhead 7.60 GiB
expert cache space 15.36 GiB 6712 slots of 2.344 MiB
host-backed experts/rank 52.74 GiB 23040 experts
host: 105.48 GiB across 2 ranks of 160.89 GiB usable
Host fits when host_total < usable. The PLE n-gram table rides memfd and is
SHARED across TP ranks.
Loading mechanism (for reasoning about peak RAM): weights never materialise
in VRAM — expert Parameters start as CPU staging tensors; each layer's
weight_loader counts shard events and, on layer completion,
ExpertTopology.split_layer moves that layer's experts into the VRAM arena
and compacts the rest into exact-size pinned host tensors behind UVA. Peak
host RAM = host-backed set + layers still loading, not the whole model. At
runtime apply_hot_cold sends arena-resident experts down a hot GEMM and the
rest straight over PCIe via UVA views — no staging copy in the critical path.
Emulation check: a dequantize-to-compute path doubles real host RAM (118
GiB offload request → ~238 GiB) and is invisible in the weight count. Read the
log for Using 'EMULATION' Mxfp4 MoE backend before concluding "doesn't fit".
6. Performance profile and honest measurement
Decode on this pair is PCIe-gather-bound, context-independent: 12.11 tok/s FLAT from 304 to 16,095 context tokens — KV attention is not the cost. With ~29 % of experts arena-resident, ~0.84 GB crosses PCIe per token at the engine's effective read rate; cold-vs-warm differing ~10 % confirms the LRU works; growth is linear in hit rate, and only speculative decode cuts sequential STEPS rather than making each cheaper.
Benchmark traps that produced fake numbers here:
max_tokens=1is not TTFT (the server can satisfy it without doing the work — prefill looked flat at 1.26 s for 16k AND 30k prompts). A 64-token non-streamed reply is time-to-64-tokens (decode-bound, flat ~9.6 s at every length). Correct TTFT: stream and time the FIRST content-bearing chunk.- A small repeating word loop is prefix-cacheable AND n-gram-predictable — a
43-word cycle reported 22,897 tok/s prefill, almost entirely cache hit. Use
a large vocabulary in shuffled order and print
cached_tokens. - ~1.2 s fixed per-request overhead swamps prefill below ~16k tokens; compare prefill at 30k+ only.
- Size prompts with
/v1/messages/count_tokens(a chat call for counting pollutes the metric); guessing words-per-token silently overran the window (HTTP 400). - Always run a canary: plant a unique token at the END of a long prompt and ask the model to quote it — proves prompts reach the model at all.
- Measure warm AND long (
podman exec+ urllib at 127.0.0.1:7770): a 33-token reply read 14.6 cold / 20.3 warm; a 768-token generation hit 25.7; two concurrent streams scaled to 47 aggregate.
Levers (all opt-in): --expert-prefetch N takes an INT depth, not a
boolean (default 0; at depth 1 only ~10 % cold-vs-warm difference — the cache
already works); CLAV_TUNABLEOP_SWEEP=1 autotunes GEMM shapes (silently
skipped under expert offload unless CLAV_TUNABLEOP_WITH_OFFLOAD=1 too —
see frank-archon for why it was rejected);
--expert-precise-memory swaps the fitted overhead figure in.
Related
- frank-archon — deployed shape, plan law, measured campaigns
- PCIe Link and Atomics — why decode is gather-bound and what cannot fix it
- RDNA4 MoE Expert Offload VRAM Budget — arena/plan arithmetic deep-dive
- frank-dragoon and frank-zealot — window discipline on live hosts