TESLA V100 32GB · PCIe POP!_OS LLAMA.CPP DFLASH2 + MTP

Cheap Volta,
60 tokens a second.

A used datacenter GPU in a desktop case will fight you: a driver that won't load, a phantom hang that isn't a hang, a heatsink with no fan, and a dozen flags that silently undo each other. Here is every wall we hit, why it happened, and the exact configuration that ended up fastest.

Recorded die temp · sustained load CUTOFF 80°C
Noctua · dense model Mining fan 23% · full power Mining fan 30% · MoE
Start here

The short version

If you already have a V100 seated and a working Linux box, this is the whole path to ~60 tok/s on a 27B dense model. Six steps, then one launch command.

Hardware prerequisite — order this first

The card's 8-pin power socket is not a standard PCIe 8-pin. You need an adapter to two PCIe 8-pin connections (the COMeap cable used here), fed from two separate PSU cables — plus real airflow, because the card has no fan. Details below.

STEP 01

Proprietary driver

Volta has no GSP support in the open kernel module. The open driver will simply not see the card.

sudo apt install nvidia-driver-580
STEP 02

Fix device order

Before anything else. Skipping this is the single biggest time sink in a mixed-GPU box.

export CUDA_DEVICE_ORDER=PCI_BUS_ID
STEP 03

Build dual-arch

gcc-10 for the whole build (CUDA 11.5 breaks on gcc-11). Arch 70 = V100, 86 = Ampere.

-DCMAKE_CUDA_ARCHITECTURES="70;86"
STEP 04

Kill ECC

Tesla cards run ECC on HBM2 by default — a real tax on every read. Needs one reboot, then it sticks forever.

sudo nvidia-smi -e 0
STEP 05

Full power, locked clocks

Don't cap a card doing speculative decoding. Lock the clock so it stops hunting through DVFS states.

sudo nvidia-smi -pm 1 -pl 250 && sudo nvidia-smi -lgc 1380
STEP 06

Real airflow

The card has no fan. A quiet fan is not enough — see the traces above. Use a high static pressure fan and a shroud.

+ an 80°C watchdog, always
The fastest config we found
# Qwen3.8-27B Q4_K_M + DFlash2 draft · V100 32GB · 60.8 tok/s on code
CUDA_DEVICE_ORDER=PCI_BUS_ID CUDA_VISIBLE_DEVICES=0 \
./build/bin/llama-server \
  -m   target-model-Q4_K_M.gguf \
  -md  dflash2-draft-model.gguf \
  -ngl 99 -ngld 99 \
  --spec-type draft-dflash --spec-draft-n-max 4 \
  -c 114688 -np 1 -fa on --jinja -b 512 -ub 256 \
  --reasoning on --reasoning-format deepseek --reasoning-effort low \
  --host 127.0.0.1 --port 8080
Why these numbers

114688 context (112K) leaves ~2.3 GB of VRAM headroom on a 32 GB card. We measured 147456 loading successfully with only ~1 GB spare — it fits, but that is too thin to trust once real generation starts allocating compute buffers. n-max 4 was our tested default; sweeps of 5 and 7 showed no clean win. reasoning-effort low because thinking tokens cost you roughly half your throughput — see the takeaways.

Findings

Six things we didn't expect

These are the results that changed how we configured the machine — the kind of thing that isn't in a README because nobody bothered to measure it.

A power cap is free — until suddenly it isn't

MoE: 150W ≡ 250W · Dense+spec: 150W costs 15–30%

On a sparse MoE model that never approached the power ceiling, capping the card at 150W changed throughput by literally nothing. On a dense model with speculative decoding, the same cap cost up to 30%. Never assume a cap is free — measure your workload.

ECC-off helps some methods far more than others

MTP +12.5% · DFlash2 +4.3%

Disabling ECC relieves a memory-bandwidth tax, so it disproportionately helps the lighter, more bandwidth-bound draft method. DFlash2's heavier batched verification is compute-bound and wants raw wattage instead. Two different bottlenecks, two different fixes.

Which spec-decode method "wins" flips with power state

150W: MTP leads · 250W + ECC off: DFlash2 leads

