Hardware setup

Host tuning

You built the recipe exactly. Every hash matched. Your B70 still decodes slower than the number on the page. This guide explains why the same recipe runs at different speeds on different hosts, when graph capture gets the speed back, how to probe your own box in five minutes, and which host fixes our lab actually needed.

The short version

  • At one request, a B70 recipe spends much of every decode step waiting for the CPU to submit the next kernel. A slower submission path means a slower GPU, even with identical cards and software.
  • Our published Qwen3.8 FP8 TP2 recipe ran 1.8x slower on a second lab host with the same two-card shape. Turning XPU graph capture on recovered about 95 percent of the published speed there. On the fast host, graph-on was 1.1 percent slower, so the recipe ships graph-off. Lab-measured
  • Graph replay is a workaround for slow submission. It is not a faster way to run the model.
  • Some host fixes changed speed. Others fixed crashes and freezes. Several changed nothing. This page says which is which.

Why the same recipe runs at different speeds on different hosts

During decode, the runtime launches a long list of small GPU kernels for every token. Our shape profile of the Qwen3.8 FP8 TP2 MTP1 stack counted 2,080 GEMMs and 1,056 all-reduces per rank across eight decode iterations, so roughly 130 two-card all-reduces and 260 GEMMs per step, plus norms, attention, and sampling. R61 shape profile

Each of those launches costs CPU time before the GPU sees it. If the GPU finishes a kernel before the CPU has queued the next one, the GPU idles. The runtime also waits on the host at points (synchronize, all-reduce, token readback). That is why two boxes with the same cards, image, model, and weights can disagree on tok/s.

The lab measured this directly with one probe run on both of its hosts inside the same container image. Lab-measured

MeasurePublishing host (EPYC 9015, PCIe Gen5, two B70s)Replay host (Threadripper PRO 5955WX, PCIe Gen4, four B70s)
Async kernel launch (tiny add)3.1 µs5.2–6.2 µs
Launch + synchronize26 µs30–33 µs
Native rms_norm at M=241.5 µs134 µs
Two-card BF16 all-reduce, [1,5120]13 µs48–51 µs
Pure-Python single-thread loop0.132 s0.169 s

Sources: four-B70 host replay note and the publishing host's probe data file (3.13 µs launch, 25.98 µs launch+sync, 41.47 µs rms_norm). The replay host's launch figures were read twice, 5.18 and 6.2 µs, and its launch+sync twice, 30.2 and 33.0 µs.

The CPU alone is about 28 percent slower single-thread. The GPU submission path is 1.65x slower per launch, launch-heavy ops are 3.2x slower, and the two-card collective is about 4x slower. With about 130 exposed host round trips per token, that adds up to the whole decode gap:

Profile (graph off, one request)Publishing hostReplay hostRatio
MTP1 (draft-only INT4 head, FP16 verifier)54.63 tok/s28.94 tok/s1.89x
MTP0 (no speculation)33.31 tok/s18.65 tok/s1.79x

All six prompt classes on the replay host sat within 27.5–30.6 tok/s on MTP1, so the factor is uniform and host-wide, not a per-prompt effect. Replay note

Where the GPU starts waiting for the CPU

On the publishing host, a bounded graph-off trace put about 50–51 percent of device-kernel time in the TP all-reduce and 45 percent in GEMM on both ranks. R59 profile The all-reduce is a host-waited call, so every one of its roughly 130 occurrences per step is a place where the GPU can stall on the CPU. When per-launch and per-collective cost rises, those stalls grow and decode falls with them. That is the mechanism, and it is why a slower host hurts one-request decode far more than it hurts prefill or batched serving.

Graph on vs graph off

vLLM can record the kernel launch sequence for a decode step once, per captured batch size, and then replay the whole recording as a single submission. On CUDA this is a CUDA graph; on Intel XPU the same mechanism is exposed as an XPU graph (VLLM_XPU_ENABLE_XPU_GRAPH=1). The knobs live in the compilation config:

  • cudagraph_mode: PIECEWISE captures the parts of the step between non-capturable ops; FULL_DECODE_ONLY captures the whole decode step and leaves prefill eager.
  • cudagraph_capture_sizes and max_cudagraph_capture_size: which batch sizes get a recording. Anything larger runs eagerly.

Capture removes the per-launch CPU cost that the section above describes. So the question is simply: how much of your decode step is that cost?

On a fast host, graph capture gained nothing

