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.
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.
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
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
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"
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
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
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
# 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
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.
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.
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.
Power delivery
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:
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.
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.
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
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.
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.
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.
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.
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:
cuobjdump --list-elf build/bin/libggml-cuda.so | grep -oE "sm_[0-9]+" | sort -u # expect: sm_70 sm_86
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.
#!/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
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.
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.
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.
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.
Fan selection & control
We tested three fans through a 3D-printed shroud. Only one of them actually worked, and the result was not subtle.
| Fan | Sustained load result | Verdict |
|---|---|---|
| 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.
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:
it87: Unsupported chip (DEVID=0x8686)
The fix is the out-of-tree fork, installed via DKMS so it survives kernel updates:
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:
sudo kernelstub -a "acpi_enforce_resources=lax"
sudo reboot
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.
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.
# 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:
# 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
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
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:
./build/bin/llama-server --help | grep spec-type
# you want "draft-dflash" in the list of allowed values
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
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:
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:
./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
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.
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.
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
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:
-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.
Reasoning gotchas
Four separate ways we produced completely empty responses before understanding what these flags actually do.
--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.
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.
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.
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.
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.
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 on | 48.3 | — | 40.5 | — |
| 180 W · ECC off · locked | 54.3 | 52.8 | 42.3 | 54.5 |
| 250 W · ECC on | 56.2 | 55.1 | 52.2 | 58.1 |
| 250 W · ECC off · locked | 52.8 | 57.5 | 60.3 | 60.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
| Change | Best result | Worth it? |
|---|---|---|
MTP with -b 2048 -ub 512 | 58.8 general | Cheap win |
DFlash2 + Unsloth UD-Q4_K_XL target | 61.6 code | Best overall |
q8_0 KV cache | ~equal | Neutral |
--spec-draft-n-max sweeps (2/3/5/7) | no clean trend | Noisy |
Reasoning penalty
| Method | Non-reasoning | While thinking | Drop |
|---|---|---|---|
| MTP | 48–58 tok/s | 27–35 tok/s | ~45% |
| DFlash2 | 50–60 tok/s | 24–32 tok/s | ~52% |
Context ceiling · 32 GB card, 18.97 GB target + 1.14 GB draft
| Context | VRAM used | Headroom | Verdict |
|---|---|---|---|
| 8,192 | ~23.0 GB | ~9.8 GB | Comfortable |
| 65,536 | 26.6 GB | ~6.2 GB | Comfortable |
| 114,688 | 29.7 GB | ~2.3 GB | Recommended |
| 131,072 | 30.7 GB | ~1.3 GB | Tight |
| 147,456 | 31.8 GB | ~1.0 GB | Too 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.