host RunPod B200 · SM 10.0 · 178 GiB HBM3e engine lmsysorg/sglang:latest — 0.5.17 target Qwen/Qwen3.8-27B-FP8 speculation in-checkpoint MTP · NEXTN k=3 backend trtllm_mha · eager measured on the pod, against 127.0.0.1
Measured 2026-08-21 · on the pod, never over the tailnet

What a rented B200 actually does with Qwen3.8-27B

One command rents the GPU and joins it to a tailnet; one gives it back. In between: +82% from a draft head that ships inside the checkpoint — and a 3.6× cut to concurrency that no throughput table shows you.

Single stream
248 tok/s
FP8 + MTP k=3
29.0 GB of weights at ~8 TB/s
Aggregate @ 64 streams
4,279 tok/s
but only 48 slots run
the other 16 are queued
Concurrency, with speculation
48 / 175
speculation costs 3.6× the slots
and 4.4× the KV pool
Warm spin-up to serving
2:55
cold, empty volume: ~45 min
$6.79/hr, billed by the second
Read the headline this way Speculation is a mode switch, not a tuning knob

At concurrency 1 it is worth +82%. At concurrency 64 it loses, 4,242 against 5,694 tok/s, because it has taken the card from 175 concurrent slots down to 48 and the surplus requests queue. Both numbers are real and they point opposite ways. Pick the mode for your workload — SPEC_K=0 reverses it — rather than inheriting a default and a benchmark that disagree.

The sweep that set k=3

one variable: draft steps

One pod, one checkpoint, one flag set. SPEC_K is the number of draft steps; SGLang wants num-draft-tokens = steps + 1, because it counts the always-committed target token. Everything else is held.

Single-stream decode throughput by draft budget
tok/s at N=1 · higher is better · B200, trtllm_mha, eager, after a warmup request
What this
does not show
This arm is NVFP4, not the shipped FP8 checkpoint. The sweep exists to choose k, and it does that cleanly because k is the only thing that moves. The shipped configuration inherits the choice of k from it — not the tok/s. FP8's own k=3 figure is 248.3, and FP8 with speculation off was never measured.

k=4 is the point of the chart. An optimum you have not seen stop is just the last point you measured. Running one budget past the peak is what turns "k=3 was the best we tried" into "k=3 is the optimum, and here is why it stops".

The mechanism is in the rate, not the length

why k=4 regresses

Acceptance length rises the whole way — 1.975, 2.95, 3.875, 4.625. Read only that and k=4 looks like the winner. The per-token acceptance rate is what actually turns over.

Per-token acceptance rate against draft budget
accepted draft tokens ÷ draft budget · flat through k=2, then it decays
Convention
warning
SGLang's accept_length includes the always-committed target token, so its floor is 1. vLLM's does not, so its floor is 0. They differ by exactly one. The rate plotted here divides accepted draft tokens by the budget, which removes that offset — but establish the floor before comparing any published acceptance figure to this one.

Once per-token acceptance starts to fall, each further draft position costs more verify compute than it returns. That is the whole story, and it is invisible in the length column.

Concurrency reverses the answer

the crossover is the finding

Every speculative arm beats the baseline at low concurrency and loses at high concurrency. Not because drafting gets slower — because speculation took the card's concurrency slots.

Aggregate throughput against offered concurrency
tok/s, 300-token completions, measured on the pod · one axis, one unit
What this
does not show
The two speculative arms are different checkpoints and are not a controlled comparison of each other. They trade wins across the sweep with no monotonic trend, which is what run-to-run noise looks like. What the chart does show cleanly is the crossover against the baseline, which both arms share.

What speculation costs

the part a tok/s table hides

The crossover above has a cause, and the engine prints it at startup. Turning speculation on takes the KV pool from 2.34 million tokens to about half a million, and max_running_requests from 175 to 48 — leaving roughly 80 GB of a 178 GB card unclaimed.

Concurrency slots and KV pool, with and without speculation
from the engine's own startup lines · FP8 and NVFP4 land at the same slot count
Attribution
FP8 and NVFP4 both land at exactly 48. That is how we know this is speculation's doing and not the checkpoint's — an attribution this study got wrong once before catching it in the captured engine facts. Forcing --max-mamba-cache-size 900 to reclaim the idle memory made it worse: KV fell 506k → 287k with concurrency unchanged.

The measurement that was wrong by 19×

negative result

The pod is reachable only over a tailnet, which is the point of the design. It is also the fastest way to publish a completely wrong number.

The same server, measured two ways
aggregate tok/s at N=64 · identical engine, identical request set
Why it is
dangerous
Nothing in the output says "network-bound". It is a plausible throughput figure with a plausible 70.4-second wall time. The DERP relay was the bottleneck, not the GPU. benchmark.sh therefore copies the probe to the pod and runs it against 127.0.0.1; LOCAL=1 opts out and stamps the output DO NOT QUOTE AGGREGATE.
The related trap The first request reads 3.7× slow

Lazy CUDA-graph and kernel warmup — 24.1 against 88.8 tok/s on the same server. N=1 is the column people quote, so an unwarmed run publishes a warmup measurement wearing a throughput label. Every probe here sends a warmup request first.

