SGLang 源码完全拆解(十八):弹性 EP 与故障恢复——扩容加入、rank 故障、专家布点重排与权重备份

16723 字
84 分钟
SGLang 源码完全拆解(十八):弹性 EP 与故障恢复——扩容加入、rank 故障、专家布点重排与权重备份

承接上一篇:把”静态并行”变成”可变的并行”

SGLang 系列前十七篇已经完整走过一条从请求入口到设备执行的路径:请求在 TokenizerManager 里被分词、经 ZMQ 送进 Scheduler,调度器把 token 预算与 KV 页分配交给 PrefillAdder 与 RadixCache,再由 TpModelWorker 和 ModelRunner 组装成一次 ForwardBatch 交给注意力后端与 MoE 层,最后从采样、语法约束一路回到增量解码器。上一篇(SGLang 源码完全拆解(十七):DP Attention 与专家并行)把这条路径上的”横向切分”讲透了:注意力用数据并行避开重复计算,MoE 用专家并行把专家摊到几十张卡上,用 DeepEP 做 all-to-all 搬 token,用 EPLB 动态重排专家位置。

但那篇讲的所有并行方案都有一个共同前提:进程组的形状在启动那一刻就固定了。tp_size、ep_size、nnodes 都是启动参数,torch.distributed 的进程组一旦建成,成员集合就是常量。这个前提在真实集群里非常昂贵:

  • 训练好的模型权重、KV 池大小、CUDA Graph 的捕获形状,全部按启动时的并行度分配。想把 EP 从 32 扩到 72,唯一办法是停服、改参数、重启、重新加载几百 GB 权重,中间这段时间整个服务不可用。
  • 集群里挂掉一张卡或一个节点,哪怕只是网卡抖动,整组 all-to-all 都会卡死或报错,因为集合通信默认要求”全体到场”。几十张卡的专家并行里,一张卡故障就让整台服务下线。
  • 更糟的是,故障恢复后专家布点是按旧的世界大小算的,如果世界大小变了,每个 rank 上的专家存储布局也得跟着变——而权重是从磁盘按固定分片加载的,不可能随随便便重摆。

SGLang 的 Elastic EP(弹性专家并行)就是为了拆掉”启动即定型”这个前提而存在的一层机制。它要回答的问题是:在不重启进程组、不重新加载权重的前提下,如何让 EP 世界大小在运行时变大,以及如何在部分 rank 掉线后把服务拉回来。

大规模专家并行的整体架构:注意力与 MoE 采用错开的并行方式,中间靠 all-to-all 搬运 token
大规模专家并行的整体架构:注意力与 MoE 采用错开的并行方式,中间靠 all-to-all 搬运 token

上图来自 SGLang 官方博客 Large-Scale Expert Parallelism for DeepSeek(2025)。它是弹性 EP 要”改变形状”的那张原本固定的图:注意力部分与 MoE 部分用不同的并行方式切分,接缝处由 all-to-all 通信缝合。本篇讲的扩容与故障恢复,本质上都是在不重建这张图的前提下重排它的成员。

本篇的目标是把这层机制从外到内讲透,覆盖四条主线:

  1. 预留:--max-ep-size 如何在启动时就把”未来可能加入的 rank”算进进程组与通信缓冲区的形状里;
  2. 扩容:一次 /scale_elastic_ep 请求如何经过一个显式状态机,把新 rank 接进可扩展的 WORLD 组,并在新世界大小下重新同步专家布点与 CUDA Graph;
  3. 故障恢复:某些 rank 掉线时,active_ranks 位图如何被用于检测、如何用 Mooncake 的 recover_ranks 把掉线的 rank 重新拉进每一个进程组;
  4. 重排与补权重:世界大小变化后 EPLB 如何按”活跃 rank 数”重新算布点,以及 ExpertBackupManager 为什么必须在每台节点上留一份专家权重的 DRAM 副本。

本篇是 SGLang 子系列的第十八篇,也是核心机制的最后一块:运行时拓扑可变。下一篇会回到系列路线图的下一站——llama.cpp 源码完全拆解的总览篇,用同一套”先架构、再模块、后关键代码”的方法建立第三个框架的源码地图。

为什么必须”预留”:进程组形状不可变的根本原因

要理解弹性 EP 的全部设计,先要理解它对抗的是什么约束。

torch.distributed 的进程组(ProcessGroup)在创建时就确定了三样东西:成员 rank 列表、通信后端、通信缓冲区。以 NCCL 为例,ncclCommInitRank 拿到的是一份固定的 rank 数组,通信域内部的通道、树结构、环形拓扑全部按这份数组预计算。后续想加一个新 rank,NCCL 没有”增量加入”的接口——只能把原来的 communicator 销毁重建。

SGLang 的并行状态集中在 python/sglang/srt/distributed/parallel_state.py 的 GroupCoordinator 里。它对每个并行维度(TP、EP、DP、PP……)持有一个 device_group(GPU 侧通信)和一个 cpu_group(CPU 侧 gloo 通信)。弹性 EP 的做法不是去改 NCCL 的语义,而是在创建进程组时就把容量按最大值分配,把”未加入的槽位”标记为不活跃。看这段初始化逻辑:

# python/sglang/srt/distributed/parallel_state.py(节选)
if max_world_size is not None:
assert max_world_size >= len(ranks), (
f"max_world_size ({max_world_size}) must be >= "
f"len of ranks ({len(ranks)})"
)
pg_active_size = max_world_size
pg_active_ranks = torch.zeros(pg_active_size, dtype=torch.int32, device="cuda")
pg_active_ranks[: len(ranks)] = 1
...
dev_opts = MooncakeBackendOptions(pg_active_ranks, recovered_rank)

三个关键点:

  • 形状按 max_world_size 分配,而不是按当前的 len(ranks)。也就是说进程组从一开始就”知道”自己未来会有多少个座位。
  • active_ranks 是一个 int32 位图,长度为 max_world_size,已加入的 rank 位置填 1,空座位填 0。这个位图不是事后统计出来的,而是进程组创建时构造、并交给通信后端的一份状态。
  • MooncakeBackendOptions(pg_active_ranks, recovered_rank) 把这个位图直接传给通信后端。这意味着通信后端在发起集合通信时会知道哪些 rank 当前在场,从而可以对不在场的槽位做跳过或等待处理。

max_ep_size 这个启动参数决定了座位数:

max_ep_size: A[
Optional[int],
"Maximum EP size the server can scale to at runtime. Pre-allocates "
"active-rank state and backend buffers to this size. Defaults to the "
"launch-time world size.",
] = None

“Defaults to the launch-time world size” 意味着:不显式设置 --max-ep-size 时,弹性 EP 退化为普通 EP——座位数恰好等于启动时的世界大小,没有空位,也就没有扩容空间。这一条设计非常重要,它解释了为什么弹性能力是”选择性付费”的:预留的座位会占用通信缓冲区显存(比如 DeepEP 的 dispatch/combine 缓冲、MessageQueue 的共享内存广播环),不预留就不付这份代价。

max_ep_size 之外还有两个配套参数,专门给”以 joiner 身份启动”的新进程用:

ep_join_rank_offset: A[
int,
Arg(
help=(
"Global rank offset of an elastic EP joining group. Scale "
"joiners must set this to the current effective EP size."
),
cli_name="--elastic-ep-join-rank-offset",
),
] = 0
elastic_ep_initial_size: A[
Optional[int],
"EP size used to define the immutable per-rank expert storage layout. "
"Scale joiners must use the primary deployment's launch-time EP size.",
] = None

ep_join_rank_offset 是 joiner 的全局 rank 起点。举例:主部署 EP=4,现在要扩到 6,那么新起来的 joiner 进程要把 --elastic-ep-join-rank-offset 设为 4,它的全局 rank 就从 4 开始编号,与主部署已有的 0–3 完全不冲突。注意 --elastic-ep-join-mode 的 help 里有一句硬约束:

'scale' joins as a new rank beyond the original group size and requires --node-rank 1.

扩容 joiner 必须是 --node-rank 1,这不是实现细节而是拓扑约定:弹性 EP 目前的实现假设每个 joiner 节点是”追加”的独立节点,不改动主节点内部的 rank 编号。

elastic_ep_initial_size 则锁定了每 rank 专家存储布局。它必须是主部署启动时的 EP 大小,而不是扩容后的目标大小。原因在后面讲权重备份时会看到:每个 rank 的 expert 权重张量形状是按初始 EP 大小切分的,扩容后一个 rank 负责的专家数量变了,但存储布局不能重排,否则加载权重与备份权重的指针映射全部失效。

启动参数作用谁设置
--elastic-ep-backend选择弹性 EP 的通信后端:mooncake 或 nixl主部署与 joiner 一致
--max-ep-size座位数,预留 active-rank 状态与后端缓冲区仅主部署
--elastic-ep-join-moderecover(故障后回到原槽位)或 scale(作为新 rank 追加)仅 joiner
--elastic-ep-join-rank-offsetjoiner 的全局 rank 起点,等于当前有效 EP 大小仅 scale joiner
--elastic-ep-initial-size锁定每 rank 专家存储布局,必须是主部署启动时的 EP 大小仅 joiner
--elastic-ep-scale-timeout一次缩放操作的超时秒数,默认 600主部署
--enable-elastic-expert-backup启用专家权重 DRAM 备份主部署

弹性状态机:一次扩容到底经历了什么阶段

弹性 EP 的状态不是散落在各处的变量,而是一个显式的有限状态机,集中在 python/sglang/srt/elastic_ep/elastic_ep.py 的 ElasticEPState 与 ElasticEPStateManager 里。

状态数据本身是一组很朴素但信息量很大的字段:

@dataclass
class ElasticEPState:
active_ranks: Optional[torch.Tensor]
last_active_ranks: Optional[torch.Tensor]
active_ranks_cpu: Optional[torch.Tensor]
effective_ep_size: int = 0
pending_ep_size: Optional[int] = None
scale_phase: str = "idle"
last_error: Optional[str] = None
pending_since: Optional[float] = None
original_ep_size: int = 0
has_scaled: bool = False
ep_join_rank_offset: int = 0

逐个解释:

  • active_ranks:设备上的位图,长度 max_world_size,谁在场谁为 1。它是”当前世界的真值”。
  • last_active_ranks:上一次快照。用于增量检测——比较它和 active_ranks 是否相等,就能判断世界是否刚刚发生了变化。
  • active_ranks_cpu:CPU 副本。存在的理由是轮询路径(比如 HTTP 侧的 is_scaling 查询、rank 故障检测)不应该触发 GPU 同步,CPU 副本是”权威读取源”。源码注释直接点明了这一点。
