Repository navigation
[Perf][PP] Copy-engine PP transport, on by default where supported (NVLink only) - #65896
Conversation
f8131f1 to
8c5f7bb
Compare
|
This pull request has merge conflicts that must be resolved before it can be |
8c5f7bb to
0abd809
Compare
|
Maybe we could make this deafult? |
|
@njhill There are certain conditions that this PR is consistently worse (e.g. 1.5-2.5K new inputs, 8 QPS are consistently 2% slower). I'm not sure what's the reason. Hence I'm a bit hesitant to make this the default. But of course if you think it's fine, we can switch it on by default (and listen to issue reports to harden it) |
0abd809 to
5406f62
Compare
|
/ci run |
|
✅ Triggered Buildkite CI #100516 for commit ℹ️ This PR is 1 commit behind upstream |
|
This pull request has merge conflicts that must be resolved before it can be |
67412cc to
a0030ef
Compare
|
Thanks @gau-nernst, this looks great. I made some further changes with claude here which recovers the regression at the low load / shorter query cases, this should also apply to the AMD version: njhill@e351d23 Probably the case for making this default is then stronger - it could fall back to NCCL when not supported. Also I think we should probably be able to extend this to support TP>1 and MNNVL? |
A non-last PP stage sends its intermediate tensors with NCCL isend, and a send reads its source until the next stage posts the matching receive. The worker therefore waits for the previous step's send before each forward, since the forward may overwrite the tensors that send is still reading. Send from a ring of two buffers sized like the profile run's outputs instead: the next forward can overwrite its outputs at once, and a send is only waited when its buffer comes around again. The buffers are allocated after the profile run and added to the non-KV-cache memory. The hand-off moves behind a small PPTransport interface (vllm/distributed/pp_transport), with NCCL as its only backend for now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Thien Tran <gau.nernst@yahoo.com.sg>
NCCL send/recv runs a kernel on SMs on both stages of a PP boundary. Add a copy_engine PPTransport backend: peer cudaMemcpyAsync into the next stage's CUDA IPC receive buffer, synchronized with GPU-side flags written and awaited with stream memory operations, so neither SMs nor the CPU wait on the transfer. The flags are awaited without CU_STREAM_WAIT_VALUE_FLUSH, which x86 hosts (e.g. HGX B300) do not support; as in NCCL's zero-SM RMA path, the backend therefore requires the PP group's GPUs to be NVLink-connected. VLLM_PP_TRANSPORT selects the backend: nccl (default), auto (copy engines when every stage qualifies, i.e. CUDA, V2 model runner, TP=1, PCP=1, one node, NVLink; NCCL otherwise), or copy_engine (fail startup instead of falling back). Setup is a collective, so all ranks pick the same backend. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Thien Tran <gau.nernst@yahoo.com.sg>
recv() queued the wait for the previous stage and the copy into the persistent tensors on the main stream before the model runner's input preparation, so that GPU work waited for the transfer at every stage boundary. Return AsyncIntermediateTensors instead, queuing the wait, copy and empty-flag write when .tensors is first read, as the NCCL path does. At PP=4 on MiniMax-M3.1-Flash (GB300) this removes the light-load TTFT and decode ITL regression vs NCCL (decode ITL p50 43.5 -> 41.4-42.1 ms, NCCL 41.6 ms). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xgy3B6LNvoZJuQY4nadJiG Signed-off-by: Nick Hill <nickhill123@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Thien Tran <gau.nernst@yahoo.com.sg>
VLLM_PP_TRANSPORT now defaults to "auto": copy engines when every PP rank can set them up, NCCL otherwise. Each check that can differ between ranks (cuda-python, stream memory ops, buffer allocation, NVLink, opening the peers' buffers, the send buffer) runs before an all-ranks exchange of errors, so every rank makes the same choice; on fallback each rank frees its buffers and closes its peer mappings before memory profiling. "copy_engine" still fails startup, on every rank alike. The startup-plan fingerprint records the transport in use rather than the env value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Thien Tran <gau.nernst@yahoo.com.sg>
e384498 to
cd73a08
Compare
Import _custom_ops inside the Ray worker of the one-rank-failure test, since Ray cannot pickle the module as a global. Pin the compiled-graphs load_model test to NCCL: it has no PP group, and "auto" would set up a PP transport. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Thien Tran <gau.nernst@yahoo.com.sg>
|
/ci run |
|
✅ Triggered Buildkite CI #100955 for commit ℹ️ This PR is 2 commits behind upstream |
…enarios One shared Ray actor per GPU now serves all three scenarios: the worker pays the vLLM import and CUDA context setup once, and each scenario only re-initializes and tears down its distributed environment. Tests keep their distinct names and run in one process (multi_gpu_marks instead of multi_gpu_test's per-test fork, which a shared Ray connection cannot survive). A failing scenario kills the pool so its peers fail fast instead of hanging on the abandoned collective, and the next test starts a fresh pool. 3 passed in 36s, down from 120s, on a 4x GB200 tray. Co-authored-by: Kimi Signed-off-by: Thien Tran <gau.nernst@yahoo.com.sg>
njhill
left a comment
There was a problem hiding this comment.
Thanks @gau-nernst! I updated the PR description with the new results that I collected.
|
/ci run |
|
✅ Triggered Buildkite CI #101240 for commit ℹ️ This PR is 79 commits behind upstream |
|
/ci retry |
|
✅ Triggered Buildkite CI #101285 for commit |
|
@gau-nernst I've opened #66677 based on top of this one to extend the support to TP>1 and MNNVL. And verified it improves performance in those cases too. |
vllm-project#65896 added the PPTransport interface (VLLM_PP_TRANSPORT) at the same hooks of gpu_worker.py; both conflicts are resolved to main's side, and the next commit moves the SDMA hand-off onto that interface. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Taoyu Zhu <zhutaoyu97@gmail.com>
Moves the SDMA hand-off onto vllm-project#65896's PPTransport instead of its own flag: `SdmaTransport` (vllm/distributed/pp_transport/sdma.py) subclasses `CopyEngineTransport` and keeps its protocol, buffers, metadata path and deferred receive; only the platform hooks differ. - copy_engine.py: the CUDA-only steps become overridable hooks (_platform_unavailable, _device_info, _setup_sender, _peer_copy, _wait_done, _wait_flag, _write_flag, _close_peer); CUDA behaviour is unchanged. - sdma.py: the peer copy is hsa_amd_memory_async_copy_on_engine on engines 2 and 4, queued by the host behind a dependency signal that the side stream opens after its `empty` wait; two 8-byte SDMA copies of the step number, queued behind the data copies, publish `full` to the receiver and `sent` to this rank (the next pack waits for `sent`). Signals rotate through a ring of 16 steps. Flags use the ROCm shared buffer, which is uncached. - VLLM_PP_TRANSPORT=copy_engine selects it on ROCm gfx950; "auto" keeps NCCL on ROCm. - Removes --pp-sdma-handoff, ParallelConfig.pp_sdma_handoff, pp_sdma.py and test_pp_sdma.py; test_pp_transport.py runs on MI355X too (MI355 2-GPU step). TODO (nothing run yet; the code is up for review first): - test_pp_transport.py on 2 and 4 x MI355X; MiniMax-M3.1-Flash TP1 x PP2 prefill vs NCCL, eager and with CUDA graphs; gsm8k. - Unverified on ROCm: hipStreamWriteValue64 into the previous stage's IPC-mapped uncached `empty` word; several copies decrementing one completion signal; torch.as_tensor over __cuda_array_interface__ (`_device_bytes`). - HIP maps streams onto 4 HW queues round-robin: if the side stream shares the compute stream's queue, its `empty` wait also stalls compute. - Whether "auto" should pick SDMA on gfx950. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Taoyu Zhu <zhutaoyu97@gmail.com>
Human note: This PR adds copy engine option for PP handoff from one rank to the next. The copy is one-sided by nature (sender sets a flag after sending the data, receiver releases a flag once finished consuming it). The main goals are (1) 0-SM copy to remove contention with compute kernels, especially persistent kernels sized by number of SMs (instead of CLC for example), and (2) replace NCCL rendezvous with GPU-side wait
AMD equivalent solution: #65658
Overview
Moves PP activations between stages with CUDA copy engines instead of NCCL send/recv. Selected by
VLLM_PP_TRANSPORT:auto(default): copy engines if every PP rank supports them, NCCL otherwise. All ranks make the same choice, and the log says which and why.nccl: always NCCL.copy_engine: always copy engines; startup fails if they are not usable.Summary
AsyncIntermediateTensors). At PP=4 this took light-load TTFT from +7–9 ms to NCCL's level and decode ITL from +4% to NCCL's.Known weaknesses
autoalso turns copy engines on for unmeasured setups (e.g. H100/H200 with NVLink; B300 was only measured before the receive fix).Validation
MiniMaxAI/MiniMax-M3.1-Flash, TP=1, GB300 (NVLink), Rust frontend, with the deferred receive.Synthetic load: Poisson arrivals at the given mean rate for 120 s, replayed open-loop. Each request is the same 100K-token prefix (cached) + new tokens drawn uniformly from the stated range + 1 output token. One unscored warm-up per workload; a prefix-cache reset and re-prime before every scored pass.
Setup: 2 nodes, one seed each. On each node, NCCL and copy engines run at the same time on separate GPU pairs (PP=2 each) with identical requests, so both arms see the same node conditions; the GPU pairs are swapped between the two nodes. No request failed in either arm (~38K scored requests per arm).
Columns: each cell gives node 1, node 2. "Change" is copy engine vs NCCL. "Paired median" is the median over requests of (copy-engine TTFT − NCCL TTFT) for the same request; its bootstrap 95% CI excludes zero at most points.
Server command
VLLM_PP_TRANSPORT=ncclfor the baseline. The copy-engine arm logsPP transport: copy engines.TTFT, 100K cached + 1.5–2.5K new
TTFT, 100K cached + 6–10K new
TTFT, 100K cached + 16–30K new
At 12 req/s with 1.5–2.5K new tokens, node 1's p50 and p99 rise (+5%, +7%) while its paired median falls (−5.4 ms): this rate sits at the knee, where bursts move the percentiles. Earlier revisions saw the same at this point on one of three loads.
PP=4, light load and decode (same model, 4 GPUs, 90 s passes at 8 req/s with 1.5–2.5K new tokens on the 100K prefix; servers run one after another on one node):
Outputs: temperature-0 completions (16 tokens, with logprobs) for prompts of 1.5K–8K tokens are identical between NCCL and copy engines at PP=2 and PP=4. Longer prompts were not compared: on this model they differ between two runs on the same NCCL server.
GSM8K (before the receive fix; 8x B300, 5-shot, chat, greedy, 1319 questions):
A paired per-question McNemar test gives p = 0.74 and p = 1.0, so there is no difference.
Tests:
tests/distributed/test_pp_transport.pycovers 2- and 4-stage chains with varying sizes and lagging stages, and a 4-stage chain where one stage fails setup: every rank raises undercopy_engine, every rank falls back to NCCL underauto, and the memory is freed. The scenarios share one Ray worker per GPU, so the file takes ~36 s on 4x GB200.Details
How it works: each stage shares a receive buffer over CUDA IPC. The sender packs its outputs on the main stream; a side stream peer-copies them (
cudaMemcpyAsync) and sets a "full" flag (cuStreamWriteValue64). The receiver waits on that flag (cuStreamWaitValue64), copies the data out and sets an "empty" flag, queued when the model runner first reads the tensors. Metadata goes over the CPU group.Requirements: CUDA, TP=1, PCP=1, one node, 64-bit stream memory ops, and NVLink between all PP GPUs. Checks that can differ between ranks run before an all-ranks error exchange, so all ranks pick the same transport; on fallback, the buffers are freed before memory profiling.
Why NVLink: x86 hosts don't support
CU_STREAM_WAIT_VALUE_FLUSH, so the flag waits run without it and rely on NVLink ordering, as NCCL's zero-SM copy-engine path does.Memory: a send and a receive buffer per stage boundary, each the size of the persistent intermediate tensors. Memory profiling counts them.
Why it might help (not established): NCCL send/recv runs kernels on SMs on both stages, and the receiver's kernel can stay on the GPU for milliseconds per step; copy engines use no SMs. An nsys profile at light load did not show model kernels running slower next to NCCL. NCCL's send also waits for the receiver once a tensor exceeds its eager threshold (64–96 MiB here, i.e. 5.5–8K tokens per step), which stalls the sending stage near saturation; copy engines never wait on the CPU.
Follow-ups, not in this PR: TP>1, PP across nodes over multi-node NVLink, and freeing the buffers in sleep mode.
Pull Request Checklist
I used vLLM's
/pr-checklistskill. (Mandatory for agents, optional for humans).AI assistance was used during the creation of this PR.
Design Fit: Minimizes impact on core components, reuses existing functionality, and justifies added complexity.
Testing and Validation: Validates the change and ensures any added tests are meaningful and reliable, with CI coverage or documented CI resource constraints and validation performed outside CI.
Code Quality and Style: Keeps code and comments clear and concise, and updates relevant documentation and examples.
Pull Request Contents: Includes a brief summary and relevant links, supports claims with evidence, explains root causes and implementation trade-offs, and follows the contributing guide.
🤖 Generated with Claude Code