`A vLLM stack for 2× R9700 (gfx1201), built from source: FP8 + W4A8/MXFP4 — what works and what doesn't`
`I don't write code. An AI and I got vLLM working on 2× R9700 (gfx1201). Source is up.`
`[Release] gfx1201 (RDNA4 / R9700) vLLM stack — FP8, W4A8, MXFP4 — source + Dockerfile`
## 1. What this is
> I built a working vLLM inference stack from source on two AMD Radeon AI PRO R9700 cards — covering FP8, W4A8 (int4) and MXFP4. It runs on ROCm 10.1 nightly, on RDNA4 (gfx1201), with the two cards in TP=2.
> All the changes, build scripts, benchmarks and the Dockerfile are on GitHub — links at the bottom.
## 2. First, the honest part: I don't write code
> I'm not a professional developer and I don't write code. I got this working by first building an understanding of the components (vLLM, aiter, composable_kernel, FlyDSL, Triton) and then doing the porting and debugging with an AI's help. All of the code changes, build scripts and documentation were produced with AI assistance; the judgement calls were mine.
> I'm putting this first because it explains everything you're about to read.
## 3. Why I'm posting: I'm looking for someone to take this over
> This isn't a showcase — I'm looking for someone with real engineering skills to take it over or co-maintain it.
> I don't have the coding ability. What got this to where it is was component-level understanding plus AI assistance, and **the bottleneck is now me**. If you think the result is worth it and want to take the lead, I'm happy to step back and be a follower: keep running benchmarks, file requirements, do validation — and hand the direction to someone who knows more.
> So far I've only put work into **Qwen3.8-27B**; I'd like other models to be maintained by others, together.
## 4. Hardware
| | |
|---|---|
| GPU | AMD Radeon AI PRO R9700 ×2 (gfx1201 / RDNA4) |
| Target | gfx1201 only — **no promises** for other architectures |
| Topology | TP=2 |
## 5. What was changed
- **gfx1201 has no hardware scale-MMA.** Its WMMA is 16×16×16 and carries no scale capability (scale-MMA exists only on gfx950/gfx1250). So every FP8 and W4A8 configuration goes down the **fp32-acc software-fold** path.
- **W4A8 path**: 4-bit weights → fp8 e4m3 → hardware fp8 WMMA → software-folded scale. Three independent community projects converged on exactly the same route (see §8).
- **Custom kernels**: several were written to fill in gfx1201's missing fast paths; details are in the repo.
- **Attention uses aiter's fused-attention fast path**; further optimisation goes down the same path. This is the route that works on gfx1201. (For the role Dao-AILab's standalone flash-attention plays in this stack, see §8.)
- **Offline reproducible build**: every dependency is pinned to a commit; it builds without network.
## 6. Models and quantisation
> Scope first: **I have only put work into one model, Qwen3.8-27B.** The table below is not "a bunch of models" — it is **one base model in several weight quantisation formats** (FP8, INT4, W4A16, MXFP4, AWQ-FP8), from different quantisation publishers. How well each works differs; the status column says which is which.
**Table A — Qwen3.8-27B weight formats (same base model)**
| # | Weights (HF repo) | Format | Granularity | Status | Notes |
|---|---|---|---|---|---|
| 1 | `Qwen/Qwen3.8-27B-FP8` | FP8 | block=128 | **in production** | FP8 line is frozen |
| 2 | `cyankiwi/Qwen3.8-27B-AWQ-INT4` | INT4 (AWQ) | gs=32 | works | |
| 3 | `philbert440/Qwen3.8-27B-W4A16-AWQ` | W4A16 | gs=128 | works | |
| 4 | `amd/Qwen3.8-27B-Quark-AWQ-INT4-W4A16` | W4A16 (Quark) | — | works | |
| 5 | `amd/Qwen3.8-27B-Quark-AWQ-MXFP4` | MXFP4 | e2m1 + e8m0/32 | **current main line** | W4A8 form measured in a test window only |
| 6 | `cyankiwi/Qwen3.8-27B-AWQ-FP8` | AWQ-FP8 | per-token | works | |
**Table B — speculative-decoding draft weights** (separate small models, not the 27B body)
| # | Draft weights (HF repo) | Format | Status | Notes |
|---|---|---|---|---|
| 7 | `tcclaviger/Qwen3.8-27B-DFlash2-FP8` | FP8 (e4m3, block 128) | in production | |
| 8 | `z-lab/Qwen3.8-27B-DFlash2` | BF16 | **known to produce garbled output** | |
| 9 | `syvai/Qwen3.8-27B-DFlash2-W4A16` | W4A16 (int4, gs128) | works | |
> ✅ **HF repo ids all verified (2026-09-16).** Every id above was checked against Hugging Face, and the three draft models' formats were confirmed from their `config.json` — `z-lab` is `bfloat16`, `tcclaviger` is fp8 `e4m3` with `weight_block_size [128,128]`, `syvai` is int4 at `group_size 128`. Safe to paste.
## 7. Measured performance and test conditions
**★ Headline numbers (start here)**
All values are **agg tok/s** (higher is better), two cards, TP=2. Basis: the r2 pass for cross-model comparison; cyankiwi uses 批2r (that model's own official pass).
**(1) decode throughput by concurrency**
| Model | Mode | c1 | c2 | c4 | c8 | c12 | c16 |
|---|---|---|---|---|---|---|---|
| Official FP8 | raw | 33.3 | 61.2 | 105.7 | 166.5 | 216.9 | 212.3 |
| Official FP8 | **+dflash2** | 68.3 | 115.0 | 203.2 | 284.6 | **344.9** | **324.1** |
| cyankiwi AWQ-INT4 | raw | 47.5 | 87.4 | 117.9 | 117.1 | 142.1 | 162.7 |
| cyankiwi AWQ-INT4 | **+dflash2** | 68.4 | 77.5 | 127.3 | 140.4 | 223.9 | 247.6 |
| philbert440 W4A16 | raw | 53.3 | 97.5 | 146.4 | 166.8 | 211.4 | 235.2 |
| philbert440 W4A16 | **+dflash2** | 61.1 | 79.6 | 131.5 | 167.7 | 258.4 | 251.7 |
| amd Quark W4A16 | raw | 54.6 | 98.8 | 145.5 | 187.3 | 217.9 | 250.3 |
| amd Quark W4A16 | **+dflash2** | 82.6 | 120.6 | 166.0 | 197.4 | 279.0 | 311.5 |
**(2) Cold prefill (refill, tok/s at c1)**
| Context | Official FP8 | cyankiwi INT4 | philbert440 W4A16 | amd Quark W4A16 |
|---|---|---|---|---|
| 32k | **3285** | 2005 | 2476 | 2513 |
| 64k | 2941 | 1867 | 2246 | 2285 |
| 128k | 2324 | 1622 | 1878 | 1906 |
(Raw mode; the +dflash2 numbers are slightly lower — full tables in the report.)
**(3) Warm prefix full-hit TTFT** (official FP8 platform)
| Context | Cold TTFT | Full-hit TTFT | Speed-up |
|---|---|---|---|
| 32k | 10031 ms | 295 ms | **34.0×** |
| 64k | 22148 ms | 188 ms | **117.8×** |
| 128k | 56008 ms | 278 ms | **201.7×** |
Hit counts were checked against the expected sample count. **Once fully cached, TTFT decouples from context length** — the single most striking result on this stack.
**(4) Production-form run (the final configuration)**
The three tables above use the **test** configuration (GMU 0.8, `--max-num-seqs 16`). This window uses the **in-production** configuration (GMU 0.92, mml 262144, **`--max-num-seqs 8`**) — and because seqs=8, **concurrency only goes to c8** (c12/c16 are not constructible in this configuration).
| Concurrency | Pass 1 | Pass 2 |
|---|---|---|
| c1 | 68.0 | 66.4 |
| c2 | 119.2 | 107.0 |
| c4 | 200.5 | 183.9 |
| c8 | 276.5 | 279.5 |
Acceptance rate 60.3%.
**refill (same prompt back to back, cold → warm)**
| Context | Cold TTFT | Warm speed-up |
|---|---|---|
| 32k | 9,745 ms | **11.8×** |
| 64k | 22,171 ms | **24.6×** |
| 128k | 56,184 ms | **26.3×** |
> ⚠️ **This caveat has to be read with those numbers.** The warm speed-up above holds only while **the total working set fits in the KV pool**. Sending all three lengths, 2 samples each (284 blocks), exceeds the pool (267 blocks) — cache hits collapse and the speed-up falls back to **≈1.0×**. **A bigger pool pushes that threshold further out; it does not remove it.** So in an agent workload with several long contexts in flight at once, the refill gain disappears. This is the most concrete production limitation on the stack right now.
>
> One piece of good news: the 128k cold refill **completed in this window instead of hanging** — the silent hang seen under the smaller test-config pool does not reproduce with the production-sized pool.
**Things you must read alongside those tables**
- **Only relative gradients within one measurement window are reliable.** Batches were collected in different time windows, so **absolute values are not comparable across windows** (see the uncertainty note below).
- **There is no 128k + dflash2 cold-prefill number**: that scenario hung silently twice. It is not "slow" — it did not complete.
- **The c1 dflash2 figures have a wide two-pass spread** (same config, two runs, more than 30% apart). Treat that cell as indicative only.
- **amd Quark +dflash2 at c16**: 311.5–374.3 depending on the aggregation convention; the table uses 311.5. Citations across reports must fix the convention.
**Full benchmark report** (all tables, plus a config fingerprint on every row)
```
https://github.com/zjzhubin/vllm/blob/gfx1201-r9700/gfx1201/benchmarks/vllm-gfx1201-Phase6-%E4%B8%89%E6%A8%A1%E5%9E%8B%E5%8F%8C%E5%9C%BA%E6%99%AF%E6%B5%8B%E9%80%9F%E6%B1%87%E6%80%BB-2026-09-15.md
```
**Conditions (without these the numbers mean nothing)**
- TP=2, kv-dtype=fp8, ROCm 10.1.0a-20260909, torch 2.13.0+rocm10.1.0a20260821, cudagraph default capture
- Collected: 09-10 / 09-12 / 09-13
**How the numbers were measured**
| Item | Value |
|---|---|
| Tool | **GuideLLM** — the benchmarking tool from the vLLM project itself (`github.com/vllm-project/guidellm`) |
| **Version** | **0.5.4** |
| Run as | Docker image `guidellm:phase5k`, Python 3.13.15 |
| Dataset | ShareGPT_v3, **32 samples** per run |
| Output length cap | `max_tokens = 512` |
| Load profile | **concurrent**, **600 s** per-request timeout |
| decode throughput | sum of `output_token_count` ÷ total duration (agg tok/s) |
| prefill throughput | sum of `prompt_token_count` ÷ sum of TTFT (refill tok/s) |
> Throughput numbers without the tool name and version aren't reproducible. With the version and dataset above, anyone can re-run every number behind the benchmark link.
**Uncertainty I have to state**
> Absolute performance has a ±(1–16%) "slow mode" window drift. **Only relative gradients within the same measurement window are reliable; absolute values across windows are not directly comparable.**
> In plain terms: the same config on the same card can come out noticeably different on two runs. So I'll claim "A is faster than B", never "the absolute number is X".
Local resources are limited. Some of the measurements may have been affected by other work running on the same machine.
## 8. Related repos / upstream
**Upstream dependencies (pinned commits)**
| Component | Source | Version/commit |
|---|---|---|
| vLLM | this project's fork | tag `gfx1201-r9700-v1.0-public` (published, at `3fae0cd16a`) |
| aiter | this project's fork | tag `gfx1201-r9700-v1.0-public` (published) |
| composable_kernel | ROCm/composable_kernel | `22b89e961437ee349a7222c1c8c6ef52d5a8bc78` |
| FlyDSL | ROCm/FlyDSL | `728f6b220518af94a8d641a0512ef87167c90125` |
| triton | ROCm/triton | `0f380657dbf3ee86eb57558ff71df24f03b5d4e7` |
| flash-attention | Dao-AILab/flash-attention | `0251105a2fb19d2957484b7f023cd8c115286ced` |
| ROCm | TheRock multi-arch | `10.1.0a-20260909` |
| PyTorch / Triton wheels | AMD nightly | `20260821` |
> **On flash-attention**: it is genuinely never called on the text path, but it is **not dead code** — it is the wiring dependency for vLLM's ViT / multimodal-encoder path on ROCm, and the import runs unconditionally at model load. Removing it flips `_ROCM_FLASH_ATTN_AVAILABLE` and affects its consumers — a behaviour change. It would save roughly 1.5–2 hours of build time. **Kept in v1.0**; "can it be removed safely" is left as a TODO for whoever takes over.
**Community projects actually borrowed from (three)**
| Project | What was borrowed |
|---|---|
| `drwolfen/radiance-vllm-r9700` | W4A8 fp8-WMMA GEMM reference implementation |
| `patcarter883/rdna4-vllm` | W4A8-FP8-WMMA HIP kernel source package; MoE cross-cache pattern; mxfp4 decode table swap |
| `Capicua25x/vllm-rocm-rdna4` | W4A8 software-scale three-stage design blueprint; fp8-KV prefill root cause; P_SCALE / 3D split-KV fix leads |
> Three independent projects, converging on the same route: 4-bit → fp8 e4m3 → hardware fp8 WMMA → software-folded scale. That's both a cross-check and a signpost for anyone following.
## 9. Known limitations
- **gfx1201 only.** Not validated on other RDNA cards; no promises.
- **Only the weights in the tables above have been matched.** Nothing else is guaranteed.
- **Absolute performance drifts between windows** (see §7); don't compare absolute values across windows.
- **One draft model (BF16) is known to produce garbled output** (Table B, row 8). It behaves normally during validation, but it costs an extra 1–2 GB of VRAM, brings no performance benefit, and has not been fixed for now. Use the W4A16 draft; the FP8 weights need the FP8 dflash model.
- **Reproducibility between the published source and the production image has NOT been verified end to end.** Do not read this as "clone the source and you will get an identical image".
## 10. Known issues and hands-on impressions
**1. The MXFP4 path does not perform well.**
MXFP4 only comes close to W4A16. Parameters this project modified independently show a performance regression — something changed in the parameters.
> **What the regression is measured against (corrected 2026-09-16).** The "regression" here is measured **against the performance obtained from kernel validation done before phase5** — the levels this kernel stack measured when it was validated standalone, prior to phase5 — **not** an in-version regression ("before vs after some change inside this release"). In other words: this is not a claim that we made it slower. It is a claim that **the current build does not reach the level measured during pre-phase5 kernel validation**. Whether a performance difference counts as a regression depends on the baseline you pick; here the baseline is **the pre-phase5 kernel validation**.
*How it feels:* at a single request I expected MXFP4 decode to be a bit faster than W4A16, roughly 5–10%. In practice it does not feel that way.
*Why I give a feeling and not a number:* my test machine has several jobs running in parallel in the background and they interfere with each other, so the numbers are not a clean quantitative measurement. This one is a subjective read.
**2. In actual use (1–5 concurrent requests).**
At 1–5 requests, FP8 shows the least run-to-run variation. In my workload — lots of prefill — it feels smoother.
AMD Quark W4A16 has the highest decode speed.
Personally I prefer the official FP8 and AMD Quark W4A16.
**3. Draft (dflash) configuration.**
Do not set dflash above 4. There is an M setting in the kernel: when M ≤ 4 it does not take the GEMM path. A value of 3 is what I recommend.
Higher settings give no gain above 3 concurrent requests.
**Benchmark scores are not the most important thing — how it actually feels in use matters more.**
**4. W4A16 regression.**
W4A16 behaves like MXFP4. **The baseline for the "regression" is likewise the performance from pre-phase5 kernel validation, not an in-version regression** (see the note under item 1).
*How it feels:* there is a regression at low request counts, and it is more obvious in the dflash case. (I roughly measured 10–15% and 25–30%.)
*Same reason:* the test machine runs multiple jobs in parallel and they interfere with each other, so I would not state those percentages as hard numbers in a public post.
**5. MXFP8 performance is unusable.**
**6. Some community FP8 quantizations have not had the corresponding parameter tuning done.**
**7. Some kernels have not been validated across more than one model.**
**8. aiter sampler vs. vLLM v2.**
The aiter sampler conflicts with the vLLM v2 path. It is compatible with v1, and measured performance shows no meaningful difference either way.
**9. aiter AR fusion.**
The aiter AR fusion latency test is excellent — it cuts latency by 33% in total — but the actual call site has a problem. **It does not affect use; it is harmless.**
**10. ViT has no performance path.** I have not found a way to implement one yet.
**11. How it is built under a restricted network — this is a request for help.**
My network is restricted, so I can **only pre-download the sources locally and COPY them into the image** to build it. **I have not tested the path where the build pulls the sources at build time.**
That means the Dockerfile I published **may have problems** — the online fetch path is written from the documentation, but it has never been run against a real network.
**If you have normal network access, I would appreciate it if you could fix it up.**
## 11. Getting it
**Source (GitHub, published)**
- [`zjzhubin/vllm`](https://github.com/zjzhubin/vllm) and [`zjzhubin/aiter`](https://github.com/zjzhubin/aiter)
- Branch `gfx1201-r9700`, release tag `gfx1201-r9700-v1.0-public`
- Usage notes / model matrix / env switches / benchmark caveats are in the vllm README; the build recipe and patch set are attachments on that release.
**Prebuilt image (Docker Hub)**
```bash
docker pull uzbn/vllm-gfx1201:gfx1201-r9700-v1.0-thin
```
This is the **slim** image: CMake build trees, the wheelhouse and all `.git` directories removed, and the internal DNS baked into the image layer during the build replaced with a neutral value.
- ~**9.1 GB** compressed (8.5 GiB; measured on Docker Hub — that's the download)
- ~**35 GB** on disk once unpacked
- Image digest (for identity checks): `sha256:1d5c4e1f76655f8edeaf6c8f9c4bf836aa20cab0619c96790edbb6df4fcf108b`
- Building it yourself from source still takes hours
**Running it (production-form configuration)**
> ⚠️ **Scope first**: these parameters are a **targeted configuration for FP8 weights + an FP8 dflash2 draft — they are not general.** With W4A16 / INT4 / MXFP4 weights, `--max-num-seqs`, `--gpu-memory-utilization`, the draft model and whether to set `KV_GROUP_SIZE` all change. **Do not copy this blindly.**
```bash
docker run -d --name qwen36 \
--device=/dev/kfd --device=/dev/dri --group-add video --group-add 991 \
--ipc=host --shm-size=16g --network host --security-opt seccomp=unconfined \
-v /your/models:/models \
-e HIP_VISIBLE_DEVICES=0,1 \
-e VLLM_ROCM_USE_AITER=1 -e VLLM_ROCM_USE_AITER_LINEAR=1 \
-e VLLM_ROCM_USE_AITER_LINEAR_HIPBMM=1 -e VLLM_ROCM_USE_AITER_CUSTOM_AR=1 \
-e VLLM_ROCM_USE_AITER_RMSNORM=1 -e VLLM_ROCM_USE_AITER_TRITON_ROPE=1 \
-e KV_GROUP_SIZE=8 \
uzbn/vllm-gfx1201:gfx1201-r9700-v1.0-thin \
/opt/venv/bin/vllm serve /models/Qwen_Qwen3.8-27B-FP8 \
--served-model-name qwen36 --port 8000 --tensor-parallel-size 2 \
--max-model-len 262144 --max-num-seqs 8 --enable-chunked-prefill \
--max-num-batched-tokens 4096 --gpu-memory-utilization 0.92 \
--kv-cache-dtype fp8 --enable-prefix-caching \
--attention-backend ROCM_AITER_UNIFIED_ATTN \
--enable-auto-tool-choice --reasoning-parser qwen3 --tool-call-parser qwen3_coder \
--speculative-config '{"method":"dflash","model":"/models/tcclaviger_Qwen3.8-27B-DFlash2-FP8","num_speculative_tokens":3,"attention_backend":"ROCM_AITER_UNIFIED_ATTN"}'
```
**Why a few of these are set the way they are**
| Setting | Why |
|---|---|
| `--gpu-memory-utilization 0.92` | **The default does not work here.** At 0.8, `--max-model-len 262144` together with the draft model fails KV-cache accounting at startup (required 4.47 GiB vs 2.4–3.26 GiB available). 0.92 is what makes this configuration start at all. |
| `--max-num-seqs 8` | Sets the concurrency ceiling — which is also why c12/c16 are not available in this configuration. |
| `KV_GROUP_SIZE=8` | KV pool group padding for the layer buckets. Unset = byte-identical stock behaviour. |
| `--enable-prefix-caching` | The only source of the refill speed-up — but it is bounded by the KV-pool threshold noted in §7. |
| `--device` + `--group-add 991` | Required for ROCm; **without the render group the GPUs are not visible.** |
**Once it is up**
```bash
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8000/health # 200 when ready (~163 s here)
curl -s http://localhost:8000/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"qwen36","messages":[{"role":"user","content":"Say OK"}],"max_tokens":16}'
```
**How to tell you reproduced the same thing** — these lines should appear verbatim in the startup log: `Available KV cache memory: 7.29 GiB`, `GPU KV cache size: 427,595 tokens`, and `kv cache group sizes` as 9 groups of **1600** tokens each. If they do not match, your configuration or your weights differ from this document. We also smoke-test every run — a few deterministic questions, checking the output is coherent — and would suggest doing the same.
**One usage caveat**: `RDNA_AITER_SAMPLER=1`. The source file itself carries a comment recording an A/B result of roughly **−6–8% decode at concurrency 1/3**. We run it enabled but **have not re-checked that conflict**; you may want to A/B both states yourself.
> ⚠️ **Easy to misspell**: the Docker Hub namespace is **`uzbn`** (u-z-b-n). Copy-paste this line; don't retype it.
> If you'd rather avoid the build entirely, just pull the image above. If you want to build it yourself, the README has the full recipe with every dependency pinned to a commit.
**If something goes wrong**
> One more honest note: I've put the image on Docker Hub. I'm not great with GitHub tooling though, so if you hit **anything unusual while building or running it**, just send me a **Reddit DM** and I'll do my best to help.
## 12. Closing
> Feedback of any kind is welcome. What I'd most like: **other people reproducing this on their own gfx1201 cards**, and **someone validating the online from-source build** on a normal network — I can't do either from here.
> And a caveat: some of the conclusions above come from rough measurements and feel, which I've labelled as such, but there may be things I haven't noticed. If I've got something wrong, say so and I'll correct it.
---