EPLB 解决的问题场景

MoE Load Balance 中介绍了各种负载均衡算法,目标是在 MoE 模型训练阶段,尽量保证模型尽可能均衡的选择专家,实现真正的 MoE「术业有专攻」的设计初衷。

DeepSeek-V3 为例,训练时已经有一套 auxiliary-loss-free 的均衡机制:给每个 expert 的路由分数加一个可学习的 bias,谁太热就把 bias 调低。得益于 auxiliary-loss-free 算法的有效性,他们在整个训练期间都没有丢弃任何 token。

No Token-Dropping. Due to the effective load balancing strategy, DeepSeek-V 3 keeps a good load balance during its full training. Therefore, DeepSeek-V 3 does not drop any tokens during training.

但是,除了训练期间的负载均衡,DeepSeek V3 论文同时提到,他们也采取了特别的部署策略,来保证推理期间也不丢弃 token1

In addition, we also implement specific deployment strategies to ensure inference load balance, so DeepSeek-V 3 also does not drop tokens during inference.

也就是说,即使在训练时专家的负载均衡做的很好,**在推理阶段,线上数据分布改变后仍可能出现热门专家:

  • 线上请求领域变化、不同 batch 的 token 分布和 GPU 拓扑仍然会带来物理失衡。
  • 领域漂移 domain-shift。线上流量的分布和 14.8 T 预训练语料不一样,而 bias 是在训练分布上学出来的,冻结之后不会再动。
  • batch 规模。训练时一个 micro-batch 足够大,大数定律会帮你抹平方差;decode 阶段每个 expert 的 batch 常常在 256 token 以内,方差本身就是一等公民。
  • 拓扑不一样。训练时 routed expert 摊在 8 个节点的 64 张卡上,部署时 prefill 是 EP32、decode 是 EP320。训练期均衡的是「每个 expert 的负载」,而部署时要均衡的是「每张卡的负载」,后者还取决于哪些 expert 恰好落在同一张卡上。

在推理阶段,已经不能继续在线修改模型的 $W_g$ 或 $b_i$,因此引入了 EPLB23。EPLB 不会去改变模型 weight,而是在物理部署层复制并重排这些专家,从而均衡物理 GPU 的负载。

机制 解决的问题 是否改变逻辑路由
Router balance / auxiliary loss 避免模型长期只选择少数专家 会影响专家选择
EPLB 复制、放置已有专家,平衡物理 GPU 负载 不改变逻辑专家
DP load balancer 把请求、序列分配给不同 DP 实例 不负责专家布局
DeepEP 执行 token 的 dispatch/combine 通信 不负责负载规划

EPLB 设计原则:Replication / Placement

对于 MoE Layer,一层的耗时由负载最重那张卡决定,不由平均负载决定。负载不均衡指标如下,其中 $L_g$ 是第 $g$ 张卡上所有 expert 的 token 数之和。

$$ \text{imbalance} = \frac{\max_g L_g}{\frac{1}{G}\sum_g L_g} $$

要压低 $\max_g L_g$,能动的只有两样东西:

  • 复制(replication):把热 expert 复制成 $r_i$ 份,每份承担 $w_i / r_i$ 的负载。
  • 摆放(placement):决定哪个 expert replica 放在哪张卡。

对应于这篇讨论4,要想解决 EP 负载不均衡,经典的两条思路:

  • 全局重排序方案:对专家重新排序,采用“高低搭配”的策略来平衡负载。这即是对应着 Placement
  • 冗余副本方案:也就是在算力闲置的 GPU 上部署热门专家的副本,并将部分输入分流到副本上,从而实现负载的均衡。这即是对应着 Replication

只有第一个 Replication 能拆开单个热 expert。假设不复制,那么最热那个 expert 的负载 $\max_i w_i$ 会完整地压在某一张卡上,于是

$$ \frac{\max_g L_g}{\overline{L}} \;\ge\; \frac{\max_i w_i}{\overline{L}} $$

EPLB 方案结合了全局重排序方案和冗余副本方案。

rebalance_experts 接口

GitHub 上给出 EPLB 最核心的接口 rebalance_experts 给定每个 logical expert 的负载估计,算出一份「每个 expert 复制几份、每份放在哪张 GPU」的部署方案

