207 lines
14 KiB
Markdown
207 lines
14 KiB
Markdown
# 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 <preset> --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.** `<tag>` 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 <base repo>`. 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.
|