@classmethod
def is_scaling(cls) -> bool:
"""Return whether a scale or recovery operation is pending.
The CPU snapshot is authoritative because rank polling uses it too.
"""
  • effective_ep_size:当前服务使用的 EP 大小。它和 active_ranks 的位图是两个不同视角:位图描述”座位上有谁”,effective_ep_size 描述”这次前向要按多大的世界算”。
  • pending_ep_size:正在申请但尚未提交的目标大小。为空表示没有在途的缩放操作。
  • scale_phase:状态机的当前阶段。
  • original_ep_size:启动时的世界大小。用于判断”当前是否运行在扩展世界里”。
  • has_scaled:是否发生过缩放。
  • ep_join_rank_offset:joiner 视角的 rank 偏移。

状态机的推进由一组类方法完成,它们构成了一条清晰的线性流程:

request_scale(n) → scale_phase = "waiting_for_cohort"
begin_scale() → scale_phase = "pending"
mark_joining() → scale_phase = "joining"
mark_configuring_data_plane() → scale_phase = "configuring_data_plane"
mark_syncing_new_world() → scale_phase = "syncing_new_world"
commit_scale() → scale_phase = "serving_expanded", effective_ep_size = pending
fail_scale(err) → scale_phase = "failed"
fail_recovery(err) → scale_phase = "recovery_unsupported"

request_scale 是入口,它做了两件事:拒绝在已有在途请求或已进入 recovery_unsupported 时重复申请,然后记录目标大小与起始时间戳。

@classmethod
def request_scale(cls, n: int) -> bool:
inst = cls._instance
if inst is None:
return False
if (
inst.pending_ep_size is not None
or inst.scale_phase == "recovery_unsupported"
):
return False
inst.pending_ep_size = n
inst.scale_phase = "waiting_for_cohort"
inst.last_error = None
inst.pending_since = time.monotonic()
return True

注意 recovery_unsupported 是一个”终态”。一旦集群进入这个状态(通常意味着恢复所需的前提条件不满足,比如备份权重缺失、Mooncake 传输引擎注册失败),后续所有缩放申请都会被直接拒绝。这是一个刻意的”熔断”设计:与其在半坏的状态下继续缩放、把错误扩散到整个集群,不如明确标记能力不可用。

状态机里最有一手设计感的是 get_data_plane_ep_size:

@classmethod
def get_data_plane_ep_size(cls) -> int:
inst = cls._instance
assert inst is not None, "Elastic EP state is not initialized."
if inst.pending_ep_size is not None and inst.scale_phase in (
"configuring_data_plane",
"syncing_new_world",
):
return inst.pending_ep_size
return inst.effective_ep_size

它区分了控制面(control plane)与数据面(data plane)两个 EP 大小。在 configuring_data_plane 与 syncing_new_world 这两个阶段,控制面已经把目标大小记为 pending_ep_size,但数据面(通信缓冲区、专家布点)还没有真正按新大小建好。这时候如果谁去问”现在的 EP 大小是多少”,答案必须是目标大小——因为所有正在构造的缓冲区都按目标大小在建;如果返回旧值,缓冲区就会按旧大小建、按新大小用,形状对不上。

这个 get_data_plane_ep_size 还被一个更宏观的判据使用:

def elastic_expanded_world_enabled() -> bool:
"""Return whether execution uses ranks admitted after server launch.
Launch-time TP groups exclude ranks admitted during scale-up.
"""
inst = ElasticEPStateManager.instance()
if inst is None:
return False
if get_parallel().max_ep_size is None:
return False
return ElasticEPStateManager.get_data_plane_ep_size() > inst.original_ep_size

函数名直译就是”扩展世界已启用”。它的注释里有一句很容易被忽略但极其重要的约束:启动时建好的 TP 组不包含扩容时加入的 rank。这句话的原因在于扩容 joiner 是以 --node-rank 1 追加进来的独立节点,它参加的是 WORLD 组与 EP 组,而不参加主部署的 launch-time TP 组。因此”扩展世界”下代码必须区别对待两类 rank:一类在原始的 TP 组里,一类只在 WORLD 与 EP 组里。

is_scaling 里还有一个细节值得注意:判断”是否在缩放中”时不能只看 pending_ep_size:

if inst.pending_ep_size is not None:
return True
active_count = int(inst.active_ranks_cpu[: inst.effective_ep_size].sum().item())
return active_count < inst.effective_ep_size

第二段逻辑说的是:即使没有在途的缩放请求,只要当前有效世界里还有 rank 是掉线状态(位图里 1 的个数小于有效大小),就认为系统处于”缩放/恢复中”。也就是说这个状态机同时覆盖了”主动扩容”和”被动恢复”两种触发源——这正是把它叫做 Elastic EP 而不是 Scale EP 的原因。

reset() 也要单独看一眼:

def reset(self):
if self.active_ranks is not None:
# Reserved slots stay inactive until their ranks join.
self.active_ranks.zero_()
self.active_ranks[: self.effective_ep_size] = 1
self.snapshot_active_to_last()
self.sync_active_to_cpu()

它先清零再按 effective_ep_size 重填前缀。注释 “Reserved slots stay inactive until their ranks join” 再次强调:预留座位默认是失活的,只有 rank 真正加入才置 1。这是一个安全默认值——如果反过来把预留座位默认置 1,集合通信会一直等一个永不到场的 rank。

扩容入口:一个 HTTP 请求如何走到调度器

弹性扩容对外的接口极简,只有两个 HTTP 端点,定义在 python/sglang/srt/entrypoints/elastic_ep.py 里:

@router.post("/scale_elastic_ep")
@auth_level(AuthLevel.ADMIN_OPTIONAL)
async def scale_elastic_ep(raw_request: Request):
"""Request an asynchronous EP scale-up."""
...
new_ep_size = body.get("new_ep_size")
...
if get_exec().moe.elastic_ep_backend is None:
return ORJSONResponse(
{"error": "elastic EP is not enabled (set --elastic-ep-backend)"},
status_code=HTTPStatus.NOT_FOUND,
)
result = await _global_state.tokenizer_manager.scale_elastic_ep(
ScaleElasticEPReqInput(new_ep_size=new_ep_size)
)
@router.get("/is_scaling_elastic_ep")
@auth_level(AuthLevel.ADMIN_OPTIONAL)
async def is_scaling_elastic_ep(raw_request: Request):
"""Return the tokenizer's mirrored Elastic EP scale state."""
...
return ORJSONResponse(_global_state.tokenizer_manager.get_elastic_ep_state())

两个端点都带 AuthLevel.ADMIN_OPTIONAL:缩放是改变集群拓扑的管理操作,不是普通推理请求,所以鉴权级别与 /generate 不同。端点本身不做任何决策,只做参数校验(new_ep_size 必须是正整数)与”功能是否开启”检查,然后把请求转给 TokenizerManager。

注意这里有一个容易被忽略的设计:HTTP 端点返回的是 result.success、old_ep_size、new_ep_size,但扩容并没有在 HTTP 请求返回时完成。端点注释写得很清楚——“Request an asynchronous EP scale-up”。这一点在整个机制里非常关键:新 rank 需要从别的节点启动进程、加入 WORLD 组、重新同步专家布点、重捕 CUDA Graph,这可能需要几十秒甚至几分钟。如果 HTTP 请求同步等待,客户端会超时;更糟的是,如果客户端在等待期间重试,就会触发重复的缩放请求。

因此接口被设计成”提交 + 轮询”两段式:POST /scale_elastic_ep 只负责把目标大小写进状态机(scale_phase 变为 waiting_for_cohort),然后立即返回;客户端用 GET /is_scaling_elastic_ep 轮询状态,直到 scale_phase 变成 serving_expanded 或 failed。

TokenizerManager 侧的镜像状态说明了这套轮询的语义:

def get_elastic_ep_state(self):
return {
"is_scaling_elastic_ep": self.elastic_pending_ep_size is not None,
"effective_ep_size": self.elastic_worker_count * self.attn_replica_size,
"pending_ep_size": self.elastic_pending_ep_size,
"scale_phase": self.elastic_scale_phase,
"last_error": self.elastic_last_error,
}

effective_ep_size 在 TokenizerManager 里是用 elastic_worker_count * attn_replica_size 算出来的,而不是直接读调度器的状态——因为 TokenizerManager 并不知道每个调度器内部的具体情况,它只维护一个”活跃的 DP worker 数”,乘以每个注意力副本的固定宽度就是有效 EP 大小。这个乘法背后的拓扑约束后面会展开。

而 scale_elastic_ep 的实现里有一个细节:它把请求发给每一个 DP 调度器(docstring 写的就是 “Send a scale request to every DP scheduler”),因为每个调度器都持有自己那份 ElasticEPStateManager 单例,状态必须同步推进:

async def scale_elastic_ep(
self, obj: ScaleElasticEPReqInput
) -> ScaleElasticEPReqOutput:
"""Send a scale request to every DP scheduler."""
if self.elastic_pending_ep_size is not None:
return ScaleElasticEPReqOutput(
success=False,
message=(
"A previous scale operation has not completed yet. Wait until "
"all pending ranks have joined before issuing another scale."
),
...
)
self.auto_create_handle_loop()
responses: List[ScaleElasticEPReqOutput] = await self.scale_elastic_ep_communicator(obj)
for res in responses:
if not res.success:
...
return res

“任一调度器失败就整体失败”是一个全有或全无的语义。这很符合直觉:如果一部分 DP 组认为 EP 已经扩到 8,另一部分认为还是 4,那么后续的 all-to-all 通信双方对不上规模,整个服务会静默地跑出错误结果。所以这里宁可直接把整个请求判失败。

决策真正发生在调度器的 handle_scale_elastic_ep 里,它一次性做了全部合法性检查:

def handle_scale_elastic_ep(
self, recv_req: ScaleElasticEPReqInput
) -> ScaleElasticEPReqOutput:
"""Begin a pending elastic EP scale-up request."""
old_ep_size = ElasticEPStateManager.get_effective_ep_size()
new_ep_size = recv_req.new_ep_size
max_ep_size = get_parallel().max_world_size
if new_ep_size <= old_ep_size:
return ScaleElasticEPReqOutput(
success=False,
message=(
f"new_ep_size ({new_ep_size}) must be greater than current "
f"effective_ep_size ({old_ep_size})."
),
...
)
if new_ep_size > max_ep_size:
return ScaleElasticEPReqOutput(
success=False,
message=(
f"new_ep_size ({new_ep_size}) exceeds --max-ep-size "
f"({max_ep_size}). Restart with a larger --max-ep-size."
),
...
)

