The root and LLM index pages still advertised two retired endpoints' pages as live procedure: - `frank-medium-radiance-mxfp4`: frontmatter said "ACTIVE frank-medium runbook" and the body opened "The ACTIVE frank-medium deployment". Both re-keyed to the era it ran (2026-09-21 -> 10-10), with [[frank-archon]]'s Eras table named as the owner of that shape. - `frank-medium-model-swap`: read as live how-to for a retired endpoint. Re-keyed to historical, pointing at [[Model Swap and Rollback]] for the current swap law. - Both index pages' rows re-keyed to match (rows are hand-copied from frontmatter - no generator - so both sites move together). Refs: vault de-dup campaign, cluster C5.
635 lines
36 KiB
Markdown
635 lines
36 KiB
Markdown
---
|
||
type: configuration
|
||
title: frank-medium vllm-radiance MXFP6 Runbook
|
||
description: RETIRED era runbook (endpoint renamed frank-archon 2026-10-10; this shape is a row in [[frank-archon]]'s Eras table) — vllm-radiance serving hugypufy/Swift-Qwen3.8-27B-PARO-MXFP6 (ParoQuant MXFP6 W4A8, DFlash2 drafter, unquantized bf16 KV) as ONE tensor-parallel process (TP=2) on Dankserver's two AMD Radeon AI PRO R9700 (gfx1201). Cut to MXFP6 on 2026-09-21 (PR #51's launch6_p kernel, image 0.13.0-e060895); the MXFP4 checkpoint (just1moremodel/Qwen3.8-27B-Uncensored-MXFP4-awq, the 2026-09-21 shape) is the on-disk rollback. Model-swap process: [[frank-medium-model-swap]]. The sections below describe the 2026-09-16 two-replica era.
|
||
tags:
|
||
- frank-medium
|
||
- dankserver
|
||
- vllm
|
||
- vllm-radiance
|
||
- mxfp6
|
||
- mxfp4
|
||
- gfx1201
|
||
- r9700
|
||
- rccl
|
||
- pcie-atomics
|
||
- quadlet
|
||
timestamp: 2026-09-21
|
||
---
|
||
|
||
# frank-medium vllm-radiance MXFP6 Runbook
|
||
|
||
**RETIRED era (2026-09-21 → 10-10; the endpoint is [[frank-archon]] today and this
|
||
shape is one row in its Eras table.)** The deployment was the `ggz14/vllm-radiance-mxfp4:0.13.0-e060895`
|
||
image (built from PR #51's head, tagged locally as
|
||
`localhost/radiance-vllm:0.13.0-e060895`) plus the `radiance-vllm-mxfp4`
|
||
patches at e060895, serving `hugypufy/Swift-Qwen3.8-27B-PARO-MXFP6`
|
||
(ParoQuant MXFP6 W4A8, 25.16 GiB, 18 shards) as **ONE tensor-parallel
|
||
process across both Radeon AI PRO R9700 (gfx1201)**. It replaced the
|
||
mainline-llama.cpp layer-split deployment ([[frank-medium-llamacpp-dflash2]],
|
||
SUPERSEDED 2026-09-20); the same image and model had earlier run here as two
|
||
single-GPU replicas, which is the shape most of this page describes.
|
||
|
||
**Serving shape since 2026-09-21: ParoQuant MXFP6 with unquantized (bf16) KV.**
|
||
This endpoint cut to the MXFP6 checkpoint above (PR #51's `launch6_p` kernel,
|
||
`quant_method=paroquant_mxfp6`, bits=6, group_size=128, krot=8, rotation on,
|
||
e4m3fn scale) with `--kv-cache-dtype auto` (unquantized bf16 KV) and
|
||
`--gpu-memory-utilization 0.98`. The Swift variant is a finetune of
|
||
`ukisai/Swift-Qwen3.8-27b` (a different base from `Qwen/Qwen3.8-27B`), so the
|
||
DFlash2-FP8 drafter's hidden-state distribution is a mismatch — acceptance may
|
||
drop from the MXFP4 era's 4.24/7, but it is still a net win over no spec
|
||
decode. **The MXFP4 checkpoint is the on-disk rollback**: flip `SNAP` back to
|
||
`/models/Qwen3.8-27B-Uncensored-MXFP4-awq`, and the image
|
||
back to `localhost/radiance-vllm:0.9.3` (re-pull `docker.io/stilldeadcode/
|
||
vllm-radiance:0.9.3` if it is no longer on disk; the MXFP4 shape auto-sized to
|
||
486,464 tokens with bf16 KV and held the full 262,144 window).
|
||
|
||
**Deployed shape since 2026-09-20: one rootless Quadlet, `--tensor-parallel-size 2`.**
|
||
The ProArt B850-Creator WiFi NEO board swap put both R9700s on CPU GPP root
|
||
ports (`00:01.1 → 03:00.0` and `00:01.3 → 07:00.0`), both Gen5 x8, every
|
||
upstream bridge `AtomicOpsCap: Routing+` and both endpoints `ReqEn+` — the wall
|
||
documented in [Why TP=2 cannot run here](#why-tp2-cannot-run-here) is gone on this
|
||
board. The collective that actually runs is vLLM's R4D one-shot allreduce, which
|
||
does not use RCCL at all (TheRock RCCL has no gfx1201 device code; see
|
||
[[Dual-R9700-Cooperation]]).
|
||
|
||
## Deployed flags (2026-09-21)
|
||
|
||
From `dankserver/frank-medium/quadlet/frank-medium.container`:
|
||
|
||
| Setting | Value |
|
||
|---|---|
|
||
| Model | `hugypufy/Swift-Qwen3.8-27B-PARO-MXFP6` (ParoQuant MXFP6 W4A8, 25.16 GiB, 18 shards; a finetune of `ukisai/Swift-Qwen3.8-27b`) |
|
||
| Speculative | `--speculative-config {"method":"dflash","model":"/models/Qwen3.8-27B-DFlash2-FP8","num_speculative_tokens":7}` (the z-lab DFlash2 external drafter; measured on the Swift finetune: mean acceptance length 7.00 / 85.7% on easy text, 5.08 / 58.3% on varied — the drafter survives the base-model change. A/B tested against MTP self-speculation — DFlash2 wins on decode, see below) |
|
||
| Parallelism | `--tensor-parallel-size 2` (`ROCR_VISIBLE_DEVICES=0,1`) |
|
||
| Context | `--max-model-len 262144` |
|
||
| Concurrency | `--max-num-seqs 8`, chunk 8192 |
|
||
| KV | auto = **unquantized bf16** (`--kv-cache-dtype auto`), **auto-sized — pool 425,047 tokens** (measured at 0.98 fill: 14.42 GiB per card available for KV, 1.62x the 262,144 window); rollback: `KV_DTYPE=fp8` |
|
||
| Prompt cache | `--enable-prefix-caching` |
|
||
| GPU fill | `--gpu-memory-utilization 0.98` (the 32 GB ceiling — 0.99 fails the startup check) |
|
||
| Parsers | `--enable-auto-tool-choice --tool-call-parser qwen3_coder`, `--reasoning-parser qwen3` |
|
||
| Sampler floor | `--override-sampling-effects` (temp 0.7 / top_p 0.95 / top_k 20) |
|
||
| Multimodal | `--limit-mm-per-prompt '{"image": 8}'` |
|
||
| Chat template | `qwen-sharp-v22.5.0.jinja` (peculiar-ragdoll Qwen-Sharp v22.5.0, repo-tracked at `dankserver/frank-medium/`, mounted at `/templates/`; terse system prompt by default — opt out per-request with `chat_template_kwargs {"terse": false}`; rollback: flip `CHAT_TEMPLATE` back to `/templates/qwen-sharp-v22.4.0.jinja`) |
|
||
|
||
**The KV pool is auto-sized, not pinned.** Production ships no `KV_MEM` pin
|
||
(`KV_MEM=0`): the pool comes up at whatever VRAM the weights + drafter leave.
|
||
MXFP6's weights are 25.16 GiB (~12.58 GiB per card at TP=2); at 0.98 fill
|
||
(31.22 GiB per card) that leaves **14.42 GiB per card for KV** — a measured
|
||
**425,047-token pool**, 1.62x the 262,144 window (boot 2026-09-22). The
|
||
DFlash2-FP8 drafter costs 2.1 GiB of VRAM on top.
|
||
**0.98 is the ceiling, not a preference**: the R9700 reports
|
||
31.86 GiB total but only 31.23 GiB free at device-init (the ROCm/HIP context +
|
||
NCCL + the radiance custom all-reduce buffers consume the 0.63 GiB gap), so a
|
||
`gpu_memory_utilization` above 0.98 (0.99 = 31.54 GiB) fails vLLM's startup
|
||
check before the weights even load. For reference the MXFP4 checkpoint (the
|
||
rollback) auto-sized to 486,464 tokens at the same 0.98 with bf16 KV (1.86x the
|
||
window); MXFP6's larger weights give a smaller pool, still comfortably above the
|
||
window. After the first healthy start, recalibrate with `calibrate-kv.sh`.
|
||
|
||
## DFlash2 vs MTP — A/B on the FP8 checkpoint (2026-09-20)
|
||
|
||
The FP8 checkpoint carries its own single-layer MTP head, so it can self-
|
||
speculate (`SPEC_METHOD=mtp`) or use the z-lab DFlash2 external drafter
|
||
(`SPEC_METHOD=dflash`, a separate quantization-agnostic 2.1 GiB
|
||
`DFlash2DraftModel`). Both were deployed and measured on the same FP8 target
|
||
(`scripts/bench-conversation.py`, 10 real prompts, `max_tokens=512`, greedy,
|
||
salted, `--burst 4`):
|
||
|
||
| Arm | Mean accept | Decode (geomean) | TTFT (mean) | Prefill (geomean) | Burst-4 aggregate | KV pool |
|
||
|---|---|---|---|---|---|---|
|
||
| **DFlash2** (depth 7) | 4.24/7 | **100.0 t/s** | 0.19 s | 923.0 t/s | **287.1 t/s** | 384,238 (1.47×) |
|
||
| MTP self-spec (depth 8) | 5.66/8 | 85.9 t/s | 0.06 s | 1096.9 t/s | 179.7 t/s | 461,540 (1.76×) |
|
||
|
||
**DFlash2 wins on decode (+16 %) and burst-4 (+60 %); MTP wins on TTFT and
|
||
prefill.** The counterintuitive part: DFlash2 accepts *fewer* tokens per step
|
||
(4.24 vs 5.66) yet decodes *faster*. Accept length is a proxy, not the metric
|
||
— throughput is `(accept + 1) / (draft time + verify time)`. The DFlash2
|
||
drafter is a small separate model that steps fast; the MTP head is the
|
||
target's own layer applied autoregressively 8×, so its draft is slower. The
|
||
faster step more than makes up the lower accept length. FP8 + DFlash2 (100.0
|
||
t/s) also beats the original MXFP4 + DFlash2 (92.4 t/s) — the higher-precision
|
||
FP8 target verifies more of the drafter's proposals. The drafter costs 2.1 GiB
|
||
of VRAM (the KV pool is 1.47× vs MTP's 1.76×), an acceptable trade for the
|
||
decode win. **DFlash2 is the deployed shape.**
|
||
|
||
## Prefix caching — measured granularity (2026-09-20)
|
||
|
||
`--enable-prefix-caching` is on, and radiance says so at boot: *"Prefix caching
|
||
in Mamba cache 'align' mode is currently enabled. Its support for Mamba layers
|
||
is experimental."* The attention block size is **1,648 tokens** (boot log:
|
||
"Setting attention block size to 1648 tokens …"), padded to the mamba/GDN page
|
||
size, and reuse happens at that granularity — not per token.
|
||
|
||
Measured on the deployed endpoint, not inferred:
|
||
|
||
| Probe | Cold | Warm | `vllm:prefix_cache_hits_total` delta |
|
||
|---|---|---|---|
|
||
| identical 13,021-token prompt | 2.30 s | 0.68 s (3.4×) | +9,888 of 13,021 queried (76 %) |
|
||
| identical 2,521-token prompt | 0.51 s | 0.51 s | +0 (under two blocks) |
|
||
|
||
Consequence: a shared prefix shorter than about two blocks (≈3.3k tokens)
|
||
re-prefills in full, so the benefit starts at ~3.3k tokens of shared prefix.
|
||
The API returns `prompt_tokens_details: null` in this build, so the
|
||
`vllm:prefix_cache_hits_total` / `vllm:prefix_cache_queries_total` counters
|
||
(both token-counted) are the only instrument — see [[Observability]].
|
||
|
||
## Battery results — why this shape won (2026-09-20)
|
||
|
||
`scripts/bench-conversation.py`, 10 real-workload prompts, `max_tokens=512`,
|
||
greedy, salted per run and per arm, service stopped between arms
|
||
(`scripts/bench-conversation.py --host http://127.0.0.1:7770 --burst 4`):
|
||
|
||
| Arm | Shape | Decode (geomean) | TTFT (mean) | Prefill (geomean) | Burst-4 aggregate |
|
||
|---|---|---|---|---|---|
|
||
| I | llama.cpp layer split 32:33 + DFlash2 Q4_K_M drafter | 45.7 t/s | 0.72 s | ~530 t/s | 71.7 t/s (26.7/stream) |
|
||
| L | llama.cpp `--split-mode tensor` 1:1, drafterless | 30.5 t/s | 0.71 s | — | 54.3 t/s |
|
||
| **R** | **vllm-radiance TP=2 + DFlash2-FP8** | **95.8 t/s** | **0.21 s** | **1493.6 t/s** | **259.4 t/s (65.6/stream)** |
|
||
|
||
Re-measured against the production unit after deploy: geomean decode 92.4 t/s,
|
||
mean TTFT 0.22 s, burst-4 aggregate 248.9 t/s — within noise of arm R.
|
||
|
||
Honest caveat: arm R carries MXFP4 (4-bit) where the llama arms carry Q8_0
|
||
(8-bit), so part of the win is quantization, not tensor parallelism alone. Arm
|
||
L exists to show that llama.cpp's own TP is not the answer here regardless —
|
||
it is slower than its own layer split, and cannot carry the drafter at all.
|
||
|
||
## Verified capabilities (2026-09-20, against the live endpoint)
|
||
|
||
- `/v1/models` → `id: frank-medium`; `/health` → 200.
|
||
- **Tool calls**: `finish_reason: tool_calls` via `qwen3_coder`. The harness
|
||
drives a bash tool through this endpoint end to end.
|
||
- **Reasoning**: `reasoning_effort` accepts `none`/`low`/`medium`/`high`
|
||
natively (vLLM's `ReasoningEffort`); thinking arrives in the `reasoning`
|
||
field, NOT llama.cpp's `reasoning_content`. `max_tokens` counts visible
|
||
content only; with no reasoning budget the model still spends it, so a tiny
|
||
budget returns `content: null` with `finish_reason: length`.
|
||
- **Vision works** despite `language_model_only: true` in `config.json` — the
|
||
Gemma4 merge path answers image requests (~83 prompt tokens for a small
|
||
synthetic), correctly identifying shape and colour on held-out images.
|
||
`models.yml` therefore lists this endpoint as `input: [text, image]`.
|
||
- Drafter is live: accept length 2.9–4.3 tokens per draft, acceptance ~0.60.
|
||
|
||
## Rollback
|
||
|
||
The llama.cpp layer-split trio is archived in `scripts/.AGENTTRASH/
|
||
20260920-llama-layersplit/` (`frank-medium.service`, `frank-medium.env`,
|
||
`serve-frank-medium.sh`) and the runtime + Q8_0 payload remain on disk:
|
||
|
||
1. `git revert` the cutover commit.
|
||
2. `deploy.sh dankserver --service frank-medium` (re-installs and re-enables the root unit).
|
||
3. Or by hand: restore the three files, move the unit symlink back into `/etc/systemd/system/`, `sudo systemctl daemon-reload && sudo systemctl unmask frank-medium && sudo systemctl start frank-medium`.
|
||
4. Gate: `curl -s http://192.168.0.235:7770/health` → 200.
|
||
|
||
## R4D vs RCCL
|
||
|
||
vLLM's R4D one-shot allreduce bypasses RCCL entirely because TheRock RCCL
|
||
builds lack gfx1201 device code (`invalid device function`). The Ubuntu
|
||
RCCL 2.27 / `librccl1` 7.1.0 build does load gfx1201 kernels — that is what
|
||
`scripts/build-llama-rdna4.sh` with `GGML_HIP_RCCL=ON` links
|
||
(`build-rdna4-rocm714-rccl/`) — so llama.cpp's tensor mode has a real
|
||
collective too. It is simply the slower shape.
|
||
|
||
> **The sections from here down describe the 2026-09-16 → 2026-09-19 era: two
|
||
> single-GPU replicas behind an adaptive-routing proxy, on the TUF GAMING
|
||
> B650-E.** The deployed shape is above; this is the evidence trail that made
|
||
> replicas necessary then and what the 2026-09-20 board swap changed.
|
||
|
||
## Architecture
|
||
|
||
```mermaid
|
||
graph LR
|
||
C[clients: Hermes, harness] --> P["frank-medium-proxy<br/>:7770 adaptive routing"]
|
||
P --> A["frank-medium-a<br/>R9700 #1, TP=1, :7771"]
|
||
P --> B["frank-medium-b<br/>R9700 #2, TP=1, :7772"]
|
||
```
|
||
|
||
Three rootless Quadlet units, all `WantedBy=default.target` + `Restart=on-failure`:
|
||
|
||
| Unit | Container | GPU | Port | Role |
|
||
|---|---|---|---|---|
|
||
| `frank-medium-a` | `frank-medium-a` | `HIP_VISIBLE_DEVICES=0` | 7771 | replica |
|
||
| `frank-medium-b` | `frank-medium-b` | `HIP_VISIBLE_DEVICES=1` | 7772 | replica |
|
||
| `frank-medium-proxy` | `frank-medium-proxy` | — | 7770 | routing |
|
||
|
||
The fleet contract is unchanged: one endpoint on :7770, served model name
|
||
`frank-medium`, OpenAI `/v1`, streaming included. The vLLM OpenAI API is
|
||
stateless per request, so no session affinity is needed for correctness. Routing
|
||
is described in [Routing](#routing).
|
||
|
||
Only `HIP_VISIBLE_DEVICES` is set per replica, never a matching
|
||
`ROCR_VISIBLE_DEVICES`: ROCR filters first and HIP then indexes into the
|
||
filtered list, so the single-valued pair (`ROCR=1,HIP=1`) leaves an empty
|
||
device set and the engine dies with `hipErrorNoDevice`.
|
||
|
||
`frank-medium.container` (TP=2, `KV_MEM=18563072000`, `MAXLEN=262144`) stays in
|
||
the repo as the target shape for a host whose second card sits on an
|
||
atomic-capable root port. `deploy-remote.sh` deliberately does not install it
|
||
and moves any stale copy into `~/.AGENTTRASH/`, because a TP=2 unit on this
|
||
host would restart forever without ever becoming healthy.
|
||
|
||
## Why TP=2 cannot run here
|
||
|
||
> **Resolved 2026-09-20.** Everything below is true of the **TUF GAMING
|
||
> B650-E WIFI**, whose second card sat behind chipset bridges advertising
|
||
> `AtomicOpsCap: Routing-`. The ProArt B850-Creator WiFi NEO swap moved both
|
||
> cards onto CPU GPP root ports (`00:01.1 → 03:00.0`, `00:01.3 → 07:00.0`, Gen5
|
||
> x8, every hop `Routing+`, both endpoints `ReqEn+`), and TP=2 now boots and
|
||
> serves. The measurement verdict against the alternatives is in
|
||
> [[Dual-R9700-Cooperation]].
|
||
|
||
### Symptom
|
||
|
||
Any two-rank collective dies at launch:
|
||
|
||
```
|
||
[FATAL ERROR]: HIP failure: 'the operation cannot be performed in the present state'
|
||
group.cc:337 -> group.cc:656 -> group.cc:801 -> enqueue.cc:3111
|
||
```
|
||
|
||
With the runtime's own API log (`AMD_LOG_LEVEL=3`) the failing call is explicit:
|
||
|
||
```
|
||
[ShaderName : ncclDevKernel_Generic_4(ncclDevKernelArgsStorage<4096ul>)]
|
||
[ :1:rocvirtual.cpp :4208: Pcie atomics not enabled, hostcall not supported]
|
||
[ :1:rocvirtual.cpp :4636: AQL dispatch failed!]
|
||
[ :3:hip_module.cpp :893 : hipLaunchKernel: Returned hipErrorIllegalState]
|
||
```
|
||
|
||
ROCclr refuses to hand the kernel a hostcall buffer when the device has no
|
||
PCIe atomics (`pcie_atomics_` is true for the device init, and ROCr passes
|
||
that buffer only when `pcie_atomics_` is true; otherwise it returns "not
|
||
supported" and the dispatch fails) — and the launch aborts.
|
||
`hipErrorIllegalState` is that refusal, not a driver fault.
|
||
|
||
### Chain of causation
|
||
|
||
1. RCCL's collective kernels carry a hidden **hostcall buffer** parameter.
|
||
2. ROCr passes that buffer only when `pcie_atomics_` is true for the device;
|
||
otherwise it returns "not supported" and the dispatch fails.
|
||
3. `pcie_atomics_` is set once, at device init, from the HSA link info between
|
||
the CPU agent and the GPU (`Device::checkAtomicSupport`). It is not re-read
|
||
per process.
|
||
4. The KFD driver populates that link info from
|
||
`pci_enable_atomic_ops_to_root()`, which — per the PCIe spec and the
|
||
kernel's implementation — requires **every upstream bridge** between the
|
||
device and its root port to advertise `AtomicOp Routing` capability
|
||
(`drivers/pci/pci.c`).
|
||
|
||
The hardware difference between the two cards:
|
||
|
||
| Card | PCIe chain (endpoint → root port) | `AtomicOpsCap` on every hop | AtomicOpsCtl |
|
||
|---|---|---|---|
|
||
| R9700 #1 (`03:00.0`) | `02:00.0` → `01:00.0` → `00:01.1` | Routing+ / Routing+ / Routing+ | `ReqEn+` (atomics ON) |
|
||
| R9700 #2 (`0b:00.0`) | `0a:00.0` → `09:00.0` → `06:09.0` → `05:00.0` → `00:02.1` | Routing+ / Routing+ / **Routing−** / **Routing−** / Routing+ | `ReqEn−` (atomics OFF) |
|
||
|
||
Card #1 is on a CPU root port; card #2 is behind the chipset bridges
|
||
`05:00.0`/`06:09.0`, which advertise `AtomicOpsCap: Routing-`. That capability
|
||
bit is **read-only** — no software can enable atomic routing through those
|
||
bridges — so the driver's atomic enablement call fails for card #2 and the KFD
|
||
marks the CPU↔GPU2 link `NO_ATOMICS`.
|
||
|
||
Writing the AtomicOp Requester bit into the endpoint's Device Control 2 by hand
|
||
(`setpci -s 0b:00.0 CAP_EXP+28.w`) does not help: the driver reads the chain at
|
||
probe time, and ROCr consumes the driver's cached answer. Only a re-probe (a
|
||
boot with the write already in place) would change it — and even then the two
|
||
`Routing-` bridges would still make `pci_enable_atomic_ops_to_root()` fail.
|
||
|
||
### What was ruled out
|
||
|
||
| Suspect | Result |
|
||
|---|---|
|
||
| Kernel version | 7.0.0-28, -30 and -31 fail identically (full `apt full-upgrade` to the newest available kernel changed nothing) |
|
||
| IOMMU | identical before and after `iommu=pt` |
|
||
| P2P hardware | fine: 45.3 GB/s bidirectional copy between the two cards; both cards pass `rocm-bandwidth-test` |
|
||
| The shipped librccl | a from-source rebuild of `ROCm/rccl` develop (with local fixes for that branch's dangling references) reproduces the same failure at the same call path — the library is not at fault |
|
||
| vLLM/pynccl | a bare two-rank `torch.distributed` allreduce with no vLLM involved fails the same way; single-rank collectives succeed on both cards (NCCL short-circuits at `world_size=1`, which is why a one-card test proves nothing) |
|
||
| Transport knobs | `NCCL_PROTO=Simple`, `P2P_DISABLE`, `SHM_DISABLE`, `CUMEM_ENABLE=0`, MSCCL off, channel counts, launch modes, `HSA_ENABLE_SDMA=0` — all identical |
|
||
| Image/support matrix | the same image on a different host with the same kernel (Proxmox VM, Codeberg issue #22) fails byte-identically |
|
||
|
||
### What would fix it
|
||
|
||
- **Move card #2 to a slot whose path is atomic-capable** (a CPU root port, as
|
||
card #1 has). This is the only software-free fix and the one that restores
|
||
true TP=2. On this AM5 platform the CPU provides the x16 slot plus x4 lanes,
|
||
so a second x16-class card necessarily lands on the chipset path — a
|
||
platform with more CPU lanes (Threadripper/EPYC) is the structural answer.
|
||
- **Done 2026-09-20 — this is exactly what happened.** The ProArt
|
||
B850-Creator WiFi NEO routes both slots to CPU root ports, the atomics
|
||
condition is satisfied, and TP=2 is the deployed shape. The Threadripper/EPYC
|
||
argument was correct about the old board and is now moot.
|
||
|
||
## Routing
|
||
|
||
The proxy decides per request, from `proxy/routing.py` (policy) and
|
||
`proxy/affinity-proxy.py` (HTTP plumbing):
|
||
|
||
- **A continuation** — a request carrying more than one message, i.e. an agent
|
||
loop re-sending its context — is pinned to the replica that served the
|
||
prefix before, because `--enable-prefix-caching` has that prefix warm there.
|
||
- **Anything else** — a one-shot question, a batch member, a burst of parallel
|
||
subagents — spreads across the cards by counter, so both prefill in parallel.
|
||
|
||
Measured on this host, 90k-token contexts and 15k-token prompts, `vllm bench`
|
||
plus a sequential-turn client:
|
||
|
||
| strategy | cold 16-request burst | warm-turn TTFT (4 turns × 2 families) |
|
||
|---|--:|--:|
|
||
| `roundrobin` (counter) | 18.6 s | 2676 ms — every other turn pays a cold prefill |
|
||
| `affinity` (hash of prefix) | 30.1 s — one card does all the prefilling | 1096 ms |
|
||
| `adaptive` (deployed) | 19.97 s | 1095 ms |
|
||
|
||
Two failures found by measuring rather than reasoning, both now covered by
|
||
`tests/frank_medium_proxy.bats`:
|
||
|
||
- Hashing a fresh prefix to pick a card sends *every* member of a same-prefix
|
||
burst to one card (29.8 s for the burst above). Only continuations may be
|
||
pinned; the spread has to come from a counter.
|
||
- The identity of a conversation is its **first 4 KiB** (`AFFINITY_BYTES`).
|
||
A prefix shorter than that window is indistinguishable from its own tail, so
|
||
turns of the same conversation hash differently and never pin.
|
||
|
||
`ROUTING=roundrobin` and `ROUTING=affinity` remain available to re-measure the
|
||
choice; the default is `adaptive`.
|
||
|
||
## Deployed configuration
|
||
|
||
Both replicas, uniform except GPU index and port:
|
||
|
||
| Knob | Value | Why |
|
||
|---|---|---|
|
||
| `TP` | 1 | one card per replica |
|
||
| `MAXLEN` | 114688 | the largest shape that boots on one card: 262144 needs 9.61 GiB of KV against 5.21 GiB available, and 131072 needs 5.58 GiB against 5.22 |
|
||
| `KV_MEM` | 0 | passed through literally as `--kv-cache-memory 0`, which the image's patched KV sizing reads as "profile this shape"; the upstream pin (2x7551-32624) is a TP=2 measurement and is invalid here. **Do not blank the variable instead of passing 0**: with the flag omitted vLLM sizes the cache from `GPU_UTIL` alone and the KV allocation dies with `torch.OutOfMemoryError` (measured: 2.16 GiB requested, 116 MiB free, 29.34 GiB already held) |
|
||
| `MAXSEQS` | 4 | matches the per-replica share of the fleet's concurrency |
|
||
| `CHUNK` | 8192 | prefill chunk (upstream default), **coupled to the context**: the prefill arena grows with the chunk size and shares the device budget with the KV cache. Measured: `CHUNK=16384` leaves 4.22 GiB of KV against the 5.21 GiB that 114688 context needs, and the engine refuses to start (`To serve at least one request with the model's max seq len (114688), 5.21 GiB KV cache is needed, which is larger than the available KV cache memory (4.22 GiB)`). Raising CHUNK therefore means cutting `MAXLEN`; single-card prefill measured ≈2.5k tok/s at 8192 (~15k-token prompt, 5992 ms TTFT) |
|
||
| `GPU_UTIL` | 0.98 | upstream default; the card is dedicated |
|
||
| `SPEC_METHOD` / `SPEC` | dflash / 7 | the z-lab DFlash2-FP8 block-diffusion drafter. Depth 7 is upstream's production default and was re-measured here on a code corpus built from this repo (see [Speculative depth](#speculative-depth)); depth 5 wins batch throughput, 7 wins single-stream latency, and this fleet is interactive |
|
||
| `spec-draft-device` | `ROCm1` | the drafter rides the card that ends up holding the target's last layers |
|
||
| `temp` / `top-p` / `top-k` | `0.7` / `0.95` / `20` | the values the vLLM predecessor served on this endpoint |
|
||
| `reasoning-format` / `reasoning-budget` | `deepseek` / `-1` | trace into `reasoning_content`, unlimited |
|
||
| `mmproj` | `mmproj-Qwen3.8-27B-Uncensored-f16.gguf` (beside the target) | vision enabled; this build has NO autoload, so the flag is explicit and the serve script refuses to start without the file |
|
||
|
||
Mounts are the TP=2 unit's minus nothing: `/models`, the HF cache, the shared
|
||
compile cache, `/patches` (the pinned radiance clone; its patches are applied
|
||
at every container start), `/r4d` (the patched libr4d — the image's shipped
|
||
`r4d.so` predates the gated-delta-net overflow fix and NaNs this model), and
|
||
the entrypoint/healthcheck/proxy scripts mounted read-only out of
|
||
`/home/dank/serverAppConfigs`.
|
||
|
||
The healthcheck reads `PORT`, so one probe serves the replicas (:7770),
|
||
the proxy (:7770) and the TP=2 target.
|
||
|
||
### Boot persistence, verified by rebooting
|
||
|
||
The trio was reboot-tested end to end (2026-09-16), because this repository has
|
||
been burned before by services that ran only from `compose.yaml` and stayed dark
|
||
after a reboot:
|
||
|
||
```
|
||
sudo reboot # unattended; ~2 min 14 s to graphical.target
|
||
# ... no manual step at any point ...
|
||
systemctl --user is-active frank-medium-a frank-medium-b frank-medium-proxy
|
||
-> active active active (containers up 59 s after boot)
|
||
# ...
|
||
curl http://127.0.0.1:7770/health # both replicas UP ~3.5 min after boot
|
||
```
|
||
|
||
Why it comes back: each unit's `[Install] WantedBy=default.target` — the generator
|
||
wires all three into `default.target`'s wants (they report `generated`, which is
|
||
the healthy state on this host's v1 quadlet generator and cannot be `enable`d) —
|
||
plus `Linger=yes` for the deploy user, so the user manager runs without a login
|
||
session. The replicas need no warm cache: the compile cache lives on disk
|
||
(`/cache`) and the model loads cold in about three minutes.
|
||
|
||
## Deploy and verify
|
||
|
||
```
|
||
./tests/run.sh # unit/config seams
|
||
./scripts/deploy.sh dankserver --service frank-medium
|
||
```
|
||
|
||
The handler re-tags `docker.io/stilldeadcode/vllm-radiance:0.9.3` as
|
||
`localhost/radiance-vllm:0.9.3` (this host's quadlet path cannot resolve
|
||
registry-prefixed refs from local storage at create time), installs the three
|
||
units, retires any stale TP=2 unit into the trash, restarts the trio and waits
|
||
for `http://127.0.0.1:7770/health`.
|
||
|
||
Verification that means something:
|
||
|
||
```
|
||
curl -s http://192.168.0.235:7770/health # proxy: names any replica that is down
|
||
curl -s http://192.168.0.235:7770/v1/models # served name must be frank-medium
|
||
# and a real completion through the proxy:
|
||
curl -s http://192.168.0.235:7770/v1/chat/completions -H 'Content-Type: application/json' \
|
||
-d '{"model":"frank-medium","messages":[{"role":"user","content":"Say OK"}],"max_tokens":400}'
|
||
```
|
||
|
||
The same call works against either replica directly (`:7771`, `:7772`) — when
|
||
one of them is down the other still serves, which is why the proxy reports the
|
||
individual replica states instead of a single boolean.
|
||
|
||
A replica that dies mid-flight does not cost a client a request: a failed
|
||
connection demotes that replica immediately and the proxy retries on the live
|
||
one (vLLM's endpoints are stateless, so re-sending a request that never reached
|
||
an engine is safe) and logs the failover. Verify with card 1 stopped:
|
||
|
||
```
|
||
systemctl --user stop frank-medium-a.service
|
||
for i in 1 2 3 4 5 6; do
|
||
curl -s -m 30 -o /dev/null -w "%{http_code} " -H 'Content-Type: application/json' \
|
||
-d '{"model":"frank-medium","messages":[{"role":"user","content":"probe"}],"max_tokens":8}' \
|
||
http://127.0.0.1:7770/v1/chat/completions
|
||
done; echo
|
||
podman logs --since 60s frank-medium-proxy | grep -i failover
|
||
systemctl --user start frank-medium-a.service
|
||
```
|
||
|
||
Six 200s and one `proxy: failover — … unreachable` line is the pass condition.
|
||
Restarting a stopped replica takes about 150 s (warm compile cache).
|
||
|
||
## Benchmarks
|
||
|
||
The harnesses live in the repository — the numbers below are reproducible with
|
||
`scripts/bench-vllm-corpus.py` (build the code corpus), `scripts/bench-vllm-serve.sh`
|
||
(concurrency sweep), `scripts/bench-turn-latency.py` (per-turn TTFT for an
|
||
agent-loop shape, which is what the routing decision optimizes) and
|
||
`scripts/bench-llm-soak.py` (sustained multi-family load). Each carries the
|
||
traps in its header: the tokenizer argument that makes a `vllm bench` run report
|
||
nothing, the salted prompts that keep a run from inheriting a warm cache, and
|
||
why the corpus is code-shaped rather than `random`.
|
||
|
||
`vllm bench serve` against the proxy (both cards serving), random dataset,
|
||
`--ignore-eos`, `2026-09-16`:
|
||
|
||
| Case | Output t/s | TTFT p50 | TPOT p50 | Acceptance |
|
||
|---|--:|--:|--:|--:|
|
||
| conc 1, in 1k / out 512 | 42.8 | 382 ms | 23.6 ms | 43.1 % |
|
||
| conc 4, in 1k / out 512 | 147.6 | 471 ms | 25.3 ms | 34.2 % |
|
||
| conc 8, in 1k / out 512 | 230.8 | 913 ms | 28.8 ms | 39.0 % |
|
||
| conc 1, in 32k / out 32 | 2.5 | 11837 ms | 29.6 ms | 25.0 % |
|
||
|
||
The 32k row is a prefill measurement: 32768 prompt tokens in 11.8 s is
|
||
**≈2.8k tok/s of prefill on a single card**. The replicas split the fleet's
|
||
traffic, they do not pool capacity for one request, against the upstream
|
||
reference's 4585 t/s at 32k on TP=2 — one card doing the whole prompt lands
|
||
where you would expect.
|
||
|
||
Read these against the upstream repo's own reference table (2x R9700, TP=2,
|
||
BetterBench corpus): step time 22.8-23.2 ms per update, combined decode
|
||
224 t/s, prefill 4585 t/s at 32k. Step time (TPOT) is the comparable number —
|
||
tokens/s swings with draft acceptance, and synthetic random prompts accept far
|
||
worse than the code/prose mix upstream measures (43 % here against 5.8
|
||
tokens/update there), so a lower token rate at the same step time is the
|
||
dataset, not the deployment.
|
||
|
||
Bench caveat: the client must be given `--tokenizer
|
||
/models/Qwen3.8-27B-Uncensored-MXFP4-awq` with `/models` mounted. Passing only
|
||
`--model frank-medium` makes the client try to resolve that alias on
|
||
HuggingFace and die before sending a request — a green-looking run that
|
||
produced no numbers at all.
|
||
|
||
### Speculative depth
|
||
|
||
Upstream ships depth 7 for dflash and notes the choice is content-dependent; it
|
||
was measured for TP=2 on their corpus. Re-measured here on one card against a
|
||
code corpus built from this repository's own sources (16 prompts, 512 output
|
||
tokens each, two runs per setting):
|
||
|
||
| Depth | conc 1 | conc 4 |
|
||
|---|--:|--:|
|
||
| `SPEC=7` (deployed) | **46.2 tok/s, TPOT 20.8 ms**, 3.86 tok/update | 118.7 / 127.0 tok/s, TPOT 25.1-29.3 ms |
|
||
| `SPEC=5` | 41.5 tok/s, TPOT 23.1 ms, 3.41 tok/update | **136.1 / 129.4 tok/s**, TPOT 25.3-26.6 ms |
|
||
|
||
The step *time* is nearly identical at both depths (≈79 ms against ≈80 ms at
|
||
conc 1) — depth 7 wins single-stream because it lands more tokens per step
|
||
(3.86 against 3.41), and depth 5 wins at conc 4 by ≈8 % because more, narrower
|
||
verify steps pack better. Depth 7 stays: this fleet serves about three people
|
||
in interactive agent loops, where per-turn latency is the number that is felt,
|
||
and it is also the better default on code at low concurrency. Switch a replica
|
||
to `SPEC=5` if batch throughput ever matters more than turn latency — it is one
|
||
environment line in the unit, at the cost of a recompile (the spec depth is
|
||
part of the graph cache key).
|
||
|
||
### Capacity: how many big conversations fit
|
||
|
||
Each replica's own ledger at boot states the ceiling:
|
||
|
||
```
|
||
GPU KV cache size: 122,636 tokens
|
||
Maximum concurrency for 114,688 tokens per request: 1.07x
|
||
[radiance] kv cache groups: size 8, 9 groups, 202 blocks/request (upstream would pick size 5)
|
||
Add 3 padding layers, may waste at most 60.00% KV cache memory
|
||
```
|
||
|
||
So one card holds ~122k tokens of KV — one full-context conversation plus a
|
||
little, or about two 30k-token agent loops with their cached prefixes. A 3-family
|
||
soak (three concurrent loops, each re-sending a ~30k-token document every turn
|
||
with growing history) shows what happens at that limit: turns alternate between
|
||
**~1.1 s** when the prefix is resident and **12-22 s** when it has been evicted
|
||
and must be prefilled again, because two families per card plus their cached
|
||
prefixes exceed the pool. Nothing failed — ~120 requests, no errors, no failover,
|
||
both replicas steady — and per-family pinning kept the fast turns on the replica
|
||
that holds the prefix.
|
||
|
||
Read that as a capacity budget, not a defect: two concurrent large-context
|
||
sessions per card are comfortable, three or more of that size trade turns
|
||
against each other. The soak ran 155 requests, 155 answers, no errors, no
|
||
failover and both replicas steady — the cost shows up as latency, not failure.
|
||
|
||
**`CHUNK` is not a lever for this.** The arena/KV budget is not monotonic in
|
||
the chunk size, and 8192 is a sweet spot rather than a compromise — measured
|
||
available KV per card: **5.44 GiB at `CHUNK=8192`** (122,636 tokens), 4.22 GiB
|
||
at 16384, and 4.16 GiB at 4096, where the engine refuses to start at all
|
||
(`5.0 GiB KV cache is needed, which is larger than the available KV cache
|
||
memory (4.16 GiB)`). Both directions from the deployed value cost capacity, so
|
||
the real lever for more concurrent large contexts is another card.
|
||
|
||
### Placement does not rescue an overloaded working set
|
||
|
||
If families compete for cache space, the obvious idea is to place a new
|
||
conversation on the card carrying less context instead of alternating. That was
|
||
implemented (decayed prompt-token estimate per replica, new families to the
|
||
lighter card, continuations still pinned) and measured against the counter on
|
||
the workload it should help: one ~80k-token family plus two ~50k-token
|
||
families, each turn re-sending the same document.
|
||
|
||
| placement | heavy | medium 1 | medium 2 |
|
||
|---|---|---|---|
|
||
| counter (13 min) | 13 turns, median 25.4 s, 3 cached (23 %) | 14 turns, median 34.7 s, 2 cached (14 %) | 13 turns, median 45.2 s, 2 cached (15 %) |
|
||
| balanced by load (~8 min) | 5 turns, median 46.7 s, 1 cached (20 %) | 7 turns, median 23.4 s, 0 cached | 6 turns, median 34.3 s, 0 cached |
|
||
|
||
No improvement — the load arm shows the same near-total loss of cache reuse
|
||
(note its smaller sample: the arm was stopped early once the pattern was
|
||
clear), so the change was reverted. `routing.py` keeps the counter and the
|
||
policy surface stays as small as it was.
|
||
|
||
The reason is arithmetic rather than policy: a running request's *live*
|
||
sequence and its cached prefix draw on the same pool, so a card that is running
|
||
two 50k-token generations has already exceeded its 122,636 tokens before any
|
||
cache entry is considered. Placement can decide *which* card is over, not
|
||
whether one is. Three concurrent large sessions do not fit in two cards'
|
||
capacity, and the levers are therefore scheduling (fewer large contexts in
|
||
flight, which is what the harness's context compaction does) or a third card.
|
||
|
||
### Caching itself is healthy; the sum is not
|
||
|
||
Before blaming the cache, it was measured directly: one card, an unloaded
|
||
engine, a ~50k-token prompt sent three times — **22.3 s cold, then 1.4 s, then
|
||
1.4 s** — and the same result with a varying tail between turns (22.4 s cold,
|
||
1.41 s warm). So long-prefix reuse works exactly as the pinning policy assumes,
|
||
and the engine's lifetime "Prefix cache hit rate: 29 %" line is an average over
|
||
every benchmark prompt a replica has ever seen, not a statement about any one
|
||
request.
|
||
|
||
**Serialising large requests does not fix the overload either.** Admission was
|
||
implemented — at most one >16k-token request per card at a time (`LARGE_TOKENS`
|
||
/ `LARGE_LIMIT`), small requests untouched — and run against the same
|
||
three-family workload:
|
||
|
||
| | heavy | medium 1 | medium 2 |
|
||
|---|---|---|---|
|
||
| no admission | 13 turns, median 25.4 s, 23 % cached | 34.7 s, 14 % | 45.2 s, 15 % |
|
||
| one large per card | 5 turns, median 48.2 s, 20 % cached | 22.7 s, 14 % | 22.6 s, 33 % |
|
||
|
||
No clear win on small samples, so it was reverted too. The reason is now
|
||
visible in the arithmetic: with three families on two cards, one card must hold
|
||
**two** families, and its pool has to carry 2 × 50k of cached prefix *plus* the
|
||
50k of the sequence currently generating — 150k against 122,636. Those two
|
||
co-tenants evict each other turn by turn whatever the concurrency limit is. A
|
||
card can hold two large families' caches, or one cache plus a live generation,
|
||
but not both. That is the wall, and it is capacity, not scheduling.
|
||
|
||
### Watching it instead of measuring it by hand
|
||
|
||
Everything above was diagnosed with ad-hoc probes. The same signals are now
|
||
scraped — Prometheus has `frank-medium` and `frank-dragoon` jobs
|
||
(`shared/prometheus/prometheus.yml`), so the numbers that matter have history:
|
||
`vllm:kv_cache_usage_perc` (per-card pool fill), `vllm:num_requests_waiting`
|
||
(queue depth), `rate(vllm:prefix_cache_hits_total[5m]) /
|
||
rate(vllm:prefix_cache_queries_total[5m])` (whether pinned conversations are
|
||
served warm), and the request-latency histograms. See [[Observability]], and one
|
||
Grafana dashboard reads them
|
||
(**LLM fleet — capacity and reuse**).
|
||
|
||
Five rules watch this service (`shared/prometheus/rules/llm-fleet.yml`): pool
|
||
saturation above 90 %, requests queueing for 10 minutes, prefix reuse collapsing
|
||
below 20 % with traffic, the scrape target down, and p95 latency above 60 s —
|
||
each threshold tied to a measurement on this page rather than to a guess. They
|
||
evaluate and show up on the dashboard's **Alerts firing** panel, but **nothing
|
||
is notified**: this stack has no Alertmanager, so an alert reaches a person only
|
||
if that person is looking. That is the honest state of "alerting" here.
|
||
|
||
## Citations
|
||
|
||
- vLLM upstream: https://github.com/vllm-project/vllm — the R4D one-shot
|
||
allreduce and the tensor-parallel path this deployment runs.
|
||
- `ROCm/clr` `rocclr/device/rocm/rocvirtual.cpp:3963` — the hostcall-buffer
|
||
refusal; `rocclr/device/rocm/rocdevice.cpp:186` — `checkAtomicSupport`.
|
||
- Linux `drivers/pci/pci.c` — `pci_enable_atomic_ops_to_root()` and its
|
||
`PCI_EXP_DEVCAP2_ATOMIC_ROUTE` requirements on upstream bridges.
|
||
- Codeberg `StillDeadcode/vllm-radiance` issue #22 — an independent host with
|
||
the same image and kernel 7.0.0-30 failing identically.
|
||
- `ggz14/radiance-vllm-mxfp4` — the patch set and the reference benchmark table
|
||
this page compares against.
|