At a 150W cap MTP beat DFlash2 outright. Lift the cap and drop ECC and the ranking reverses completely. Any benchmark comparing these two without stating its power and ECC state is describing one point on a curve, not a verdict.

Reasoning tokens cost you half your speed

~55 tok/s → 24–32 tok/s while thinking

And it hits both methods almost identically, so it isn't a flaw in either. A draft model's whole value is predicting the target; reasoning traces are exploratory and self-correcting, so draft acceptance drops. Always state whether a tok/s figure includes thinking.

Manual placement flags silently disable auto-fit

→ OOM on device 0 with 40GB free elsewhere

Setting -ncmoe, -ot, or --tensor-split makes llama.cpp abandon its whole multi-GPU fitting plan and fall back to naive single-device placement. It logs one line about it and then OOMs. Removing the flag is the fix.

The KV cache is far cheaper than you'd budget for

8K → 128K context cost only ~7.5GB

On a low-KV-head GQA model, going from 8,192 to 131,072 tokens of context added about 7.5 GB. If you've been running 8K out of caution on a 32 GB card, you are probably leaving an enormous context window unused.

The long version

Everything, in order

The full build log — hardware, the failures worth knowing about, and the reasoning behind each setting. Written so you can skim to the part that's currently biting you.

What's in the box

The card is a PCIe Tesla V100 32GB (Volta, sm_70). One thing worth knowing straight away: it reports itself as Tesla PG500-216 in nvidia-smi and GV100GL [Tesla PG500-216] in lspci — not as "V100". Don't grep for the string "V100" in your scripts and expect a match.

It shared the box with an RTX 3080 10GB on a Gigabyte X470 AORUS ULTRA GAMING (BIOS F6, PCIe Gen 3) with a Ryzen 7 2700 and 48 GB of DDR4, running Pop!_OS 22.04. Two of those details matter more than they look: Pop!_OS boots with systemd-boot rather than GRUB, so every "add a kernel parameter" instruction you'll find online needs translating — and the CPU has 8 physical cores, which is exactly why -t 8 beat -t 16 when we tested thread counts.

01 · Before you power on

Power delivery

Buy this before the card arrives

The PCIe V100 has a single 8-pin power socket, and it is not a standard PCIe 8-pin. You cannot plug a normal PSU lead into it. You need an aftermarket adapter that converts it into two PCIe 8-pin connections.

The adapter used in this build:

the part you will forget to order
COMeap NVIDIA Graphics Card Power Adapter Cable
8-pin  ->  dual PCIe 8-pin (6+2)

https://www.amazon.com/dp/B07M9X68DS

Order it at the same time as the card. It's easy to overlook, and without it the build simply stops until it turns up.

Why it takes two cables

The card pulls up to 250 W. A single PCIe 8-pin is rated for roughly 150 W continuous. That gap is the entire reason the adapter has two inputs — and why both of them need to come from separate PSU cables, not one cable with a pigtail on the end, and not a motherboard-sourced auxiliary feed.

Two things go wrong otherwise. The mild one is voltage sag under load, which makes the card throttle defensively and produces baffling benchmark results — you change the power limit and nothing moves, because the card was never actually reaching that limit. The serious one is that sustained high current through a connector not rated for it is how connectors melt.

This build initially ran the card from a single motherboard-sourced pigtail before that was corrected. If you see unexplained throttling, or performance that refuses to respond to power-limit changes, check your power path before you start tuning software.

02 · Getting it detected

The driver

Volta is not supported by the open-source NVIDIA kernel module — it lacks the GSP firmware support that generation needs. If you're on the open driver, the card simply will not appear. There is no configuration that fixes this; you need the proprietary driver.

install + reboot
sudo apt install nvidia-driver-580   # or current proprietary
sudo reboot

# then confirm both cards are visible
nvidia-smi --query-gpu=index,name,pci.bus_id,memory.total --format=csv
03 · The one that cost us an afternoon

The hang that isn't a hang

If you read one section of this page, make it this one. It is the highest-value thing we learned and it is almost invisible from the symptoms.

Symptom

llama-server starts and then does nothing. No error, no progress, no output at all, for minutes. Every instinct says it has deadlocked.