三条前置校验分别对应三种物理约束:

  • 只能变大(new_ep_size > old_ep_size)。弹性 EP 当前的实现是单向的——只支持扩容,不支持缩容。原因不难理解:缩容意味着要撤掉一部分 rank 并把它们持有的专家权重转移出去,而权重的存储布局在 elastic_ep_initial_size 时就被锁定了,撤掉 rank 会让剩下的 rank 需要接收新专家、重新分配张量切片,工程量远大于扩容。错误信息里的措辞也很直白:“Restart with a larger —max-ep-size”——参数写小了没有别的办法,只能重启。
  • 不能超过座位数(new_ep_size <= max_ep_size)。座位在启动时就分配好了,超出只能重启。
  • 必须对齐注意力副本宽度:
from sglang.srt.elastic_ep.topology import attn_replica_size
replica_size = attn_replica_size()
if new_ep_size % replica_size != 0:
return ScaleElasticEPReqOutput(
success=False,
message=(
f"new_ep_size ({new_ep_size}) would admit an incomplete "
f"attention replica with size {replica_size}."
),
...
)

这条约束最能体现”SGLang 里注意力并行与专家并行必须错开”这一设计(上一篇讲过)的后果。topology.py 里的定义是:

def attn_replica_size() -> int:
"""Return the fixed physical width of one attention-DP replica."""
parallel = get_parallel()
return parallel.attn_tp_size * parallel.attn_cp_size

一个注意力副本(attention replica)是同时承载同一份注意力计算的一组 rank,宽度等于 attn_tp_size × attn_cp_size(cp 是 context parallel)。在 DP Attention 模式下,若干个这样的副本共存,每个副本处理自己那部分请求。于是:

DP size = EP size / attn_replica_size

也就是说,新增的 EP rank 必须是整数个注意力副本。如果新的大小只增加半个副本(比如副本宽度是 4,而 EP 从 8 扩到 10),那么新增的 2 张卡里没有一张能凑成完整的、能独立做注意力的副本——第 3 到第 4 号位置空着,构成一个”残缺副本”。这个副本内部的 TP 组不完整,注意力计算无法启动。

topology.py 里还有个专门的校验函数,用来保证 joiner 与主部署使用同样的注意力拓扑:

def validate_matching_attention_topology(
*,
primary_attn_tp_size: int,
primary_attn_cp_size: int,
joining_attn_tp_size: int,
joining_attn_cp_size: int,
) -> None:
primary_topology = (primary_attn_tp_size, primary_attn_cp_size)
joining_topology = (joining_attn_tp_size, joining_attn_cp_size)
if joining_topology != primary_topology:
raise ValueError(
"Primary and joining cohort must use the same attention topology "
"for Elastic EP scale-up "
...
)

“joining cohort”(加入队列)是一个重要概念:扩容不是一张卡一张卡地加,而是一整批同构的 rank 一起加入。这批 rank 必须与主部署在注意力侧使用完全一致的 attn_tp_size 与 attn_cp_size,否则它们算出的 hidden state 与主部署对不上,DP Attention 的 gather/scatter 就无法拼接。这解释了一个非常自然的实现选择:joiner 通常就是按与主部署完全相同的启动参数再起一个节点,只是额外带上 --node-rank 1、--elastic-ep-join-mode scale、--elastic-ep-join-rank-offset <当前有效EP大小> 和 --elastic-ep-initial-size <初始EP大小>。

topology.py 还提供了两个把”物理 rank 视角”折叠成”注意力副本视角”的工具:

def collapse_physical_rank_status(
status: Sequence[bool], attn_replica_size: int
) -> list[bool]:
"""Collapse physical-rank health with an all-members replica rule."""
physical_ep_size_to_dp_size(len(status), attn_replica_size)
return [
all(bool(value) for value in status[start : start + attn_replica_size])
for start in range(0, len(status), attn_replica_size)
]

这个函数的规则是 “全成员才健康”:一个注意力副本只有在它全部的 attn_replica_size 个物理 rank 都健康时才算健康,任何一个掉线,整个副本判定为不健康。这条规则的价值在于把”rank 级健康信息”归约成”DP 级健康信息”,因为调度器与路由是按 DP 组来分配流量的——残缺的副本不能被派活。函数名里的 “collapse” 就是这个归约动作。

加入队列:cohort 注册与新世界同步

主部署收到缩放请求后,只是把状态推进到 waiting_for_cohort,然后等待。真正把事情推进的是 joiner 进程在自己的 ModelRunner 启动阶段执行的一段逻辑:

def _initialize_elastic_ep_joiner(self) -> None:
if not (
get_exec().moe.elastic_ep_backend is not None
and get_exec().moe.is_ep_scale_joiner
):
return
parallel = get_parallel()
join_effective_ep_size = parallel.ep_join_rank_offset + parallel.tp_size
dist.barrier(group=parallel.tp_group.cpu_group)
if parallel.tp_rank == 0:
register_scale_cohort(
parallel.ep_join_rank_offset,
join_effective_ep_size,
self._elastic_cuda_graph_enabled(),
)
join_scale_process_group()
get_context().override("elastic_ep.scale_join", ep_size=join_effective_ep_size)

这段代码是整个扩容流程的”握手”核心,逐行拆开:

第一步:算出加入后的新世界大小。

join_effective_ep_size = parallel.ep_join_rank_offset + parallel.tp_size

ep_join_rank_offset 是 joiner 的全局 rank 起点(等于主部署当前的有效 EP 大小),加上 joiner 自己的 tp_size,就是加入后的新世界大小。举例:主部署 EP=4,joiner 是 2 张卡的一组(tp_size=2),并且把 --elastic-ep-join-rank-offset 设为 4,那么 join_effective_ep_size = 4 + 2 = 6。这与前面调度器里校验的 new_ep_size 是同一个数——双方必须独立算出相同的结果,否则握手就对不上。这也是为什么 --elastic-ep-join-rank-offset 必须”等于当前有效 EP 大小”,写错了会直接导致 new_ep_size 与实际加入人数不一致。

第二步:在 TCPStore 上登记 cohort。

def register_scale_cohort(
rank_offset: int, target_ep_size: int, cuda_graph_enabled: bool
) -> None:
store = get_global_tcp_store()
if store is None:
raise RuntimeError("Elastic EP scale-up requires the global TCPStore.")
payload = msgspec.json.encode(
ScaleCohort(
target_ep_size=target_ep_size,
cuda_graph_enabled=cuda_graph_enabled,
)
)
store.set(f"{_SCALE_COHORT_KEY_PREFIX}/{rank_offset}", payload)

ScaleCohort 是一个极简的消息结构:

class ScaleCohort(msgspec.Struct, frozen=True, kw_only=True):
target_ep_size: int
cuda_graph_enabled: bool

它只携带两个字段,但每一个都必要:target_ep_size 告诉主部署”我们这批人加入后世界应该多大”,cuda_graph_enabled 告诉主部署”我们这批人启用了 CUDA Graph”——因为老 rank 必须据此决定要不要重新捕获图。这是异构配置下必须显式协商的东西:如果 joiner 开了图捕获而主部署没开,双方的执行路径就不一样,KV 池的内存占用也不同。

存储用的是 get_global_tcp_store(),一个 torch.distributed 的 TCPStore。选择 TCPStore 而不是集合通信来传递这条信息,是有原因的:此刻 joiner 还没有正常加入任何进程组,它只能通过一个外部的、所有进程都能访问的键值存储来”留言”。get_scale_cohort 是对称的读取端,先 store.check([key]) 再 store.get(key) 解码——用 check 而不是直接 get,是为了让主部署能区分”还没人登记”与”登记了但读失败”两种情况。

第三步:真正加入 WORLD 组。

def join_scale_process_group() -> None:
"""Join the expandable WORLD group for an append-only scale operation."""
_join_world_group()
_refresh_ep_members()
def _join_world_group() -> None:
from mooncake.pg import join_group
join_group(torch.distributed.group.WORLD)

注意这里调的不是 torch.distributed 的 API,而是 mooncake.pg.join_group。这是整套机制里最关键的一处”借力”:弹性 EP 依赖 Mooncake(Moonshot AI 开源的 KVCache 传输与通信库)提供的进程组扩展能力。标准 PyTorch 进程组没有”中途加入”的接口,Mooncake 的 mooncake.pg 模块补上了这一能力——它允许在一个已经建好的、容量被预留的进程组里,把新 rank 通过 join_group 接进来,并通过 recover_ranks 让掉线的 rank 重新入场。

_refresh_ep_members() 则是通知 MoE token dispatcher 更新成员表:

def _refresh_ep_members() -> None:
from sglang.srt.layers.moe.token_dispatcher.mooncake import EPBuffer
buffer = EPBuffer.get_existing_buffer()
if buffer is not None:
buffer.update_ep_member()

第四步:在新世界大小下重建依赖规模的组件。

get_context().override("elastic_ep.scale_join", ep_size=join_effective_ep_size)
global_ep_rank = parallel.tp_rank + parallel.ep_join_rank_offset
broadcast_global_expert_location_metadata(
model_config=self.model_config,
moe_ep_rank=global_ep_rank,
src_rank=0,
)
set_global_expert_distribution_recorder(
ExpertDistributionRecorder.init_new(
get_global_expert_location_metadata(),
rank=global_ep_rank,
)
)
from sglang.srt.layers.dp_attention import enable_joiner_all_gather
enable_joiner_all_gather()
update_dp_attention_for_elastic_ep(
physical_ep_size=join_effective_ep_size,
physical_ep_rank=global_ep_rank,
)

这里四件事各自解决一个问题:

  • get_context().override(...):把”我是以 scale joiner 身份加入的、加入后的真实 EP 大小是多少”写进运行时上下文,供后续所有依赖 EP 大小的构造代码读取。
  • broadcast_global_expert_location_metadata(..., src_rank=0):从 rank 0 广播全局专家布点。joiner 手里没有主部署的专家布点信息(哪一层哪个专家在哪个物理位置),必须从主部署同步一份。src_rank=0 写死在这里,是因为扩容路径下主部署的 rank 0 一定活着——它是发起缩放的节点,不可能同时是掉线节点。
  • ExpertDistributionRecorder.init_new(...):初始化专家负载统计器。EPLB 的重排决策依赖每个 rank 上报的专家调用次数,joiner 必须从接入的一刻就开始记账。
  • enable_joiner_all_gather() + update_dp_attention_for_elastic_ep(...):把 DP Attention 的模块级状态改成”扩展世界”模式。因为 joiner 不参与主部署启动时的 TP 组,DP Attention 的 all-gather 必须走一条新的、覆盖全部活跃 rank 的路径。

最后,joiner 把新世界写回自己的状态机快照:

state = ElasticEPStateManager.instance()
if state is not None:
state.active_ranks.zero_()
state.active_ranks[:join_effective_ep_size] = 1
state.snapshot_active_to_last()
state.sync_active_to_cpu()
state.scale_phase = "syncing_new_world"

