Tensor-parallel scaling
TP=1,2,4,8 on one node: measure the speedup, then attribute the gap to collectives with a profile.
Doubling the GPUs does not guarantee twice the speed, and the interesting question is not how much you lost but where it went. This lab measures TP = 1, 2, 4, 8 on one node, then makes you account for the shortfall with a profile rather than a shrug — the residual can include collectives, kernel efficiency, KV traffic, launch gaps and rank imbalance, and both engines ship the kernel-name map that finds it.
One node with 8 × 80 GB and a full NVLink/NVSwitch fabric — an
HGX H100 or A100 box. TP across PCIe is a valid experiment with a different topology; record negotiated bandwidth and do not compare it to an NVSwitch prediction without changing the model. The model must fit at TP=1, so use an 8B: Llama-3-8B
in bf16 is 16.1 GB of weights and runs at every TP from 1 to 8. On 4 GPUs, run TP = 1,
2, 4 and read the TP=8 row as the prediction it is. With no GPU at all, run
--predict-only: Part A is arithmetic and needs nothing.
run.py was written against the flags, config fields and result-file formats cited
below, and its argument handling and arithmetic were exercised, but it has not been run
against a live engine — no GPU was available while writing. Every number on this page is
derived (arithmetic, labelled as such) or cited (read out of the pinned source).
None is measured. Treat the prediction tables as predictions and report anything that disagrees.
What you measure
Four numbers per TP size, and one residual:
- Decode-step latency at fixed batch and context, so the sweep is a clean function of $p$.
- Speedup $S(p) = t(1)/t(p)$, and scaling efficiency $S(p)/p$.
- Output-token throughput at a batch large enough to matter, which scales differently from latency and will disagree with it.
- All-reduce time per step, taken from a profile, in microseconds and as a share of the step.
The residual is the exercise: predicted step time minus measured step time, with the gap attributed to a specific collective at a specific message size. A sweep that lands at, say, 62% efficiency at TP=8 and stops there has measured nothing you could act on.
The model you are predicting against
§5.1 gives two terms and they behave completely differently. Let $p$ be the TP size, $L$ the layer count, $d$ the model width, $n$ the tokens in the step, $b$ bytes per element, $\beta$ HBM bandwidth and $B_{\text{net}}$ the interconnect bandwidth. A ring all-reduce of $S$ bytes moves $2\frac{p-1}{p}S$ per rank, so the bandwidth term is
and the latency term is $2L\lambda$, where $\lambda$ is the fixed cost of one small collective — a constant that tensor parallelism does not amortise. For Llama-3-8B ($L=32$, $d=4096$, bf16) that is 64 collectives per forward pass and an 8 KiB residual slice per token. The step's floor is the weight stream: §0.4 puts Llama-3-8B at 15.01 GB streamed per decode step — 7.505 B parameters, because the input embedding table is gathered rather than streamed — which at the H100 SXM's 3.35 TB/s is 4.481 ms at TP=1, and $1/p$ of that under tensor parallelism because weights, embedding and LM head all shard.
§5.1's exercise 3 uses the cruder $2P/p\beta$ form and lands about 7% higher, because it
counts the input embedding table as streamed. Both are defensible; pick one and say which. This lab
uses the §0.4 figure throughout, and run.py exposes it as
--streamed-gb so you can switch conventions and watch the residual move.
| TP | Ring factor $2\frac{p-1}{p}$ | Bytes / rank / token | Time / token | % of floor at batch 1 | % of floor at batch 128 |
|---|---|---|---|---|---|
| 2 | 1.00 | 512 KiB | 1.165 µs | 0.052% | 6.7% |
| 4 | 1.50 | 768 KiB | 1.748 µs | 0.156% | 20.0% |
| 8 | 1.75 | 896 KiB | 2.039 µs | 0.364% | 46.6% |
Read that table before you run anything. At batch 1 the bandwidth term is two parts in ten thousand — it cannot possibly explain a 40% efficiency loss. This rules out that small idealized bandwidth term as the entire explanation, but does not identify the residual. Measure collective duration, launch gaps, KV traffic and achieved kernel throughput separately.
| TP | $\lambda = 2\,\mu$s | $\lambda = 5\,\mu$s | $\lambda = 10\,\mu$s | |||
|---|---|---|---|---|---|---|
| step | eff. | step | eff. | step | eff. | |
| 1 | 4481 µs | 100% | 4481 µs | 100% | 4481 µs | 100% |
| 2 | 2368 µs | 95% | 2560 µs | 88% | 2880 µs | 78% |
| 4 | 1248 µs | 90% | 1440 µs | 78% | 1760 µs | 64% |
| 8 | 688 µs | 81% | 880 µs | 64% | 1200 µs | 47% |
Three curves, one free parameter. Fit $\lambda$ to your measured TP=8 point, then check the fit against TP=2 and TP=4 — if one $\lambda$ fits all three, the curve is consistent with that model, not proof of a unique cause. If it does not, something in the next section is happening to you.
Running it
Part A needs no GPU:
$ python3 run.py --predict-only --model-preset llama-3-8b --tp 1 2 4 8 --lambda-us 2 5 10
$ python3 run.py --help # every knob, including --streamed-gb and --nvlink-gb-s
Timing contract: the vLLM harness reports full-generation mean latency, while SGLang reports median decode-only latency. Dividing vLLM's mean by output length does not remove prefill or turn a mean into a median. The script labels these separately and skips decode-cost attribution for that vLLM statistic. For a cross-engine comparison, capture matched decode-only windows or use the same end-to-end harness for both; never place the two different statistics in one speedup ranking.
Part B drives the engine once per TP size. run.py shells out to each engine's own
single-batch harness rather than reimplementing a timing loop, because the harnesses already handle
warmup and CUDA-graph capture correctly. vLLM's takes an explicit warmup count and writes a JSON of
per-iteration latencies:
"--num-iters-warmup",
type=int,
default=10,
help="Number of iterations to run for warmup.",
)
parser.add_argument(
"--num-iters", type=int, default=30, help="Number of iterations to run."
)
if args.output_json:
results = {
"avg_latency": np.mean(latencies),
"latencies": latencies.tolist(),
"percentiles": dict(zip(percentages, percentiles.tolist())),
}
with open(args.output_json, "w") as f:
json.dump(results, f, indent=4)
SGLang's single-batch harness is the closer instrument for this lab, because it separates the prefill from the decode steps and reports the median decode latency — which is the quantity the model above predicts, with the prefill and the first token excluded:
if output_len > 1:
med_decode_latency = np.median(decode_latencies)
med_decode_throughput = batch_size / med_decode_latency
rank_print(
f"Decode. median latency: {med_decode_latency:6.5f} s, median throughput: {med_decode_throughput:9.2f} token/s"
)
measurement_results["median_decode_latency"] = med_decode_latency
measurement_results["median_decode_throughput"] = med_decode_throughput
# vLLM: --tensor-parallel-size is the knob (vllm/config/parallel.py); latency in the JSON
vllm bench latency --model meta-llama/Meta-Llama-3-8B-Instruct \
--tensor-parallel-size 8 --batch-size 1 --input-len 128 --output-len 128 \
--num-iters-warmup 10 --num-iters 30 --output-json tp8.json
# SGLang: --tp-size is the same knob; median_decode_latency lands in the jsonl
python3 -m sglang.benchmark.one_batch --model-path meta-llama/Meta-Llama-3-8B-Instruct \
--tp-size 8 --batch-size 1 --input-len 128 --output-len 128 \
--result-filename tp8.jsonl
# or let run.py do the whole sweep and print the table
$ python3 run.py --engine vllm --model meta-llama/Meta-Llama-3-8B-Instruct \
--tp 1 2 4 8 --batch-size 1 --workdir ./results
Part C is the profile. Do not guess which kernels are the collectives — both projects ship a kernel-name-to-bucket map for exactly this, and they agree on the regex:
"llama": {
"fused_moe_kernel|GroupProblemShape|group_gemm_starts|bmm_|GemmUniversal": "moe_gemm",
"gemm|nvjet": "gemm",
"moe|sigmoid": "moe",
"CatArrayBatched|prepare_inputs": "prepare_next",
"ncclDevKernel|cross_device_reduce": "nccl_and_custom_ar",
"_norm_|Norm": "norm",
"act_and_mul_": "activation",
"Rotary": "rope",
"SoftMax": "softmax",
"flash|fmha": "attn",
"elementwise": "elementwise",
"fp8_quant|cvt_": "quantize",
"reduce_kernel": "reduce",
"triton": "triton_kernel",
"CUDA mem": "non-gpu-H_D_memops",
".*": "misc"
"llama": {
"gemm|nvjet": "gemm",
"fused_moe_kernel|GroupProblemShape|group_gemm_starts|bmm_|GemmUniversal": "moe_gemm",
"moe|sigmoid": "moe",
"CatArrayBatched|prepare_inputs": "prepare_next",
"ncclDevKernel|cross_device_reduce": "nccl_and_custom_ar",
"_norm_|Norm": "norm",
"topk": "topk",
"act_and_mul_": "activation",
"Rotary": "rope",
"SoftMax": "softmax",
"flash|fmha": "attn",
"elementwise": "elementwise",
"fp8_quant|cvt_|quantize": "quantize",
"reduce_kernel": "reduce",
"triton": "triton_kernel",
"CUDA mem": "non-gpu-H_D_memops",
".*": "misc"
The nccl_and_custom_ar bucket is your answer. It matches two families of symbol:
NCCL's device kernels, whose names begin ncclDevKernel, and the engines' own one-shot and
two-shot kernels, whose names begin cross_device_reduce. vLLM's are readable in tree:
template <typename T, int ngpus>
__global__ void __launch_bounds__(512, 1)
cross_device_reduce_1stage(RankData* _dp, RankSignals sg, Signal* self_sg,
T* __restrict__ result, int rank, int size) {
using P = typename packed_t<T>::P;
using A = typename packed_t<T>::A;
// note: we don't reorder the address so the accumulation order is the same
SGLang's custom all-reduce is in tree at 7d89325, under
python/sglang/kernels/aot/csrc/allreduce/ — the sources the
sgl_kernel wheel is built from, vendored into the repo. Reading it changes the
advice. The CUDA path declares three __global__ kernels in
custom_all_reduce.cuh: cross_device_reduce_1stage (L325),
custom_all_reduce_2shot (L365), and cross_device_reduce_2stage (L474).
So a trace filter matching only cross_device_reduce — which is what SGLang's
own bucket map above suggests — silently drops the two-shot kernel and under-counts
your all-reduce time. Match cross_device_reduce|custom_all_reduce_2shot, and check
the symbol names in your first trace rather than trusting either regex. The ROCm variant in
custom_all_reduce_hip.cuh carries only the cross_device_reduce_* pair.
The Python dispatch wrapper is
python/sglang/srt/distributed/device_communicators/custom_all_reduce.py.
Capture with nsys, then run the shipped script — it produces a per-bucket CSV
and an HTML chart, and the bucket you want is one row of it. The invocation is documented in the
repo's own README (tools/profiler/nsys_profile_tools/README.md):
nsys profile -t cuda -o tp8 -f true --trace-fork-before-exec=true \
--cuda-graph-trace=node --delay 60 --duration 60 \
vllm serve meta-llama/Meta-Llama-3-8B-Instruct --tensor-parallel-size 8
python3 $VLLM/tools/profiler/nsys_profile_tools/gputrc2graph.py \
--in_file tp8.nsys-rep,vllm,llama,0 --title "TP=8 kernel budget"
If you would rather stay inside torch.profiler, both engines expose start/stop over
HTTP; §10.5 owns the flags
(vLLM's --profiler-config.profiler=torch plus
--profiler-config.torch_profiler_dir; SGLang's SGLANG_TORCH_PROFILER_DIR and
a POST body). Nsight Systems is the better tool here for one specific reason: it is the only one of the
three that shows rank skew, and a TP sweep that decays faster than the model predicts is
usually one slow rank making seven others wait.
What to expect
The simple model predicts decreasing efficiency; real shape/backend transitions can be nonmonotonic. Five things make it fall faster than $t(p) = t(1)/p + 2L\lambda$ predicts, and they are worth checking in this order.
1. The all-reduce implementation changes under you, by message size
This is the one that ambushes people, because nothing logs it. vLLM tries a ladder of all-reduce implementations per call and falls through to NCCL when none of the fast paths accepts the tensor:
# always try quick reduce first, then flashinfer, then the AITER or vLLM
# custom allreduce, and then pynccl. (quick reduce just for ROCM MI3*)
qr_comm = self.qr_comm
# ... flashinfer and AITER branches elided ...
ca_comm = self.ca_comm
if (
ca_comm is not None
and not ca_comm.disabled
and ca_comm.should_custom_ar(input_)
):
out = ca_comm.custom_all_reduce(input_)
assert out is not None
return out
symm_mem_comm = self.symm_mem_comm
if symm_mem_comm is not None and symm_mem_comm.should_use_symm_mem(input_):
out = symm_mem_comm.all_reduce(input_)
assert out is not None
return out
pynccl_comm = self.pynccl_comm
if pynccl_comm is None or pynccl_comm.disabled:
out = input_.clone()
torch.distributed.all_reduce(out, group=self.device_group)
return out
assert pynccl_comm is not None
out = pynccl_comm.all_reduce(input_)
The gate on the custom kernel is a byte count:
def should_custom_ar(self, inp: torch.Tensor):
if self.disabled or self.world_size > 8:
return False
inp_size = inp.numel() * inp.element_size()
# custom allreduce requires input byte size to be multiples of 16
if inp_size % 16 != 0:
return False
if not is_weak_contiguous(inp):
return False
# for 4 or more non NVLink-capable GPUs, custom allreduce provides
# little performance improvement over NCCL.
if self.world_size == 2 or self.fully_connected:
return inp_size < self.max_size
return False
and max_size is a per-architecture, per-world-size table:
CUSTOM_ALL_REDUCE_MAX_SIZES = {
"9.0": {
2: 64 * MiB, # 64 MB
4: 32 * MiB, # 32 MB
6: MiB // 2, # 512 KB
8: MiB // 4, # 256 KB
},
On an H100 (compute capability 9.0) at TP=8 the ceiling is 256 KB. A Llama-3-8B
residual slice is 8 KiB per token, so the custom kernel handles up to 31 tokens per step and
batch 32 and above runs on NCCL instead. That is a discontinuity in the middle of any
batch-size sweep, invisible from outside, and visible in a profile as the kernel name changing from
cross_device_reduce_* to ncclDevKernel*.
SGLang gates the same kernel on a single flat constant —
_MAX_CAR_SIZE = 8192 * 1024, 8 MiB, at every world size
(python/sglang/srt/distributed/device_communicators/custom_all_reduce.py:L41-L42) —
so at TP=8 it keeps its custom kernel out to 1,024 tokens where vLLM has long since switched. If your
two engines diverge sharply at TP=8 and moderate batch, this is why, and it is a threshold choice
rather than a kernel-quality difference.
| TP | vLLM on sm 9.0 |
SGLang, all architectures |
|---|---|---|
| 2 | 8,192 tokens | 1,024 tokens |
| 4 | 4,096 tokens | 1,024 tokens |
| 8 | 32 tokens | 1,024 tokens |
Both engines let you delete the variable entirely, under the same flag spelling — vLLM's field
is disable_custom_all_reduce at vllm/config/parallel.py:L205, SGLang's is
declared here. Force NCCL on both sides and re-run: the difference between the two sweeps is the
custom kernel's whole contribution.
disable_custom_all_reduce: A[
bool,
Arg(
help="Disable the custom all-reduce kernel and fall back to NCCL.",
resolvable=True,
),
NS("exec.comm"),
] = False
2. Fixed collective latency does not amortise
Sixty-four collectives per forward pass, and $\lambda$ is a property of the fabric and the launch path, not of $p$. At TP=8 the step floor is 560 µs, so the entire budget is 560/64 = 8.75 µs per collective before communication consumes the whole step. This is why efficiency decays even at batch 1, where the bandwidth term is 0.18% of the floor.
3. KV heads stop dividing, so throughput and latency diverge
Llama-3-8B has $h_{kv} = 8$, so at TP=8 every rank holds exactly one KV head and past TP=8 they would replicate. Latency keeps improving with $p$; per-card KV capacity stops. Measure both and you will see the sweep's two halves disagree — §5.1 derives the capacity law, and lab 02 is where you read the engine's reported pool size at each TP.
4. CUDA-graph bucketing flattens the batch axis
If you sweep batch size as well as TP, remember the capture ladder: batches 9 through 16 replay the same graph. A plateau in a batch sweep is bucketing, not scaling. Sweep on the rungs, or report the padded size — §10.3 has the full confounder table, and every row of it applies to this lab.
5. One slow rank
All-reduce is a barrier. If one GPU is thermally throttled, on a different NUMA node, or sharing a
PCIe switch with the NIC, all $p$ ranks run at its speed and the profile shows the other ranks parked
inside the collective. This is the failure that looks exactly like "collectives are expensive" and is
not. It is the reason Part C uses Nsight Systems rather than a single-rank
torch.profiler trace.
Do not compare a TP=1 number from one process against a TP=8 number from another and call the
ratio "scaling" without holding the batch, the context length, the attention backend, the
quantization and the graph-capture setting fixed. Four of the five items above will move if you do
not pin them, and the resulting curve is uninterpretable. run.py prints the full
invocation for every point it runs so the sweep is auditable afterwards.
Exercises
- Before running anything: at TP=4, batch 1, what fraction of the predicted step time is the bandwidth term? Now do the same at batch 128. Which of the two regimes is your sweep in, and does that change which of the five causes above you should look at first?
- Run the sweep at batch 1 and fit a single $\lambda$ to your TP=8 point. Predict TP=2 and TP=4 from that $\lambda$, then compare. If one constant explains all three, what have you proved? If it does not, which direction is the error and what does that direction rule out?
- Predict, then verify. Run vLLM at TP=8 with batch 16 and batch 64, profile both, and diff the
kernel names in the
nccl_and_custom_arbucket. Predict which kernel each run uses before you look, from the table above. Then set--disable-custom-all-reduceand re-run batch 16: how much did you lose, and does it match the gap between the two batch sizes? - Read the file. Open
vllm/distributed/device_communicators/cuda_communicator.pyand list, in order, every all-reduce implementation the dispatch tries before NCCL. For each, name the predicate that can reject the tensor. Which of them can a benchmark user turn off from the command line? - Measure throughput as well as latency: re-run the sweep with
--batch-size 64and compute both $S_{\text{lat}}(p)$ and $S_{\text{tput}}(p)$. They will not agree. Explain the sign of the disagreement using the bandwidth-term table, and say which of the two a serving deployment should be optimising.
Answers
- 0.078% at batch 1 and 10.0% at batch 128 (derived; the table above). At batch 1 the bandwidth term cannot explain any visible loss, so the only candidates left are cause 2 — fixed per-collective latency — and cause 5, rank skew. At batch 128 bandwidth is a real term and the custom-kernel threshold (cause 1) has certainly been crossed at TP=8, so the two must be separated before either can be blamed.
- Fitting one $\lambda$ to TP=8: $\lambda = (t_8 - 560\,\mu\mathrm{s})/64$. If that same $\lambda$ reproduces TP=2 and TP=4, you have shown the shortfall is entirely a per-collective constant and that nothing size-dependent is happening — which at batch 1 is what the arithmetic says should be true. If the fitted $\lambda$ grows with $p$, the constant is not constant: ring latency itself scales with hop count, or you have rank skew (cause 5). If it shrinks with $p$, your TP=1 baseline is wrong — most often because TP=1 is not memory-bound at the batch you chose, so $t(1)$ is not $4481\,\mu$s and the whole ratio is off.
- Batch 16 at TP=8 is 131,072 bytes, under the 262,144-byte ceiling, so it uses
cross_device_reduce_*; batch 64 is 524,288 bytes, over it, so it usesncclDevKernel*. Forcing--disable-custom-all-reduceat batch 16 should reproduce roughly the per-collective cost you inferred from batch 64 — if it does, you have isolated the kernel switch from every other batch-size effect, which is the only way to separate it from cause 4. - In order: NCCL symmetric-memory all-reduce (gated on world size and tensor symmetry), quick
reduce (ROCm only), FlashInfer, AITER (ROCm), vLLM's own custom all-reduce
(
should_custom_ar: not disabled, world size ≤ 8, byte size a multiple of 16, weakly contiguous, and undermax_size), Torch symmetric memory, then pynccl, thentorch.distributed.all_reduce. From the command line the user can turn off the custom kernel with--disable-custom-all-reduce; the rest are selected by predicate, which is exactly why the profile is the only reliable way to know which one ran. - Latency speedup is larger than throughput speedup at high $p$. At batch 64 the bandwidth term is real (11.6% of the floor at TP=8, derived by scaling the batch-128 column), and it is charged once per step regardless of how many tokens the step carries — so it is amortised over more tokens at large batch, which helps throughput, while the fixed $2L\lambda$ term is charged per step and hurts both equally. Meanwhile latency at batch 1 pays $2L\lambda$ against a much smaller floor. A serving deployment cares about throughput at an SLO, which is neither of these two numbers; see lab 11.
Key takeaways
- At batch 1 the bandwidth term is 0.18% of the step floor even at TP=8 (derived). Any efficiency loss you measure there is fixed per-collective latency, and 64 collectives per forward pass on Llama-3-8B gives you a budget of 8.75 µs each at TP=8 before communication eats the whole step.
- One fitted constant $\lambda$ should explain the entire TP=1→8 curve at batch 1. If it does not, the residual is telling you about rank skew or a wrong TP=1 baseline — not about collectives.
- vLLM's custom all-reduce ceiling on an H100 at TP=8 is 256 KB, which is 32 tokens for Llama-3-8B. Cross that and the engine silently runs NCCL instead. SGLang's ceiling is a flat 8 MiB at every world size, so the two engines are running different kernels over most of a batch sweep.
- Attribute with the shipped map, not by eye. Both repos define
"ncclDevKernel|cross_device_reduce": "nccl_and_custom_ar"and ship a script that turns annsystrace into per-bucket time. Guessing which kernels are collectives is how people attribute an attention regression to the fabric. - Latency scaling and throughput scaling are different curves and they diverge with $p$. Report both, or say which one you measured.