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

This is not optional. An uncapped card here hit 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
Check your tooling actually supports the model. Ollama 0.11.10 accepted this model at 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
Two flags that are not obvious, both of which cost us an afternoon.

--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.

Know what you are taking on. This is one person's fork, pinned to vLLM 0.27.1, and its optimizations are checkpoint-specific. Upstream, the prep scripts hard-code a 128 group size for requantization while the embedding patch reads the size the checkpoint declares — so a checkpoint quantized at any other group size fails at load with an opaque tensor-shape error (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

SettingValuesWhat we measured
SPECempty = built-in MTP k=4
dflash2 = 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.
CTXfast bf16 KV, ~90K
long fp8 KV, 150K
huge 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_CACHE0 / 1Set 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.
The 65,536 default is not a limit. The launcher reads 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
Do not use tensor parallelism on a board like this one. --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

Restart the load balancer whenever you recreate a replica. nginx resolves its upstream addresses once, at config load. Recreating a replica gives it a new address on the Docker network, and every request then returns 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.

The two cards are not identical. In the same configuration, one measured 5–10% slower than the other, reproducibly, and we never isolated why. If you compare two settings by running one on each card, swap them halfway and average — otherwise you cannot separate "this setting is faster" from "this card is faster".

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

ConfigurationWeightsTotal VRAMContext
llama.cpp, 4-bit GGUF, q8_0 KV~17 GB16.9 GB64K comfortably
Stock vLLM, 4-bit~17.5 GB19.5–20.3 GB4K–8K at these flags
Patched vLLM, CTX=fast~17.5 GB22.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 card22.6 GBeach204K 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.

Quantizing the KV cache is a different decision, and it is not free. It is tempting to reach for fp8 to buy context. Measured: fp8 runs 22% slower than bf16 (124.4 against 158.8 tok/s) because it adds dequantization to every attention operation, and both fp8 and KVarN diverge from the bf16 reference within the first ~100–200 characters at 8K, 32K and 64K alike. Each is internally deterministic; they simply are not the same model output. Use them for context you cannot otherwise fit, not as a free optimization.
On Unsloth GGUF. Unsloth publishes GGUF only for this model (no AWQ, GPTQ or AutoRound; their NVFP4 is Blackwell-only). Their Dynamic quantization genuinely has better quality per byte, but vLLM’s GGUF support is documented as “highly experimental and under-optimized” and has moved out-of-tree, so in practice GGUF means llama.cpp — 27.6–42.6 tok/s against the patched stack’s 167.8. That is roughly 4× the speed to buy the better quantization.

Three things that will waste your afternoon

1. Reasoning is on by default, and it fails silently. Recent models emit reasoning tokens unless told otherwise, inflating token counts several-fold. Worse: if your server runs a reasoning parser (--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.
2. Your reverse proxy truncates long prompts. nginx defaults 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.
3. Model-written code will hang your harness. If you run any benchmark that executes generated code, impose your own timeout. One case here stalled for 68 minutes on a 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.

FileWhat it is
docker-compose.ymlBoth 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.confnginx round-robin with metadata-only request logging — timings, which replica, status, byte counts. Deliberately never logs prompts or the auth header.
env.exampleEvery knob: the API key, per-replica speculator and context profile, prefix caching.
preflight.shPost-boot checks: power cap, persistence, Xid history, image, cache volume, ports, disk. Refuses to pass if the cap is missing.
up.shBrings 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.