Cause

nvidia-smi orders devices by PCI bus ID. CUDA, by default, does not. In a mixed-GPU machine those two orderings can disagree — so CUDA_VISIBLE_DEVICES=0 may quietly hand you a different card than the one you were looking at in nvidia-smi.

If your binary was compiled for only one architecture — say sm_70 for the V100 — and it lands on the RTX 3080 instead, CUDA has to JIT-compile PTX for the unexpected architecture at runtime. That is a one-time, multi-minute, completely silent operation. It is indistinguishable from a hang unless you already know to look for it.

Fix

Set CUDA_DEVICE_ORDER=PCI_BUS_ID so CUDA's indices match what nvidia-smi shows you. Put it in your shell profile and never think about it again.

Better fix

Build for both architectures — -DCMAKE_CUDA_ARCHITECTURES="70;86". Then a mismatch costs you nothing at all instead of a silent recompile. The build takes longer; it is worth it.

False leads, so you can skip them

We first suspected a regression in llama.cpp's HTTP layer and checked out an older tag. The hang persisted. We then suspected a compiler ABI mismatch and rebuilt the entire project with gcc-10. The hang persisted. Both were plausible, both were wrong, and both cost hours. Check device ordering first whenever a CUDA program appears to hang shortly after you add a second GPU to a machine.

04 · Compiling

Build toolchain

CUDA 11.5's headers do not compile against gcc-11 — you'll get template errors deep inside std::function. Use gcc-10, and use it for the entire build, not just as the CUDA host compiler.

llama.cpp · dual-arch CUDA build
sudo apt install gcc-10 g++-10

git clone https://github.com/ggml-org/llama.cpp.git
cd llama.cpp

cmake -B build \
  -DGGML_CUDA=ON \
  -DCMAKE_CUDA_ARCHITECTURES="70;86" \
  -DCMAKE_C_COMPILER=/usr/bin/gcc-10 \
  -DCMAKE_CXX_COMPILER=/usr/bin/g++-10 \
  -DCMAKE_CUDA_HOST_COMPILER=/usr/bin/g++-10 \
  -DCMAKE_BUILD_TYPE=Release

cmake --build build -j$(nproc)

Budget real time for this. A modern llama.cpp with flash-attention and all the template-instance kernels, compiled for two architectures, took 20–40+ minutes on a modest CPU. You can verify afterwards that both architectures actually made it into the binary:

verify
cuobjdump --list-elf build/bin/libggml-cuda.so | grep -oE "sm_[0-9]+" | sort -u
# expect: sm_70  sm_86
05 · Not optional

Thermal safety

A passively-cooled datacenter GPU has no fan of its own. It was designed for a server chassis with a wall of screaming 15,000 RPM fans behind it. Your desktop case is not that.

Run a watchdog every single time until you have proven your cooling under a sustained, real workload — not a 30-second benchmark. Ours killed the server three separate times during testing, each time correctly.

thermal watchdog · run alongside the server
#!/bin/bash
SAFE_MAX=80
while true; do
  T=$(nvidia-smi --query-gpu=temperature.gpu --format=csv,noheader,nounits -i 0)
  if [ -n "$T" ] && [ "$T" -ge "$SAFE_MAX" ]; then
    echo "!!! V100 HIT ${T}C — KILLING SERVER !!!"
    pkill -9 -f "build/bin/llama-server"
  fi
  sleep 5
done

Four things we got wrong building that

Don't use a rate-of-rise trigger

Our first version killed the server if temperature rose 8°C or more between polls. It fired immediately on a completely normal idle-to-load transition — 52°C to 61°C is just what happens when a GPU starts working. Use an absolute ceiling and nothing else.

Never let the watchdog exit after firing

A watchdog that exits after one kill leaves you completely unprotected on the next launch. This bit us twice in one afternoon — we relaunched the server and only noticed the missing protection when the card was already sitting at 80°C. Loop forever.

80°C is your margin, not the chip's limit

The V100's real throttle and shutdown thresholds are higher. A kill at 80°C means the watchdog worked, not that you damaged anything. Let it cool and rethink airflow.

Watch out for pkill matching itself