1
2
3
4
5
6
7
phy2log, log2phy, logcnt = rebalance_experts(
    weight,        # [layers, num_logical_experts] 负载估计
    num_replicas,  # 副本总数(= physical experts 槽位数),必须是 num_gpus 的整数倍
    num_groups,    # expert group 数(node-limited routing 用的那个分组)
    num_nodes,     # 节点数,节点内是 NVLink
    num_gpus,      # GPU 总数,必须是 num_nodes 的整数倍
)

关键输入参数:

Notation 对应变量 含义
$L$ - layer 层数
$E$ - 专家数
$M$ num_replicas 副本总数(= physical experts 槽位数)
$G$ num_groups expert group 数(node-limited routing 用的那个分组)
$N$ num_nodes 节点数,节点内是 NVLink
$P$ num_gpus GPU 总数,必须是 num_nodes 的整数倍

rebalance_experts 的输出为以下 3 个变量:

名字 shape 含义
phy2log [L, M] 第 $s$ 个物理槽位上装的是哪个 logical expert。加载权重时用它
log2phy [L, E, maxlogcnt] logical expert $e$ 的所有副本槽位号,不足的位置填 -1。dispatch 时用它
logcnt [L, E] logical expert $e$ 有几个副本

下图展示了经过 rebalance_experts 之后,如何基于这 3 个输出对 expert 排布进行调整。其中 P2P 搬运权重是最重的部分,而且期间那些层不能服务请求。SGLang 的解法是按层分块--eplb-rebalance-layers-per-chunk),EPLBManager 那个生成器在块与块之间 yield 回去继续跑推理,把一次全量重排摊成很多小步5

生产级 EPLB 实现

以 SGLang 为例,在生产环境中,基于 EPLB 搭建的动态负载均衡系统大致如下图所示:

参考 SGLang 的 python/sglang/srt/eplb/ 对应代码6

  1. 统计负载ExpertDistributionRecorder 在每次 forward 里累计每个 logical expert 收到的 token 数,dump_record() 出来的 logical_count 就是喂给 rebalance_expertsweight
  2. 决定何时重排EPLBManager 是个生成器,每 eplb_rebalance_num_iterations 次 forward 醒一次,还会先看 average_utilization_rate_over_window 判断值不值得动——重排本身有代价,不均衡不严重时最好别碰。
  3. 算方案。对应于 DeepSeek 那份代码,eplb_algorithms/deepseek.pyeplb.py 的逐行搬运,唯一的改动是把自动的策略选择换成显式的 enable_hierarchical
  4. 搬权重ExpertLocationUpdater 按新的 phy2log 把 expert 权重在 GPU 之间 P 2 P 搬过去。

Q: 专家动态调整是发生在什么时候?

取决于预测算法的设计,频率可高可低。因为调整是有开销的,一般不会在每轮迭代中进行一次调整。

Q: 调整过程需要重新加载权重吗 ?

需要。如果是小 EP,只涉及小 EP 范围内的设备,如果是大 EP 调整范围更大。

EPLB 的实现

原语:replicate_experts

num_log 个槽位是原件,之后每多一个槽位,就给「当前每副本负载最高」的那个 expert 再复制一份。 weight / logcnt 是每个 logical expert 摊到每个副本上的负载,取 argmax 就是当前的瓶颈。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def replicate_experts(weight: torch.Tensor, num_phy: int):
    n, num_log = weight.shape
    num_redundant = num_phy - num_log
    assert num_redundant >= 0
    phy2log = torch.arange(num_phy, dtype=torch.int64).repeat(n, 1)
    rank = torch.zeros(n, num_phy, dtype=torch.int64)
    logcnt = torch.ones(n, num_log, dtype=torch.int64)
    arangen = torch.arange(n, dtype=torch.int64)
    for i in range(num_log, num_phy):
        redundant_indices = (weight / logcnt).max(dim=-1).indices
        phy2log[:, i] = redundant_indices
        rank[:, i] = logcnt[arangen, redundant_indices]
        logcnt[arangen, redundant_indices] += 1
    return phy2log, rank, logcnt

它在优化的目标是

$$ \min_{r} \; \max_i \frac{w_i}{r_i} \quad \text{s.t.} \quad \sum_i r_i = P,\; r_i \ge 1 $$

值得强调的是,这个贪心不是启发式,它就是精确最优解。把它写成「反复给当前最高平均值追加一个席位」,就会发现这正是选举里的 D’Hondt(Jefferson)最高平均数法,而最高平均数法对 min-max 目标是最优的。