R58 turned on a size-one XPU graph on the publishing host's deterministic MTP1 profile. Outputs stayed 12/12 exact against the MTP0 oracle. Decode went from 51.81 to 51.23 tok/s, a 1.1 percent loss, which missed the preregistered 99 percent non-inferiority floor by 0.06 tok/s. Graph-off stayed selected. Lab-measured R58 note · CURRENT.md

On a slow host, it gave 90–95 percent back

Replay host armtok/svs graph-off on that hostvs published
MTP0, PIECEWISE, sizes [1]31.151.67x94%
MTP0, FULL_DECODE_ONLY, sizes [1]31.531.69x95%
MTP1, sizes [1] only28.561.0x (no gain)52%
MTP1, PIECEWISE, sizes [1,2]51.321.77x94%
MTP1, FULL_DECODE_ONLY, sizes [1,2]52.051.80x95%

The MTP1 row with only size [1] matters. With one captured size, the single-row target step is recorded but the two-row verification step still runs eagerly, so the draft's launch cost cancels its token gain. You must capture every batch shape the decode step actually uses. Across 2K–32K real-content depth, the graph-on replay host held 90–94 percent of the published MTP1 curve and 93–95 percent of the MTP0 curve, with every MTP1 array matching its own MTP0 oracle. Replay note

Graph replay is a workaround, not a speedup

Graph capture does not make any kernel faster. It hides CPU submission cost. If your host already submits fast enough, there is nothing to hide and you pay the trade-offs for no gain. That is exactly what R58 measured. Treat graph-on as the setting for hosts whose probe numbers (next section) are high.

What graph capture costs you

  • Fixed capture sizes. Only recorded batch sizes replay. Under concurrency, a batch that does not match a captured size is padded to the next one or bypasses the graph and runs eagerly.
  • Startup time and memory. Every captured size is a recording that takes time to build at startup and holds its own memory. The lab's four-B70 Flash-Next runs report full-graph capture as a separate startup phase (52 seconds on all four ranks in one attempt). A52 note
  • Identity under concurrency. At one request the outputs were byte-identical to graph-off in every lab check: 12/12 strict digests on the replay host, and 64/64 c1 oracles for both profiles on the publishing host (R163). Under load the picture is mixed. Graph-on MTP1 on the replay host was exact through c16, then 29/32 and 59/64; graph-on MTP0 there was exact through c8, then 15/16 at c16. On the publishing host, R163 graph-on MTP0 was exact through c32 (63/64 at c64) and graph-on MTP1 was 15/16 at c16 and 57/64 at c64, while the published graph-off MTP0 profile (R156) is 64/64 at c64. Lab-measured Replay ladders · R163 result · CURRENT.md
  • Less visibility. Inside a replay, individual kernels are not separately submitted, so per-kernel profiling and tracing lose resolution. Diagnose graph-off; then re-enable capture.
  • Support status. vLLM warns that XPU graph is experimental and officially supports single-GPU execution. The lab's TP2 arms passed their gates, but you are past the documented envelope.

How to profile your own host in five minutes

The lab's probe is a short pure-Python script that runs inside the recipe's container image on one card. It reports three numbers that matter and two that are specific to the image. Probe script

# inside the R139 (or later) image, one B70 visible
python3 qwen38-fp8-host-submission-latency-probe.py probe.json
cat probe.json

How to read it:

  • launch_us_async: microseconds per back-to-back tiny kernel launch (2,000 one-element adds, no sync). This is your pure submission cost.
  • launch_plus_sync_us: launch then synchronize, 500 times. This is the cost of every host-waited point in the step.
  • ir_rms_norm_m2_us: a real launch-heavy op at decode shape. This amplifies the first number.
  • w8a16_m2_qkv_us: a decode-shaped W8A16 GEMM. On the publishing host this returned a scale-layout error rather than a time; treat it as optional.

Reference points from the lab: 3.1 µs / 26 µs / 41 µs on the host that ran graph-off at full published speed; 5.2 µs / 30 µs / 134 µs on the host that needed graph-on to get within 5 percent. If your launch number is at or above about 5 µs, or your rms_norm number is several times 41 µs, expect graph-off to underperform and try graph capture first. Between those points we have no measurement; probe, then compare decode both ways. For a two-card recipe, also time the exact-shape all-reduce the model uses: the lab saw 13 µs versus 48–51 µs for a [1,5120] BF16 reduce. Replay note

