执行摘要
- 一句话:PD 解码前检查 LoRA adapter 槽位,防越限崩溃
- 推荐动作:值得精读,尤其关注三点:
1) running_loras 的统计口径(包含 finished 请求)对"槽位即 GPU 资源"的建模;
2) 复用调度器闸门而非另写一套判定的模块化选择;
3) e2e 用确定性单服务器参考做逐字符比对的测试设计。对维护 PD 调度或 LoRA 多 adapter 场景的同学有直接参考价值。
功能与动机
PR body 明确写出:"PD decode can admit more LoRA adapters than its slots allow, crashing in fetch_new_loras." 即 decode 侧预分配请求时只检查 KV 内存与设备缓冲,未检查 LoRA 槽位上限,导致 adapter 数量超限后后端在加载新 adapter 时崩溃;同时需要保证被阻塞的请求不会 abort,而是排队等待。
实现拆解
-
公开调度器的 LoRA 闸门:python/sglang/srt/managers/scheduler.py 中把私有方法 _can_schedule_lora_req 重命名为 can_schedule_lora_req,同步更新 prefill 路径 _get_new_batch_prefill_raw 的调用点。该方法统一了 drainer 状态检查与 validate_lora_batch / try_overlap_load_lora 的可用性判定,公开后可供 decode 侧跨模块复用,避免两套判定逻辑漂移。
-
decode 侧构建在途 adapter 集合:python/sglang/srt/disaggregation/decode.py 的 DecodePreallocQueue.pop_preallocated 在预分配循环前,当 scheduler.enable_lora 为真时汇总所有占槽请求:PP 模式下遍历 scheduler.running_mbs,否则取 running_batch;再并入 transfer_queue.queue 与 scheduler.waiting_queue。注释明确包含已 finished 请求,因为 GPU 上 adapter 权重可能仍在排空,提前释放会造成后续 fetch_new_loras 崩溃。
-
逐请求闸门与槽位记账:循环内依次检查 token pool、metadata buffer、hisparse budget 之后,在 _pre_alloc 与 radix 锁定之前插入 can_schedule_lora_req 检查;不满足则 continue,请求保留在 self.queue 等待下一轮。预分配成功后立刻把 lora_id 加入 running_loras,保证同轮循环中后续请求能看到刚被占用的槽位。
-
测试配套:新增 e2e 测试 test/registered/e2e/disaggregation/test_disaggregation_lora.py,用 3 个真实 adapter + base 模型在单服务器上生成确定性参考输出,再以 PD 双角色并发重放并逐字符比对;新增单元测试 test_prealloc_lora_slots_cover_inflight_microbatches_and_queues 覆盖 running / finished / transferring / waiting 四种占槽场景;既有 decode 队列清理测试统一补 scheduler.enable_lora = False mock,避免新分支导致 AttributeError。
关键文件:
python/sglang/srt/disaggregation/decode.py(模块 解码调度;类别 source;类型 core-logic;符号 pop_preallocated): 核心修复点:DecodePreallocQueue.pop_preallocated 在 KV 预分配与 radix 锁定之前新增 LoRA 槽位闸门,并跨 microbatch 与 transfer/waiting 队列统计在途 adapter,是防止 fetch_new_loras 崩溃的关键改动。
python/sglang/srt/managers/scheduler.py(模块 调度器;类别 source;类型 core-logic;符号 can_schedule_lora_req): 将原有 LoRA 调度判定方法 _can_schedule_lora_req 公开为 can_schedule_lora_req,使 decode 侧能复用同一套 drainer + adapter 可加载性判定,是本次无重复逻辑的关键前置改动。
test/registered/e2e/disaggregation/test_disaggregation_lora.py(模块 集成测试;类别 test;类型 test-coverage;符号 generate, TestDisaggregationLoRA, test_more_adapters_than_slots_match_single_server): 新增 GPU e2e:8 个并发请求(base + 3 adapter、2 slot)与单服务器确定性参考逐字符比对,并校验双角色进程健康,是该 bugfix 最重要的端到端回归保障。
test/registered/unit/managers/test_priority_scheduling_disaggregation.py(模块 单元测试;类别 test;类型 test-coverage;符号 test_prealloc_lora_slots_cover_inflight_microbatches_and_queues): 新增单元测试覆盖 running / finished / transferring / waiting 四种在途请求占槽场景,验证 blocked 请求留在队列、可调度请求正常进入预分配,是核心逻辑的最小回归保障。
test/registered/unit/disaggregation/test_decode_queue_cleanup.py(模块 单元测试;类别 test;类型 test-coverage): 为既有 decode 队列清理测试的 mock scheduler 补充 enable_lora = False,避免新增分支读取未定义属性导致回归测试失败,属于配套兼容修改。
关键符号:can_schedule_lora_req, pop_preallocated, test_more_adapters_than_slots_match_single_server, test_prealloc_lora_slots_cover_inflight_microbatches_and_queues
关键源码片段
python/sglang/srt/disaggregation/decode.py
核心修复点:DecodePreallocQueue.pop_preallocated 在 KV 预分配与 radix 锁定之前新增 LoRA 槽位闸门,并跨 microbatch 与 transfer/waiting 队列统计在途 adapter,是防止 fetch_new_loras 崩溃的关键改动。
# 启用 LoRA 时,先汇总所有“在途”请求占用的 adapter 槽位,
# 后续循环据此判断新请求能否被接纳。
if self.scheduler.enable_lora:
# 流水线并行下每个 microbatch 都占用 GPU 资源;
# 非 PP 模式只需检查当前 running_batch。
running_batches = (
self.scheduler.running_mbs
if is_pp_mode
else (self.scheduler.running_batch,)
)
# 包含已 finished 请求:GPU 上可能仍有未排空的
# adapter 权重在工作,过早释放槽位会导致 fetch_new_loras 崩溃。
running_loras = {
req.lora_id for batch in running_batches for req in batch.reqs
}
# transfer_queue 与 waiting_queue 中的请求同样占用槽位预算
running_loras.update(r.req.lora_id for r in self.transfer_queue.queue)
running_loras.update(req.lora_id for req in self.scheduler.waiting_queue)
for i, decode_req in enumerate(self.queue):
# ... 前面的 token pool / metadata buffer / hisparse budget 检查 ...
# 槽位不足时跳过请求,留在队列等待后续轮次,
# 而不是进入 KV 预分配流程,避免 fetch_new_loras 崩溃
if self.scheduler.enable_lora and not self.scheduler.can_schedule_lora_req(
decode_req.req, running_loras
):
continue
# ... 预分配与 send_metadata ...
# 预分配成功后立即记账,保证同轮循环中
# 后续请求能看到刚被占用的 adapter 槽位
if self.scheduler.enable_lora:
running_loras.add(decode_req.req.lora_id)
python/sglang/srt/managers/scheduler.py
将原有 LoRA 调度判定方法 _can_schedule_lora_req 公开为 can_schedule_lora_req,使 decode 侧能复用同一套 drainer + adapter 可加载性判定,是本次无重复逻辑的关键前置改动。
def can_schedule_lora_req(
self, req: Req, running_loras: set[Optional[str]]
) -> bool:
"""
检查一个 LoRA 请求当前是否可调度。
两个条件:
1. drainer 允许调度(基于 draining 状态);
2. adapter 可以加载(已在运行中,或能加入新的 batch)。
"""
if self.lora_drainer and not self.lora_drainer.can_schedule(req):
return False
# 已在 running_loras 中的 adapter 直接放行,不重复占用新槽位
if req.lora_id in running_loras:
return True
if self.enable_lora_overlap_loading:
# 重叠加载模式下逐个尝试加载 adapter,避免批量加载导致显存峰值
return self.lora_overlap_loader.try_overlap_load_lora(
req.lora_id, running_loras
)
else:
# 非重叠模式:询问 LoRA 管理器,当前集合能否作为一个 batch 加载
new_lora_set = {req.lora_id} | running_loras
return self.tp_worker.model_runner.lora_manager.validate_lora_batch(
new_lora_set
)
test/registered/e2e/disaggregation/test_disaggregation_lora.py
新增 GPU e2e:8 个并发请求(base + 3 adapter、2 slot)与单服务器确定性参考逐字符比对,并校验双角色进程健康,是该 bugfix 最重要的端到端回归保障。
def test_more_adapters_than_slots_match_single_server(self):
"""超额 adapter 请求必须等待,不能中止或改变输出。"""
names = [None, *ADAPTERS]
# 第一步:用单服务器(非 PD)确定性推理生成参考输出。
# 同一 prompt + adapter 组合在 PD 模式下必须逐字符一致。
process = popen_launch_server(
self.model,
self.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
other_args=LORA_ARGS,
)
try:
expected = {
(prompt, name): generate(self.base_url, prompt, name)
for prompt in PROMPTS
for name in names
}
finally:
terminate_and_kill_process_tree(process)
# 校验 adapter 确实生效:同一 prompt 下 adapter 输出必须与 base 不同
for name in ADAPTERS:
self.assertTrue(
any(
expected[prompt, name] != expected[prompt, None]
for prompt in PROMPTS
),
name,
)
# 第二步:PD 模式下并发打出 8 个请求(base + 3 adapter)× 2 轮,
# 每个请求的输出必须与单服务器参考完全一致。
self.launch_all()
jobs = list(zip(PROMPTS, names)) * 2
with ThreadPoolExecutor(max_workers=len(jobs)) as pool:
texts = list(pool.map(lambda job: generate(self.lb_url, *job), jobs))
for job, text in zip(jobs, texts):
self.assertEqual(text, expected[job], job)
# 压力过后两个角色进程都必须仍然健康
assert_process_healthy(self, "prefill", self.process_prefill, self.prefill_url)
assert_process_healthy(self, "decode", self.process_decode, self.decode_url)
评论区精华
评审 ShangmingCai 一次性 approve,原话 "Everything looks good.",无任何 review 评论,说明实现无争议。PR body 中作者明确 "Decode-side starvation handling remains a follow-up":被闸门拦下的 adapter 请求没有公平性保障,可能长期排队,这是本次改动遗留的最大未决问题。另外 AMD ROCm 10 CI 运行失败,作者通过 /rerun-failed-ci 重跑,最终 Base / Extra CI 均绿后合并,失败项未阻塞。
- 评审一次性通过,无争议 (other): 无修改要求,PR 按原样合并。
- decode 侧 starvation 处理留待后续 (design): 本次仅保证不崩溃、不 abort,公平调度作为已知后续工作,未在本 PR 解决。
- AMD ROCm 10 CI 失败与重跑 (other): 失败未阻塞合并,属环境性或既有问题,与本次 LoRA 改动无直接关联。
风险与影响
- 风险:回归风险:
decode.py 现在直接读取 scheduler.enable_lora,任何手工构造 Scheduler mock 的测试或第三方嵌入代码若不设置该属性会抛 AttributeError——本 PR 已同步修补 3 处既有测试,但仓库外调用方需注意。starvation:被闸门拦下的请求没有超时或优先级机制,长期满载场景下可能出现 decode 侧饥饿,作者已承认留作后续。性能:每轮 pop_preallocated 构建 running_loras 集合的复杂度为 O(在途请求数),且逐请求调用一次 can_schedule_lora_req,对调度热路径有轻微额外开销,量级很小。一致性:prefill 与 decode 两个角色必须启用相同 LoRA 配置与 adapter 集合,否则 decode 端槽位判定与实际加载结果可能不一致。CI 成本:新增 e2e 注册为 base-b 阶段 2-gpu-large、约 400 s 的 GPU 用例。
- 影响:用户影响:PD + LoRA 部署不再因 adapter 超限崩溃,超额请求以排队方式降级,且 e2e 保障了输出与单服务器确定性推理逐字符一致。系统影响:decode 侧调度循环新增一道 LoRA 闸门,槽位语义从"运行中独占"扩展为"运行中 + 排队中 + 传输中 + 排空中 adapter 都占槽",对 PP 多 microbatch 场景尤其关键。团队影响:后续 PD LoRA 公平调度(starvation 处理)将以此为基座,新增 GPU e2e 为回归兜底,同时多 worker 配置一致性成为部署前提。
- 风险标记:decode 调度热路径变更, LoRA 请求可能饥饿等待, 多 worker LoRA 配置一致性要求, 新增 GPU e2e 测试成本
关联脉络
- PR #39318 Scope prefetch cache state to the request attempt: 同为 PD decode 调度路径(scheduler / decode)的稳定性修复,与本 PR 在同一批 PD 健壮性工作线上,共享
running_batch 与队列语义。
- PR #39357 [PD] Preserve the prefill rank during rebootstrap: PD 调度修复系列之一,与本 PR 同属 PD 拆分解码稳定性演进,后续饥饿处理可能与其队列逻辑交互。
- PR #39368 [CI][PD] Skip the flaky decode HiCache file-backend disaggregation test: 同一 PD 测试维护面,说明 PD 路径近期处于高频修复阶段,本 PR 新增的 LoRA e2e 也进入该 CI 区域。
参与讨论