← The bench · run of

Qwen3-32B, three configs.

A 32B model on one RTX 5090 went from 75 to 350 tok/s on code edits with a single flag and no second model: n-gram speculative decoding. Same 24 prompts across three server configs, with determinism checked byte for byte rather than assumed.

Qwen3-32B-AWQ · vLLM 0.20.0 · RTX 5090, 32 GB · AMD Ryzen 9 9950X3D · 64 GB DDR5

At a glance

350
Fastest decode, tok/s
n-gram speculation on code edits · 75 baseline
4.65×
Code edit speedup
2.41× on RAG · 0.95× on creative writing
51.3%
Draft acceptance
2,635 of 5,140 drafted tokens accepted
0
Extra models loaded
drafts come from the prompt itself
24 / 24
Control, byte-identical
two baseline runs, same 24 prompts
18 / 24
n-gram, byte-identical
all six differences in creative writing
12 / 24
FP8 KV, byte-identical
why FP8 KV stays off on my server
656
Aggregate tok/s, peak
16 concurrent · FP8 KV cache

Why this matters

A 32-billion-parameter model on a single consumer card decodes at about 75 tokens a second, and that number is a wall. It is set by memory bandwidth, not compute: every token the model produces, it has to stream all 17 GB of its weights through the GPU once. You can quantize harder or buy a second card. Those are the two options everyone talks about.

This run is about a third option that costs nothing. With one flag on the vLLM server, the same model on the same card produced code edits at 350 tokens a second, 4.65 times the baseline, and answered retrieval questions at 181, 2.41 times. No second model was loaded. No VRAM was spent. Output quality on those two workloads was byte for byte identical to the baseline, which I checked rather than assumed. The time to first token got slightly better, not worse.

That is the whole result, and it matters because code editing and quoting from a document are what a local agent does most of the day. An agent that rewrites a file, or answers from a retrieved passage, spends most of its output repeating text that was already in its prompt. This run measures exactly how much speed that repetition is worth on hardware people already own, and what it costs on the one workload where there is nothing to repeat.

How n-gram speculation works

Speculative decoding is the general trick: guess the next several tokens cheaply, then let the big model verify all of them in one forward pass. Verifying six tokens costs about the same as producing one, because the cost is in reading the weights, not in the arithmetic. Every guess that turns out right is a token you got for the price of a fraction of one.

Most speculative setups guess with a second, smaller model, which has to be trained to match the big one and takes its own slice of VRAM. The n-gram method (vLLM calls it ngram; the papers call it prompt lookup decoding) has no model at all. After each token it looks at the last two to four tokens it has generated, searches the prompt for the same sequence, and if it finds one, proposes the five tokens that followed it there. For “rename load_rows to read_csv_rows everywhere”, nearly every line of the output already exists in the prompt, so the guess is almost always right. For “write a story about a lighthouse keeper”, it almost never is, and each wrong guess costs one wasted verification step. That is the 0.95× on creative writing: a 5 percent tax for a 4.65× gain where it counts.

The acceptance rate over the whole run was 51.3 percent (2,635 of 5,140 drafted tokens), averaged across all three workloads. Per prompt, the spread tells the story better: the two pure rename edits ran at 364 and 381 tok/s, edits that insert new text (docstrings, type hints, a new argument) ran at 198 to 228, retrieval answers that quote verbatim ran at 160 to 270, and every creative prompt sat between 70.8 and 73.7.

Decode throughput by workload

baselineFP8 KV cachen-gram speculation
0 175 350 Code edit Code edit · baseline: 75.2 tok/s 75.2 Code edit · FP8 KV cache: 76.1 tok/s 76.1 Code edit · n-gram speculation: 349.8 tok/s 349.8 Creative Creative · baseline: 75.5 tok/s 75.5 Creative · FP8 KV cache: 76.7 tok/s 76.7 Creative · n-gram speculation: 71.9 tok/s 71.9 RAG RAG · baseline: 75.3 tok/s 75.3 RAG · FP8 KV cache: 76.1 tok/s 76.1 RAG · n-gram speculation: 181 tok/s 181

Qwen3-32B-AWQ · vLLM 0.20.0 · RTX 5090 · sequential decode, median tok/s per workload · runs of 2026-07-21

Results by config

Workloadbaseline (tok/s)FP8 KV cache (tok/s)n-gram speculation (tok/s)
Code edit75.276.1349.8
Creative75.576.771.9
RAG75.376.1181
16-way concurrent, aggregate635.9656
baselineFP8 KV cachen-gram speculation
TTFT, code edit (median)0.144 s0.144 s0.137 s
TTFT, RAG (median)0.114 s0.116 s0.110 s
TTFT, creative (median)0.033 s0.033 s0.025 s
KV cache pool at boot33,760 tokens65,536 tokens32,064 tokens
Max concurrent 16K requests2.06×4.00×1.96×
24 prompts, sequential, wall80.3 s47.5 s
Output identical to baseline24 / 24 (control)12 / 2418 / 24

The determinism test

A speed number means nothing if the faster config is quietly producing different text. So before comparing anything, the harness had to prove it could detect a change at all. I ran the baseline configuration twice, same 24 prompts, temperature 0, seed 0, and compared the outputs character by character. All 24 matched. That is the control: on this server, the same input produces the same bytes, so any difference in the other configs is real and caused by the config.

Against that control, n-gram speculation matched 18 of 24. All six that differed were creative-writing prompts, and none differed early: the first divergence came at character 189, the others at 274, 496, 684, 1,208 and 1,410. Every code edit and every retrieval answer was byte for byte the baseline’s output. That is the pair of facts that makes the result usable: the speedup lives entirely in the workloads where the output did not change.