The trap that never stops lmsysorg/sglang:latest moves

Every number on this page is sglang 0.5.17. A pod rented later is a different engine, and on a related host an engine-layer bump alone moved prose throughput ~9%. Pin the image to a digest, or compare only against your own baseline.

Quantization buys 1.6×, not the 2.25× the bytes predict

one pod · one image · two checkpoints

A B200 can hold Qwen3.8-27B at BF16 and a usable KV pool, which makes the control cheap: one pod, one flag set, two --model-path values. Most boxes cannot run this comparison at all.

BF16 against a 4-bit build, no speculation on either arm
armN=1N=8N=16N=32N=64weightsKV poolslots
BF1693.6606.51,279.21,961.83,333.051.05 GB1,850,304138
NVFP4152.51,038.11,922.83,269.85,306.122.67 GB2,339,968175
ratio1.63×1.71×1.50×1.67×1.59×0.44×1.26×1.27×

The missing 0.6× is the state cache. Decode reads 51.05 GB of BF16 weights against 22.67 GB quantized, which alone predicts 2.25×. It does not arrive, because the hybrid GDN state work is not quantized: the mamba cache is 50.78 GB on BF16 and 64.21 GB quantized, and that traffic is BF16 in both arms. The ratio is flat across concurrency, so this is not a batch effect — roughly a third of decode traffic is untouched by the weight format, which caps the return on going lower-precision on this architecture.

What to rent, and what it costs

measured, not list price
Best configuration per part — not a controlled comparison; the allowed attention backends differ
B200RTX PRO 6000 Blackwell SE
price$6.79/hr$2.09/hr
VRAM178 GiB96 GiB
compute capabilitySM 10.0SM 12.0
attention backendtrtllm_mhaflashinfer
KV pool2,339,9681,016,563
max_running_requests17576
N=1152.542.9
N=64 aggregate5,3062,121
cost per 1k output tok at N=64$0.00036$0.00027
Rule of thumb 2.5× the throughput, 25% worse per token

Rent the B200 when latency or concurrency binds. Rent the cheaper part when total spend binds. GPU_TYPES pins either — and the capacity fallback will hand you the cheap one anyway on a dry day, still named b200. Read the hourly rate in ./status.sh before assuming which you got.

Answered with the pod's own metrics Serverless is not cheaper here

976 requests over 1.64 h at 34.9% duty cycle — under the 78.6% break-even, so the arithmetic says serverless wins. It does not. The mean gap between requests is 4.1 seconds; no flex worker scales to zero on that, so it bills continuously at a rate 27% above the pod. Serverless needs idle stretches longer than the ~3-minute cold start.

The bigger lever was never the billing model. That same workload ran at concurrency 1 on a card sized for 175, yielding 39 output tok/s averaged over wall-clock from hardware that does 5,306. Pushing the client to 8–16 in flight finishes the queue several times sooner at the same hourly rate — and saves far more than the $1.85/hr serverless was ever going to. Note the interaction with everything above: at SPEC_K=3 the ceiling is 48, not 175.

What is not measured

read this before citing anything

The default checkpoint is Qwen's own FP8. Three of the four reasons for that are evidence. The fourth — the one people will want to quote — is not.

Evidence The weight saving buys nothing that binds

With speculation on, ~78 GB of the card is already unclaimed and FP8 and NVFP4 land at the same 48 slots. The usual argument for 4-bit does not apply on 178 GiB.

Evidence "NVFP4" is a label, not a specification

Builds carrying it differ by up to 52% on prose because they disagree about which modules they quantize. One publishes a recipe that does not reproduce its own shipped config. Read quantization_config, always.

Evidence Qwen's FP8 declares its MTP head

mtp is in its ignore list. Checkpoints that omit it make the loader die on a fused name present in neither targets nor ignore. The bootstrap detects and works around that, but it is one more thing to be wrong on a host billed by the second.

A prior, not a result That FP8 is closer to BF16 than 4-bit

Untested here. FP8 scores 10/10 on the ten-task probe against 4-bit's 9/10 — but that same probe scored BF16 and 4-bit identically while their greedy prose diverged at word 33 and shared 24.4% of its words. That is noise, not evidence.

Do not cite this work as evidence that FP8 is more faithful than NVFP4. The instrument that would settle it — KL divergence and top-1 agreement against a BF16 reference, three arms on one pod — ships in the repo as eval/kl-eval.py and has not been run. It would take about 40 minutes and $5.

Reproducing any of it

four commands

Everything above came off the committed scripts. Nothing was measured by hand.

cp .env.example .env       # RunPod key, HF token, EPHEMERAL Tailscale key
./check-requirements.sh    # runs entirely before anything bills
./up.sh                    # rent, join the tailnet, serve
./benchmark.sh             # the sweep — copied to the pod and run there
./down.sh                  # and prove nothing is still billing

Raw JSON for every figure on this page is in results/, and each file carries the engine's own startup lines so the configuration behind a number stays checkable after the pod is gone. The reasoning lives in the docs/ bundle.