# vLLM on fl (docker, ROCm) — side by side with the llama.cpp router, never at the same time Added 2026-10-21 to answer one question: **how does vLLM compare with llama.cpp on this card, on the same models?** It is a bench rig, not a second production endpoint. Nothing here was run on fl yet — fl has no sshd, so every command below is for you to run there; the first run will find the rough edges. - **Box**: the same fl as the router — RX 7600 XT 16 GB (gfx1102, RDNA3), 5600X, 48 GB RAM. The card drives the display. llama.cpp runs the Arch Vulkan build; vLLM needs ROCm, which comes **inside the docker image** — nothing ROCm is installed on the host. - **Files**: `vllm.ini` (presets, same `key = --flag` convention as `../llamacpp/config.ini`), `vllmctl.py` (start/stop with the VRAM guards), `bench-vs.py` (one table for both engines). - **Port 11348**. Not in the router, not in any client list (opencode/pi/aichat/OWUI) on purpose. ## ⚠ The one rule: one engine resident at a time vLLM pre-allocates `gpu-memory-utilization` × 16 GiB at startup and keeps it, idle or not. The router loads models lazily on request. So while vLLM is up, **any client request to :11343** makes the router load a model on top of it → overcommit → GTT spill → frozen PC (the same failure `--models-max 1` guards against). Unloading resident models is not enough; the clients on the LAN are the problem. `vllmctl.py up` therefore refuses while `llama.service` is active. The sequence is always: ```fish ~/.config/vllm/vllmctl.py up --stop-router # sudo systemctl stop llama.service, then start vLLM # … use / bench vLLM … ~/.config/vllm/vllmctl.py down # stop container, wait for VRAM to drain sudo systemctl start llama.service # router back for the LAN ``` The other order is the only one that should fail safely: vLLM compares its requested budget against free VRAM at startup and aborts when a router model is resident ("free memory … is less than desired GPU memory utilization" — reported by users, not something verified on this card). Everything else freezes. ## What "same models" means here, and the size surprise llama.cpp runs GGUF; vLLM runs Hugging Face safetensors (bf16 / AWQ / GPTQ / compressed-tensors). The GGUF files cannot be loaded natively, so the pairs below are **the same model at a vLLM-native quant**, not the same files (same-file GGUF loading is phase 2, see the end). Sizes from the HF API, 2026-10-21 — the "4-bit" vLLM checkpoints are **10–11.5 GB** because embeddings, lm_head and the vision tower stay bf16. They are bigger than the Q6/Q8 GGUFs they pair with, so on 16 GB they are **clean-desktop only**, at `gpu-memory-utilization` ≈ 0.78 and 16k context: | pair | llama.cpp preset (ledger) | vLLM preset (`vllm.ini`) | vLLM weights | fits? | |---|---|---|---|---| | smoke | — | `Qwen3-0.6B-smoke` = `Qwen/Qwen3-0.6B` | 1.5 GB | yes — proves docker+GPU+image | | gemma-4-E4B | `gemma-4-E4B-it-UD-Q8_K_XL` — 57 t/s **with MTP**, 96k ctx, ~7 GB | ~~`google/gemma-4-E4B-it-qat-w4a16-ct`~~ | 11.5 GB | **✗ no** (2026-10-21): vLLM needs ~16 GiB before any KV — the bf16 per-layer embedding table (262144 × 42 × 256 × 2 B = 5.6 GB) is held twice. Tombstone in the ini. | | Qwen3.5-9B | `Qwen3.5-9B-UD-Q6_K_XL` — 32 t/s **with MTP**, 32k ctx, 9 GB | `Qwen3.5-9B-w4a16` = `RedHatAI/Qwen3.5-9B-quantized.w4a16` | 11.0 GB | the last viable pair: 0.75 on a 2.1 GB desktop, 0.78 clean; prefill batch 512 | **Cannot be compared on this card at all**: every 30B+ MoE (Coder-30B, GLM-4.7-Flash, Qwen3.6-35B, Ornith, gemma-4-26B), Coder-Next 80B and Qwen3.8-27B — they only fit llama.cpp through `n-cpu-moe` expert offload into RAM, which vLLM does not have (its MoE CPU-offload RFC died stale; the only option is whole-weight `cpu_offload_gb`, which is slow). gpt-oss-20b (MXFP4, ~13 GB) is marginal and its RDNA3 kernel status is unknown — not in the ini. **Why the "docker should just work" expectation does not hold here**: of the three first-day failures, one was the image (no gfx1102 kernels → the override, solved) and two were memory. vLLM keeps everything in VRAM — bf16 embeddings, fp32 logits during profiling, CUDA-graph memory — on a card that also drives the desktop. No image changes a model's memory need; the ROCm images on Docker Hub are the right ones. So the comparison is **not apples to apples** and the table says so: llama.cpp rows run Q8/Q6 with a speculative MTP draft at a bigger context; vLLM rows run int4 without speculation at 16k. Read it as "what each engine delivers on this card for this model", not as a kernel shoot-out. ## Setup on fl (once) ```fish sudo pacman -S docker # ~20 GB free disk for the ROCm image + 25 GB for two models sudo systemctl enable --now docker sudo usermod -aG docker anon # then log out / in (or `newgrp docker`) cd ~/.dots && ./install.sh # links ~/.config/vllm/* (new files are not live until re-stow) ~/.config/vllm/vllmctl.py pull # vllm/vllm-openai-rocm:v0.32.0 (ROCm 10.0; 11.6 GB download, ~25 GB on disk) ~/.config/vllm/vllmctl.py gpu-check # torch sees the card? prints device name + gfx arch ``` `gpu-check` must print `cuda available: True`, a `gfx1100` device (that is the override at work) and `torch kernel: ok`. **The override is required** (found 2026-10-21): `v0.32.0` carries no gfx1102 code objects, and a kernel launch without one aborts the process *silently* — the engine core vanishes, the API server only says `Failed core proc(s): {}`, and even the `print`s before the abort are lost to stdout buffering. `[global] env.HSA_OVERRIDE_GFX_VERSION = 11.0.0` presents the card as gfx1100 (RX 7900), whose RDNA3 code runs on this chip. Nothing else on the host changes. Then the smoke test (tiny model, 30 % of the card, proves the whole path before any big download — ✓ passed 2026-10-21: 154.9 t/s decode, 1462 t/s prefill, TTFT 0.02 s, 7.7 GB used incl. a 2.9 GB desktop): ```fish ~/.config/vllm/vllmctl.py up Qwen3-0.6B-smoke --stop-router curl -s localhost:11348/v1/chat/completions -H 'content-type: application/json' \ -d '{"model":"Qwen3-0.6B-smoke","messages":[{"role":"user","content":"hi"}],"max_tokens":20}' ~/.config/vllm/vllmctl.py down; sudo systemctl start llama.service ``` First start of any model compiles Triton kernels: minutes, with `vllmctl.py up` echoing the log tail every 30 s. **Tags and updates.** `` is the part after the colon: `latest` = newest stable release (was `v0.32.0` on 2026-10-18, ROCm 10.0), `nightly` = today's main branch (18 GB), `latest-rocm72` / `nightly-rocm72` = the same on the older ROCm 7.2 stack (smaller, 12 GB), `…-base` = without vLLM itself. The ini pins `v0.32.0` so a re-pull cannot silently change the engine under a bench run. To update: pick the new `vX.Y.Z` on hub.docker.com/r/vllm/vllm-openai-rocm/tags, put it in `[global] image`, `vllmctl.py pull`, then `vllmctl.py down` / `up` — the container is recreated from the image on every `up`. Afterwards `docker image prune -f` drops the superseded layers (`docker images` shows what is kept). `HF_TOKEN` is only needed for gated repos; none of the three presets is gated. If one ever is, `hf auth login` on fl writes `~/.cache/huggingface/token`, which the mounted cache hands to the container. ## Running the benchmark Two phases, because the engines cannot be resident together. Do both on a **clean desktop** (close the browser; `vramx` must show ≤ ~1.5 GB used) — the vLLM presets leave ~2 GB free at 0.78 and an evening desktop does not fit. ```fish # phase 1 — vLLM (router stopped by --stop-router; first run also downloads the model) ~/.config/vllm/vllmctl.py up gemma-4-E4B-it-w4a16 --stop-router ~/.config/vllm/bench-vs.py -t vllm:gemma-4-E4B-it-w4a16 --ctx 8000 --concurrency 4 ~/.config/vllm/vllmctl.py down ~/.config/vllm/vllmctl.py up Qwen3.5-9B-w4a16 # router is still stopped ~/.config/vllm/bench-vs.py -t vllm:Qwen3.5-9B-w4a16 --ctx 8000 --concurrency 4 ~/.config/vllm/vllmctl.py down # phase 2 — llama.cpp (vLLM down, router back) sudo systemctl start llama.service ~/.config/vllm/bench-vs.py -t llamacpp:gemma-4-E4B-it-UD-Q8_K_XL -t llamacpp:Qwen3.5-9B-UD-Q6_K_XL --ctx 8000 --concurrency 4 ``` Every row lands in `bench-vs-history.md` (append) and the latest run in `bench-vs-results.md`, same folder. The guards: a `llamacpp` target is skipped while vLLM answers `/health`; a `vllm` target is skipped while the router has a model loaded or `llama.service` is active (`--force` overrides both). **How to read it** - `decode t/s` is usage tokens over wall time after the first token, measured the same way on both engines; llama.cpp's own `timings` number is in parentheses so rows match `../llamacpp/bench-results.md`. - Thinking is **off** on both (`chat_template_kwargs.enable_thinking=false`) and a reasoning delta counts as first token, so TTFT compares the engines, not the preamble lengths. `--think` reverts that. - `spec` shows the MTP draft on the llama.cpp side; vLLM rows have none unless the preset sets `speculative-config`. Expect the gemma/Qwen llama.cpp rows to carry a 1.5–2× draft bonus. - `agg t/s @N` is continuous batching's home turf — and llama-server still won it (85 vs 61 at 4 streams): its default slot count on b11146 is evidently > 1, so no `parallel` key is needed. - `--ctx 8000` adds decode at an 8k prompt — the number that matters for agent loops. ## Results **2026-10-21, Qwen3.5-9B, same evening, same script, thinking off, 256 tok, 0.6 GB desktop:** | engine · checkpoint | spec | decode t/s | prefill t/s | TTFT s | decode @8k prompt | 4 streams agg t/s | VRAM used | |---|---|---|---|---|---|---|---| | vLLM · RedHatAI w4a16 @0.80, 16k, batch 512 | — | 29.4 | **181** | **0.17** | 16.0 | 60.6 | 12.5 GB | | llama.cpp · UD-Q6_K_XL, 32k | MTP draft | **61.3** | 114 | 0.27 | **51.1** | **85.4** | 10.9 GB | What it says, for this card: - **Decode**: llama.cpp wins 2× single-stream and 3× at an 8k prompt. Most of the 2× is the MTP draft (the ledger's pre-MTP Q8 number was 17 t/s in July; a no-draft Q6 today is probably ~30, i.e. parity with vLLM). The 8k gap is not MTP: vLLM's decode halves with context here (16.0 vs 29.4) while llama.cpp loses 17 % — on RDNA3 vLLM's attention path is the Triton generic one, llama.cpp's is a tuned Vulkan kernel. - **Prefill**: vLLM +59 % even with its batch capped at 512 for memory. This is its real strength on the box, and it shows in TTFT. - **Concurrency**: the column that was supposed to be vLLM's — it batched 4 streams to 2× its single (60.6), but llama-server served the same 4 at 85.4. Its default slot count is evidently > 1 on this build, so the README's "llama.cpp serializes" caveat does not hold; it does not need a `parallel` key. - **Memory**: vLLM holds 1.6 GB more for a model with 2 GB less context, and only runs on a ≤ 0.6 GB desktop. 0.75 was 0.11 GiB short of KV room; 0.80 is the setting. **Verdict**: no vLLM model earns a roster slot on fl; the bench rig stays, the router stays, llama-swap is not needed. The one follow-up that could move the decode column is MTP in vLLM (the knob is in the ini) — but it cannot touch the memory column or the MoE roster, so it is curiosity, not a decision. ## When it does not start - **`No available memory for the cache blocks`** (seen 2026-10-21, gemma @0.73/8k on a 2.1 GB desktop): the model loaded, but budget − weights − activation reservations left nothing for KV. What vLLM reserved is in the log: `docker logs vllm 2>&1 | grep -iE 'profil|kv cache|weights take|peak'`. Cheapest fixes first: text-only (`limit-mm-per-prompt` all zero — skips the image/audio encoder warmup, now in both presets), smaller `max-num-batched-tokens` (2048, now in both presets), lower `max-num-seqs`; then a cleaner desktop so the projection allows 0.78–0.82; last resort the smaller GPTQ checkpoint. `--set key=val` tries any of these without editing the ini. - **`ABORT: projected free …`**: the desktop is using more than the preset assumes. Close GPU apps or take the suggested utilization it prints. `--force` exists; the freeze is real. - **compressed-tensors / "no kernel" for int4 on ROCm**: both real presets use compressed-tensors pack-quantized. If this image lacks a ROCm kernel for it, swap in the GPTQ alternative noted in the ini (`Vishva007/gemma-4-E4B-it-W4A16-AutoRound-GPTQ`, 10.0 GB) — different kernel path. - **Hangs / garbage output**: RDNA3 attention backend. Set `env.VLLM_ATTENTION_BACKEND = TRITON_ATTN` explicitly; option names moved between versions — `vllmctl.py exec vllm serve --help` shows this image's. - **GTT growing during a run** (`vramx`, the GTT line): the budget is leaking into system RAM — stop, lower the utilization. Do not wait for the desktop to stutter. - **Root-owned files in `~/.cache/huggingface`**: the container runs as root, so its downloads land root-owned in anon's HF cache. Harmless for the rig; `sudo chown -R anon: ~/.cache/huggingface` if the host-side `hf` tooling complains. - **Container died**: `docker logs vllm` (the container is kept after a crash; `vllmctl.py up` shows the tail and removes it on the next start). ## Phase 2 — if the numbers justify more 1. **Same-file GGUF in vLLM**: GGUF loading moved to an out-of-tree `vllm-gguf-plugin`; it is "highly experimental and under-optimized" per vLLM's docs and needs a custom image layer (`pip install vllm-gguf-plugin`), plus `--tokenizer `. Untested on ROCm. It would make the weights identical — and show vLLM's unoptimized path, so interpret carefully. 2. **MTP in vLLM**: `RedHatAI/Qwen3.5-9B-quantized.w4a16` ships `model_mtp.safetensors`; the commented `speculative-config` in the ini is the knob (syntax per this image's `--help`). 3. **Permanent coexistence**: replace the router's `--models-preset` mode with llama-swap (AUR `llama-swap-bin`), which spawns llama-server *or* the vLLM container per model id, one at a time, with a `ttl` idle unload. Same host/port/model ids for every client. Only worth it if a vLLM model earns a place in the daily roster.