Setup · reproduce it yourself
Getting this running
Five rungs, from the twenty-minute version to the one that runs at 212 tokens per second. Every command here was run on the machine described in the results; where something is a third-party fork or an unverified step, it says so.
You need one RTX 3090 (24 GB) for everything up to rung 4. Rung 5 uses two. Docker with the NVIDIA container toolkit is assumed from rung 3 onward. Weights are 4-bit throughout, so the model itself is ~17–19 GB and the rest of the card is KV cache.
Before anything else: cap the power
Xid 79 mid-benchmark — "GPU has fallen off the bus". Unrecoverable; the machine needed a reboot. It was a 12 V rail brownout from transient spikes under a Triton autotuner, not a driver bug. We proved that by deliberately re-running the exact workload that triggered it after capping: no recurrence across eight server starts.
sudo nvidia-smi -pl 250 # per-GPU power limit, watts
sudo nvidia-smi -pm 1 # persistence mode
Both reset on reboot. Re-apply them every time, before you start a server. The cap cuts peak 12 V current by roughly 29%. It costs about 14% on unassisted llama.cpp decode and nothing measurable on the vLLM path — a trade worth making, because the failure it prevents is a hard lockup mid-run.
Rapid idle → full → idle cycling is what provokes it, which is exactly what kernel autotuners and benchmark loops do. If you only ever run steady inference you may never see it; that is not a reason to skip the cap.
Rung 1 — llama.cpp, as it comes
27.6 tok/s 16.9 GB VRAM ~20 minutes, no Docker
Build llama.cpp with CUDA, pull a 4-bit GGUF, point it at the card. This is where most people start and it is a perfectly reasonable place to stop if you only need occasional single-user generation.
# build (CUDA, Ampere = compute capability 8.6)
git clone https://github.com/ggml-org/llama.cpp && cd llama.cpp
cmake -B build -DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=86
cmake --build build --config Release -j
# fetch a 4-bit GGUF (~17 GB)
hf download unsloth/Qwen3.8-27B-GGUF Qwen3.8-27B-UD-Q4_K_XL.gguf
# serve — every layer on the GPU, nothing else tuned
./build/bin/llama-server -m Qwen3.8-27B-UD-Q4_K_XL.gguf \
-ngl 99 --host 127.0.0.1 --port 8080
create time — it parses the GGUF container fine — and then failed at inference with an opaque load error. That build predated the qwen35 hybrid-GDN architecture by about eleven months. A successful import is not support. If you are on Ollama, check its release notes for your model's architecture before concluding the model is broken.
Rung 2 — the same llama.cpp, with flags
42.6 tok/s +54% 16.9 GB VRAM no new software
Flash attention, a quantized KV cache, and speculative decoding using the model's own multi-token-prediction head. Same binary, same weights — this is the cheapest large win available.
./build/bin/llama-server -m Qwen3.8-27B-UD-Q4_K_XL.gguf \
-ngl 99 \
-fa on \ # flash attention
-ctk q8_0 -ctv q8_0 \ # 8-bit KV cache: ~+24% prefill, no quality cost
--spec-type draft-mtp \ # use the model's built-in MTP head
--spec-draft-n-max 1 \
-c 65536 \
--host 127.0.0.1 --port 8080
The KV quantization is close to free: it bought about 24% on prompt processing here with no measurable effect on generation. It also halves the KV memory, which is what makes a 64K context comfortable on a 24 GB card.
Draft depth is workload-dependent. With the separate DFlash2 drafter, depth 4 beat the model card's recommended 7 on this hardware — the larger verify batch cost more than the extra accepted tokens returned. Sweep it on your own traffic rather than trusting a default.
Rung 3 — stock vLLM
85.6 tok/s single ~960 tok/s at 64 concurrent 20.3 GB VRAM
Move here when you need concurrency. llama.cpp has no continuous batching; vLLM does, and that is the entire reason to take on the extra complexity. Single-stream speed roughly doubles too, from speculative decoding done well.
docker run -d --name qwen38 --gpus '"device=0"' --ipc=host \
-e PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True \
-v ~/.cache/huggingface:/root/.cache/huggingface \
-p 8000:8000 \
vllm/vllm-openai:latest \
--model dbirks/Qwen3.8-27B-W4A16-AutoRound \
--gpu-memory-utilization 0.95 \
--kv-cache-memory=1500000000 \
--max-model-len 4096 \
--max-num-seqs 8 \
--enforce-eager \
--trust-remote-code
--gpu-memory-utilization does not reserve room for CUDA graph capture. At 0.93 vLLM consumed 22.99 GiB against a 22.1 GiB budget and then OOM'd needing 784 MiB for graphs. Pin the KV pool explicitly with --kv-cache-memory instead of trusting the fraction.
--max-num-seqs is bounded by Mamba cache blocks on this hybrid architecture — one block per decoding sequence. Pinning KV to 1.5 GiB left 31 blocks against a default of 256, which is a hard ValueError at startup, not a warning.
--enforce-eager skips CUDA graph capture entirely. It is the safe starting point; dropping it is worth about +20% once you have the memory budget settled.
Rung 4 — patched vLLM
167.8 tok/s (MTP) 212.1 tok/s (DFlash2) 22.4–22.7 GB VRAM single replica
Those two speeds were measured with the second replica stopped. Running two replicas behind a load balancer — rung 5 — costs about 11% single-stream just for the neighbour being resident: the same MTP config measures ~149.6 there. Budget for it if you are serving a team.
The largest remaining gain comes from syv-ai/qwen38-27b-rtx3090, a third-party fork of vLLM 0.27.1 carrying fourteen patches. They target the verify step, which is where speculative decoding actually spends its time: split-KV verify attention, an int4-requantized lm_head and drafter, and a 40k-token draft vocabulary covering what the model actually emits.
git clone https://github.com/syv-ai/qwen38-27b-rtx3090
cd qwen38-27b-rtx3090
echo "VLLM_API_KEY=$(openssl rand -hex 24)" > .env
docker compose --profile single up -d # one or a few users
# or: docker compose --profile batch up -d # many concurrent requests
The build downloads the checkpoint (~19.5 GB) and runs its own requantization steps. Expect 10–20 minutes for the image, ~5 for prep, and up to 15 minutes on a first-ever cold start while torch.compile, CUDA graph capture and FlashInfer JIT populate the cache. Warm restarts are about 60 seconds — preserve that cache volume, it is the difference.
size of tensor a (160) must match tensor b (40), i.e. 5120/32 against 5120/128) rather than an honest "unsupported quantization". We wrote a local patch deriving the group size from the checkpoint's own config; it is not upstream and has not been verified end-to-end against a live serve. If you plan to try arbitrary community checkpoints, stock vLLM accepts formats this fork rejects.
Choosing a speculator and a context profile
| Setting | Values | What we measured |
|---|---|---|
SPEC | empty = built-in MTP k=4dflash2 = DFlash2 k=7 | DFlash2 wins on short prompts and code; MTP holds up better past ~8K and on prose. Neither can change output quality — speculative decoding is exact. |
CTX | fast bf16 KV, ~90Klong fp8 KV, 150Khuge KVarN 4/2-bit, ~200K | Stay on fast unless you genuinely need more than ~90K. The quantized caches are not free: fp8 is 22% slower than bf16 and both measurably change the model’s output. huge additionally shows degraded secondary indicators. Take them when the request would not otherwise fit, never for speed. |
PREFIX_CACHE | 0 / 1 | Set 0 for clean benchmarking, 1 in production. On repeat turns over the same document the fork reports ~23 s → ~1 s time-to-first-token. |
MAX_LEN=${MAX_LEN:-65536} — an overridable variable, and for a long time we
mistook it for a hardware ceiling. The server was reporting GPU KV cache size: 89,437
tokens at those settings the whole time. 85,000 is now the shipped default.
Verified working: MAX_LEN=85000 with
GPU_UTIL=0.96 and MAX_SEQS left at 8; MAX_LEN=90000 at
0.97 and 2. Above roughly 90,000 vLLM refuses at load and tells you the
arithmetic. We assumed the extra context had to be bought by cutting sequence slots. It
doesn’t — raising GPU_UTIL to 0.96 lifts KV capacity to 103,042
tokens, which carries 85,000 with all eight slots intact and no measurable speed
cost (148.5 ±0.1 against a 149.6 ±0.2 control). Only past ~90,000
does concurrency have to give.
MAX_LEN=85000 GPU_UTIL=0.96 ./up.sh --wait # MAX_SEQS stays at 8
--tensor-parallel-size 2
does work, and it is the only way to reach the model’s full 262,144-token window at bf16
fidelity — a 204,058-token prompt served successfully. But it cost 29% of single-stream speed
and 62% of concurrent throughput here, and it is the only workload in this study twice associated
with a GPU falling off the PCIe bus. Check your topology first with lspci -vv: our
GPU 1 sits behind the chipset at x4, so every allreduce crossed the DMI link via host memory.
On a board with both cards CPU-attached the trade may be entirely different.
Rung 5 — two cards
502 Bad Gateway until the balancer is restarted as well — which looks like a total outage rather than a stale DNS cache. docker restart qwen38-lb fixes it.
905.6 tok/s at 16 concurrent 151 tok/s single-stream
Run two independent replicas behind a load balancer — do not split one model across both cards. A tensor/layer split gained only about 10% on decode here, because the PCIe hops it adds very nearly cancel the extra memory bandwidth. It did buy ~35% on prefill, so it is not useless; it is just the wrong trade for most workloads. Two replicas double your throughput instead.
cp env.example .env # set VLLM_API_KEY, REPO_DIR
./preflight.sh # power cap, image, ports, disk
./up.sh --wait # both replicas + LB, block until healthy
# traffic goes to :18000 — the replica ports are for debugging and telemetry
Round-robin, not least_conn, and that choice is deliberate: least_conn sends more traffic to whichever replica is currently faster, so if you are ever comparing two configurations the faster arm also gets a larger and differently-distributed sample. Equal weights keep the comparison honest.
Thinking on? Serve Swift beside it
With thinking enabled the base model spends most of its output reasoning, and Swift-Qwen3.8-27B reaches the same answers on about half of it. There is no 4-bit vLLM checkpoint of Swift yet, so it runs as a GGUF through llama.cpp. Q4_K_M is the size that leaves a 24 GB card room for two 40K slots; Q6_K (22.8 GB) does not fit with usable context.
hf download ukisai/Swift-Qwen3.8-27B-GGUF Swift-Qwen3.8-27B-Q4_K_M.gguf --local-dir models/gguf
docker run -d --name swift --gpus '"device=1"' --ipc=host -p 18021:18021 \
-v $PWD/models/gguf:/models:ro ghcr.io/ggml-org/llama.cpp:server-cuda \
-m /models/Swift-Qwen3.8-27B-Q4_K_M.gguf --alias swift-qwen3.8-27b \
--host 0.0.0.0 --port 18021 --api-key $KEY \
-ngl 99 -fa on -ctk q8_0 -ctv q8_0 \
-c 81920 -np 2 \ # two slots of 40K: a 32K thinking answer plus its prompt
--spec-type draft-mtp --spec-draft-n-max 1 \
--reasoning-format deepseek --jinja # thinking arrives in reasoning_content, not content
About 55 tok/s for one user, 20.5 GB of VRAM. To put both models behind one endpoint, route on the
request’s model field. nginx cannot read a JSON body from plain config, and a js_set
variable used in proxy_pass is evaluated before the body has been read — every request
falls through to the default upstream and the wrong model answers with a 404. Read the body in a content handler
and redirect internally instead:
# router.js
function route(r) {
var m = ""; try { m = JSON.parse(r.requestText).model || ""; } catch (e) {}
var up = m.toLowerCase().indexOf("swift") !== -1 ? "swift" : "base";
r.internalRedirect("/_" + up + r.uri);
}
export default { route };
# nginx.conf (http block; image nginx:1.27-alpine ships the njs module)
load_module modules/ngx_http_js_module.so;
js_import router.js;
upstream base { server replica-gpu0:18020; }
upstream swift { server replica-gpu1:18021; }
server {
listen 18000;
client_max_body_size 32m; client_body_buffer_size 32m; # body must be in memory for requestText
location / { js_content router.route; }
location ^~ /_base/ { internal; rewrite ^/_base(/.*)$ $1 break; proxy_pass http://base;
proxy_buffering off; proxy_read_timeout 1800s; }
location ^~ /_swift/ { internal; rewrite ^/_swift(/.*)$ $1 break; proxy_pass http://swift;
proxy_buffering off; proxy_read_timeout 7200s; }
}
Set client timeouts generously on the Swift side. A 32K-token answer at 25–55 tok/s is ten to twenty minutes, and most OpenAI clients give up at ten.
What fits in 24 GB
| Configuration | Weights | Total VRAM | Context |
|---|---|---|---|
| llama.cpp, 4-bit GGUF, q8_0 KV | ~17 GB | 16.9 GB | 64K comfortably |
| Stock vLLM, 4-bit | ~17.5 GB | 19.5–20.3 GB | 4K–8K at these flags |
Patched vLLM, CTX=fast | ~17.5 GB | 22.4–22.7 GB | ~90K 65,536 is only the default |
Patched vLLM, CTX=long (fp8 KV) | ~17.5 GB | ~23 GB | ~150K |
Patched vLLM, CTX=huge (KVarN) | ~17.5 GB | ~23 GB | ~192K measured |
| Patched vLLM, TP=2 (both cards) | ~8.75 GBper card | 22.6 GBeach | 204K verifiednot recommended here |
A 4-bit checkpoint whose lm_head and drafter stay in BF16 costs about 1.5 GB more than one where they are also quantized — enough that speculation stopped fitting on a single card in one of our comparisons. If you are tight on memory, that is the first thing to look at.
Two 4-bit quantizations of the same model measured 32.7 and 32.9 tok/s under identical conditions — indistinguishable. Do not agonize over which 4-bit checkpoint to use; do check whether its lm_head is quantized.
Three things that will waste your afternoon
--reasoning-parser qwen3), that content is extracted into a separate field — so a client expecting inline tags receives completely empty content with a normal HTTP 200 and a full token count. One of our benchmarks ran to completion scoring a model whose output it could not read. Client-side flags are frequently no-ops. What works is putting it in the request body:
{"model": "...", "messages": [...],
"chat_template_kwargs": {"enable_thinking": false}}
Then verify you are getting non-empty content back. Do not assume.
client_max_body_size to 1 MB and returns 413 before the request ever reaches vLLM. A 64K-token prompt exceeds that comfortably, and the failures look like model errors. Set it explicitly:
client_max_body_size 32m;
While you are in there: proxy_buffering off for streaming, and proxy_read_timeout 1800s — a 192K-context prefill genuinely takes about six minutes.
node process spinning at 99.9% CPU — model-written JavaScript with an infinite loop. Aider's benchmark has no per-test wall-clock kill. Run it in a container, and monitor for stalls.
Verify it works
curl -s http://127.0.0.1:18000/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $VLLM_API_KEY" \
-d '{"model":"qwen3.8-27b",
"messages":[{"role":"user","content":"Write a Python function that reverses a linked list."}],
"max_tokens":200, "temperature":0,
"chat_template_kwargs":{"enable_thinking":false}}' | jq -r '.choices[0].message.content'
If that returns an empty string, you have hit gotcha 1 above — the tokens were generated and went into a field you are not reading.
Config files
These are the working files from the two-card deployment, with the API key replaced by a placeholder and host-specific paths genericized. Nothing else has been changed.
| File | What it is |
|---|---|
| docker-compose.yml | Both replicas, the load balancer and the one-shot prep job. Each replica configured independently so a config change is an .env edit, never a hand-built docker run. |
| qwen-lb.conf | nginx round-robin with metadata-only request logging — timings, which replica, status, byte counts. Deliberately never logs prompts or the auth header. |
| env.example | Every knob: the API key, per-replica speculator and context profile, prefix caching. |
| preflight.sh | Post-boot checks: power cap, persistence, Xid history, image, cache volume, ports, disk. Refuses to pass if the cap is missing. |
| up.sh | Brings the stack up and optionally blocks until both replicas answer /health. |
What is verified and what is not. Every command on this page was run on the machine described in the results, except where noted: the llama.cpp build commands are reconstructed from a working install rather than replayed from scratch, and the local group-size patch to the syv-ai prep scripts unblocks loading only — whether the resulting checkpoint is numerically sound was never tested against a live serve.
Numbers here are one model, one machine, at 4-bit, with reasoning disabled. They are honest measurements of this deployment, not claims about the model's capability, and they are not comparable to published leaderboard figures. See Lessons for what has to be held constant before two numbers can be compared at all.