【LLM推理与Serving⑧】多机多卡Serving:TP/PP/负载均衡与弹性扩缩容全攻略
系列文章索引:本系列从基础概念到工程实践,系统梳理LLM推理与Serving的核心知识。本文为第8篇,深入讲解多机多卡场景下的并行策略、负载均衡与弹性架构。
一、开篇:单卡不够用时怎么办?多机多卡的并行维度
做LLM Serving的同学,迟早会遇到一个问题:单张GPU放不下模型了,或者单卡吞吐不够了,怎么办?
答案很简单:加卡。但"加卡"这两个字背后,有一整套的并行策略和工程架构需要搞清楚。
你可能听过这些名词:DP、TP、PP、EP、ZeRO......它们分别是什么意思?什么时候用TP,什么时候用PP?多机之间怎么通信?流量大了怎么做负载均衡?流量忽高忽低怎么做弹性扩缩容?
这篇文章就带你把这些问题一个个讲清楚。我们从最基础的四种并行方式讲起,然后深入到Serving场景下的TP/PP选型,再到负载均衡策略和弹性扩缩容架构,最后聊聊生产环境中vLLM等框架的二次开发实践。
先建立一个直觉:多机多卡Serving的本质,是用更多的硬件资源,换取更大的模型容量、更高的吞吐、或更低的延迟------同时付出通信开销、工程复杂度和成本的代价。
二、四种并行方式对比:DP/TP/PP/EP
在深入Serving之前,我们先把四种经典并行方式的概念过一遍。虽然这些概念更多来自训练侧,但推理侧同样适用,而且理解起来更简单(因为没有反向传播)。
2.1 数据并行(Data Parallel, DP)
切分对象:数据。每张卡上放完整的模型,不同的卡处理不同的输入数据。
GPU0: [完整模型] + [数据分片0]
GPU1: [完整模型] + [数据分片1]
GPU2: [完整模型] + [数据分片2]
GPU3: [完整模型] + [数据分片3]
优点 :实现简单,线性扩展(理想情况下N卡=N倍吞吐)。
缺点 :每张卡都要存完整模型,模型太大时单卡放不下。
适用场景:模型单卡能放下,但吞吐不够,需要横向扩容。
2.2 张量并行(Tensor Parallel, TP)
切分对象:模型参数(按层内的张量维度切分)。
以一个线性层 Y = XA 为例,我们可以把权重矩阵A按列切分:
A = [A1 | A2] (按列切分)
Y = X[A1 | A2] = [XA1 | XA2] = [Y1 | Y2]
每张卡只存一部分权重,计算时各自算一部分,最后通过All-Gather拼接结果。
GPU0: [Layer1的前半部分] → [Layer2的前半部分] → ...
GPU1: [Layer1的后半部分] → [Layer2的后半部分] → ...
↑ 每层之间需要All-Reduce通信
优点 :模型被切分到多张卡上,可以放更大的模型;延迟较低(层内并行)。
缺点 :通信量大(每层都要通信),对网络带宽敏感;通常只能在单机内做(NVLink)。
适用场景:模型单层太大、单卡放不下,或者追求低延迟。
2.3 流水线并行(Pipeline Parallel, PP)
切分对象:模型层(按层的维度切分)。
把模型的不同层放到不同的卡上,数据从第一张卡流进去,经过每一层计算,最后从最后一张卡输出。
GPU0: Layer 0~7 → 传递激活值 → GPU1: Layer 8~15 → 传递激活值 → GPU2: Layer 16~23 → ...
优点 :通信量小(只在层边界传递激活值);可以跨机器做。
缺点 :有Pipeline Bubble(流水线气泡)------前面的卡算完后面的卡还没开始,硬件利用率低;延迟高(数据要流经所有卡)。
适用场景:模型层数很多,跨机器部署大模型。
2.4 专家并行(Expert Parallel, EP)
切分对象:MoE模型中的Expert。
对于混合专家模型(MoE),每个token只激活部分专家,我们可以把不同的专家放到不同的卡上。
GPU0: Expert 0, Expert 1, ...
GPU1: Expert 2, Expert 3, ...
GPU2: Expert 4, Expert 5, ...
优点 :对于MoE模型,扩展效率很高。
缺点 :只适用于MoE架构;路由策略复杂。
适用场景:MoE大模型推理。
2.5 四种并行方式对比表
| 并行方式 | 切分对象 | 通信量 | 适用模型大小 | 延迟影响 | 复杂度 |
|---|---|---|---|---|---|
| DP(数据并行) | 数据 | 小(推理时几乎没有) | 单卡能放下 | 无影响 | 低 |
| TP(张量并行) | 层内张量 | 大(每层都要) | 单层太大 | 降低延迟 | 中 |
| PP(流水线并行) | 模型层 | 中(层间传递) | 层数很多 | 增加延迟 | 中高 |
| EP(专家并行) | MoE专家 | 中 | MoE大模型 | 视情况 | 高 |
一句话记住:
- 吞吐不够 → 加DP
- 模型太大单层放不下 → 用TP
- 模型太大层数太多 → 用PP
- MoE模型 → 加EP
三、Tensor Parallel Serving:TP切分什么、收益与代价
下面我们重点看推理Serving场景下的TP和PP,这是最常用的两种模型并行方式。
3.1 TP在推理时到底切分了什么
以LLaMA的一个Transformer层为例,TP主要切分这些地方:
- Attention的QKV投影:按输出维度切分,每张卡算一部分head
- Attention的输出投影:按输入维度切分,最后All-Reduce
- MLP的Gate/Up投影:按输出维度切分
- MLP的Down投影:按输入维度切分,最后All-Reduce
简单说:凡是矩阵乘法,能按行或列切分的地方都切了。
python
# 伪代码:TP下的Attention计算
def attention_tp(x, num_heads, head_dim, tp_size):
"""
张量并行下的Attention:
- QKV按head维度切分到不同GPU
- 每张卡独立计算自己的attention
- 输出投影后做All-Reduce
"""
# 每张卡只持有部分head的QKV权重
local_num_heads = num_heads // tp_size
# 1. 线性投影得到Q, K, V(每张卡只算自己的部分)
q = linear(x, W_q_local) # [batch, seq_len, local_num_heads * head_dim]
k = linear(x, W_k_local)
v = linear(x, W_v_local)
# 2. 本地计算Attention(不需要通信)
attn_output = scaled_dot_product_attention(q, k, v)
# 3. 输出投影
out = linear(attn_output, W_o_local)
# 4. All-Reduce 汇总所有卡的结果
out = all_reduce(out)
return out
3.2 TP的收益与代价
收益:
- 降低延迟:因为每层的计算被分到多张卡上并行执行,单token生成时间可以缩短
- 放更大的模型:单卡放不下的模型,TP可以把参数分散到多张卡
代价:
- 通信开销:每层都需要All-Reduce通信。对于A100这种有NVLink的机器,通信很快;但如果是PCIe连接的消费级显卡,通信可能成为瓶颈
- 显存利用率:虽然参数被分摊了,但KV Cache还是每张卡都要存完整的(因为每张卡都需要完整的KV来做attention)------等等,不对,TP下KV Cache也是分片的。让我纠正一下:TP下每张卡只存部分head的KV,所以KV Cache也是分摊的
- 编程复杂度:需要引入通信库(NCCL),初始化分布式环境
3.3 TP的适用场景
- 低延迟场景:比如实时对话,对TTFT和TPOT都有要求,TP能并行计算减少延迟
- 大模型单卡放不下:比如70B模型在A100-80G上FP16需要140GB显存,单卡肯定放不下,用TP=2就可以放下
- 单机多卡:TP最适合单机内的多卡,因为NVLink的带宽远高于PCIe和网络
经验值:TP通常做到2或4比较常见。TP=8虽然理论上更快,但通信开销和调度开销可能抵消收益。
四、Pipeline Parallel Serving:PP切分什么、Bubble问题
4.1 PP在推理时怎么工作
PP把模型的层按顺序分配到不同的GPU上。比如一个32层的模型,PP=2的话:
- GPU0:Layer 0~15
- GPU1:Layer 16~31
推理时,输入先到GPU0,算完前16层,把激活值传给GPU1,GPU1接着算后16层,最后输出。
时间 →
GPU0: [计算Layer0-15] [空闲] [计算下一个batch的Layer0-15]
GPU1: [等待] [计算Layer16-31] [等待] ...
↑ 这就是Pipeline Bubble(气泡)
4.2 Pipeline Bubble问题
PP最大的问题就是流水线气泡(Pipeline Bubble)------因为数据是顺序流过各阶段的,在填充和排空阶段,总有GPU是空闲的。
举个例子:PP=4,batch_size=1时,GPU利用率只有25%(因为同一时间只有一张卡在算,其他三张在等)。
怎么减少气泡?增大batch size / micro-batch数量。
PP=4, 4个micro-batch的情况:
GPU0: [m0] [m1] [m2] [m3]
GPU1: [m0] [m1] [m2] [m3]
GPU2: [m0] [m1] [m2] [m3]
GPU3: [m0] [m1] [m2] [m3]
↑ 填充阶段 ↑ 满载阶段 ↑ 排空阶段
可以看到,当micro-batch数量远大于PP阶段数时,气泡的比例就很小了。公式如下:
气泡比例 = (PP_size - 1) / (PP_size + num_microbatches - 1)
当num_microbatches很大时,气泡比例趋近于 (PP_size - 1) / num_microbatches,几乎可以忽略。
但在推理Serving场景中,情况不一样:
- 推理是自回归生成的,每一步都要等前一个token生成完才能生成下一个
- 所以PP推理的bubble问题比训练更严重------因为每个token的生成都要经历一次流水线的填充和排空
4.3 PP的适用场景
- 超大模型:比如千亿参数模型,即使TP也放不下,需要PP+TP组合
- 跨机器部署:PP的通信量相对TP小很多(只在层边界传激活值),更适合跨网络的多机场景
- 吞吐优先而非延迟优先:PP会增加延迟(数据要流经所有卡),但通过大batch可以获得不错的吞吐
4.4 TP+PP组合
实际生产中,大模型通常是TP+PP组合使用的:
- 单机内用TP(利用NVLink高带宽)
- 跨机器用PP(减少跨机通信量)
比如一个70B模型,部署在2台8卡A100上:
- 每台机器内 TP=8(8张卡做张量并行)
- 两台机器之间 PP=2(流水线并行)
- 总并行度:TP8 × PP2 = 16卡
五、TP/PP估算与部署建议
说了这么多理论,来点实用的:怎么估算我的模型需要几张卡?该用TP还是PP?
5.1 显存估算公式
先回忆一下LLM推理的显存占用:
总显存 = 模型参数显存 + KV Cache显存 + 激活值显存 + 运行时开销
模型参数显存(FP16/BF16):
参数量 × 2 bytes
例如:7B → 14GB,70B → 140GB,405B → 810GB
KV Cache显存(FP16/BF16):
2 × num_layers × num_heads × head_dim × seq_len × batch_size × 2 bytes
(前面的2是K和V各一份)
简化一下:对于LLaMA架构,KV Cache大约是 参数量 × seq_len / hidden_dim × batch_size × 2 bytes
经验值:
- 7B模型,4096上下文,batch_size=32,KV Cache大约 4~6GB
- 70B模型,4096上下文,batch_size=32,KV Cache大约 40~60GB
5.2 典型配置参考
以下是一些常见模型在A100-80G上的部署配置参考(FP16,留出余量给KV Cache):
| 模型大小 | TP=1 | TP=2 | TP=4 | TP=8 |
|---|---|---|---|---|
| 7B | 单卡轻松 | - | - | - |
| 13B | 勉强(KV空间小) | 轻松 | - | - |
| 34B | 放不下 | 勉强 | 轻松 | - |
| 70B | 放不下 | 放不下 | 勉强 | 轻松 |
| 405B | 放不下 | 放不下 | 放不下 | 需PP+TP |
5.3 部署优先级顺序
一个实用的部署策略优先级:
单卡能放下 → 直接上(最简单,成本最低)
↓ 单卡放不下
用TP(优先单机内TP,延迟低,实现简单)
↓ 单机卡不够
用PP+TP组合(跨机器,PP负责跨机,TP负责机内)
↓ 吞吐不够
加DP(数据并行,多副本,线性扩展吞吐)
为什么DP放在最后? 因为DP是"堆机器换吞吐",成本最高。先把单副本的性能榨干(TP/PP/量化/优化),再考虑横向扩展。
5.4 vLLM中启用TP的示例
bash
# vLLM 启用 Tensor Parallel (TP=2)
vllm serve meta-llama/Llama-2-70b-chat-hf \
--tensor-parallel-size 2 \
--gpu-memory-utilization 0.9 \
--max-model-len 4096
# 启用 Pipeline Parallel (PP=2) + TP=4
# vllm serve meta-llama/Llama-2-405b-chat-hf \
# --tensor-parallel-size 4 \
# --pipeline-parallel-size 2 \
# --gpu-memory-utilization 0.9
就这么简单,vLLM已经帮你封装好了分布式通信的细节。
六、负载均衡:不能只看QPS
当你有多个推理实例(DP副本)时,怎么把请求分配到各个实例上?这就是负载均衡要解决的问题。
6.1 为什么LLM Serving的负载均衡不一样
普通的Web服务,负载均衡策略很简单:轮询(Round Robin)、随机、最少连接数......基本够用。
但LLM Serving不一样,因为请求的"重量"差异极大:
- 有的请求输入只有10个token,输出50个token
- 有的请求输入有4000个token,输出2000个token
- Prefill阶段(处理输入)是计算密集型的
- Decode阶段(生成输出)是访存密集型的
不同的请求对GPU的压力完全不同。如果简单用轮询,可能出现有的GPU忙死、有的GPU闲死的情况。
6.2 常用负载均衡策略对比
| 策略 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| Round Robin | 按顺序轮流分配 | 实现简单 | 完全不考虑负载,容易不均匀 |
| 最少连接数 | 分给当前连接最少的实例 | 比RR好 | 连接数不等于实际负载 |
| 最少正在处理token数 | 分给当前正在处理token最少的实例 | 更精准 | 需要统计token数,有开销 |
| 基于队列长度 | 看每个实例的请求队列长度 | 反映真实排队压力 | 需要框架支持队列长度查询 |
| 基于GPU利用率 | 分给GPU利用率最低的实例 | 直接反映硬件压力 | GPU利用率有滞后,不准 |
| Prefix-aware路由 | 相同前缀的请求路由到同一实例(利用Prefix Cache) | 提升缓存命中率 | 实现复杂,需要解析请求 |
6.3 推荐策略:基于队列长度 + Prefix感知
对于LLM Serving,比较推荐的组合策略是:
-
主策略:基于请求队列长度 / 正在处理的token数
- 这是最能反映实例"忙不忙"的指标
- 很多推理框架(vLLM、TGI)都暴露了相关的metrics
-
辅助策略:Prefix感知路由
- 对于有大量共享前缀的场景(比如SaaS产品,系统提示词相同),把相同前缀的请求路由到同一个实例,可以大幅提升Prefix Cache命中率
- 实现方式:对prompt做hash,相同hash的请求路由到同一实例(类似一致性哈希)
python
# 伪代码:Prefix感知的负载均衡
class PrefixAwareLoadBalancer:
def __init__(self, instances, prefix_length=100):
self.instances = instances
self.prefix_length = prefix_length # 取前N个字符做hash
def select_instance(self, prompt):
# 1. 提取前缀
prefix = prompt[:self.prefix_length]
prefix_hash = hash(prefix)
# 2. 计算候选实例(一致性哈希)
candidate_idx = prefix_hash % len(self.instances)
candidate = self.instances[candidate_idx]
# 3. 检查候选实例负载是否过高,如果过高则选次优
if candidate.queue_size() < self.max_queue:
return candidate
else:
# 降级:选队列最短的
return min(self.instances, key=lambda x: x.queue_size())
6.4 健康检查与背压
健康检查:
- 不仅要检查进程是否存活,还要检查GPU是否正常、模型是否加载成功
- 建议做"深度健康检查":发一个测试请求,看能不能正常返回
- 不健康的实例要及时从负载均衡中摘掉
背压(Backpressure):
- 当所有实例都满载时,不能把请求直接拒掉(也不能无限排队)
- 应该有一个排队队列,超出队列长度返回503(服务不可用)或排队中
- 客户端需要有重试和降级逻辑
python
# 伪代码:带背压的请求处理
async def handle_request(request):
if current_queue_size >= max_queue_size:
# 返回503,告诉客户端稍后重试
return Response(status_code=503, content="服务繁忙,请稍后重试")
# 加入队列
queue_item = QueueItem(request)
await queue.put(queue_item)
# 等待处理结果
result = await queue_item.result_future
return result
七、弹性扩缩容:不止看GPU利用率
线上流量是波动的------白天高峰、深夜低谷。如果一直按峰值配机器,成本太高;如果按低谷配,高峰时又扛不住。
弹性扩缩容(Auto Scaling)就是解决这个问题的:流量大了自动加机器,流量小了自动减机器。
7.1 扩容指标:不止GPU利用率
普通Web服务扩容看CPU利用率,但LLM Serving不能只看GPU利用率。为什么?
因为GPU利用率高不一定是真的"忙"------可能只是在等KV Cache,或者batch size太小导致GPU没吃饱。
更合理的扩容指标组合:
| 指标 | 说明 | 扩容阈值参考 |
|---|---|---|
| GPU利用率 | 最直观,但有滞后 | 持续 > 80% |
| 请求队列长度 | 最灵敏,反映排队压力 | 队列 > 阈值持续N秒 |
| P99延迟 | 反映用户体验 | P99 > SLA阈值 |
| 拒绝请求数 | 最直接的压力信号 | 出现503错误 |
| 正在处理的token数 | 反映实际负载 | 接近上限 |
推荐做法 :用多个指标组合判断,比如"GPU利用率>80% 且 队列长度>10 且 持续30秒"才扩容。单一指标容易误判。
7.2 扩缩容策略
Scale Out(扩容)
- 触发条件:负载持续超过阈值
- 扩容步长:一次加几台?建议从小步开始,观察效果再调整。比如一次加2台,而不是一下加10台
- 预热(Pre-warm):新实例启动时需要加载模型,这个过程可能要几分钟。不能等满了才扩容,要提前预判
Scale In(缩容)
- 触发条件:负载持续低于阈值
- 缩容步长:比扩容更保守,一次减1-2台
- 冷却时间(Cooldown):缩容后要等一段时间再判断是否继续缩,避免频繁抖动
冷却时间(Cooldown)
- 扩容后冷却N分钟再判断是否继续扩
- 缩容后冷却M分钟再判断是否继续缩(M通常比N大,缩容要更保守)
- 目的:防止指标波动导致频繁扩缩容
python
# 伪代码:弹性扩缩容控制器
class AutoScaler:
def __init__(self, min_replicas, max_replicas):
self.min_replicas = min_replicas
self.max_replicas = max_replicas
self.current_replicas = min_replicas
self.last_scale_time = 0
self.cooldown_seconds = 300 # 5分钟冷却
def check_and_scale(self, metrics):
# 冷却期内不操作
if time.time() - self.last_scale_time < self.cooldown_seconds:
return
need_scale_out = (
metrics['gpu_util'] > 0.8 and
metrics['queue_size'] > 10 and
metrics['p99_latency'] > 2.0 # 秒
)
need_scale_in = (
metrics['gpu_util'] < 0.3 and
metrics['queue_size'] == 0 and
self.current_replicas > self.min_replicas
)
if need_scale_out and self.current_replicas < self.max_replicas:
self.scale_out(step=2)
self.last_scale_time = time.time()
elif need_scale_in:
self.scale_in(step=1)
self.last_scale_time = time.time()
7.3 预热与快速启动
扩容最大的痛点是新实例启动慢------加载一个70B模型可能要好几分钟。等你启动完,高峰可能已经过去了。
怎么解决?
- 预热池(Warm Pool):保持几台预热好的实例待命,需要时立即挂上流量
- 预测式扩容:基于历史流量模式预测,提前扩容。比如每天早上9点流量上升,8:45就开始扩容
- 模型分片加载:从分布式存储/本地缓存快速加载模型权重
- 镜像优化:把模型权重打包到Docker镜像里?不推荐,镜像太大。更好的方式是用PVC或节点本地缓存
- Serverless方案:云厂商的Serverless推理服务(如AWS SageMaker Endpoints、阿里云PAI-EAS)底层已经帮你做了弹性
八、云端vLLM框架二次开发:分层原则与工程实践
很多团队会基于vLLM做二次开发,加上自己的业务逻辑。这里分享一些架构设计上的经验。
8.1 分层原则
一个好的二次开发架构,应该遵循分层原则:
┌─────────────────────────────────┐
│ 业务层(Business Layer) │ ← 业务逻辑、鉴权、计费、限流
├─────────────────────────────────┤
│ 编排层(Orchestration Layer) │ ← 负载均衡、弹性扩缩容、服务发现
├─────────────────────────────────┤
│ 引擎层(Engine Layer) │ ← vLLM / TensorRT-LLM 引擎
└─────────────────────────────────┘
核心原则 :尽量不要改引擎层的代码,而是在外面包一层。
为什么?因为vLLM迭代很快,如果你改了它的源码,后续升级会非常痛苦------每次升级都要解决冲突。
8.2 常见需求的推荐位置
| 需求 | 推荐位置 | 实现方式 |
|---|---|---|
| 用户鉴权 / API Key | 业务层 | 在API网关或业务服务中做 |
| 限流 / 配额 | 业务层 | Redis计数、令牌桶 |
| 计费 | 业务层 | 统计token用量,对接计费系统 |
| 负载均衡 | 编排层 | 独立的LB服务,或用K8s Service + Istio |
| 弹性扩缩容 | 编排层 | K8s HPA / KEDA / 自研控制器 |
| 请求队列管理 | 编排层 | 独立的队列服务,异步派发 |
| Prefix Cache优化 | 引擎层(尽量不动) | 通过路由策略间接优化 |
| 自定义算子 | 引擎层 | 以插件形式接入,而非直接改源码 |
| 监控日志 | 各层都有 | 统一接入Prometheus + Grafana |
8.3 参考架构
一个典型的生产级LLM Serving架构:
┌──────────┐
│ API网关 │ ← 鉴权、限流、路由
└────┬─────┘
│
┌────▼─────┐
│ 业务服务 │ ← 业务逻辑、计费、审计
└────┬─────┘
│
┌──────────▼──────────┐
│ 负载均衡/调度器 │ ← 智能路由、队列管理
└──┬───┬───┬───┬─────┘
│ │ │ │
┌─────▼┐ ┌▼──┐ ┌▼──┐ ┌▼─────┐
│vLLM-1│ │v-2│ │v-3│ │vLLM-N│ ← DP副本
└──────┘ └───┘ └───┘ └──────┘
A100 A100 A100 A100
8.4 工程坑位提醒
最后分享一些踩过的坑:
-
版本升级要谨慎:vLLM每个版本的性能和行为可能有差异,升级前一定要做完整的回归测试。不要追新,稳定最重要。
-
显存碎片问题:长时间运行后,PagedAttention的显存可能产生碎片,导致可用显存越来越少。解决方案:定期重启实例,或者监控可用显存自动重启。
-
请求超时处理:一定要设置超时时间,否则一个超长请求可能占着茅坑不拉屎,把整个实例拖垮。超时后要确保资源正确释放。
-
优雅关闭:缩容下线实例时,要等正在处理的请求完成再关闭,不能强杀。K8s里可以用preStop钩子。
-
日志要结构化:不要用print打日志,用JSON格式的结构化日志,方便后续检索和分析。
-
监控要全链路:从API网关到引擎层,每个环节的延迟和错误率都要监控。出问题时能快速定位是哪一层的锅。
九、小结与思考
多机多卡Serving是LLM从Demo走向生产的必经之路。我们来回顾一下本文的核心内容:
- 四种并行方式:DP扩吞吐、TP降延迟+放更大模型、PP放超大模型、EP专用于MoE
- TP vs PP:TP适合单机内、低延迟场景;PP适合跨机器、大模型场景。生产中常用TP+PP组合
- 部署优先级:单卡→TP→PP→DP,先榨干单副本性能再横向扩展
- 负载均衡:不能只看QPS,要看队列长度、正在处理的token数。Prefix感知路由能提升缓存命中率
- 弹性扩缩容:不能只看GPU利用率,要多指标组合。缩容比扩容更保守,预热池和预测式扩容能解决启动慢的问题
- 二次开发:遵循分层原则,尽量不要改引擎层源码,在外面包业务层和编排层
最后想说:架构是演化出来的,不是设计出来的。 一开始不用搞太复杂,从单实例开始,流量上来了再加负载均衡,再上来了再加弹性扩缩容。每一步都要有数据支撑,而不是"感觉需要"。