Quick host checks

lscpu | grep -E 'Model name|^CPU\(s\)|MHz|NUMA'
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor
cat /sys/module/pcie_aspm/parameters/policy
xpu-smi discovery
scripts/report-pcie-topology.sh            # live and max link state for every hop
sudo lspci -vv -s 21:00.0 | grep -E 'LnkSta|LnkCap'   # the card's upstream switch port, not the GPU
sudo dmesg | grep -i -E 'aer|corrected|xe '
The B70 endpoint always shows 2.5 GT/s x1

The GPU function sits behind the card's own PCIe switch, and that internal hop reports 2.5GT/s x1 by design. The real host link is the switch's upstream port (on the two-B70 host, 21:00.0 and 25:00.0), which showed 16.0 GT/s x16 on the Gen4 replay host. Measure the root or upstream port, not the endpoint. Host link audit · PCIe topology guide

Host fixes the lab actually needed

Each row is something the lab did on its own B70 hosts, with the symptom, the fix, and what it measurably changed. Every claim links to the note. Lab-measured

FixSymptomWhat changedEffect
GPU GuC firmware Four-card runs stopped at the oneCCL topology warning and never loaded the model; the kernel then looped GuC resets (360 per minute across three cards) while idle. dmesg said GuC 70.54.0 was recommended but only 70.44.1 was found. Installed the upstream xe/bmg_guc_70.bin (70.72.1) in place of Ubuntu's 70.44.1 via install-upstream-xe-firmware.sh; unbind/reload of xe clears a wedge without reboot. Stability. Speed unchanged: the probe read 5.15 µs / 31.2 µs / 132 µs on 70.44.1 versus 5.18 / 30.2 / 134 on 70.72.1. GuC wedge note · Replay note
Root NVMe PCIe corrected-error storm Corrected RxErr AER events from the Samsung 980 PRO root SSD, about 1 per 30–60 s on old firmware and about 2 per second at Gen4 after a firmware update; journal-less host freezes during model load; runs aborted by the lab's AER-delta guard. Temporarily pinned the SSD's root port to Gen3 (zero errors, reads fell from about 6.5 to 3.3 GB/s), then flashed the motherboard BIOS (below), which cleared the errors at Gen4. Stability. Decode was unaffected because the model is resident on the B70s; load time was. Gen4 margin note · Firmware prep · AER gate stop
Motherboard BIOS update Supermicro M12SWA-TF on BIOS 2.0b (2022) logged the NVMe errors above at Gen4. Flashed BIOS 2.4a (2025-07-17) in place. Four B70s, 32 GB Resizable BARs, IOMMU and boot order survived. Stability. Root SSD corrected events went from about 2 per second to 0; a 1800 s idle plus 4 GiB read clearance passed at Gen4 with zero events. No decode change claimed. BIOS 2.4a section
PCIe ASPM policy Wanted to test whether link power management added latency. Wrote performance to /sys/module/pcie_aspm/parameters/policy at runtime. On the four-B70 host in July three cards went to runtime_status=error and needed a reboot; on the two-B70 host in August both Xe devices vanished and the host panicked. On the four-B70 host's current stack (kernel 7.0.0-30, GuC 70.72.1) the Flash-Next run wrapper now writes performance before every launch with the cards active and restores the original policy afterwards, without incident. No decode change was ever measured. The two wedges happened with the cards idle-suspended on older driver stacks. Treat a runtime ASPM write as a risk on any stack where you have not tested it, stop the model first, and prefer the boot command line or BIOS if you want a fixed policy. Four-B70 note · Two-B70 note
ECC off Roughly 28 GB visible per card with ECC on. sudo xpu-smi config --device N --memoryecc 0, reboot, verify Current and Pending. Each lab card reports 32,656 MiB. Capacity. Prevents OOM, offload, or a smaller KV budget. No bandwidth claim. ECC guide · Markdown
PCIe topology and lane placement Every B70 endpoint showed 2.5 GT/s x1; feared a Gen1 x1 link. Audited the full tree; the endpoint line is the card's internal switch hop and the root-facing link was 16 GT/s x16. A controlled Gen4 x16 vs Gen3 x16 A/B on a Qwen3.6 TP2 shape stayed inside the control envelope (c1 53.2–58.1 vs 54.5–58.3 tok/s). Neutral for Gen3 x16 vs Gen4 x16 at TP2. Bulk all-reduce did drop (5.57 to 4.59 GB/s). Untested for x1, x4, or Thunderbolt. Topology guide · Link audit
Swap off and memory-pressure guards Four-B70 Flash-Next runs pin about 48 GB of host RAM at worker start; a warm page cache plus that pinning caused a reclaim burst and memory PSI full avg10 of 11.4. The run wrapper turns swap off, records MemAvailable, PSI, paging and AER deltas every second, and kills the server if pressure or link errors rise. Launch after echo 1 > /proc/sys/vm/drop_caches when the run pins large host buffers. Stability and honest evidence. No decode change claimed. A75 note · A57 note
Page cache and storage on model load Load time swung with the storage path. Load from local NVMe rather than an external drive. The lab's 131-shard checkpoint took about 78 s at Gen4, on the order of two minutes at Gen3, and about 570 s from USB. Load time only. Decode was unchanged once weights were resident. Gen4 margin note
iommu=pt and ACS IOMMU in translated mode with ACS redirect on the root ports; suspected peer-to-peer detour. Rebooted with iommu=pt; then cleared ACS bits on both root ports and both card switch ports. Neutral. All-reduce 50.8 to 48.2 to 47.8 µs; MTP0 graph-off 18.72 vs 18.65 tok/s. The collective floor was launch cost, not the fabric route. Replay note
CPU governor amd-pstate-epp powersave with EPP balance_performance; cores at 1.4–3.1 GHz under load. Switched to performance/performance. Neutral. MTP0 18.65 vs 18.61 tok/s. Replay note
CCD / NUMA pinning Two-CCD Threadripper; suspected cross-CCD latency. docker update --cpuset-cpus 0-7,16-23 to pin the container to one CCD. Neutral. MTP0 19.02 vs 18.65 tok/s, about 2 percent, far short of the 1.8x gap. Replay note
The one host lever that moved decode