代价也小:循环 num_redundant 次,每次是一个 [n, num_log] 的向量化 max,所有层、所有节点并行推进。真正贵的是下一个原语。

原语:balanced_packing

balanced_packing 不搬 tensor,也不直接操作 GPU;它只计算一张摆放坐标表。这个函数会在分层策略里调用两次,同一个“物品/包”在两次调用中指代不同东西:

调用位置 待装的物品 weight 表示什么
Step 1 expert group node group 收到的 token 总数
Step 3 physical expert replica GPU 该副本预计承担的 token 数

先把输入抽象成一个普通装箱问题。设一层里有 $n$ 个物品、$m$ 个包:

  • weight[l, j] 是第 $l$ 行中物品 $j$ 的重量;每一行独立装箱。
  • num_packs = m 是包的数量。
  • groups_per_pack = n / m 是每个包必须容纳的物品数,记为 $k$。

目标是让各包的总重量尽量接近,但有一个不能违反的硬约束:每个包必须恰好装 $k$ 个物品

每次只做一件事:在还没有装满的包里,找到当前总重量最小的那个,把下一个物品放进去。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def balanced_packing(weight: torch.Tensor, num_packs: int):
    num_layers, num_groups = weight.shape
    assert num_groups % num_packs == 0
    groups_per_pack = num_groups // num_packs

    if groups_per_pack == 1:
        pack_index = torch.arange(weight.size(-1), dtype=torch.int64,
                                  device=weight.device).expand(weight.shape)
        rank_in_pack = torch.zeros_like(weight, dtype=torch.int64)
        return pack_index, rank_in_pack

    indices = weight.float().sort(-1, descending=True).indices.cpu()
    pack_index = torch.full_like(weight, fill_value=-1, dtype=torch.int64, device='cpu')
    rank_in_pack = torch.full_like(pack_index, fill_value=-1)
    for i in range(num_layers):
        pack_weights = [0] * num_packs
        pack_items = [0] * num_packs
        for group in indices[i]:
            pack = min((i for i in range(num_packs) if pack_items[i] < groups_per_pack),
                       key=pack_weights.__getitem__)
            pack_index[i, group] = pack
            rank_in_pack[i, group] = pack_items[pack]
            pack_weights[pack] += weight[i, group]
            pack_items[pack] += 1
    return pack_index, rank_in_pack

核心循环直接读成四步:

  1. indices = ...sort(...).indices:得到从重到轻的原始物品 ID
  2. pack_items[i] < groups_per_pack:过滤掉已经装满的包。
  3. min(..., key=pack_weights.__getitem__):在剩余包中选择当前总负载最小的包。
  4. 先记录 pack_indexrank_in_pack,再更新这个包的累计重量和物品数。因为 rank_in_pack 写在 pack_items[pack] += 1 之前,所以槽位从 0 开始编号。

这就是带固定基数约束的 LPT(Longest Processing Time first):先处理最重的物品,避免把大物品拖到最后;再用“高低搭配”压低最重包的总负载。

分层策略:三步走

两个原语拼起来就是 rebalance_experts_hierarchical,三步:

flowchart LR
    W["weight<br/>L x E"] --> S1
    S1["Step 1<br/>group 装进 node<br/>balanced_packing"] --> S2
    S2["Step 2<br/>node 内复制<br/>replicate_experts"] --> S3
    S3["Step 3<br/>副本装进 GPU<br/>balanced_packing"] --> O["phy2log<br/>log2phy<br/>logcnt"]

为什么要分层?因为 DeepSeek-V3 用的是 node-limited routing:256 个 routed expert 分成 8 个 group,每个 token 先选 top-4 个 group、再在里面选 8 个 expert,从而保证一个 token 最多只需要访问 4 个节点。如果把同一个 group 的 expert 全部放在同一个节点内,那么 group 内部的流量就走 NVLink 而不是 IB。分层策略就是为了保住这个性质:先决定哪些 group 归哪个节点,之后所有操作都锁在节点内部,绝不跨节点搬 expert。

复制与摆放的三步;负载均为示意,非任何模型的实测结果单独打开 ↗

分层策略与全局策略

rebalance_experts 支持两种策略:

  • 分层策略
  • 全局策略

在具体实现上,全局策略是分层策略在 $N=1$ 和 $G=1$ 的特殊实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
if num_groups % num_nodes == 0: # global load balancing
    phy2log, phyrank, logcnt = rebalance_experts_hierarchical(
                                weight,
                                num_replicas,
                                num_groups=G,
                                num_nodes=N,
                                num_gpus=P,
                            )