pgrep -af llama-server will happily match the shell wrapper of whatever diagnostic command you just typed, making a dead server look alive. Match a specific path like build/bin/llama-server and verify with an isolated call.

A hardware backstop

Set your motherboard's own fan curve to ramp hard past 80°C on whatever slot-adjacent sensor it exposes (often labelled something like "PCIEX16 slot temp"). That's a safety net that works even if your software watchdog isn't running.

But calibrate it against reality first. That sensor reads the board area near the slot, not the GPU die, and it runs noticeably cooler under load. A curve that waits for the slot to hit 60°C may not engage until the die is far hotter than you intended. Compare it against nvidia-smi before trusting the trigger point.

06 · Moving the air

Fan selection & control

We tested three fans through a 3D-printed shroud. Only one of them actually worked, and the result was not subtle.

FanSustained load resultVerdict
Quiet static-pressure case fan Insufficient even at 100% Fails
Noctua (quiet-optimised) Fine on short bursts (~60°C); hit 80°C in 3–4 min sustained Bursts only
High-static-pressure mining fan Flat 35–41°C at 30% duty on MoE workloads Works

The nuance worth carrying away: required fan speed depends enormously on workload type. The mining fan at 30% held a sparse MoE model at a flat 40°C indefinitely. That same fan at 15–23%, running a dense model with speculative decoding at full power, climbed to 80°C and tripped the watchdog — repeatedly. Sparse MoE inference leaves the GPU idle far more of the time than dense inference with a draft model does.

Check the fan's current draw against the header

High-output fans pull real current. Ours was labelled 12V / 1.85A against a 2A (24W) motherboard header — about 93% of its rating at full tilt. We ran it at 60% for margin. Read the label on the fan itself; online listings are frequently wrong.

Software fan control on Gigabyte boards

The in-kernel it87 driver didn't recognise this board's Super I/O chip:

dmesg
it87: Unsupported chip (DEVID=0x8686)

The fix is the out-of-tree fork, installed via DKMS so it survives kernel updates:

it87 · out-of-tree fork
git clone https://github.com/frankcrawford/it87.git
cd it87
sudo ./dkms-install.sh

If it then reports No such device despite installing cleanly, ACPI is reserving the Super I/O's ports. You need a kernel parameter — and here's where Pop!_OS differs from most guides you'll find, because it uses systemd-boot rather than GRUB:

Pop!_OS · systemd-boot, not GRUB
sudo kernelstub -a "acpi_enforce_resources=lax"
sudo reboot
Some headers simply aren't reachable from Linux

We swept every PWM channel it87 exposed and never found the board's dedicated PUMP header — it's wired through Gigabyte's own EC/WMI path. The mainlined gigabyte-wmi driver exposes temperature sensors read-only, not fan control. If a header won't respond, set it to a fixed speed in the BIOS and move on rather than burning an evening on it.

07 · The free performance

Power, ECC and clocks

Three settings, applied together, were worth roughly 30% additional throughput on a demanding workload versus a stock 150W ECC-on card.

ECC off

Tesla-class cards run ECC on their HBM2 by default. That is a genuine bandwidth and latency cost on every memory read, and for a homelab inference box the tradeoff rarely makes sense. Disabling it requires one reboot — and then it stays disabled permanently, because it lives in the GPU's non-volatile state rather than being reapplied at boot.

Power limit

Our card's range was 100–250W. Whether capping costs you anything is entirely workload-dependent, which was one of the more surprising results of the whole exercise: on sparse MoE inference, 150W and 250W were identical. On dense inference with a draft model, the cap cost 15–30%.

Clock lock

Find your card's actual maximum first rather than copying a number from a guide — ours topped out at 1380 MHz. Locking it stops the card hunting through DVFS states between requests. Small, but free.

all three
# find your card's real max graphics clock
nvidia-smi -i 0 -q -d SUPPORTED_CLOCKS | grep Graphics | head -1

sudo nvidia-smi -i 0 -pm 1      # persistence mode
sudo nvidia-smi -i 0 -e 0       # ECC off — needs one reboot, then permanent
sudo nvidia-smi -i 0 -pl 250    # full TDP
sudo nvidia-smi -i 0 -lgc 1380  # your card's max, not necessarily 1380

