The Tesla V100 came out in 2017. The SXM2 version - a socketed module rather than a PCIe card - used to be cheap because it doesn't plug into anything a normal PC has, yet each one still has 16 GB of very fast memory. That has changed over the last few months: with capable models like Qwen3.8 coming out, demand has picked up, and the 32 GB version has gone up to around AUD 1,000. I got in early and picked up my 16 GB ones when they were going for as little as AUD 80 each. I wanted four of them running Qwen3.8-27B as my own coding assistant, for Qwen Code, with no code leaving the house. This post is about one thing: how fast I could make it, and what actually moved the number.
The short version: 31.8 → 74.5 tok/s from one small kernel port, most other ideas didn't help, and speculative decoding looked great on benchmarks but made my real sessions slower.
The Rig
| Part | What it is |
|---|---|
| GPUs | 4 × Tesla V100-SXM2-16GB (64 GB total) |
| GPU carriers | SXM2 carrier boards behind a PLX PEX 8749 PCIe switch card |
| CPU / board | AMD Threadripper 1920X on an ASUS PRIME X399-A, 32 GB RAM |
| Model | Qwen3.8-27B, QUASAR NVFP4 (4-bit, quantization-aware trained) |
| Serving | 1Cat-vLLM, a vLLM fork for V100s |
The model needs ~54 GB at 16-bit, so it's stored at 4 bits and split across all four GPUs. The wiring between them decides almost every number in this post:
flowchart LR
subgraph A["Island A"]
GPU0 <-- "NVLink ~145 GB/s" --> GPU1
end
subgraph B["Island B"]
GPU2 <-- "NVLink ~145 GB/s" --> GPU3
end
A <-. "PCIe ~6.4 GB/s" .-> B
Two fast NVLink pairs, joined by a PCIe link about twenty times slower. I call them the two islands.
Step 1: Two GPUs (36–51 tok/s)
On an old Intel board with only two V100s, vLLM ran at ~51 tok/s but could only fit 8K tokens of context, useless for an agent. llama.cpp managed ~36–42 tok/s with the full 262K context, so it served for the first couple of weeks.
Step 2: Four GPUs Online
Four GPUs needed a bigger platform, so I moved to a Threadripper. Getting the V100s to actually power on there was a saga of its own (see Pitfalls); the short answer is that only one slot on the board can wake them.
Step 3: The Slow Bridge (31.8 → 74.5 tok/s)
With tensor parallelism across four GPUs (TP4), every layer ends with an all-reduce: the four GPUs add up their partial results. That happens ~128 times per token. My first TP4 number was 31.8 tok/s, slower than two GPUs:
| Configuration | tok/s |
|---|---|
| TP2 on an NVLink pair | 45.1 |
| TP2 on a PCIe-only pair | 45.9 |
| TP4 on all four | 31.8 |
PCIe-only matching NVLink showed bandwidth wasn't the problem. vLLM only uses its fast all-reduce when every GPU is NVLinked to every other; two islands fail that check, so everything fell back to generic NCCL.
The fork already had a two-level all-reduce for 8 GPUs in two groups of four. My layout is the same shape with groups of two, so the port was a handful of 4 → 2 edits behind an opt-in flag. After an exact-match test against NCCL (100 seeds, 2,000 iterations, zero difference), and narrowing it to the message sizes where it actually wins:
| Before | After | |
|---|---|---|
| Decode, TP4 | 31.8 tok/s | 74.5 tok/s (2.3×) |
| DFlash2 speculative, 1 stream | 49.1 tok/s | 64.6–71.4 tok/s |
| DFlash2, 2 streams combined | – | 85–92 tok/s |
That matches what the fork's authors get on a machine with all four GPUs NVLinked. It's the biggest win of the project.
Step 4: Where the Time Goes
Writing runs at ~70 tok/s, about 14 ms per token:
| Part of one token | Time |
|---|---|
| Unpacking and multiplying the 4-bit weights | 8.9 ms |
| All-reduces between GPUs | 2.4 ms |
| Attention and small kernels | ~1.6 ms |
| GPU idle | ~1.0 ms |
Reading the prompt is the slow part: a 32K-token prompt takes ~36 s before the first word. About two-thirds of that is the all-reduce: during prefill each one carries 82 MB and takes 42.8 ms to cross the islands. Faster attention code changed nothing (36.4 s), because the bottleneck is the wire.
Prefix caching fixes the common case for agents: resending a 14K-token context dropped from 14.3 s to 0.19 s.
Step 5: One Copy or Two?
If crossing the bridge is expensive, you can avoid it by running one model copy per island (TP2 × 2):
| Scenario | TP4 | TP2 × 2 |
|---|---|---|
| 32K prompt, time to first word | 36.1 s | 22.9 s |
| Writing speed, one conversation | 70.1 tok/s | 51.5 tok/s |
| Two agents at once, per session | 245 s | 161 s |
| Max context | 262K | ~98K |
TP4 wins for one agent with full context; TP2 × 2 wins for parallel agents. I kept TP4.
Step 6: Speculative Decoding - Great on Paper
The checkpoint ships an MTP head that guesses the next few tokens; the main model checks them all in one pass. On my coding benchmark:
| Coding prompts | Greedy | Temp 0.6 |
|---|---|---|
| No speculation | 70.2 tok/s | 67.5 tok/s |
| MTP, 2 guesses | 80.4 | 71.8 |
| MTP, 4 guesses | 96.3 | 84.8 |
On real sessions (long reasoning, tool calls, ~20K context) it was slower:
| Real queries | tok/s |
|---|---|
| MTP, 4 guesses | 43–57 |
| MTP, 2 guesses | ~53 |
| MTP off | 59–60 |
Coding answers accepted ~3.9 tokens per step; my real work only 2.0–2.7. A profile showed why that isn't enough here: a speculative step takes 36.4 ms against 13.9 ms, and the GPU sits idle for 14 ms of it waiting on Python bookkeeping between the draft and verify passes. The weight math itself costs the same for 3 tokens as for 1. MTP is off.
Step 7: Everything Else I Tried
| Idea | Result |
|---|---|
| 8-bit (FP8) checkpoint | 58 tok/s vs 71 for 4-bit. V100 has no low-precision math, so fewer bytes wins. |
| RadixArk NVFP4 checkpoint | 66.7 tok/s, slower than QUASAR |
| AWQ 4-bit checkpoints | no gain expected: same size or larger, and not quantization-aware trained |
| Third-party Volta kernels | never reached: the fork's own kernels already handle this checkpoint |
| llama.cpp TP4 + MTP | 56.5 plain / 77.1 with MTP: a tie, with less validated tooling |
| DFlash2 batched-verify flag | 5× faster kernel, halved real throughput (51.1 → 25.7) |
| "Push" all-reduce | slower than NCCL at decode size (29.6 vs 23.9 µs) |
| FP8 E4M3 KV cache | 50.3 vs 63.0 tok/s after a 32K prompt |
| Fast prefill route | ran, no gain (36.4 s): the bottleneck is the link |
| Bigger KV cache (upstream change) | +23–29% cache, crashed under real agent use; reverted |
| Newer model runner for MTP | slower (72.5 vs 80.4 tok/s greedy) |
Where It Landed
| Setup | Decode speed |
|---|---|
| 2 × V100, llama.cpp, 262K context | ~36–42 tok/s |
| 2 × V100, vLLM, 8K context | ~51 tok/s |
| 4 × V100, TP4, stock all-reduce | 31.8 tok/s |
| 4 × V100, TP4, hierarchical all-reduce | 70–74.5 tok/s (~60 at 20–30K context) |
| + MTP, coding benchmark | 96.3 tok/s |
| + MTP, real sessions | 43–57 tok/s |
| TP2 × 2 | 51.5 tok/s each |
Production: TP4, hierarchical all-reduce, QUASAR NVFP4, FP8 E5M2 KV cache, prefix caching on, MTP off, full 262K context with ~767K tokens of cache. Cold prompts read at ~900 tok/s; repeated context is near-instant.
Pitfalls
- SXM2 GPUs need an SMBus command to power on. They show up in
lspcibut report "fallen off the bus" until the carrier gets a command on the slot's SMBus pins. On the X399-A only one slot's SMBus reaches the BIOS. - That same SMBus reads the RAM. The BIOS reads each stick's ID chip over it during memory training, while the carrier is waking up. Four sticks won't train with the card installed; I'm on two.
- Manual power-on only half works. Booting with the carriers off, then switching them on and rescanning PCIe, brought up 64 GB and powered GPUs - but the driver failed and the RAM corrupted under load.
- First-gen Threadripper plus four non-ECC DIMMs shows instability as random crashes, not logged errors.
- The template default disabled reasoning. The fork's server defaults to
enable_thinking: false; set it explicitly. - Qwen Code gates vision in the client. Add
"capabilities": {"vision": true}to the model entry; the server supported images all along. - Short tests miss long-session bugs. The bigger-KV change passed every short test and crashed within 23 minutes of real use.
- GPUs can be "up" but not ready. Put a real CUDA smoke test in front of the service.
- Benchmark your real workload. MTP was +37% on coding prompts and slower on real sessions.
- A faster kernel isn't a faster server. Measure end to end.
- Correctness before speed. A broken all-reduce can look fast while corrupting output; exact-match against NCCL first.
- Check upstream claims at the source. Read the actual diff, not a summary.
Recommendations
- Buy a server platform. EPYC 7002/7003 on an ASRock Rack ROMED8-2T or Supermicro H12SSL: 128 lanes, ECC RAM, proper slot SMBus. Skip first-gen Zen.
- Better: an SXM2 server with every GPU on NVLink (Dell C4140, Supermicro 4029GP). The two-island bridge is what caps prompt reading and speculative decoding here.
- Use a quantization-aware 4-bit checkpoint. On a V100, fewer bytes is the speed.
- Fix the all-reduce first if your GPUs aren't fully connected; it can silently cost half your speed.
- TP4 for one agent, TP2 per NVLink pair for several. Keep prefix caching on.
- Measure speculative decoding on real sessions before leaving it on.
Next: an all-reduce that sums within each NVLink pair before crossing the bridge, to cut prompt-reading time, and a mixture-of-experts model for fast tool-calling work.