Why do the creative ones drift at all, when at temperature 0 the verifier only ever accepts the token the big model would have picked anyway? Because verifying five drafts at once changes the shape of the batch going through the kernels, and with it the order the floating-point sums are done in. In open-ended prose the top two candidates are often within rounding error of each other, so a sum done in a different order can flip the choice, and from that point the text goes its own way. In code and quotation the leading candidate wins by a wide margin and nothing flips. That is my reading of the pattern; I am reporting what was measured, which is 18 of 24, not the theoretical “lossless”.

FP8 KV cache: twice the room, different words

The second config stores the KV cache in 8-bit floats instead of 16. The gain is capacity: the pool went from 33,760 to 65,536 tokens, so the card can hold four full 16K-token conversations at once instead of two, and aggregate throughput at 16-way concurrency rose from 636 to 656 tok/s with zero preemptions in either run. Sequential speed moved by about 1 percent.

The cost showed up in the determinism test. Only 12 of 24 outputs matched the baseline. Creative prompts diverged almost immediately (characters 12, 15, 26, 60, 63, 64), two of the eight code edits diverged, and two of the eight retrieval answers did. Nothing I read looked wrong, but “looked fine” is not the standard this bench holds to, and a config that changes two code edits out of eight is not one I want behind an agent. FP8 KV stays off on my server. The 656 at the top of the page is the honest peak; it is not the config I run.

Timeline, from the log timestamps

TimeWhat happened
12:05Baseline server starts. Ready at 12:07 with a 33,760-token KV pool, 2.06× concurrency at 16K.
12:09Baseline run 1: 24 prompts, 5,900 tokens, 80.3 s.
12:10Baseline run 2: 5,900 tokens, 80.4 s. Byte-identical to run 1 on all 24.
12:11Baseline, 16-way concurrent: 64 requests, 14,056 tokens, 22.1 s, 635.9 tok/s aggregate, 0 preemptions.
12:11n-gram server starts with num_speculative_tokens 5, prompt_lookup 2 to 4. Ready in 50 s, KV pool 32,064 tokens.
12:12n-gram run: 5,825 tokens in 47.5 s. Code edits 350 tok/s median, RAG 181, creative 72.
12:12FP8 KV server fails to boot: FlashInfer needs nvcc on PATH to detect the card and the default environment did not have it.
12:15FP8 KV server boots under the CUDA 13.2 environment. KV pool 65,536 tokens, 4.00× concurrency.
12:17FP8 KV sequential and 16-way runs complete: 656.0 tok/s aggregate, 0 preemptions. Twelve minutes of GPU time in all.

Methodology

One server, three configs, same everything else. vLLM 0.20.0 serving Qwen/Qwen3-32B-AWQ (awq_marlin kernels) on a single RTX 5090, with 16K max context, 32 sequence slots, 0.90 GPU memory utilization, prefix caching disabled, seed 0. The configs: baseline; baseline + --kv-cache-dtype fp8; and baseline + --speculative-config '{"method": "ngram", "num_speculative_tokens": 5, "prompt_lookup_max": 4, "prompt_lookup_min": 2}'.

The client sends 24 prompts, eight per workload, at temperature 0, seed 0, streaming, thinking disabled, with two unrecorded warm-up requests per run. The code-edit prompts hand the model a 40 to 60 line Python file and ask for a specific change returned as the whole file (rename a function, add docstrings, change a separator, add type hints). The RAG prompts hand it a passage and ask for an extractive answer quoting verbatim. The creative prompts ask for a short piece with nothing to copy from. TTFT is time to first streamed token; decode tok/s is (generated − 1) / decode time; concurrent numbers are 16-way with each prompt duplicated ×4 (64 requests) over the code and RAG sets. vLLM’s /metrics is scraped before and after every run so draft and acceptance counts and preemptions can be attributed to that run.

What I did not capture

Acceptance rate per workload. vLLM exposes drafted and accepted counts as global counters, so 51.3 percent is the blend across all 24 prompts; the per-prompt tok/s is the proxy for how it splits. The n-gram and FP8 configs were each run once sequentially, so the 24/24 control is the only repeated run. No GPU telemetry (power, clocks, temperature) was logged. The creative-writing divergence explanation above is an inference from where the divergences fall, not something I instrumented.

Reproduce it

# three servers in turn, same flags apart from the one under test
vllm serve Qwen/Qwen3-32B-AWQ --port 8199 --served-model-name qwen3-32b-awq \
  --gpu-memory-utilization 0.90 --max-model-len 16384 --max-num-seqs 32 \
  --seed 0 --no-enable-prefix-caching --no-enable-log-requests \
  [--kv-cache-dtype fp8 | --speculative-config '{"method":"ngram","num_speculative_tokens":5,"prompt_lookup_max":4,"prompt_lookup_min":2}']

python bench.py --port 8199 --model qwen3-32b-awq --label baseline_run1 --out results/baseline_run1.json
python bench.py --port 8199 --model qwen3-32b-awq --label baseline_conc16 --out results/baseline_conc16.json \
  --concurrency 16 --dup 4 --workloads code_edit,rag
python analyze.py | tee results/summary.md   # medians, speedups, acceptance, byte diffs

vLLM 0.20.0 · awq_marlin · RTX 5090, 32 GB · 16,384 max context · temperature 0, seed 0 · measured 2026-07-21