On the slow host, every host-side knob above was neutral within a few percent. The only change that closed the 1.8x gap was graph capture with the right sizes, because the gap was submission cost, not fabric, clocks, firmware, or placement. Probe first, so you do not spend a week on the neutral rows.

Decision table

SymptomLikely causeWhat to checkFix
Recipe passes every gate but decodes 1.5–2x slower at one requestSlow CPU submission pathRun the probe; compare launch and launch+sync to 3.1 / 26 µsEnable XPU graph with capture sizes covering every decode batch shape ([1,2] for MTP1); re-verify outputs
Graph-on helps MTP0 but not MTP1Only size 1 captured; verification step runs eagerlycudagraph_capture_sizesCapture [1,2] and set max_cudagraph_capture_size 2
Two-card recipe slow, one-card fineSlow all-reduceTime the exact-shape all-reduce (lab: 13 vs 48–51 µs)Graph capture hides launch cost inside the collective; iommu=pt and ACS were neutral here
Four-card run stalls at the oneCCL topology warning; GuC resets in dmesgStale GuC firmwaredmesg | grep GuC for a "recommended" lineInstall upstream bmg_guc_70.bin; unbind and reload xe
Host freezes with no kernel log during model load; corrected AER events climbingPCIe link margin on the root SSDdmesg | grep -i -E 'aer|corrected'; AER counters in sysfsSSD firmware, then motherboard BIOS; pin the slot to Gen3 as a stopgap
Cards vanish after a sysfs power or ASPM writeBroken PCI resume pathruntime_status under the GPU's sysfs power directoryReboot; on that stack set the policy at boot instead of at runtime
Model does not fit or KV budget is smallECC on (about 28 GB)xpu-smi config --device NECC off, reboot, verify Current and Pending
Server killed by memory pressure at worker startWarm page cache plus large pinned host buffersMemory PSI, MemAvailable, swapSwap off, drop caches before launch, load from local NVMe
Endpoint shows 2.5 GT/s x1Normal for the B70's internal switch hopUpstream switch port and root port link stateNothing, if the upstream port shows x16 at the platform speed
What we have not measured

Two hosts only: an EPYC 9015 Gen5 box with two B70s and a Threadripper PRO 5955WX Gen4 box with four. We have no numbers for Intel desktop CPUs, consumer AM5 boards, Gen3-only platforms at x8 or narrower, or Thunderbolt. The mechanism should transfer; the exact tok/s will not. Probe your box, then measure decode graph-off and graph-on before choosing.