else:
    phy2log, phyrank, logcnt = rebalance_experts_hierarchical(
                                weight,
                                num_replicas,
                                num_groups=1,
                                num_nodes=1,
                                num_gpus=P,
                            )

相当于告诉算法:

把所有 expert 看成一个 group,把全部 GPU 看成一个大节点,不考虑真实节点边界。

因此原来的三层分层约束自然退化成全局策略。

策略的选择是自动的:num_groups % num_nodes == 0 就分层,否则全局。 DeepSeek-V3 有 8 个 group,于是 prefill 的 4 节点(8 % 4 = 0)走分层、decode 的 40 节点(8 % 40 = 8)走全局。

效果与代价

下面这组数是我在 DeepSeek-V3 的形状上跑出来的:61 层里前 3 层是 dense,所以 58 个 MoE 层,每层 256 个 routed expert、8 个 group。部署按 prefill 的配置:EP32、4 个节点、288 个副本(也就是每卡 8 + 1 个 expert,正是 paper 里的 32 个冗余 expert)。

负载是人造的,因为真实的线上 expert 负载分布没有公开数据。我用了两种:对数正态($w_i = e^{\sigma z_i}$,模拟训练期均衡之后残留的温和偏斜)和 Zipf($w_i \propto \text{rank}^{-a}$,模拟病态的热点)。所有绝对数值都只在这两个人造分布下成立,但它们揭示的机制是真的。

温和偏斜下,EPLB 基本把问题解决了

偏斜(对数正态 $\sigma$) 不用 EPLB 分层 288 全局 288 分层 320 全局 320
0.1 1.073 1.007 1.002 1.006 1.001
0.2 1.151 1.011 1.002 1.010 1.001
0.3 1.237 1.020 1.008 1.018 1.004
0.5 1.444 1.031 1.008 1.031 1.007

训练已经把 expert 负载拉得比较平时,EPLB 能把 7%~44% 的单卡偏差压到 3% 以内。增加 32 个冗余副本已经拿到了大部分收益,继续增加到 64 个帮助不大。

全局策略更均衡,但分层策略只差一两个百分点,同时能保证 expert group 不跨节点。对跨节点带宽敏感的 Prefill 来说,这个交换是划算的。

偏斜严重时,分层策略会撞上节点级上限

偏斜(Zipf $a$) 不用 EPLB 分层 288 全局 288 分层 320 全局 320
0.0(完全均匀) 1.000 1.000 1.000 1.000 1.000
0.3 1.399 1.024 1.001 1.025 1.002
0.6 2.430 1.084 1.001 1.083 1.001
1.0 6.064 1.415 1.006 1.341 1.001
1.5 13.448 2.900 1.178 2.364 1.033

在 $a=1.0$ 时,全局策略仍能做到 1.006,分层策略却停在 1.415。问题不在节点内部,而在节点之间:

层级 max/mean
节点之间(4 个节点的总负载) 1.328
节点内部(每个节点的 8 张卡) 1.024
整体(32 张卡) 1.415

分层策略先把 expert group 分给节点,之后的复制和装箱都只发生在节点内部。设节点 $n$ 的总负载为 $L_n$,每个节点有 $P/N$ 张 GPU,那么:

$$ \underbrace{\frac{\max_g L_g}{\;\sum_n L_n / P\;}}_{\text{整体不均衡}} \;\ge\; \underbrace{\frac{\max_n L_n}{\;\sum_n L_n / N\;}}_{\text{节点级不均衡}} $$

右边就是节点级不均衡,也是分层策略的硬下限。增加冗余副本只能改善节点内部的分配,不能把负载搬到其他节点:

副本数 冗余数 全局策略 分层策略
256 0 5.370 5.377
288 32 1.006 1.416
384 128 1.001 1.330
640 384 1.001 1.329

全局策略逐渐接近 1.00,分层策略则停在 1.33 左右。

这个下限和 expert group 的粒度有关。DeepSeek-V3 只有 8 个 group,放进 4 个节点后,每个节点恰好两个 group,可调整空间很小。实验中把 group 数从 8 增加到 64,节点级 max/mean 从 1.0225 降到 1.0023,整体也从 1.0305 降到 1.0118。

Prefill 分层策略,Decode 全局策略