active_ranks.zero_() 之后把前 join_effective_ep_size 位置为 1,等价于”承认 0 到 join_effective_ep_size-1 全部在场”。这与 reset() 的逻辑一致,都是”先清空、再按有效大小重填前缀”。

还有一个细节值得单独讲:joiner 在整段流程里做了一件很反直觉的事——主动关掉 EPLB:

if self.eplb_manager is not None:
self.eplb_manager.disable_rebalance(
"EPLB rebalance is disabled while elastic EP scale-up "
"is being finalized"
)

主部署在收到缩放请求时也做同样的事(eplb_manager.disable_rebalance("elastic EP scale-up is pending"))。双方一起禁掉 EPLB,原因是:扩容期间专家布点正在被重新计算与广播,此时如果 EPLB 并发地去做一次重排(它的重排也是搬专家权重、改 physical_to_logical_map),两者会互相覆盖对方的元数据,结果是某些 rank 按旧布点路由、某些按新布点路由,token 被送到错误的专家上。禁重排这个动作把风险窗口彻底关掉,等缩放走到 serving_expanded 再由主部署主动触发一次重排(见后文 maybe_rebalance_after_rank_fault)。

rank 故障:位图检测、peer 等待与逐组恢复

扩容讲完,接下来是另一半:故障恢复。这是弹性 EP 比”动态扩容”更难的部分,因为它面对的是不可预期的成员变化——rank 可能在任意时刻消失,而剩下的 rank 必须在不重启的前提下继续服务。

检测的入口在 ModelRunner 的前向路径上:

def maybe_recover_ep_ranks(
*,
tp_group: parallel_state.GroupCoordinator,
eplb_manager: EPLBManager,
model_config: ModelConfig,
moe_ep_rank: int,
) -> bool:
# TODO(perf): `active_ranks.all()` on a CUDA tensor triggers host-device
# synchronization, and this function is on the forward-path.
# This check only runs when `--elastic-ep-backend` is enabled, so the
# synchronization overhead does not propagate to other configs.
# Leave for future optimization of the elastic EP path.
if tp_group.active_ranks.all() and tp_group.active_ranks_cpu.all():
return False

这个函数的快路径就是这一行判断:如果两张位图(设备上的与 CPU 上的)全是 1,立即返回 False,什么也不做。绝大多数前向调用走的就是这条路径。

源码里的 TODO(perf) 注释很有价值,它诚实地记录了这个设计的代价:在 CUDA 张量上调用 .all() 会触发一次 host-device 同步。GPU 侧位图在显存里,要让 CPU 判断它是否为全 1,必须把数据拷回主机,这会打断 CPU 与 GPU 的异步流水线。作者的处理方式是”知道代价但接受”,理由是这条检查只在开启 --elastic-ep-backend 时才执行,不会污染普通配置的性能。这也解释了为什么状态里要维护一份 active_ranks_cpu:故障检测路径尽量只读 CPU 副本,避免为了一次”世界是否健康”的探测而付 GPU 同步的代价。

当快路径不成立(存在不在场的 rank),进入慢路径:

tp_active_ranks = tp_group.active_ranks.detach().cpu().numpy()
tp_active_ranks_cpu = tp_group.active_ranks_cpu.detach().numpy()
tp_active_ranks &= tp_active_ranks_cpu
# NOTE: `ranks_to_recover` uses indices in `tp_group`. For the current
# Mooncake elastic EP implementation we assume `--pp-size=1`, so the
# tp-group index is the same as the global rank index.
ranks_to_recover = [
i for i in range(len(tp_active_ranks)) if not tp_active_ranks[i]
]

三件事:

  1. 两份位图按位与(&=)。设备位图与 CPU 位图可能存在短暂不一致(一方已更新、另一方还没同步),取交集是保守做法:只有两边都认为不在场的 rank 才被当作需要恢复。取并集会导致把”其实已经回来了但设备位图还没刷新”的 rank 当成故障 rank 去恢复,凭空制造一次多余的集合通信。
  2. ranks_to_recover 是”不在场”的 rank 列表,不是”在场”的。
  3. 注释坦白了当前实现的拓扑假设:索引是 TP 组内的局部索引,之所以能直接当全局 rank 用,是因为当前 Mooncake 弹性 EP 实现假设 --pp-size=1。这是一个明确的适用范围声明——如果你用了流水线并行,这条恢复路径的前提就不成立了。

真正的恢复动作在 try_recover_ranks 里,它做的事情远比名字看上去复杂:

def try_recover_ranks(global_ranks: List[int]) -> bool:
"""Recover ranks in WORLD and every launch-time parallel group."""
if not _try_recover_world(global_ranks):
return False
from mooncake.pg import recover_ranks
for group in _iter_live_parallel_groups():
local_ranks = _map_global_to_group_local_ranks(group.ranks, global_ranks)
if not local_ranks:
continue
_wait_for_peer_state(group.device_group, local_ranks)
recover_ranks(group.device_group, local_ranks)
_wait_for_peer_state(group.cpu_group, local_ranks)
recover_ranks(group.cpu_group, local_ranks)
_maybe_create_message_queue(group)
_refresh_ep_members()
return True

这段代码回答了一个非常具体的问题:一个 rank 掉线时,它到底从”多少个进程组”里掉出去了?

答案是——每一个它参与过的进程组。一个 TP rank 可能同时属于 WORLD、TP 组、EP 组、DP 组、注意力组等等,_iter_live_parallel_groups() 把这些组全部遍历出来:

def _iter_live_parallel_groups() -> Iterator[parallel_state.GroupCoordinator]:
groups = []
for group_ref in parallel_state._groups.values():
group = group_ref()
if group is not None:
groups.append(group)
yield from sorted(groups, key=lambda group: group.unique_name)

两个细节:parallel_state._groups 里存的是弱引用(group_ref() 解引用后可能为 None,说明该组已被回收),所以要先过滤掉已死的组;排序用 unique_name 是为了让所有 rank 以完全一致的顺序遍历组——如果各 rank 以不同顺序调 recover_ranks,集合通信的匹配就会错位,这是分布式代码里最经典的一类 bug。

然后对每个组做四步操作:

local_ranks = _map_global_to_group_local_ranks(group.ranks, global_ranks)

第一步:全局 rank 转组内 rank。 因为每个进程组有自己的编号体系(组内 rank 0 未必是全局 rank 0),恢复操作必须用组内编号:

def _map_global_to_group_local_ranks(
group_ranks: List[int], global_ranks: List[int]
) -> List[int]:
rank_to_local = {rank: index for index, rank in enumerate(group_ranks)}
return [rank_to_local[rank] for rank in global_ranks if rank in rank_to_local]

注意最后的 if rank in rank_to_local——如果一个故障 rank 不属于这个组,就跳过它(比如某个注意力副本组的成员范围不含这个 rank)。if not local_ranks: continue 处理的就是”这个组里一个故障 rank 都没有”的情况。

第二步:等待对端到达就绪态。

_PEER_STATE_POLL_INTERVAL_SEC = 0.01
def _wait_for_peer_state(backend, ranks: List[int]) -> None:
from mooncake.pg import get_peer_state
while not all(get_peer_state(backend, ranks)):
time.sleep(_PEER_STATE_POLL_INTERVAL_SEC)

这里用的是轮询等待(间隔 10ms)而不是阻塞式等待。为什么必须等?因为恢复是”把掉线的 rank 拉回来”,而 rank 能不能被拉回来,取决于对方进程是否已经重启并处于可接入状态。轮询是唯一能表达”等对方准备好”的方式。

maybe_recover_ep_ranks 里有一段注释专门解释了为什么这个看起来”很危险”的轮询其实是安全的:

# try_recover_ranks polls peer state via Mooncake EP backend.
# Mooncake's internal semantics guarantee that all ranks observe
# consistent peer readiness state, so collective operations below
# are safe even though polling appears local.

也就是说:各个 rank 各自独立地判断”对端准备好了吗”,表面上它们可能在不同时刻得出不同结论,从而在不同时刻进入集合通信——这是分布式系统里的经典死锁模式(一部分 rank 已经在集合通信里等,另一部分还在轮询)。Mooncake 的语义保证消除了这个风险:所有 rank 观察到一致的 peer 就绪状态。这个保证是整个恢复机制能成立的前提,也是它必须绑定 Mooncake 这类特殊后端、无法直接用原生 PyTorch 进程组实现的原因。

第三步:设备组与 CPU 组各恢复一次。

recover_ranks(group.device_group, local_ranks)
_wait_for_peer_state(group.cpu_group, local_ranks)
recover_ranks(group.cpu_group, local_ranks)

每个 GroupCoordinator 都持有两个通信通道(device_group 走 NCCL/NVLink,cpu_group 走 gloo),两者必须分别恢复,因为它们的成员表、缓冲区、连接是各自独立的。先恢复设备组再恢复 CPU 组,并且各自都先 _wait_for_peer_state——顺序与等待都不能省。

第四步:重建共享内存广播环。

def _maybe_create_message_queue(group) -> None:
if not group.use_message_queue_broadcaster or group.world_size <= 1:
return
from sglang.srt.distributed.device_communicators.shm_broadcast import MessageQueue
group.mq_broadcaster = MessageQueue.create_from_process_group(
group.cpu_group, 1 << 22, 6
)

MessageQueue 是 SGLang 用来做”一份数据广播给多个 rank”的共享内存环(1 << 22 是 4 MiB 的环大小,6 是槽位数)。它在创建时就绑定了进程组的成员,rank 故障会让旧的消息队列失效,所以在恢复完 CPU 组之后必须重建。use_message_queue_broadcaster 判断保证只有真正用到广播的组才重建。

_try_recover_world 和 try_admit_scale_ranks 是 WORLD 组的对应版本:

def _try_recover_world(global_ranks: List[int]) -> bool:
from mooncake.pg import get_peer_state, recover_ranks
world_backend = torch.distributed.group.WORLD
if not all(get_peer_state(world_backend, global_ranks)):
return False
recover_ranks(world_backend, global_ranks)
logger.debug("[Elastic EP][recover] WORLD recover_ranks(%s) done", global_ranks)
return True
def try_admit_scale_ranks(global_ranks: List[int]) -> bool:
"""Admit append-only ranks into the expandable WORLD group."""
if not _try_recover_world(global_ranks):
return False
_refresh_ep_members()
return True

注意 _try_recover_world 与 _wait_for_peer_state 的行为差异:前者用 if not all(...): return False 立即返回失败,不做等待;后者是死等到就绪。这个差异有明确用意——扩容路径不能阻塞主部署的调度循环。新 rank 什么时候起来由外部运维决定,主部署不能为了等它而把整个前向路径堵住。所以扩容侧是”探测到就接入,探测不到就下次再来”,而恢复侧是”既然已经确认要恢复,就等到底”。

