Squeezing vLLM on AMD Strix Halo: 2.3× llama.cpp under load

vLLM on the Strix Halo iGPU, measured against llama.cpp with the same 35B model: 2.1× faster per token with graphs, 2.3× the throughput with 16 concurrent requests, startup from 9 minutes to 97 seconds, and the one case where llama.cpp still wins.
AMD’s Strix Halo pairs a large integrated GPU (gfx1151) with unified memory the GPU can use almost entirely: with the kernel tuned, PyTorch sees 124 GB of GPU memory. That’s enough to serve a 35-billion-parameter model without a discrete card.
The model here is Qwen3.6-35B-A3B: a mixture of experts with 3 billion parameters active per token and a hybrid architecture that mixes normal attention with Gated DeltaNet, a linear attention with a recurrent state. llama.cpp runs it well. The question was what vLLM adds on the same hardware, and what it takes to get there.
The short version, with the same model and the same prompts:
- 2.3× llama.cpp’s throughput with 16 concurrent requests (2.07 vs 0.89 requests per second).
- 2.1× faster per generated token with graphs instead of eager mode (39 vs 81 ms).
- Startup from 9 minutes to 97 seconds.
- Identical answers for identical requests.
- And one honest exception: on a single long request, llama.cpp is still a little faster.
Four levers got there. None of them touches the model or its kernels.
Lever 1: an autotuning loop that ran for hours
On first launch, vLLM sat in its warm-up with the GPU at 100 % and no log output. py-spy dump (through podman unshare, since ptrace isn’t allowed inside a rootless container) showed where: _warmup_prefill_kernels → Triton’s autotuner → triton.testing.do_bench.
do_bench decides how many times to run a kernel by timing five launches and dividing its budget by that estimate, and it clears a 256 MiB buffer before every run. The warm-up autotunes tiny kernels whose estimated time on this iGPU is practically zero, so the division gives hundreds of thousands of repetitions for every configuration being compared. It would have finished, in hours.
Replacing do_bench from a sitecustomize.py in the image, which Python loads before anything else, fixes it:
# Triton's do_bench computes repetitions as rep / estimate. For tiny kernels
# the estimate is ~0 ms on gfx1151, so it runs hundreds of thousands of times.
# Three warm-ups and ten timed runs are enough to rank autotune configs.
import triton.testing as tt
def do_bench(fn, warmup=25, rep=100, grad_to_none=None, quantiles=None, return_mode="mean", **_):
import torch
for _ in range(3):
fn()
torch.cuda.synchronize()
times = []
for _ in range(10):
start, end = torch.cuda.Event(enable_timing=True), torch.cuda.Event(enable_timing=True)
start.record(); fn(); end.record(); torch.cuda.synchronize()
times.append(start.elapsed_time(end))
return tt._summarize_statistics(times, quantiles, return_mode)
tt.do_bench = do_bench
Ten timed runs are plenty to rank autotuning configurations. The same autotuning now takes seconds (one of the kernels, merge_16x16_to_64x64_inverse_kernel, in 4.07 s).
Lever 2: stop redoing work on every start
With autotuning fixed, the first start took 9 minutes and later ones 5 to 9. Two costs were paid on every start and thrown away.
- aiter compiled its core module every time. AMD’s kernel library builds
module_aiter_corewithhipccthe first time it’s imported: about 46 seconds, inside the container, lost when it stopped. It’s now compiled once, at image build time. The catch: aiter detects the GPU architecture by querying the device and ignoresGPU_ARCHS, so the build needs the GPU (podman build --device /dev/kfd --device /dev/dri). 22 seconds, once. - Triton cached kernels, not verdicts. Compiled kernels were cached, but every autotuning measurement was repeated on each start.
TRITON_CACHE_AUTOTUNING=1stores which configuration won. The caches live outside the container: 239 MB for Triton and 642 MB fortorch.compile.
Ready at 97 seconds: weights in 33 s, compilation, autotuning and profiling in about 15 s, graph capture in about 15 s. The final image is 14.7 GB, against 61.8 GB for AMD’s official one.
Lever 3: graphs instead of eager, 2.1× per token
The usual advice for vLLM on iGPUs is --enforce-eager. In eager mode every generated token cost 81 ms, five times what llama.cpp needs on the same hardware: Python dispatches every kernel of every layer for every token, and with only 3 billion active parameters that overhead is most of the work.
Without --enforce-eager, vLLM compiles the model (44 s the first time, then cached) and captures seven piecewise graphs, so each step replays a recorded graph instead of launching every kernel one by one.
39 ms per token. The answers on my evaluation set didn’t change, and a repeated request still came back bit for bit identical.
Lever 4: bigger batches, 2.3× llama.cpp under load
Throughput under concurrency is where vLLM earns its place. llama.cpp serves a fixed number of slots and, on Vulkan, doesn’t batch the prefill of several sequences together. vLLM schedules every running request into each forward pass.
The last lever was --max-num-batched-tokens: up to 8,192 tokens per step instead of 2,048. With 8 concurrent requests throughput went from 0.98 to 1.18 requests per second; with 16, from 1.77 to 2.07. Latency for a single request didn’t get worse.
2.07 requests per second with 16 at a time: 2.3× llama.cpp with the same model (llama.cpp with a Q4 GGUF and 4 slots). Every lever adds up: eager vLLM already beats llama.cpp under load, graphs add 13 %, and bigger batches another 17 %.
One request at a time: where llama.cpp still wins
With a single request, vLLM wins on short prompts, they tie on medium ones, and llama.cpp is still a little faster on long ones. Alone, the iGPU is bound by memory bandwidth, and llama.cpp’s Q4 kernels are tuned precisely for that. Under concurrency, batching wins by a wide margin. If your traffic is one user at a time with long prompts, llama.cpp is still a great choice; if it’s many users, vLLM is.
Same request, same answer
The number that doesn’t fit in a throughput chart: in vLLM, the same request repeated gives the same output, bit for bit. In llama.cpp, with this family of hybrid models, the same prompt at temperature 0 moved by up to 0.44 in log-probability between runs. For anything that reads log-probabilities, or that must give the same answer twice, that alone decides it. Why llama.cpp drifts deserves a post of its own.
What didn’t pay off
- MTP (speculative decoding with the model’s own draft head). 76 % of drafted tokens were accepted with complete log-probabilities, but prefix-cache hits dropped to zero, this build lacks the fused kernel for verifying the draft so that step falls back to Triton, and with 16 concurrent requests throughput fell to 0.80 per second. Not yet.
- More prefix-cache reuse through tuning. In a hybrid model vLLM sizes attention cache pages to match the recurrent state, which fixes attention blocks at 1,056 tokens. Prompts of ~1,200 tokens rarely reuse more than one block: 28 % of tokens hit.
--mamba-block-sizedoesn’t change it.
Takeaways
- Profile a stall before blaming the hardware. A process at 100 % with no logs is busy.
py-spy dumpsays with what in one command. - Give timing loops a floor. Anything that divides a budget by a measured time explodes when the measurement is almost zero.
- Cache the verdicts, not only the artifacts. Autotuning results and aiter’s build were tens of seconds on every start.
- Try graphs, even where eager is the advice. Here they halved the time per token.
- Benchmark concurrency and single requests separately. They crown different winners.
- Batch size is a throughput knob. 8,192 tokens per step was a free 17 % under load.
With four levers and no changes to the model, vLLM on the Strix Halo went from not finishing its warm-up to 2.3 times llama.cpp’s throughput, twice as fast per token, and ready in 97 seconds.