但 group 数属于模型结构,部署时不能随便修改。

  • 分层策略真正换来的,是通信局部性:每个 group 只在一个节点内
  • 全局策略则可能把同一 group 铺到所有节点,削弱 node-limited routing 对跨节点通信的约束

因此 DeepSeek-V3 在 Prefill 使用分层策略,在节点更多、主要依赖 IB 点对点通信的 Decode 使用全局策略。

Prefill 分层策略 Decode 全局策略
规模 4 节点 / EP 32 40 节点 / EP 320
Group 与节点 8 groups 可均分到 4 节点 8 groups 无法均分到 40 节点
每 GPU expert 数 9 个 1 个
通信 IB 到节点,再用 NVLink 转发 Direct P2P over IB
副本范围 限制在所属节点内 可以放到任意 GPU
主要收益 保持 locality、控制跨节点通信 更充分地消除全局热点
主要代价 存在节点级负载不均衡地板 可能牺牲 group/node locality

分层策略用于 EP size 较小的 prefill,全局策略用于 EP size 较大的 decode

DeepSeek V3 论文提到,Prefill 不增加跨节点 token All-to-All,是因为冗余副本仍放在原 expert 所属的节点内。Dispatcher 只在该节点内部选择不同 GPU,不会把同一个 token 同时发给多个副本。

在 Decode 阶段,DeepSeek-V3 Decode 正是每张 GPU 一个 physical expert,因此 只要决定热点 expert 复制几份,单卡负载就基本确定了。副本放在哪张 GPU,对计算装箱来说没有区别,因此可以把 320 张 GPU 当成一个扁平的 physical slot 池,不存在一个重排布的过程。

推理与部署实践

DeepSeek 在 2025 年介绍了他们 DeepSeek V3 的推理部署系统7

计算通信 Overlap

对于 prefill 阶段,两个 batch 的计算和通信交错进行,一个 batch 在进行计算的时候可以去掩盖另一个 batch 的通信开销

对于 decode 阶段,不同阶段的执行时间有所差别,所以我们把 attention 部分拆成了两个 stage,共计 5 个 stage 的流水线来实现计算和通信的重叠。

更好的 Load Balancer

对于 MoE 模型的 Serving,结合 DeepEP 和 EPLB 可以比较好的实现高性能部署,然而在某些情况下仍然存在 imbalance 的情况,为此社区又进一步有一些新的方案89

  • LPLB10
  • Waterfall11

Waterfill LPLB
管哪部分负载 shared expert(每个 token 都要过,稠密) routed expert 里被 EPLB 复制过的那些(稀疏)
决定什么 每个 token 的 shared 槽位由哪张卡执行 一个 logical expert 的 token 怎么在它的多个副本之间分配
手段 按剩余空隙灌水的轻量启发式 每层解一个 min-max 线性规划(GPU 上)
前提 shared expert fusion EPLB 得真的放了冗余副本
开销 接近零 一次 all-reduce + 一次 LP 求解

参考资料


  1. DeepSeek-V3 Technical Report, https://arxiv.org/abs/2412.19437 ↩︎

  2. deepseek-ai/EPLB, https://github.com/deepseek-ai/EPLB ↩︎

  3. DeepSeek Open Source Week Day 4 - Optimized Parallelism Strategies(DualPipe / EPLB / profile-data,2025-02-27), https://github.com/deepseek-ai/open-infra-index ↩︎

  4. MoE 并行负载均衡:EPLB 的深度解析与可视化, https://zhuanlan.zhihu.com/p/29963005584 ↩︎

  5. Support layerwise rebalancing experts, sgl-project/sglang#6851, https://github.com/sgl-project/sglang/pull/6851 ↩︎

  6. sglang/python/sglang/srt/eplb/,其中 eplb_algorithms/deepseek.pyeplb.py 的移植, https://github.com/sgl-project/sglang/tree/main/python/sglang/srt/eplb ↩︎

  7. DeepSeek-V3 / R1 推理系统概览, https://zhuanlan.zhihu.com/p/27181462601 ↩︎

  8. https://www.lmsys.org/blog/2025-05-05-large-scale-ep ↩︎

  9. https://docs.sglang.io/docs/advanced_features/expert_parallelism#workload-balancer ↩︎

  10. https://github.com/deepseek-ai/LPLB ↩︎

  11. https://www.lmsys.org/blog/2026-06-26-waterfill-lplb ↩︎