aksh.me
← field notes

Four V100s, Two Islands: Six Weeks Running a 27B Model on 2017 GPUs

Getting Qwen3.8-27B from 31.8 to 74.5 tok/s on four retired Tesla V100s - where the time actually goes, what worked, what didn't, and why speculative decoding won on benchmarks and lost on real work.

hardwarellmgpulinux

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.

A bare Tesla V100 SXM2 module held in one hand
A Tesla V100 SXM2 module. No PCIe connector and no fan: it expects a server board to plug into.

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
A carrier board holding two V100 modules, with blue SFF-8654 cables attached on the left
One carrier board holding two V100s. The SFF-8654 cables run to the PLX switch card in the PC.

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.

A carrier board with two V100 modules resting on foam and bubble wrap outdoors Two carrier boards with four large tower heatsinks installed, cables attached
A carrier fresh out of the box, and both carriers with heatsinks: four V100s.

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.

ASUS PRIME X399-A motherboard with a Threadripper installed in an open steel frame next to a power supply Two stacked frame tiers, each holding a carrier with two heatsinks and its own power supply
The X399 board in an open frame, and the carriers in their own tiers below it.

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.

Three-tier frame with an AIO radiator on top and pairs of fans in front of each GPU tier
A pair of fans in front of each GPU tier, blowing straight through the heatsinks.

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

The finished three-tier rig from the front, with blue SFF-8654 cables and large fans on each tier The finished rig from an angle, showing the radiator on top and fans on each tier
The finished rig: motherboard and radiator on top, one carrier per tier below.
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 lspci but 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.
Close-up of a carrier board corner with a small power switch and four SFF-8654 cable ports beside a heatsink
The carrier's power switch and, beside it, the AT/MT switch that picks automatic or manual power-on.

Recommendations

  1. 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.
  2. 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.
  3. Use a quantization-aware 4-bit checkpoint. On a V100, fewer bytes is the speed.
  4. Fix the all-reduce first if your GPUs aren't fully connected; it can silently cost half your speed.
  5. TP4 for one agent, TP2 per NVLink pair for several. Keep prefix caching on.
  6. 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.