张量、流水线与数据并行
主线位置:「一个请求的一生」里的前向这一段,以及调度结果怎么交给多张卡。两个引擎在这里都是 Python 编排、NCCL 或自带的 all-reduce kernel 做通信,没有 Rust。
版本:vLLM v0.32.0、SGLang v0.5.22
先说结论:
- 谁持有调度器,两边不一样。 vLLM 一个 EngineCore 进程里只有一个调度器;TP × PP 大于 1 时,它通过共享内存消息队列把调度结果广播给每卡一个的 worker 进程,只收一个 rank 的输出。SGLang 每张卡起一个 Scheduler 进程,每个进程都跑一份调度器;rank 0 从主进程收请求,再广播给同组的其他 rank,各自做同样的决定。
- 数据并行也不一样。 vLLM 每个 DP rank 一个 EngineCore;稠密模型的各 rank 完全独立,MoE 模型的各 rank 每 16 步对齐一次。SGLang 多起一个 DataParallelController 进程按轮询分发。SGLang 另有「注意力按 DP、FFN 按 TP」的 DP attention,v0.5.22 起参数改名为
--attn-dp-size。 - 每层两次 all-reduce。 两边的 Llama 实现都是 QKV 和 gate_up 列切、o_proj 和 down_proj 行切;列切不通信,行切之后各做一次 all-reduce。32 层加上词嵌入一共 65 次,每次每 token 8 KiB。
- 通信量(估算)。 按 Llama-3.1-8B 的公开配置,每 token 的 all-reduce 数据量是 520 KiB;按环形算法,TP=2 时每卡每 token 发 520 KiB,TP=8 时 910 KiB。实际耗时和吞吐待实测(C3-7)。
配置项
| 维度 | vLLM 参数(默认) | SGLang 参数(默认) |
|---|---|---|
| 张量并行 | --tensor-parallel-size / -tp(1) | --tp-size / --tensor-parallel-size(1) |
| 流水线并行 | --pipeline-parallel-size / -pp(1) | --pp-size / --pipeline-parallel-size(1) |
| 数据并行 | --data-parallel-size / -dp(1) | --dp-size / --data-parallel-size(1) |
| 注意力 DP | 无单独参数 | --attn-dp-size(1);旧写法 --dp-size N --enable-dp-attention 已弃用 |
| 专家并行 | --enable-expert-parallel / -ep(False) | --ep-size(1),--moe-a2a-backend(none) |
出处:vLLM 的默认值在 ParallelConfig 里,pipeline_parallel_size(parallel.py#L139)、tensor_parallel_size(#L141)、data_parallel_size(#L146)、enable_expert_parallel(#L189),命令行别名在 arg_utils.py#L1177。SGLang 的在 fields/parallel.py:tp_size(#L45)、pp_size(#L59)、dp_size(#L73)、attn_dp_size(#L81)、ep_size(#L269);moe_a2a_backend 在 fields/exec_.py#L773。dp_size、attn_dp_size、ep_size 都是 resolvable=True,启动时可能被改写,下面会遇到两处。
vLLM 一个 DP rank 里的卡数是 world_size = PP × TP × PCP(parallel.py#L945);PCP 是预填充的上下文并行,默认 1,本页不展开。
进程结构
flowchart TB
subgraph V["vLLM,TP=2"]
vapi["API server 进程"]
subgraph VCORE["EngineCore 进程"]
vsched["Scheduler(唯一一个)"]
vexec["MultiprocExecutor"]
end
vw0["worker 进程 rank 0<br/>GPU 0"]
vw1["worker 进程 rank 1<br/>GPU 1"]
vapi -->|"ZMQ"| vsched
vsched --> vexec
vexec -->|"共享内存队列广播 SchedulerOutput"| vw0
vexec -->|"共享内存队列广播 SchedulerOutput"| vw1
vw0 -->|"只回 output rank 的结果"| vexec
vw0 <-.->|"NCCL / custom all-reduce"| vw1
end
subgraph S["SGLang,TP=2"]
smain["主进程<br/>TokenizerManager"]
s0["Scheduler 进程 rank 0<br/>调度器 + TpModelWorker<br/>GPU 0"]
s1["Scheduler 进程 rank 1<br/>调度器 + TpModelWorker<br/>GPU 1"]
sdet["DetokenizerManager 进程"]
smain -->|"ZMQ"| s0
s0 -->|"广播请求"| s1
s0 -->|"ZMQ"| sdet
s0 <-.->|"NCCL / custom all-reduce"| s1
end
vLLM:一个调度器,每卡一个 worker
单卡时执行器是 uni,模型就在 EngineCore 进程里(见 01 vLLM)。一个 DP rank 里卡数大于 1 时,distributed_executor_backend 默认改成 mp:
if self.distributed_executor_backend is None and self.world_size_across_dp > 1:
# We use multiprocessing by default if world_size fits on the
# current node and we aren't in a ray placement group.
# ...
backend: DistributedExecutorBackend = "mp"
单机卡数不够时报错,在 Ray placement group 里才换成 ray(同一段的 #L1065 起)。
MultiprocExecutor 先建一个共享内存的广播队列,再为本机每个 rank 起一个 worker 进程:
vllm/v1/executor/multiproc_executor.py#L164
self.rpc_broadcast_mq = MessageQueue(
self.world_size,
self.local_world_size,
# ...
)
# ...
for local_rank in range(self.local_world_size):
# ...
unready_worker_handle = WorkerProc.make_worker_process(
worker 进程只做一件事:从队列里取 RPC 执行(worker_busy_loop,#L1037)。调度器每步的 SchedulerOutput 广播给所有 worker,但只等一个 rank 的回复:
vllm/v1/executor/multiproc_executor.py#L548
def _get_output_rank(self) -> int:
# Only returns ModelRunnerOutput from TP rank=0 and PP rank=-1
# (the first TP worker of the last PP stage).
# ...
return (
self.world_size
- self.parallel_config.tensor_parallel_size
* self.parallel_config.prefill_context_parallel_size
)
所以 vLLM 的调度、KV 块表都只有一份,在 EngineCore 里;worker 只持有模型分片和本卡的 KV 显存。
SGLang:每卡一个调度器进程
_launch_scheduler_processes 对本机每个 (pp_rank, tp_rank) 起一个 Scheduler 进程:
python/sglang/srt/entrypoints/engine.py#L927
for pp_rank in pp_rank_range:
for tp_rank in tp_rank_range:
# ...
proc = mp.Process(target=target, args=args)
前向由进程自己持有的 TpModelWorker 执行(scheduler.py#L1009),不另起 worker。每个 Scheduler 都跑完整的调度循环和自己的前缀树。为了让各 rank 做同样的决定,只有 rank 0 从主进程收请求,再广播给其他 rank:
python/sglang/srt/managers/scheduler_components/request_receiver.py#L80
def recv_requests(
self, local_reqs: Optional[List[AbortReq]] = None
) -> List[Union[TokenizedGenerateReqInput, TokenizedEmbeddingReqInput, Any]]:
"""Receive results at tp_rank = 0 and broadcast it to all other TP ranks.
两种做法的差别:vLLM 的 CPU 侧调度只做一次,但每步要把调度结果序列化、经共享内存发给 N 个进程;SGLang 的调度在 N 个进程里各做一次,进程间只广播新请求。哪种 CPU 开销更小,取决于每步新请求的多少和调度本身的耗时,没有实测,待 C3-7。
线性层怎么切
两边的 Llama 模型文件用的是同一套切法。SGLang 的 layers/linear.py 文件头写明改自 vLLM v0.6.4.post1 的同名文件(linear.py#L3)。
- 列切(
ColumnParallelLinear):权重 A 按输出维切成 [A₁ … A_t],每卡算 Y_i = X·A_i。输入 X 每卡一份完整的,输出是分片的,不通信。gather_output默认 False。 - 行切(
RowParallelLinear):权重按输入维切,输入本来就是分片的,每卡算出一个部分和,最后 all-reduce 加起来。
vllm/model_executor/layers/linear.py#L1780
if self.reduce_results and self.tp_size > 1:
output = tensor_model_parallel_all_reduce(output_parallel)
else:
output = output_parallel
SGLang 的同一处多了几条分支(DP attention 时只在注意力 TP 组内 reduce、预填充可选量化通信),默认走的还是 TP 组 all-reduce(linear.py#L1800)。
一层 Llama decoder 的四个矩阵:
| 矩阵 | 切法 | vLLM | SGLang | 通信 |
|---|---|---|---|---|
| qkv_proj | 列切(按头) | llama.py#L165 | llama.py#L200 | 无 |
| o_proj | 行切 | #L175 | #L209 | 1 次 all-reduce |
| gate_up_proj | 列切 | #L95 | #L97 | 无 |
| down_proj | 行切 | #L103 | #L105 | 1 次 all-reduce |
flowchart LR x["hidden<br/>每卡完整一份"] --> qkv["qkv_proj 列切<br/>每卡 n_h/t 个 Q 头<br/>n_kv/t 个 KV 头"] qkv --> attn["注意力<br/>每卡只算自己的头<br/>只存自己那份 KV"] attn --> o["o_proj 行切<br/>部分和"] o --> ar1(["all-reduce ①"]) ar1 --> gu["gate_up 列切"] gu --> act["SiLU × up"] act --> down["down_proj 行切<br/>部分和"] down --> ar2(["all-reduce ②"]) ar2 --> nxt["下一层"]
列切让注意力按头分到各卡,所以 KV cache 也按 KV 头分:Llama-3.1-8B 有 8 个 KV 头,TP=2 时每卡存 4 个,每 token 每卡 64 KiB;TP=8 时每卡 1 个头、16 KiB(估算,按 为什么服务大模型难 的每 token 128 KiB 除以 t)。KV 头数小于 TP 时,每个 KV 头要复制到几张卡上(num_kv_head_replicas,linear.py#L1055),那时每卡的 KV 不再继续变小。
层外还有两处通信:
- 词嵌入:
VocabParallelEmbedding按词表切,每卡只查得到自己那段词表,查完 all-reduce 一次(vocab_parallel_embedding.py#L558)。 - 输出层:lm_head 也按词表切,每卡算出 V/t 个 logits,再 all-gather 成完整的 V 个(logits_processor.py#L122;
use_all_gather在基类平台里默认 True,interface.py#L1267)。只对要采样的位置做,预填充时每个请求只有最后一个位置。
所以一次前向是 2 × 32 + 1 = 65 次 all-reduce 加一次 logits all-gather。这是源码事实,不随批大小变。
小消息走自带的 all-reduce
解码时每次 all-reduce 的数据很小(见下一节),这时启动开销比带宽重要。vLLM 在 CUDA 上默认启用自带的 custom all-reduce(disable_custom_all_reduce 默认 False,parallel.py#L240),条件是 2 卡,或卡间全互联、不超过 8 卡,且消息小于上限:
vllm/distributed/device_communicators/custom_all_reduce.py#L499
def should_custom_ar(self, inp: torch.Tensor):
if self.disabled or self.world_size > 8:
return False
# ...
if self.world_size == 2 or self.fully_connected:
return self.batch_invariant or inp_size < self.max_size
return False
上限默认 8 MiB(#L141),对部分卡型和卡数还会按表再调小。按 Llama-3.1-8B 每 token 8 KiB 算,8 MiB 是 1024 个 token:解码批都在这以内,长预填充的块会超过,交给 NCCL(估算)。
每 token 的通信量(估算)
输入是 Llama-3.1-8B 模型仓库 config.json 的公开值:hidden_size 4096、num_hidden_layers 32、num_key_value_heads 8、vocab_size 128256,激活按 bf16。脚本只用 Python 标准库。
- 每次 all-reduce 的数据是 hidden 向量,每 token 4096 × 2 B = 8 KiB。
- 一次前向 65 次,每 token 合计 520 KiB。
- 环形 all-reduce 每卡要发 2(t−1)/t 倍的数据。这只是一个参照:custom all-reduce 和 NCCL 实际选的算法不一定是环形。
all-reduce per forward = 64 (layers) + 1 (embedding) = 65
each all-reduce payload per token = 8192 B = 8 KiB
total all-reduce payload per token = 520 KiB
tp=2: ring factor 1.00, bytes sent per rank per token = 520 KiB
tp=4: ring factor 1.50, bytes sent per rank per token = 780 KiB
tp=8: ring factor 1.75, bytes sent per rank per token = 910 KiB
tp=2: logits all-gather per sampled token, received per rank = 125 KiB
tp=4: logits all-gather per sampled token, received per rank = 188 KiB
tp=8: logits all-gather per sampled token, received per rank = 219 KiB
换成一步来看:
prefill 2048 tokens: one all-reduce = 16384 KiB, 65 of them = 1040.0 MiB payload
decode B=16: one all-reduce = 128 KiB, 65 of them = 8.1 MiB payload
decode B=64: one all-reduce = 512 KiB, 65 of them = 32.5 MiB payload
读法:
- 预填充是带宽问题。 一块 2048 token 的预填充,65 次 all-reduce 一共约 1 GiB 数据,每次 16 MiB。卡间是 NVLink 还是 PCIe,差别会直接体现在这一步的耗时里。
- 解码是次数问题。 B=16 时每次只有 128 KiB,一步却要做 65 次,每次都要同步所有卡。这时每次 all-reduce 的固定开销乘以 65 决定了通信的耗时,这也是 custom all-reduce 和 CUDA graph 要解决的事。
- TP 换来的是每卡少读权重。 解码访存受限(见 为什么服务大模型难),TP=t 时每卡每步只读 1/t 的权重和 1/t 的 KV,代价是上面这 65 次同步。对一个 24 GB 卡放得下的 8B 模型,TP 是赚还是亏,待实测(C3-7)。
流水线并行
PP 按层切:每段只建自己那几层,其他层是 PPMissingLayer,非末段把 hidden_states 和 residual 打包成 IntermediateTensors 交给下一段(vLLM llama.py#L443)。每 token 每个段边界传 2 × 4096 × 2 B = 16 KiB(估算),比 TP 每 token 520 KiB 少得多,所以跨机时 PP 比 TP 省带宽。PP 和 TP 同时开时,vLLM 每个 TP rank 只发自己那 1/t,下一段收到后在 TP 组内 all-gather(parallel_state.py#L1221)。
PP 的问题是气泡:一个批在第 1 段算完,才能进第 2 段。两边都靠同时在途多个批来填:
- vLLM:EngineCore 的批队列长度是
max_concurrent_batches,等于 PP 段数(异步调度和 V2 model runner 时再加一)(vllm.py#L577),调度器在第一个批出结果之前就排下一个(step_with_batch_queue,core.py#L698)。 - SGLang:PP 时换成
event_loop_pp,按微批轮转,每段收上一段的请求和中间张量,跑完发给下一段(scheduler_pp_mixin.py#L101)。每段的 Scheduler 进程仍然各自跑调度循环。
数据并行
DP 就是起多份完整的模型副本,每份有自己的调度器和 KV。两边的区别在副本之间要不要同步。
vLLM:每个 DP rank 一个 EngineCore
--data-parallel-size N 起 N 个 EngineCore 进程。稠密模型时各 rank 被当成 DP=1 处理,互相不知道对方:
if data_parallel and vllm_config.model_config.is_moe:
# Set data parallel rank for this engine process.
parallel_config.data_parallel_rank = dp_rank
engine_core = DPEngineCoreProc(*args, **kwargs)
else:
# Non-MoE DP ranks are completely independent, so treat like DP=1.
# ...
parallel_config.reconfigure_for_independent_dp_rank()
engine_core = EngineCoreProc(*args, engine_index=dp_rank, **kwargs)
MoE 模型不同:专家层按 TP × DP 切(enable_expert_parallel 打开时是专家并行,否则专家层也按张量切),每一步所有 DP rank 都要参加 MoE 层的集体通信。DPEngineCoreProc 在没有请求时也跑空批(execute_dummy_batch),并每 dp_sync_interval 步(默认 16,parallel.py#L162)做一次 all-reduce 确认还有没有 rank 在忙(_has_global_unfinished_reqs,core.py#L2410)。另有一个 DPCoordinator 进程收集各 rank 的队列长度、广播「波次」开始(coordinator.py#L23)。
请求分给哪个 rank,由 API server 里的客户端决定:按各 rank 的 waiting + running 和在途数打分,取最小(core_client.py#L1642)。
SGLang:DataParallelController
dp_size > 1 或 attn_dp_size > 1 时,多起一个 DataParallelController 进程,它从主进程收请求,再发给各 DP 组(engine.py#L965)。分发策略 load_balance_method 默认 auto,启动时解析:
python/sglang/srt/arg_groups/serving_hook.py#L215
if cfg.load_balance_method == "auto":
# Default behavior:
# - non-PD: round_robin
# - PD prefill: follow_bootstrap_room
# - PD decode: round_robin
不分离时就是轮询(data_parallel_controller.py#L802)。follow_bootstrap_room 在 预填充与解码分离 里讲。
SGLang 的 DP attention
DP attention 把一组 TP 卡再分成几个注意力 DP 组:注意力部分各组各自批、各自存 KV,FFN / MoE 部分仍在整个 TP 组里切。注意力 TP 宽度是剩下的那部分:
python/sglang/srt/runtime_context.py#L73
def derive_attn_tp_size(*, tp_size: int, attn_cp_size: int, attn_dp_size: int) -> int:
# ...
return tp_size // attn_dp_size // attn_cp_size
它的用处是 KV 头很少的模型(比如 MLA 模型只有一个潜向量「头」):按 TP 切注意力时每卡都要存一份完整的 KV,按 DP 切就不重复。代价是 FFN 前后要在 DP 组之间 gather / scatter token(layers/dp_attention.py)。对 Llama-3.1-8B 这种有 8 个 KV 头的模型,TP ≤ 8 时 KV 本来就不重复,用不上。
v0.5.22 的写法变了:参数是 --attn-dp-size N;--enable-dp-attention 还在,但打印弃用提示,启动时把旧写法 --dp-size N --enable-dp-attention 改写成 attn_dp_size = N、dp_size = 1(parallel_hook.py#L39;弃用提示在 server_args.py#L418,注明 2026-12-31 后删)。副本数与注意力 DP 组数相乘才是调度器分组数,两者有一个是 1(runtime_context.py#L202)。网上较早的教程用的是旧写法。
专家并行,只点到
MoE 模型的专家层可以按专家切:每卡放一部分专家,token 按路由结果 all-to-all 发到专家所在的卡。vLLM 用 --enable-expert-parallel 打开,专家分布在 TP × DP(× PCP)张卡上;SGLang 用 --ep-size,all-to-all 的实现由 --moe-a2a-backend 选(DeepEP、Mooncake、NIXL 等,默认 none)。本卷用的 8B 模型是稠密模型,不展开。
待实测
C3-7 要在多卡机器上回答:
- 同一个 8B 模型,TP=1 / 2 / 4 的单请求 TPOT 和满载吞吐,两个引擎各一组。
- 一步解码里通信占多少时间:打开和关闭 custom all-reduce 各测一次。
- PP=2 对 TP=2 在同样卡数下的吞吐与 TTFT。
- DP=2(两个独立副本)对 TP=2 的总吞吐。
小结
- vLLM:一个 EngineCore 一个调度器;卡数大于 1 时默认
mp执行器,每卡一个 worker 进程,调度结果经共享内存广播,只收 TP rank 0、末段的输出。SGLang:每卡一个 Scheduler 进程,各自跑调度,rank 0 收请求后广播。 - 列切不通信、行切后 all-reduce:一层 Llama 两次,一次前向 65 次,加一次 logits all-gather。
- 估算(Llama-3.1-8B 公开配置,bf16):每 token all-reduce 数据 520 KiB;环形算法下 TP=2 每卡发 520 KiB、TP=8 发 910 KiB;PP 每段边界每 token 16 KiB。预填充受带宽限制,解码受 65 次同步的固定开销限制。
- DP:vLLM 稠密模型各 rank 独立,MoE 模型每 16 步对齐;SGLang 由 DataParallelController 默认轮询分发。SGLang 的 DP attention 在 v0.5.22 改用
--attn-dp-size。 - 效果全部待实测(C3-7)。