Making it survive reboots

Everything except ECC resets on reboot. A systemd unit fixes that — and note it targets the GPU by UUID rather than index, so it keeps working if you ever move cards between slots:

/etc/systemd/system/nvidia-v100-tune.service
# get your UUID first:
#   nvidia-smi -i 0 --query-gpu=uuid --format=csv,noheader

[Unit]
Description=Tesla V100 power limit + clock lock
After=multi-user.target nvidia-persistenced.service
Wants=nvidia-persistenced.service

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/nvidia-smi -i GPU-<your-uuid> -pm 1
ExecStart=/usr/bin/nvidia-smi -i GPU-<your-uuid> -pl 250
ExecStart=/usr/bin/nvidia-smi -i GPU-<your-uuid> -lgc 1380

[Install]
WantedBy=multi-user.target
enable + verify after reboot
sudo systemctl daemon-reload
sudo systemctl enable --now nvidia-v100-tune.service

# after a reboot, all four should already be correct:
nvidia-smi --query-gpu=power.limit,clocks.gr,ecc.mode.current,persistence_mode --format=csv
08 · Going fast

Speculative decoding

A small draft model proposes several tokens ahead; the full model verifies them in one batched pass. When the draft is right often enough, that's much cheaper than generating every token autoregressively.

DFlash2

DFlash2 landed in llama.cpp mainline on 27 Aug 2026 via PR #27342 — so there's no branch juggling required, just build current master. Confirm your binary has it before downloading gigabytes of weights:

confirm support
./build/bin/llama-server --help | grep spec-type
# you want "draft-dflash" in the list of allowed values
DFlash2 launch
CUDA_DEVICE_ORDER=PCI_BUS_ID CUDA_VISIBLE_DEVICES=0 \
./build/bin/llama-server \
  -m target.gguf -md dflash2-draft.gguf \
  -ngl 99 -ngld 99 \
  --spec-type draft-dflash --spec-draft-n-max 4 \
  -c 114688 -np 1 -fa on --jinja -b 512 -ub 256
Benign errors on every DFlash2 load

You will see these on a completely successful startup. They're part of DFlash2's internal memory-fitting probe. Wait for model loaded before concluding anything is wrong:

harmless — keep waiting
E llama_init_from_model: failed to initialize the context: dflash requires
  ctx_other to be set (this warning is normal during memory fitting)
W operator(): failed to measure the memory of the extra model, fitting without it

MTP

Multi-token prediction uses a small sidecar model, usually published alongside the main weights as mtp-<model>-<quant>.gguf:

MTP launch
./build/bin/llama-server \
  -m target.gguf -md mtp-sidecar.gguf \
  -ngl 99 -ngld 99 \
  --spec-type draft-mtp \
  -c 114688 -np 1 -fa on --jinja
Test both — the winner depends on your power state

Not all quants ship MTP tensors even when the model card implies they do. If you see context type MTP requested but model doesn't contain MTP layers, that quant simply lacks them; there's no flag that fixes it.

09 · Flags that fight each other

The auto-fit trap

Modern llama.cpp has an automatic memory-fitting system (-fit on, the default) that computes n_gpu_layers, tensor split, and MoE overrides together, as one plan, across all your GPUs. It is good at this.

It also refuses to run at all if you have manually set any of -ncmoe, -ot, --tensor-split, or -ngl alongside them — and then falls back to naive placement that can dump everything onto device 0 and OOM, even when your combined VRAM was more than sufficient.

what it looks like when this happens
W common_fit_params: failed to fit params to free device memory:
    model_params::tensor_split already set by user, abort
E ggml_backend_cuda_buffer_type_alloc_buffer: allocating 32277.02 MiB
    on device 0: cudaMalloc failed: out of memory
The fix is removing flags, not adding them

Drop your manual placement flags and let auto-fit do the whole job. We burned two OOM cycles trying to hand-tune a split that the built-in fitter solved correctly the moment we stopped interfering. Tune the fitter itself with -fitt (per-device margin) and -fitc (minimum context) instead.