恢复完成后,还有两件收尾工作:

if ranks_to_recover and try_recover_ranks(ranks_to_recover):
eplb_manager.reset_generator()
broadcast_global_expert_location_metadata(
model_config=model_config,
moe_ep_rank=moe_ep_rank,
src_rank=get_healthy_expert_location_src_rank(
invoked_in_elastic_ep_rejoin_path=False
),
)
ElasticEPStateManager.instance().reset()
logger.info(f"recover ranks {ranks_to_recover} done")
return True
  • eplb_manager.reset_generator():把 EPLB 的重排生成器重置。因为世界大小变了,之前算到一半的布点已经作废。
  • 重新广播专家布点,但这次 src_rank 不再写死 0,而是走一个专门的挑选函数:
def get_healthy_expert_location_src_rank(
*, invoked_in_elastic_ep_rejoin_path: bool
) -> int:
world_group = get_parallel().world_group
# NOTE: do not key off the launch-time `ep_join_mode` here.
# A rank that was started as a rejoin rank may later act as a healthy
# rank in a subsequent recovery cycle.
local_rejoin_flag = bool(invoked_in_elastic_ep_rejoin_path)
gathered_rejoin_flags = world_group.all_gather_object(local_rejoin_flag)
for rank_in_group, is_rejoin_rank in enumerate(gathered_rejoin_flags):
if not is_rejoin_rank:
return world_group.ranks[rank_in_group]
raise RuntimeError(
"No healthy rank found for broadcasting expert location metadata. "
"All ranks are marked as elastic_ep_rejoin."
)

这个函数解决的是一个身份漂移问题。注释说得很直白:“do not key off the launch-time ep_join_mode here”——不能用启动时写的 --elastic-ep-join-mode 来判断谁是健康 rank。原因是:一个以 recover 身份启动的 joiner,在故障恢复完成后就变成了正常的服务 rank;如果之后再发生一次故障,它完全可能充当”提供布点的健康源”。启动参数描述的是”这个进程出生时的角色”,而恢复需要的是”此刻谁手里有正确的布点”——这是两个不同的问题。

实现方式是用 all_gather_object 收集所有 rank 的”我是不是 rejoin rank”标志,取第一个不是 rejoin 的 rank 作为广播源。兜底逻辑也很有意思:如果所有人都自称 rejoin rank,直接抛错而不是随便挑一个——因为随便挑一个可能挑到手里没有正确布点的 rank,把错误的专家布点广播出去,那比直接失败糟糕得多。

世界变了,专家布点必须重算

扩容或恢复完成后,世界大小变了,但专家布点还是按旧世界算的。这时必须重排,而重排的第一件事是把”活跃 rank 数”喂给 EPLB 的算法。

在 python/sglang/srt/eplb/eplb_algorithms/__init__.py 里可以看到这个喂法:

ElasticEPStateManager.instance().active_ranks
if ElasticEPStateManager.instance() is not None
else ElasticEPStateManager.healthy_rank_state()

而 healthy_rank_state 是构造”全员健康”位图的工具:

@classmethod
def healthy_rank_state(
cls, *, ep_size: Optional[int] = None, device: Optional[torch.device] = None
) -> torch.Tensor:
size = ep_size if ep_size is not None else torch.distributed.get_world_size()
dev = device if device is not None else cls._select_device()
return torch.ones(size, dtype=torch.int32, device=dev)

注意这里 _select_device() 的存在:位图优先放在 CUDA 上,没有 CUDA 时回落到 CPU——

@staticmethod
def _select_device() -> torch.device:
if is_cuda():
return torch.device("cuda")
elif is_cpu():
return torch.device("cpu")
else:
raise NotImplementedError("Only CUDA and CPU support elastic ep now.")

只有 CUDA 与 CPU 两条路径,其它加速器(NPU、ROCm 等)在当前实现里会直接报错。这是一个明确的适用范围边界。

拿到位图之后,重排算法本身要做的事在 python/sglang/srt/eplb/eplb_algorithms/elasticity_aware.py 里:

def rebalance_experts(
weight: torch.Tensor,
num_replicas: int,
num_groups: int,
num_nodes: int,
num_gpus: int,
enable_hierarchical: bool,
active_ranks: torch.Tensor,
) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""
Entry point for expert-parallelism load balancer.
Parameters:
weight: [layers, num_logical_experts], the load statistics for all logical experts
num_replicas: number of physical experts, must be a multiple of `num_gpus`
...
"""
num_layers, num_logical_experts = weight.shape
weight = weight.float().cpu()
num_active_ranks = active_ranks.sum().item()
num_local_experts = num_replicas // num_gpus
if num_active_ranks < num_gpus:
# Must fall back to global load-balance policy
# and fix some params
phy2log, phyrank, logcnt = rebalance_experts_hierarchical(
weight,
num_local_experts * num_active_ranks,
1,
1,
num_active_ranks,
)
elif enable_hierarchical:
...

关键在于 num_active_ranks = active_ranks.sum().item()。布点算法不是按”物理 GPU 总数”算的,而是按”当前活跃的 GPU 数”算的。当有 rank 掉线时,num_active_ranks < num_gpus 成立,于是走一条特殊的退路:

rebalance_experts_hierarchical(
weight,
num_local_experts * num_active_ranks,
1,
1,
num_active_ranks,
)

它把 num_replicas 从”完整规模”降到”每卡专家数 × 活跃卡数”,同时把 num_groups 与 num_nodes 都设成 1,也就是放弃层次化均衡、退回到全局扁平均衡。这个退路的动机是:层次化均衡的目标是”尽量减少跨节点通信”,它需要知道节点拓扑;而当有节点掉线时,拓扑已经不完整了,继续按旧拓扑做层次化均衡可能把专家搬到已经不存在的节点上。退回到扁平模式虽然通信代价更高,但至少保证布点落在真实存在的 rank 上。

后面还有一段专门处理”如何把布点结果重新对齐到完整的 rank 空间”:

if num_active_ranks < num_gpus:
phy2log_slices = list(
phy2log.view(num_layers, num_active_ranks, -1).unbind(dim=1)
)
active_ranks_list = active_ranks.tolist()
for idx, active_rank in enumerate(active_ranks_list):
if not active_rank:
phy2log_slices.insert(idx, torch.zeros_like(phy2log_slices[0]))
log2phy = torch.where(
log2phy >= idx * num_local_experts,
log2phy + num_local_experts,
log2phy,
)
phy2log = torch.stack(phy2log_slices, dim=1).contiguous().view(num_layers, -1)

这段代码做的是”把压缩的布点结果张开回完整规模”:

  • 循环遍历完整位图,遇到掉线的 rank 就插入一片全零(phy2log_slices.insert(idx, torch.zeros_like(...))),把掉线 rank 的位置占住,并把它之后所有 rank 的编号整体后移。
  • log2phy 的修正要跟着做:log2phy 是”逻辑专家到物理位置”的反查表,当某个位置被插入的零片推开后,所有 >= idx * num_local_experts 的条目都要 + num_local_experts 才指向正确的物理专家。

为什么非要”张开”而不是干脆输出压缩后的表? 因为下游的所有消费者——MoE 层的 physical_to_logical_map、token dispatcher 的 all-to-all 路由表、ExpertBackupClient 的物理专家编号运算——都假设表的下标就是物理 rank 编号,长度等于完整规模。如果这里输出一个长度只有活跃卡数的紧凑表,下游就得整体改接口。张开一次、让下游保持不变,是更省事也更不容易出错的选择。

ExpertLocationMetadata.logical_to_all_physical(...) 反查的用法在权重备份客户端里能看得更清楚:

physical_expert_ids = global_expert_location_metadata.logical_to_all_physical(
layer_id, expert_id
)
for physical_expert_id in physical_expert_ids:
if physical_expert_id not in range(
num_local_experts * self.moe_ep_rank,
num_local_experts * (self.moe_ep_rank + 1),
):
continue
param = weight_param[physical_expert_id % num_local_experts]

“逻辑专家 → 所有物理副本”是一对多的:一个逻辑专家可能因为冗余专家机制被复制到多个物理位置。% num_local_experts 把全局物理编号折算成当前 rank 内部的槽位编号。这套编号体系正是上一篇讲的 EPLB 机制的延续,弹性 EP 只是给它加上了”活跃集合会变”这个新维度。

扩容后布局究竟怎么变:_compute_elastic_expert_layout

到此为止,“每 rank 专家存储布局不变、世界变大”这句话一直是当作前提在用的。它在源码里的准确含义由 python/sglang/srt/eplb/expert_location.py 里一个六行的函数给出:

def _compute_elastic_expert_layout(
base_num_physical_experts: int,
initial_ep_size: int,
effective_ep_size: int,
) -> tuple[int, int]:
assert base_num_physical_experts % initial_ep_size == 0
num_local_physical_experts = base_num_physical_experts // initial_ep_size
return (
num_local_physical_experts * effective_ep_size,
num_local_physical_experts,
)

两个返回值分别对应:

  • 全局物理专家槽位数(num_physical_experts)= 每 rank 槽位数 × 有效 EP 大小;
  • 每 rank 物理专家槽位数(num_local_physical_experts)= 总槽位数 ÷ 初始 EP 大小。

注意这两个除数的区别:前者乘的是”现在多大”,后者除的是”出生时多大”。这一乘一除定义了整套弹性布局模型的全部内容:

每 rank 槽位数(不变量) = base_num_physical_experts / initial_ep_size
全局槽位数(随世界变) = 每 rank 槽位数 × effective_ep_size

由此得到一个非常干净的结论:扩容时,每张卡上的专家槽位数不变,全局槽位数随有效 EP 大小线性增长。新加入的 rank 不是”重新划分已有的槽位”,而是自带一整套空槽位进场——每个新 rank 带来 num_local_physical_experts 个新槽位,全局槽位总数从 每卡槽位 × 4 变成 每卡槽位 × 6。

这也解释了两个此前悬空的实现选择:

  • 为什么每 rank 的张量形状可以在扩容中保持不变:num_local_physical_experts 是常量,每一层的 experts.w13_ 张量第一维(专家槽位数)与 w2_ 一样都不需要改。新 rank 到来后,前向路径只需按”当前有效世界的槽位总数”去遍历,而不是按物理上分配了多少张量。
  • 为什么槽位里原来装的是谁这件事必须被重写:槽位总数变了,physical_to_logical_map((layers, num_physical_experts) 的”物理槽位 → 逻辑专家”映射)的列数随之变化。初始化路径里有一段专门的填充逻辑 append_trivial_expert_slots——把新增的槽位先按 arange(start, start + count) % num_logical_experts 做一个平凡的轮转映射(意思是”先随便摆着”),真正的布点由随后触发的 EPLB 重排覆盖。

而 _init_common 里有一段注释直接点名了扩容对参数的影响:

# elastic-EP scale-up rewrites ep_size on the published config
ep_size = get_parallel().ep_size

ep_size 这个并行状态是被扩容”改写”的,而不是重新启动时传入的。joiner 侧的 _initialize_elastic_ep_joiner 里那句 get_context().override("elastic_ep.scale_join", ep_size=join_effective_ep_size),改写的正是这里读到的值。也就是说,扩容的语义是”把一个运行中的进程的 ep_size 原地改大,并保证所有依赖它的组件都被重新初始化”——这是一个比”重新启动”难度高得多的操作,也是本篇大部分复杂度(位图、状态机、备份、重排)的来源。

权重备份:为什么每台节点必须留一份 DRAM 副本

最后一个大问题:世界大小变了,专家在 rank 之间的分布变了,那么原本只加载了”自己那一份”专家的 rank,需要拿到新分给它的专家权重——这些权重从哪来?

最直接的办法是从磁盘重新加载。update_expert_location_with_recovery 里就保留着这条兜底路径:

if len(p2p_missing_logical_experts) > 0:
# Load the missing expert weights from disk
if callable(getattr(model, "generate_weight_name_filter", None)):
# Filter and load only missing expert weights
weight_name_filter = model.generate_weight_name_filter(
p2p_missing_logical_experts
)
else:
# Do a full reload from disk/DRAM
logger.info(
"[Elastic EP] Model does not implement generate_weight_name_filter. "
"Performing full weight reload."
)
weight_name_filter = None
if expert_backup_client is not None and expert_backup_client.use_backup:
# Load the missing weights from the DRAM backup
expert_backup_client.update_weights(weight_name_filter)
else:
# Load the missing weights from disk
update_weights_from_disk_callable(
get_model().model_path,
get_model().load_format,
weight_name_filter=weight_name_filter,
)

代码里有一个清晰的三级降级链:

  1. 模型实现了 generate_weight_name_filter:只加载缺失的那部分专家权重,代价最小。
  2. 模型没实现:weight_name_filter = None,退化成全量重载——把所有专家权重重新读一遍,日志里专门打了一条 “Performing full weight reload” 提示。
  3. 备份可用 vs 不可用:即使拿到了 filter,还有一个岔路——从 DRAM 备份读(expert_backup_client.update_weights(...))还是从磁盘读(update_weights_from_disk_callable(...))。

第三点就是 ExpertBackupManager 存在的意义。为什么需要 DRAM 备份,直接从磁盘读不行吗?

考虑一次恢复的真实时间线:一个 8 卡节点在运行中掉线,运维重启了它;主部署检测到 rank 缺失,发起恢复;恢复流程需要把这些 rank 原来持有的专家权重补齐(因为重排后布点变了)。如果从磁盘读,那么这次恢复的耗时 = 存储系统读取数百 GB 权重的耗时。对一个大 MoE 模型,专家权重可能占几十到上百 GB;如果是网络存储或慢盘,这段时间可能是几分钟到几十分钟。期间服务处于降级状态。

而专家权重在每台节点的 DRAM 里留一份副本,恢复时就变成一次内存到显存的搬运,走的是 Mooncake 的 RDMA 传输引擎(register_memory + batch_transfer_sync_read),带宽比磁盘高一个数量级以上。

ExpertBackupManager 的构造过程就是”把权重读进 DRAM 并注册给传输引擎”:

def backup_weights_from_disk(self):
load_config = LoadConfig(load_format=self.load_format)
loader = get_model_loader(load_config, self.model_config)
with set_default_torch_dtype(self.model_config.dtype):
iter = loader._get_weights_iterator(
DefaultModelLoader.Source.init_new(self.model_config, None)
)
total_bytes = 0
weight_info_dict = {}
for name, weight in iter:
expert_id = extract_expert_id(name)
if expert_id < self.idmx and expert_id >= self.idmn:
...
total_bytes += byte_size
if total_bytes == 0:
self.continuous_buffer = None
self.weight_pointer_map = {}
return
self.continuous_buffer = torch.empty(
total_bytes, dtype=torch.uint8, device="cpu"
)

几个要点:

  • extract_expert_id(name) 用正则 \.experts\.(\d+)\. 从参数名里挖出专家编号。这是”按参数名做数据分片”的典型做法——不需要额外的元数据文件,参数名本身就编码了专家编号。
  • 按节点分片:self.idmn 与 self.idmx 是这台节点负责的专家编号区间:
self.expert_num = self.model_config.hf_config.n_routed_experts
self.idmn = (self.expert_num // self.engine_num) * self.engine_rank
self.idmx = (self.expert_num // self.engine_num) * (self.engine_rank + 1)

engine_num 是节点数(nnodes),engine_rank 是节点编号(node_rank)。每个节点备份的是”全局专家编号里属于自己那一段”的权重。这个划分是静态的、按节点的,与世界大小(EP 大小)无关——这正是前面 elastic_ep_initial_size 要”锁定每 rank 专家存储布局”的原因:备份的划分是按初始布局算的,扩容后布局不能变,否则备份的区间就对不上。

  • continuous_buffer 是一整块连续 CPU 内存:所有本节点的专家权重被按名字排序后顺序拷贝进一块 torch.uint8 缓冲:
for name in sorted(weight_info_dict.keys()):
weight_info = weight_info_dict[name]
weight = weight_info["weight"]
byte_size = weight_info["byte_size"]
weight_flat = weight.flatten().contiguous()
weight_bytes = weight_flat.view(torch.uint8)
start_byte = current_byte_offset
end_byte = current_byte_offset + byte_size
weight_ptr = buffer_base_ptr + current_byte_offset
self.continuous_buffer[start_byte:end_byte].copy_(weight_bytes)
self.weight_pointer_map[name] = ExpertWeightPointer(
weight_ptr=weight_ptr,
byte_size=byte_size,
)
current_byte_offset = end_byte

为什么必须是一整块连续缓冲,而不是每个权重单独一个张量? 因为 RDMA 传输引擎需要注册一段连续的、物理地址稳定的内存区域(register_memory(server_ptr, server_len))。零散的张量意味着要为每一块单独注册,注册开销与元数据管理都会爆炸。合成一块连续缓冲后,只需注册一次,并记录每个权重在这块缓冲里的字节偏移(weight_ptr + byte_size),传输时按偏移取出即可。

专家并行中专家权重的分布示意:并行策略如何把不同专家摊到不同设备上
专家并行中专家权重的分布示意:并行策略如何把不同专家摊到不同设备上

上图来自 SGLang 官方博客 Large-Scale Expert Parallelism for DeepSeek(LMSYS,2025-05-05)。它展示并行设计下专家权重的落位方式——正是”哪张卡持有哪些专家”这件事,在弹性扩容与故障恢复之后必须被重新计算,也是 DRAM 备份要按节点分片保存专家权重的原因。

  • weight_pointer_map 是”参数名 → (指针, 字节数)“的映射表。这张表会被序列化后通过 ZMQ 发给所有客户端:
back_req = BackupDramReq(
rank=self.engine_rank,
weight_pointer_map=self.weight_pointer_map,
session_id=self.session_id,
buffer_size=self.continuous_buffer.numel()
* self.continuous_buffer.element_size(),
)
sock_send(self.send_to_expert_backup_client, back_req)

session_id 是 Mooncake 传输引擎的会话标识,客户端拿到它才能发起跨机的内存读取。还有一段”等所有客户端报到”的逻辑,注释解释了它存在的原因:

# Block until all expert backup clients have reported readiness, to avoid
# losing the initial PUB message due to slow joiners.
num_ready_clients = 0
while num_ready_clients < get_parallel().tp_size:
sock_recv(self.recv_from_expert_backup_client)
num_ready_clients += 1

这是 ZMQ PUB/SUB 模式的经典陷阱:PUB 端发出的消息只送给”此刻已连接”的 SUB 端,晚连接的订阅者会静默丢掉在它连接之前发出的消息(“slow joiner” 问题)。如果 manager 不等客户端就绪就发 BackupDramReq,部分客户端永远收不到,use_backup 会一直是 False。解决方案是加一条 PUSH/PULL 同步通道:客户端连上后先往 PULL 端口发一条 UpdateExpertBackupReq,manager 数够 tp_size 条才发正式消息。代码里那句注释 “Synchronization socket to avoid PUB/SUB slow joiner issues” 说的就是这件事。

客户端侧接收完后进入线程循环:

def _receive_loop(self):
cnt = 0
while cnt < self.engine_num:
response = sock_recv(self.recv_list[cnt])
self.dram_map_list[response.rank] = response.weight_pointer_map
self.session_id_list[response.rank] = response.session_id
self.buffer_size = max(self.buffer_size, response.buffer_size)
cnt += 1
self.use_backup = True
self.start_transfer_client()

每个客户端保存全部节点的映射表(dram_map_list 是长度为 engine_num 的列表),而不是只保存自己节点的。这是必需的:恢复时一个 rank 需要的专家可能原本属于任意节点,客户端必须能从任何节点拉权重。buffer_size 取所有节点的最大值,用于分配足够大的传输缓冲。

客户端的注册是把自己的全部参数注册给传输引擎:

def start_transfer_client(self):
from sglang.srt.distributed.parallel_state import get_mooncake_transfer_engine
self.transfer_engine = get_mooncake_transfer_engine()
self.params_dict = dict(self._get_model().named_parameters())
for name, param in self.params_dict.items():
param_data = param.data
ret_value = self.transfer_engine.engine.register_memory(
param_data.data_ptr(), param_data.numel() * param_data.element_size()
)
if ret_value != 0:
self.use_backup = False
logger.warning("Register fails. Stop using expert weight backup!")
break

注册失败就关掉备份,而不是抛异常。这是一处很务实的容错:备份只是”加速恢复”的手段,不是正确性的必要条件(磁盘路径始终兜底),所以注册失败时降级到磁盘读取即可,不必让整个服务起不来。

真正搬运权重的是 update_weights,它是全篇最复杂的一段逻辑,因为它同时要处理三件事:名字映射、专家编号映射、张量切片。

def update_weights(self, weight_name_filter=None):
global_expert_location_metadata = get_global_expert_location_metadata()
num_experts = (
self.model_config.hf_config.n_routed_experts
+ get_exec().moe.ep_num_redundant_experts
)
num_local_experts = num_experts // self.moe_ep_size
for i in range(self.engine_num):
server_ptr_list = []
local_ptr_list = []
weight_size_list = []
for name, weight_info in self.dram_map_list[i].items():
...
layer_id, expert_id, weight_name = extract_layer_and_expert_id(name)

名字映射:磁盘上的参数名(HuggingFace 命名)与运行时模型里的参数名不同。客户端做了一次规范化替换:

if weight_name == "gate_proj":
shard_id = "w1"
param_name = "experts.w13_"
elif weight_name == "down_proj":
shard_id = "w2"
param_name = "experts.w2_"
elif weight_name == "up_proj":
shard_id = "w3"
param_name = "experts.w13_"
else:
raise RuntimeError(f"Unknown weight name {weight_name}")
name = name.replace(f"experts.{expert_id}.{weight_name}.", param_name)
weight_param = self.params_dict[name]

这里有三处值得说明的细节:

  • gate_proj 与 up_proj 都被映射到 experts.w13_。运行时模型把 MLP 的两个投影合并存储在一张 w13 张量里(这是为了配合 fused MoE kernel 的 group GEMM,一次读出两个投影的权重),而磁盘上是分开的两份。
  • shard_id 记录了”这一份对应合并张量的哪一半”,下面切片时就靠它:
param = weight_param[physical_expert_id % num_local_experts]
if shard_id == "w1":
param = param.narrow(0, 0, param.shape[0] // 2)
elif shard_id == "w3":
param = param.narrow(
0, param.shape[0] // 2, param.shape[0] // 2
)

w1(gate)取合并张量的前半段,w3(up)取后半段,w2(down)不切。这段切片逻辑是”存储布局与磁盘布局不同构”的必然代价——它之所以出现在恢复路径上,是因为恢复是从磁盘格式的备份里取值,要往运行时格式的张量里写。

  • else: raise RuntimeError:遇到不认识的投影名直接报错,不静默跳过。静默跳过会造成”权重看起来加载成功了但某些投影没更新”的最坏情况——模型能跑、输出却是错的。

专家编号映射:这是恢复路径里最绕的一段。

physical_expert_ids = (
global_expert_location_metadata.logical_to_all_physical(
layer_id, expert_id
)
)
for physical_expert_id in physical_expert_ids:
if physical_expert_id not in range(
num_local_experts * self.moe_ep_rank,
num_local_experts * (self.moe_ep_rank + 1),
):
continue
param = weight_param[physical_expert_id % num_local_experts]

三个编号空间在这里交汇:

  • expert_id:备份文件里的编号,是磁盘布局的专家下标(对应 elastic_ep_initial_size 时的划分)。
  • physical_expert_id:全局物理专家编号,由 EPLB 的 physical_to_logical_map 决定,可能包含冗余专家。
  • num_local_experts * self.moe_ep_rank 到 ... * (moe_ep_rank + 1) 的区间:当前 rank 负责的物理专家区间。只有落在这个区间里的物理专家副本才需要被写入本 rank。同一个逻辑专家被复制到多个物理位置时,logical_to_all_physical 返回多个编号,客户端只为”属于自己”的那些写入。

最后是传输本身:

server_ptr_list.append(weight_info.weight_ptr)
local_ptr_list.append(param.data_ptr())
assert param.numel() * param.element_size() == weight_info.byte_size
weight_size_list.append(weight_info.byte_size)
...
ret = self.transfer_engine.engine.batch_transfer_sync_read(
self.session_id_list[i],
local_ptr_list,
server_ptr_list,
weight_size_list,
)

assert param.numel() * param.element_size() == weight_info.byte_size 是一个”安全阀”:它保证目标张量的大小与源数据的字节数完全一致。这个断言如果失败,说明名字映射或切片逻辑错了;如果不做这个断言,RDMA 会按错误的长度读写,结果是显存越界或静默写入错误数据——后者远比崩溃难排查。用一句 assert 把可能的内存损坏转化成一次明确的失败,是这段代码里最有工程价值的一处设计。

batch_transfer_sync_read 是同步批量读:一次性提交多个 (源指针, 目的指针, 长度) 三元组,由传输引擎批量发起 RDMA 读。批量提交而不是逐条提交,是因为 RDMA 的每次操作都有固定开销(QP 提交、完成队列轮询),把几百个小传输合并成一次批量提交,开销可以摊薄一个数量级以上。

工程实践:从启动命令到一次完整的扩缩演练

前面把机制讲完了,这一节把它落到可执行的形态上。

主部署的启动形态

一台 8 卡节点,跑 DeepSeek 类 MLA 模型,EP=8、注意力与专家并行错开(这也是上一篇讲过的 DP Attention 配置),同时预留弹性座位:

Terminal window
sglang serve \
--model-path deepseek-ai/DeepSeek-V3 \
--tp 8 \
--attn-dp-size 8 \
--moe-a2a-backend mooncake \
--elastic-ep-backend mooncake \
--max-ep-size 16 \
--enable-eplb \
--ep-num-redundant-experts 32 \
--deepep-mode low_latency \
--enable-dp-lm-head \
--disable-custom-all-reduce \
--mem-fraction-static 0.5

几个参数的作用需要点明:

  • --tp 8 --attn-dp-size 8:TP 为 8,其中注意力侧全部走 DP(attn_dp_size = 8 意味着 attn_tp_size = 1),于是 attn_replica_size = 1 × 1 = 1,DP size = EP size = 8。这是最简的拓扑,每个 rank 就是一个注意力副本,扩容时的对齐约束退化为”new_ep_size 是任意整数”。
  • --max-ep-size 16:座位数 16,意味着可以从 8 扩到 16。若设成 12,那么 /scale_elastic_ep 传 16 会被拒绝,错误信息会提示重启并写更大的值。
  • --elastic-ep-backend mooncake:弹性 EP 的通信后端选 Mooncake——这是当前唯一支持 recover_ranks 语义后端的完整路径(另一个选项 nixl 对应 NIXL-EP)。
  • --moe-a2a-backend mooncake 与 --deepep-mode low_latency:MoE 的 all-to-all 也走 Mooncake,且 decode 用低延迟模式(CUDA Graph 兼容)。
  • --mem-fraction-static 0.5:静态显存占比压到 0.5。扩容场景下这个值要留出余量,因为 max_ep_size 对应的通信缓冲区与预留状态都要显存。

joiner 的启动形态

扩容时,在另一台 8 卡节点上以”追加”方式起 joiner,只使用其中 8 张卡里的 8 张(或按需取一部分),关键是三个弹性参数要与主部署对得上:

Terminal window
# 在第二台节点上执行;假设主部署当前有效 EP 为 8,希望扩到 16
sglang serve \
--model-path deepseek-ai/DeepSeek-V3 \
--tp 8 \
--attn-dp-size 8 \
--nnodes 2 \
--node-rank 1 \
--dist-init-addr <主部署节点IP>:25555 \
--moe-a2a-backend mooncake \
--elastic-ep-backend mooncake \
--elastic-ep-join-mode scale \
--elastic-ep-join-rank-offset 8 \
--elastic-ep-initial-size 8 \
--moe-dense-tp-size 1 \
--disable-custom-all-reduce \
--mem-fraction-static 0.5

四个必须解释的参数:

  • --node-rank 1:joiner 是第二个节点。这是 --elastic-ep-join-mode scale 的硬要求。
  • --dist-init-addr:必须指向主部署的分布式初始化地址,因为 joiner 要加入的是主部署已经建好的 WORLD 组,而不是另起一个。TCPStore 也挂在这个地址上,register_scale_cohort 写进去的 cohort 信息才能被主部署读到。
  • --elastic-ep-join-rank-offset 8:等于主部署当前有效 EP 大小。写错会导致 join_effective_ep_size 与主部署的 new_ep_size 对不上——两边各算各的,握手失败。
  • --elastic-ep-initial-size 8:等于主部署启动时的 EP 大小。它决定了 joiner 每张卡分配多少专家槽位,必须与主部署一致,否则张量形状对不上。

--elastic-ep-join-rank-offset 与 --elastic-ep-initial-size 的区别是最容易搞混的一处:前者是”从哪一号开始编号”,随每次扩容而变;后者是”槽位怎么切”,永远等于出生时的规模。

触发与轮询

主部署侧的 HTTP 端点:

Terminal window
# 提交扩容请求(异步,立即返回)
curl -X POST http://<主部署>:30000/scale_elastic_ep \
-H 'Content-Type: application/json' \
-d '{"new_ep_size": 16}'

成功后返回 old_ep_size 与 new_ep_size,同时 scale_phase 为 waiting_for_cohort。随后轮询状态:

Terminal window
# 轮询进度,直到 scale_phase 变为 serving_expanded
curl -s http://<主部署>:30000/is_scaling_elastic_ep

返回结构就是前面 get_elastic_ep_state 那五个字段。典型的状态推进是:

waiting_for_cohort → syncing_new_world → serving_expanded

如果 joiner 迟迟不起来,waiting_for_cohort 会一直保持;超过 --elastic-ep-scale-timeout(默认 600 秒)后失败。

三种错误码的语义

/scale_elastic_ep 的错误码被刻意区分开,因为它们的处置方式完全不同:

  • 400 Bad Request:参数不合法、new_ep_size <= old_ep_size、或没有 pending 状态却拒绝了请求。这类错误重试无用,要改参数或检查集群。
  • 409 Conflict:pending_ep_size is not None,即已有一次缩放正在进行。这类错误的正确处置是等,而不是改参数重试。
  • 404 Not Found:elastic_ep_backend is None,即服务启动时根本没开弹性 EP。这类错误只能改启动命令重启。

源码里这段映射就写在端点里:

status_code=(
HTTPStatus.CONFLICT
if result.pending_ep_size is not None
else HTTPStatus.BAD_REQUEST
),

“有在途请求”被判为 409 而不是 400,是很严谨的 HTTP 语义使用:请求本身没错,只是当前状态不允许——这正是 Conflict 的定义。

一个必须知道的正确性细节:Radix Cache 的命名空间

扩容会改变专家的分布,进而改变同样的输入 token 得到的 hidden state 与输出——因为不同数量的专家、不同的布点,路由结果不同。但 KV Cache 的复用是基于 token 序列哈希的(参见 SGLang 源码完全拆解(五):KV 内存池与 RadixCache 的所有权闭环 与 RadixAttention 完全拆解)。如果扩容前缓存的 KV 在扩容后被复用,就等于用旧世界算出的 KV 去服务新世界的前向,结果不可信。

SGLang 的处理方式是在请求上打一个与世界大小绑定的命名空间:

def _maybe_namespace_elastic_radix_cache(self, req: Req) -> None:
if (
get_exec().moe.elastic_ep_backend is None
or not self.tree_cache.supports_prefix_sharing()
):
return
from sglang.srt.elastic_ep.elastic_ep import ElasticEPStateManager
inst = ElasticEPStateManager.instance()
if inst is None:
return
namespace = f"elastic_ep_size={ElasticEPStateManager.get_effective_ep_size()}"
if req.extra_key:
req.extra_key = f"{req.extra_key}|{namespace}"
else:
req.extra_key = namespace
if self._last_logged_elastic_radix_namespace != namespace:
self._last_logged_elastic_radix_namespace = namespace
logger.debug(
"[Elastic EP][scale] radix cache namespace is now %s",
namespace,
)

extra_key 是请求在 Radix Cache 里的索引维度之一。把 elastic_ep_size=8 拼进 extra_key 之后,8 卡时代缓存的 KV 与 16 卡时代缓存的 KV 天然落在两个不相交的命名空间里,不会互相命中。这个设计还有一个附带好处:扩容前的缓存并没有被删除,如果之后回滚到原来的规模(虽然当前不支持缩容,但重启后回到小规模是可能的),旧缓存仍然可用。

代码里那句注释 “Keyed by the final extra_key, so it must run after elastic namespacing”(在调用点上方)说明这个函数必须在 LoRA 的 extra_key 拼接之后执行——否则命名空间会被后续的覆写冲掉。这类”元数据拼接的顺序依赖”是 KV 前缀复用系统里最容易出错的地方。

这个细节值得单独强调,因为它是弹性 EP 里唯一会静默产生错误结果的地方:位图、状态机、恢复逻辑写错会直接报错或卡住,而 Radix Cache 的命名空间写错,服务会正常返回错误答案。

专家负载与重排的效果

EPLB 的重排不是纯理论上的可能,它带来的收益可以直接从官方给出的负载分布与吞吐数据上看出来:

EPLB 重排前后的专家负载分布:均衡后每个专家的利用率被拉平
EPLB 重排前后的专家负载分布:均衡后每个专家的利用率被拉平

上图来自 SGLang 官方博客 Large-Scale Expert Parallelism for DeepSeek(LMSYS,2025-05-05)。EPLB 的组内均衡(group-wise)与全局均衡把原本长短不齐的专家利用率拉平——本篇讲的弹性重排沿用的正是同一套算法,只不过输入的”活跃 rank 数”在扩容或故障后会变化。

EPLB 均衡带来的吞吐收益对比
EPLB 均衡带来的吞吐收益对比

上图来自 SGLang 官方博客 Large-Scale Expert Parallelism for DeepSeek(LMSYS,2025-05-05)。负载均衡的收益需要靠这类对比来量化,而弹性 EP 在扩容/恢复后主动触发一次重排(maybe_rebalance_after_rank_fault → eplb_manager.rebalance()),就是为了让世界大小变化后尽快回到均衡状态。

边界、代价与当前不支持的场景

弹性 EP 的适用范围在源码里留下了大量显式的边界标记,把它们集中列出来,比散落各处更有用。

只支持 CUDA 与 CPU。 _select_device 在遇到其它加速器时直接抛错:

@staticmethod
def _select_device() -> torch.device:
if is_cuda():
return torch.device("cuda")
elif is_cpu():
return torch.device("cpu")
else:
raise NotImplementedError("Only CUDA and CPU support elastic ep now.")

只支持扩容,不支持缩容。 handle_scale_elastic_ep 的第一条校验就是 new_ep_size <= old_ep_size 直接拒绝。缩容需要把专家的所有权转移出去,代价远大于扩容。

恢复路径假设 --pp-size=1。 前面那段 ranks_to_recover 的注释已经说明:TP 组内索引直接当全局 rank 用,前提是没有流水线并行。

扩容 joiner 必须是 --node-rank 1,且注意力拓扑必须与主部署一致(validate_matching_attention_topology)。

新增的 EP rank 数必须是注意力副本宽度的整数倍(attn_replica_size() 的整除校验),否则会造出残缺副本。

必须绑定 Mooncake(或 NIXL)后端。 原生 PyTorch 进程组没有 join_group / recover_ranks / get_peer_state,弹性 EP 的全部成员变更能力都建立在这套语义之上。

“一次一操作”的串行约束。 request_scale 与 TokenizerManager.scale_elastic_ep 都拒绝在 pending_ep_size 非空时接受新请求。这意味着扩容不是流水线上的批量操作,而是一次一个的串行事务。

代价清单:max_ep_size 预留的通信缓冲区与状态占用显存;active_ranks.all() 在前向路径上引入一次 host-device 同步(源码 TODO 明确记录了这一点);ExpertBackupManager 在每台节点上额外占用一份专家权重的 DRAM;扩容期间 EPLB 被禁用,负载均衡短暂停摆。

最后是弹性 EP 与健康检查的分工。 弹性 EP 负责”把掉线的 rank 拉回来”和”改变世界大小”,但它不负责发现故障。在 maybe_recover_ep_ranks 的调用链上,active_ranks 位图是被”别人”更新的——SGLang 里有独立的 watchdog 与健康检查基础设施(python/sglang/srt/utils/watchdog.py 里的 SubprocessWatchdog 与 _WatchdogReal、kv_canary 目录下的 KernelRunCounterHealthChecker,后者每 100 步检查一次内核运行计数是否对得上)。也就是说,检测是外层的职责,恢复是本层的职责,active_ranks 位图是两者之间的接口。这种”检测与恢复解耦”的分层让弹性 EP 可以在不知道故障是怎么被发现的前提下工作。

小结

回到最初的问题:在不重启的前提下让世界大小可变,需要同时解决四件事,它们各自对应一套机制。

一、容量预留。 进程组的形状按 max_world_size 分配,active_ranks 位图标记谁在场。--max-ep-size 决定座位数,不设置就退化为普通 EP。MooncakeBackendOptions 把位图交给通信后端,使其能感知成员变化。

二、显式状态机。 ElasticEPState 用一个 dataclass 承载全部缩放状态,scale_phase 走 waiting_for_cohort → syncing_new_world → serving_expanded。get_data_plane_ep_size 区分控制面与数据面大小,is_scaling 同时覆盖主动扩容与被动恢复,recovery_unsupported 作为熔断终态。

三、成员变更。 扩容靠 joiner 的 _initialize_elastic_ep_joiner:算出新世界大小、在 TCPStore 上登记 ScaleCohort、join_group(WORLD)、广播专家布点、切换 DP Attention 模式、禁用 EPLB。故障恢复靠 try_recover_ranks:把故障 rank 逐个进程组地 recover_ranks,设备组与 CPU 组各一次,并重建 MessageQueue;get_healthy_expert_location_src_rank 用 all-gather 挑出真正的健康源,避免按启动角色误判。

四、布局与权重。 _compute_elastic_expert_layout 定义了唯一的不变量——每 rank 槽位数不变,全局槽位数随有效 EP 线性增长。世界变化后 EPLB 按活跃 rank 数重算布点(活跃数不足时退回扁平均衡),ExpertBackupManager 用一块连续的 DRAM 缓冲 + RDMA 批量读把补权重的代价从”读磁盘”降到”读内存”,update_expert_location_with_recovery 保留了三级降级链兜底。

贯穿全部四条主线的,是一个反复出现的设计原则:把”会变的东西”和”不变的东西”显式分开,并让接口的两侧对同一件事独立算出相同答案。位图是会变的、槽位布局是不变的;ep_join_rank_offset 每次扩容都变、elastic_ep_initial_size 永远不变;effective_ep_size 变了但 num_local_physical_experts 不变。joiner 与主部署各自算出 join_effective_ep_size 然后核对,get_healthy_expert_location_src_rank 靠 all-gather 而非启动参数判定角色——这些都不是实现技巧,而是分布式成员变更这个问题的本质要求:没有一个中心知道所有节点的真实状态,所以一致性必须靠每个节点独立推导同一结论来达成。

回到系列的路线上:至此 SGLang 的核心模块——架构、请求生命周期、调度状态机、PrefillAdder、KV 池与 RadixCache、Overlap Scheduler、ForwardBatch 与 ModelRunner、注意力后端、CUDA Graph、采样与语法、投机解码、P/D 分离、多 LoRA、多模态、内存池、HiCache、DP Attention 与专家并行——正好补齐了最后一块”运行时拓扑可变”的能力。

下一篇会进入 llama.cpp 源码完全拆解(一):整体架构与模块全景,开始第三个框架的源码地图——llama.cpp 的 ggml 后端抽象、llama_model / llama_context 的职责划分、图构建与 KV Cache 组织方式,与 vLLM、SGLang 形成了很不一样的取舍。SGLang 侧还剩下的两处接缝——上层路由(sgl-model-gateway 如何与本篇的 active_ranks 协同做流量切换)与量化层的挂载路径——会在需要时以补篇形式补齐。本篇讲的所有映射表——active_ranks、effective_ep_size、physical_to_logical_map、num_local_physical_experts——已经构成了理解大规模 EP 服务的公共词汇,后续文章会继续沿用。

参考资料

  1. SGLang: Efficient Execution of Structured Language Model Programs
  2. SGLang: Large-Scale Expert Parallelism for DeepSeek
  3. SGLang Expert Parallelism 官方文档
  4. SGLang 源码:elastic_ep/elastic_ep.py
  5. SGLang 源码:elastic_ep/expert_backup_manager.py
  6. SGLang 源码:elastic_ep/expert_backup_client.py
  7. SGLang 源码:elastic_ep/topology.py
  8. SGLang 源码:entrypoints/elastic_ep.py
  9. SGLang 源码:eplb/eplb_algorithms/elasticity_aware.py
  10. SGLang 源码:eplb/expert_location.py
  11. SGLang 源码:eplb/expert_location_updater.py
  12. SGLang 源码:distributed/parallel_state.py
  13. SGLang 源码:managers/scheduler.py(handle_scale_elastic_ep)
  14. SGLang 测试:test/manual/ep/test_elastic_scale.py
  15. SGLang 测试:test/manual/ep/test_elastic_recover.py
  16. Mooncake: A KVCache-centric Disaggregated Architecture for LLM Serving
  17. DeepEP: an efficient expert-parallel communication library
  18. NIXL: NVIDIA Inference Xfer Library
  19. DeepSeek-V3/R1 Inference System Overview

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

SGLang 源码完全拆解(十八):弹性 EP 与故障恢复——扩容加入、rank 故障、专家布点重排与权重备份
https://pinghaoyang.com.cn/articles/insights/posts/sglang-elastic-ep-fault-recovery/
作者
平昊阳
发布于
2026-10-19
许可协议
CC BY-NC-SA 4.0

评论区

访客信息
--°天气加载中

正在获取所在地区…

💨 风力--
💧 湿度--

正在读取天气

Profile Image of the Author
平昊阳
乘长风,破巨浪, 展鸿图于未央!
公告
欢迎来到我的个人博客!欢迎关注交流吖!
更多相关公告,见
「社交-留言」。
音乐
封面

音乐

暂未播放

0:000:00
暂无歌词
站点统计
文章
448
分类
29
标签
451
总字数
6,178,652
运行时长
0 天
最后活动
0 天前

文章目录