How I got Qwen3.8 27B decoding 2.4x faster on an AMD R9700
After the 3090 work, a friend gave me remote access to an AMD Radeon AI PRO R9700 (RDNA4, gfx1201). I'd never done inference on AMD before. I wanted to know how much of what I learned tuning Qwen3.8 27B on the 3090 carries over to a different vendor and a different backend.
I spent one day on it (Sep 27, 2026), all on llama.cpp's Vulkan backend. On short prompts, decode went from 36 tok/s on stock llama.cpp to 88 to 96 tok/s with MTP speculative decoding and a few RDNA4 patches. With a combined drafter it reached 138 tok/s on JSON. This is a smaller project than the CUDA ones, and it isn't finished.
The fork is TheRealBluesun/llama.cpp, branch r9700: 10 commits on top of upstream llama.cpp as of Sep 26.
Baseline
Stock llama.cpp, Vulkan, UD-IQ4_XS quant, no speculative decoding:
| Result | |
|---|---|
| Decode, every prompt type | ~36 tok/s (41 on very short answers) |
| Prefill at 18K | ~1,050 tok/s |
None of the CUDA kernels transfer. The general approach does: speculative decoding, cheaper drafting and fewer state copies.
What I changed
RDNA4 mat-vec tuning (commit d7051a9). On RDNA3, llama.cpp's Vulkan backend already caps rows per workgroup for the int-dot mat-vec path at 4 or more columns. RDNA4 didn't have that rule. It matters for speculative verify, where the target multiplies a handful of columns at once. Per the commit notes, at 8 columns K-quants go from ~370-425 to ~570-595 GB/s. The same commit forces the q6_K MMVQ path for batches on RDNA4. MMVQ holds ~95% of bandwidth up to 8 columns, while the float path drops to ~46%. At one column they tie.
Magic bias on the RDNA4 cm1 epilogue (commit fc314e1). The cooperative-matrix int8 matmul shader converts accumulators with a float magic-bias trick, but the trick was disabled for wave32. I enabled it on RDNA4 for the symmetric quant types only.
Fused GDN snapshot copy (commit 6abed85). Most of Qwen3.8 27B is gated delta net (GDN) linear attention. During speculative decoding the GDN op writes a state snapshot per draft position, and a separate CPY node then copies those snapshots into the recurrent cache. I fused the two in the Vulkan backend, so the shader writes the snapshots directly into the recurrent cache.
MTP speculative decoding, k=3. The drafter is Qwen's own MTP head. Together with the patches above, this accounts for most of the gain in the results table below.
Reduced-vocab MTP draft head (commit e52a191). The full LM head takes about 40% of a draft step, and the drafter only needs the top candidates. An optional draft-only head covers a subset of frequent tokens. It computes logits for that subset and scatters them into a full-vocab row filled with a large negative value, so the sampler and everything after it stay the same. The target still verifies with the full head. My vLLM fork does the same thing with an FP8 hot-token draft head.
Combined drafters (commits b5f2b62 and b22fb9f). MTP and the DFlash2 drafter do well on different workloads, so I run both. The server gets a second context for MTP. DFlash2 drafts first on each request, gated by an EMA of its accepted tokens (alpha 0.3). If the EMA drops below a threshold, or the context passes a set position, DFlash2 declines and MTP drafts instead. Every N steps a probe step keeps the EMA updated so DFlash2 can come back. New flags: --spec-mtp-n-max, --spec-dflash-ctx-max, --spec-dflash-min-acc, --spec-dflash-probe. I also sized the block-draft context to one verify block instead of the full context.
The remaining commits add experiment knobs (GGML_VK_MMV_ROWS_N, GGML_VK_RDNA4_CM1_ALL), profiling for MTP process sync cost, and backend-op tests for the shapes involved.
Results
Qwen3.8 27B, stock vs tuned (tok/s):
| Code | Prose | Explain | JSON | Decode at 1K / 30K / 103K | |
|---|---|---|---|---|---|
| Stock llama.cpp, IQ4_XS, no speculation | 36 | 36 | 36 | 36 | 36 / 32 / 26 |
| UD-Q4_K_XL + MTP 3 + patches | 88 | 57 | 71 | 96 | 76 / 68 / 49 |
The two rows use different quants. This table compares whole setups and doesn't isolate the patches.
Combined drafters, short prompts (greedy tok/s):
| Code | Prose | Explain | JSON | |
|---|---|---|---|---|
| MTP 3 only | 88 | 57 | 71 | 96 |
| DFlash2-7 only | 108 | 48 | 70 | 144 |
| Combined (tuned) | 98 (+12%) | 56 (-2%) | 73 (+3%) | 138 (+44%) |
Decode after a long prompt (tok/s):
| 1K | 7K | 30K | |
|---|---|---|---|
| MTP 3 only | 76 | 68 | 68 |
| DFlash2-7 only | 78 | 57 | 50 |
| Combined (tuned) | 74 (-3%) | 68 (+1%) | 66 (-3%) |
DFlash2 is much faster on structured output like JSON and code, and slows down as context grows. MTP holds up better at depth. The combined setup keeps most of each one's gains and loses a few percent at long context.
Next
- A prefill GEMM kernel for RDNA4.
- A cleaner way to pick DFlash2 or MTP per request than the EMA gate.
- Upstreaming. Each commit is tagged with a PR group, so they can go to llama.cpp as separate PRs: the RDNA4 mat-vec rule, the cm1 magic bias, the GDN snapshot fusion, the reduced-vocab draft head and the multi-drafter support.
Code
github.com/TheRealBluesun/llama.cpp, branch r9700.