ML Interview Notes
18 min read5 sections
Lab 07 · for chapter 05-01

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.

Hardware

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.

Not executed here

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.

§1

What you measure

Four numbers per TP size, and one residual:

  1. Decode-step latency at fixed batch and context, so the sweep is a clean function of $p$.
  2. Speedup $S(p) = t(1)/t(p)$, and scaling efficiency $S(p)/p$.
  3. Output-token throughput at a batch large enough to matter, which scales differently from latency and will disagree with it.
  4. 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

$$t_{\text{bw}}(p, n) = \frac{2L \cdot 2\frac{p-1}{p} \, n\,d\,b}{B_{\text{net}}}$$

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.

Two conventions

§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.

Bandwidth term for Llama-3-8B on H100 SXM over 450 GB/s one-direction NVLink (900 GB/s aggregate bidirectional) — derived, arithmetic from published shapes and the vendor bandwidth figure. Nothing measured.
TPRing factor $2\frac{p-1}{p}$Bytes / rank / token Time / token% of floor at batch 1% of floor at batch 128
21.00512 KiB 1.165 µs0.052%6.7%
41.50768 KiB 1.748 µs0.156%20.0%
81.75896 KiB 2.039 µs0.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.

Predicted batch-1 step time under $t(p) = 4481/p + 64\lambda$ µs — derived. $\lambda$ is a swept parameter, not a measurement; measuring it is the point of Part C.
TP$\lambda = 2\,\mu$s$\lambda = 5\,\mu$s $\lambda = 10\,\mu$s
stepeff.stepeff.stepeff.
14481 µs100% 4481 µs100% 4481 µs100%
22368 µs95% 2560 µs88% 2880 µs78%
41248 µs90% 1440 µs78% 1760 µs64%
8688 µs81% 880 µs64% 1200 µs47%

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.

§2

Running it

Part A needs no GPU:

shell — labs/07-tp-scaling shell
$ 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:

vllm/benchmarks/latency.py:L46-L53 vLLM
        "--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."
    )
vllm/benchmarks/latency.py:L170-L177 vLLM
    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:

python/sglang/benchmark/one_batch.py:L857-L864 SGLang
    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
shell — one TP point, each engine shell
# 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:

tools/profiler/nsys_profile_tools/vllm_engine_model.json:L3-L19 vLLM
    "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"
examples/profiler/nsys_profile_tools/sglang_engine_model.json:L3-L20 SGLang
    "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:

csrc/custom_all_reduce.cuh:L7-L13 vLLM
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
Match all three kernel names, not one

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):

shell — capture and bucket a TP=8 run shell
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.

§3

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:

vllm/distributed/device_communicators/cuda_communicator.py:L287-L289, L316-L336 vLLM
        # 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:

vllm/distributed/device_communicators/custom_all_reduce.py:L348-L361 vLLM
    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:

vllm/distributed/device_communicators/all_reduce_utils.py:L31-L37 vLLM
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.

Largest step, in tokens, that still uses the custom all-reduce kernel for Llama-3-8B ($d=4096$, bf16, 8 KiB per token) — derived from the two cited thresholds.
TPvLLM on sm 9.0 SGLang, all architectures
28,192 tokens1,024 tokens
44,096 tokens1,024 tokens
832 tokens1,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.

python/sglang/srt/server_args.py:L2021-L2028 SGLang
    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

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.

§4

Exercises

  1. 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?
  2. 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?
  3. 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_ar bucket. Predict which kernel each run uses before you look, from the table above. Then set --disable-custom-all-reduce and re-run batch 16: how much did you lose, and does it match the gap between the two batch sizes?
  4. Read the file. Open vllm/distributed/device_communicators/cuda_communicator.py and 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?
  5. Measure throughput as well as latency: re-run the sweep with --batch-size 64 and 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
  1. 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.
  2. 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.
  3. 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 uses ncclDevKernel*. Forcing --disable-custom-all-reduce at 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.
  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 under max_size), Torch symmetric memory, then pynccl, then torch.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.
  5. 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.
§5

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 an nsys trace 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.

The Inference Engineering Course · code read at vllm@a556f3fccb and sglang@7d893255c3, pinned 2026-08-21. See SOURCES for the full provenance record.

Explore the library

Reading preferences

Appearance
18 px