When you do need manual control: oversized MoE

For a Mixture-of-Experts model too big for VRAM, -ncmoe N keeps the expert weights of the first N layers in system RAM. Tune N empirically — a cudaMalloc failed on the compute buffer means back off a couple of layers, not a dozen.

For architectures with enormous n-gram or hash embedding tables (tens of GB on their own), pin that specific tensor to CPU and let it stay memory-mapped from SSD:

SSD-backed embedding offload
-ncmoe 46 -ot "ple_ngram_embd=CPU"
# --tensor-read-lazy defaults to auto: tensors >4GB are read lazily from disk

This will run models far above your nominal VRAM+RAM budget, at a real throughput cost. It's an experimentation tool, not a production configuration — and remember it's exactly the manual override that disables auto-fit, so you're tuning by hand from that point on.

10 · Thinking models

Reasoning gotchas

Four separate ways we produced completely empty responses before understanding what these flags actually do.

Thinking doesn't show up in the UI

--reasoning-format defaults to auto, which for many chat templates resolves to none — leaving <think> tags raw inside content instead of split into reasoning_content. Pass --reasoning-format deepseek explicitly.

reasoning-effort values are per-template

They are not standardised. One model we tested accepted only xhigh, medium and low — passing high threw a Jinja exception straight out of the chat template. Don't assume the OpenAI-style ladder applies.

Per-request reasoning_budget may be silently ignored

On an experimental architecture branch we set reasoning_budget to 0, to a number, and to "high" — all three ignored, every time, burning the entire token budget on unbounded thinking and returning empty content. The server-level --reasoning off flag worked immediately, because it's a different code path: it changes chat-template application rather than a runtime parameter.

Empty content with finish_reason: length

Check reasoning_content — the model almost certainly spent the entire budget thinking and had nothing left for the answer. Complex prompts on heavy reasoning models wanted 15,000–20,000 max_tokens to complete. And note that the server's -c ceiling always beats a larger max_tokens in the request: if total_tokens exactly equals your launch -c, that's your real limit.

Serving it to your other devices

Bind to your Tailscale interface IP rather than 0.0.0.0. The server is then reachable from your phone and laptop, and from nothing else — not your LAN, not the internet. No port forwarding, no firewall rules.

tailnet-only binding
tailscale ip -4          # e.g. 100.x.y.z
./build/bin/llama-server ... --host 100.x.y.z --port 8080

There's no API key by default — fine inside a private tailnet, worth adding before you expose it any further.

11 · Measured

The numbers

Qwen3.8-27B, Q4_K_M target, V100 alone, identical prompts throughout. Single-run samples — treat small differences as noise.

Power & ECC state

Configuration MTP general MTP code DFlash2 general DFlash2 code
150 W · ECC on48.340.5
180 W · ECC off · locked54.352.842.354.5
250 W · ECC on56.255.152.258.1
250 W · ECC off · locked52.857.560.360.8

All figures tok/s. The bottom row is the configuration in the recipe. Note how DFlash2 climbs steadily as constraints lift while MTP largely plateaus — that divergence is the whole story.

Micro-tuning · 250 W, ECC on, clocks locked

ChangeBest resultWorth it?
MTP with -b 2048 -ub 51258.8 generalCheap win
DFlash2 + Unsloth UD-Q4_K_XL target61.6 codeBest overall
q8_0 KV cache~equalNeutral
--spec-draft-n-max sweeps (2/3/5/7)no clean trendNoisy

Reasoning penalty

MethodNon-reasoningWhile thinkingDrop
MTP48–58 tok/s27–35 tok/s~45%
DFlash250–60 tok/s24–32 tok/s~52%

Context ceiling · 32 GB card, 18.97 GB target + 1.14 GB draft

ContextVRAM usedHeadroomVerdict
8,192~23.0 GB~9.8 GBComfortable
65,53626.6 GB~6.2 GBComfortable
114,68829.7 GB~2.3 GBRecommended
131,07230.7 GB~1.3 GBTight
147,45631.8 GB~1.0 GBToo thin

Loading successfully is not the same as running safely — the last two rows load fine but leave little room for compute buffers once generation starts allocating.