Prhub

#34337 [Spec][LoRA] Support multi-adapter LoRA with EAGLE/NEXTN/DFLASH/DSPARK speculative decoding

原始 PR 作者 jybsuper 合并时间 2026-08-22 05:21 文件变更 23 提交数 10 评论 5 代码增减 +1487 / -51

执行摘要

多适配器 LoRA 支持 EAGLE/NEXTN/DFLASH/DSPARK 投机解码

Issue #12903 只启用了 LoRA 与 NGRAM 投机解码的组合,本 PR 将其扩展为 EAGLE / NEXTN / EAGLE3、DFLASH 和 DSPARK 多适配器共批,完成 #11762 中 LoRA 事项的一项。PR body 明确指出旧方案 #28395 依赖 "strips LoRA from a per-draft ServerArgs copy" 的机制,而该机制在 per-runner 值改为构造参数后已不存在,因此需要重新设计:"Adapters apply to the target model only; one shared draft runs unadapted. Speculation stays lossless per adapter (verify samples from the adapted target), so only the accept rate is affected."

建议精读。这是投机解码与 LoRA 两个核心子系统交叉处的重要功能 PR,设计决策值得学习:共享草稿 + 无损验证的取舍、基于 is_draft_worker 的门控而非配置、cuda-graph 宽度断言防静默错贴、共享模块在所有获取点统一解包,以及“只告警不拒绝 embedding 适配器”的务实选择。测试方法论(以 spec-on vs spec-off 为 oracle、先测复现性地板、用 adapter 身份前缀直接检验 batch 内适配器归属)也很有借鉴价值。后续值得跟进 TBO 与 overlap-loading 两个已知 LoRA 问题的独立修复。

讨论亮点

Review 由 Qiaolin-Yu 提出两个关键问题,均被作者确认是真实缺口并修复:

  1. DFLASH 的 embedding 模块是否也要解包 LoRAdflash_worker_v2.py):作者确认 "it was a real gap",因为 embed_module 会被用于草稿形状输入(noise_embedding = embed_module(block_ids)),若被 LoRA 包裹,会把目标适配器 delta 套在草稿 token id 上;已在提交 a9a6a32235 修复。作者同时说明该配置的适配器只作用于 attention / MLP,unwrap 在该 run 中是 no-op,真正覆盖 wrapped 路径需要 embedding 型适配器。

  2. DSPARK 草稿路径中 embedding 获取点遗漏dspark_draft.py):作者确认 "dspark_draft.py acquires the embedding independently of the worker, so unwrapping only at attach_shared_modules left that path wrapped",已一并修复;追查中还发现同文件 hasattr(lm_head, "weight") 检查在解包前执行,BaseLayerWithLoRA 不透传 weight 属性,会导致 LoRA 包裹的 lm_head 被误判为缺少 weight 而报错,解包顺序随之调整。

实现拆解

  1. 入口与参数校验python/sglang/srt/server_args.py 将原先 "LoRA 只兼容 NGRAM" 的硬拒绝替换为 _check_lora_speculative_compatibility(),新增 _LORA_SPEC_ALGORITHMS 白名单(EAGLE / EAGLE3 / DFLASH / DSPARK),并对 --speculative-adaptiveexperimental_sgl_trtllm MoE runner、SGLANG_ENABLE_OVERLAP_PLAN_STREAM、DSPARK 非 static ragged 模式逐一给出带原因的拒绝;NEXTN 预先折叠为 EAGLE,因此无需特判。

  2. 草稿 runner 排除 LoRA 与共享模块解包model_runner.pymaybe_init_lora_manageris_draft_worker 为门控键;ModelRunner.lora_manager 属性恒存在(未启用时为 None),下游所有 LoRA 路径从“读配置”改为“读 lora_manager is not None”,使草稿 runner 天然跳过。lora/layers.py 新增 unwrap_lora_layer,MTP 草稿(set_lm_head_from_target)、DFLASH worker 和 DSPARK worker 在获取目标模型的 lm_head / embed_tokens 时统一解包,避免草稿把目标适配器 delta 作用于草稿形状的激活;DSPARK 还顺带修复了 hasattr(lm_head, "weight") 在解包前执行导致误报的 bug。

  3. TARGET_VERIFY token 计数统一与宽度断言lora/utils.py 新增 get_batch_token_counts,集中处理 decode / target-verify / extend 三种模式的(总 token 数、每请求最大 token 数);base_backend.py_add_moe_lora_infotriton_backend.pyprepare_lora_batchchunked_backend.py_determine_chunk_size 三个调用点全部改走该函数,消除 max(None) / sum(None) 崩溃。triton_backend.pyuse_cuda_graphTARGET_VERIFY 时断言 spec_info.draft_token_num == batch_info.max_len,防止把按 capture 宽度预填的 seg_lens 静默套用到其他宽度批次(适配器错贴到错误 token 行)。

  4. MoE 缓冲区与加载侧告警lora_manager.pyinit_lora_cuda_graph_moe_buffersmax_bs 改为 max_bs * speculative_num_draft_tokens 计大小,因为 verify capture 喂入的是 bs * draft_token_num 个 token,该 under-size 对 NGRAM + MoE LoRA 同样存在;load_lora_weights / load_lora_weights_from_tensors 加载后调用 warn_if_adapter_targets_embeddings,对 EAGLE 系列下携带 embedding 权重的适配器给出接受率提示(不拒绝,因为会排除所有 MTP 能力基座)。

  5. 测试与配套:新增 CPU 单测 test_draft_runner_skips_lora.py、GPU 单测 test_lora_spec_verify_batch_info.py(覆盖 eager 均匀段、graph 宽度断言、MoE token 计数)、E2E test_lora_spec_decoding.py(只断言服务属性而非输出文本,避免贪心解码跨 batch 形状不可复现导致的 flaky),以及两组手动验证工具 test/manual/lora/run_spec_lora_matrix.py(spec-on vs spec-off 逐适配器矩阵)和 check_spec_baseline_divergence.py(判断分歧是否与 LoRA 无关)。

文件 模块 状态 重要度
python/sglang/srt/lora/utils.py LoRA 工具 modified 7.43
python/sglang/srt/server_args.py 参数校验 modified 7.41
python/sglang/srt/lora/backend/triton_backend.py LoRA 后端 modified 6.33
python/sglang/srt/lora/backend/base_backend.py LoRA 后端 modified 6.37
python/sglang/srt/lora/lora_manager.py LoRA 管理 modified 6.07
python/sglang/srt/model_executor/model_runner.py 模型执行器 modified 5.92
python/sglang/srt/lora/layers.py LoRA 层 modified 5.89
python/sglang/srt/speculative/dflash_worker_v2.py 草稿执行器 modified 5.68
python/sglang/srt/speculative/dspark_components/dspark_worker_v2.py 草稿执行器 modified 5.68
test/registered/lora/test_lora_spec_decoding.py E2E 测试 added 7.88
test/registered/unit/lora/test_lora_spec_verify_batch_info.py 单元测试 added 7.77
test/manual/lora/run_spec_lora_matrix.py 验证工具 added 8.15
python/sglang/srt/speculative/eagle_worker_v2.py 草稿执行器 modified 5.59

关键符号

get_batch_token_counts warn_if_adapter_targets_embeddings _check_lora_speculative_compatibility unwrap_lora_layer maybe_init_lora_manager init_lora_cuda_graph_moe_buffers prepare_lora_batch _add_moe_lora_info set_lm_head_from_target

关键源码片段

python/sglang/srt/lora/utils.py core-logic

新增 `get_batch_token_counts` 统一三种 forward mode 的 token 计数,是 TARGET_VERIFY 段计算修复的核心;新增 `warn_if_adapter_targets_embeddings` 加载侧告警。

"""TARGET_VERIFY 模式下的 token 计数与 embedding 告警(LoRA + 投机解码核心工具)。TARGET_VERIFY 的 ForwardBatch 走 decode 风格的位置分支,extend_seq_lens 保持
None,但每个请求实际携带 spec_info.draft_token_num 个 token —— 这就是此前
triton eager 路径 max(None)、MoE _add_moe_lora_info sum(None) 崩溃的根源。
"""
​
​
def get_batch_token_counts(forward_batch: ForwardBatch) -> Tuple[int, int]:
    """返回 (总 token 数, 每请求最大 token 数),供 LoRA 段计算使用。"""
    mode = forward_batch.forward_mode
    if mode.is_decode():
        # decode 模式每请求 1 个 token
        return forward_batch.batch_size, 1
    if mode.is_target_verify():
        # 投机验证:每个请求固定 draft_token_num 个 token,统一宽度
        num_tokens_per_req = forward_batch.spec_info.draft_token_num
        return forward_batch.batch_size * num_tokens_per_req, num_tokens_per_req
    if mode.is_extend():
        # 常规 extend:从 CPU 侧计数取最大长度,避免 D2H 拷贝
        return forward_batch.extend_num_tokens, max(forward_batch.extend_seq_lens_cpu)
    raise ValueError(f"Unsupported forward mode: {mode}")
​
​
def warn_if_adapter_targets_embeddings(
    lora_name: str,
    embedding_layer_names: Iterable[str],
    speculative_algorithm: Optional[str],
) -> None:
    """EAGLE 系列投机解码下,适配器携带 embedding 权重时给出告警。    共享草稿模型消费的是 base embedding 权重,适配器对 embedding 的 delta
    不会影响草稿,只会降低接受率;输出不受影响,因此只告警不拒绝。
    """
    if speculative_algorithm not in ("EAGLE", "EAGLE3"):
        return
    modules = sorted(embedding_layer_names)
    if not modules:
        return
    logger.warning(
        "LoRA adapter '%s' targets embedding modules (%s) while EAGLE-family "
        "speculative decoding is enabled. The shared draft consumes their "
        "base weights, so those deltas do not influence drafting and may "
        "reduce the accept rate. Outputs are unaffected.",
        lora_name,
        ", ".join(modules),
    )
python/sglang/srt/server_args.py core-logic

用 `_check_lora_speculative_compatibility` 替换 NGRAM-only 硬拒绝,是功能开关的入口;白名单与拒绝项设计直接决定支持边界。

# 各投机算法在 TARGET_VERIFY 阶段呈现 " 每请求固定 token 宽度 ",
# 这是 LoRA 段布局(seg_lens 均匀)成立的前提。
_LORA_SPEC_ALGORITHMS = ("EAGLE", "EAGLE3", "DFLASH", "DSPARK")
​
​
def _check_lora_speculative_compatibility(self):
    """校验 LoRA + 投机解码的参数组合是否合法。"""
    if self.speculative_algorithm in ["NGRAM", None]:
        return
​
    if self.speculative_algorithm not in _LORA_SPEC_ALGORITHMS:
        promoted = (
            " (NEXTN/EAGLE with a Gemma4 assistant draft is automatically "
            "promoted to FROZEN_KV_MTP, which does not support LoRA)"
            if self.speculative_algorithm == "FROZEN_KV_MTP"
            else ""
        )
        raise ValueError(
            "LoRA is only compatible with NGRAM, EAGLE, NEXTN, EAGLE3, "
            "DFLASH, or DSPARK speculative decoding, not "
            f"{self.speculative_algorithm}{promoted}."
        )
​
    ragged_mode = envs.SGLANG_RAGGED_VERIFY_MODE.get()
​
    # 逐项列出不支持的组合,原因统一拼接到共享前缀后,
    # 让报错信息能点出具体组合而非只报某个 flag。
    unsupported = [
        (
            self.speculative_algorithm == "DSPARK" and ragged_mode != "static",
            f"does not support SGLANG_RAGGED_VERIFY_MODE={ragged_mode!r}: "
            "the per-request verify lengths it schedules break the "
            "uniform-width LoRA segment layout",
        ),
        (
            self.speculative_adaptive,
            "does not support --speculative-adaptive: the draft is built "
            "from a static ServerArgs snapshot, and the runtime-state "
            "swap does not rebuild LoRA cuda-graph metadata",
        ),
        (
            "experimental_sgl_trtllm"
            in (self.moe_runner_backend, self.speculative_moe_runner_backend),
            "does not support the experimental_sgl_trtllm MoE runner: its "
            "TopK reads the LoRA config per forward, which the draft "
            "resolves against the target's after its own publish ended",
        ),
        (
            envs.SGLANG_ENABLE_OVERLAP_PLAN_STREAM.get(),
            "does not support SGLANG_ENABLE_OVERLAP_PLAN_STREAM=1: LoRA "
            "batch preparation would run on the plan stream, unordered "
            "against in-flight forwards",
        ),
    ]
    for is_unsupported, reason in unsupported:
        if is_unsupported:
            raise ValueError(
                f"LoRA with EAGLE/NEXTN/EAGLE3 speculative decoding {reason}."
            )
python/sglang/srt/lora/backend/triton_backend.py core-logic

cuda-graph 宽度断言与 eager 段计算改造所在,直接影响适配器是否被正确贴到 token 行上,是静默错误防护的关键点。

def prepare_lora_batch(self, forward_batch, weight_indices, lora_ranks,
                       scalings, use_cuda_graph):
    bs = forward_batch.batch_size
    if use_cuda_graph:
        assert (
            self.cuda_graph_batch_info is not None
        ), "CUDA Graph batch info is not initialized."
        batch_info = self.cuda_graph_batch_info
        if forward_batch.forward_mode.is_target_verify():
            # seg_lens 是在 capture 时按当时的每请求宽度预填的,之后永不刷新;
            # 若 verify 批次宽度不一致,直接断言失败,而不是把适配器
            # 静默错贴到错误的 token 行上。
            assert forward_batch.spec_info.draft_token_num == batch_info.max_len, (
                "target-verify width "
                f"{forward_batch.spec_info.draft_token_num} does not match "
                f"the captured LoRA cuda-graph width {batch_info.max_len}"
            )
        batch_info.bs = forward_batch.batch_size
        batch_info.num_segments = forward_batch.batch_size
        # ... 后续按 bs 截断 / 清零 seg_lens 并重算 seg_indptr ...
    else:
        # 宽度来自 CPU 侧计数,避免 D2H 传输;
        # generate_sequence_lengths 按 forward mode 生成均匀或变长段。
        _, max_len = get_batch_token_counts(forward_batch)
        seg_lens = generate_sequence_lengths(forward_batch, device=self.device)
        seg_indptr = torch.zeros((bs + 1,), dtype=torch.int32, device=self.device)
        seg_indptr[1:] = torch.cumsum(seg_lens, dim=0)
        # ... 其余 segment / MoE 组装逻辑 ...

评论区精华

DFLASH 草稿的 embedding 模块是否需要解包 LoRA 正确性

Qiaolin-Yu 在 dflash_worker_v2.py 的 embed_module 获取处提问:"do we need to unwrap lora layer for embedding tokens too?"。作者确认这是真实缺口:embed_module 被用于草稿形状输入(noise_embedding = embed_module(block_ids)),LoRA 包裹的 VocabParallelEmbeddingWithLoRA 会把目标适配器 delta 套在草稿 token id 上,与 lm_head 解包要避免的失败完全同类。

结论:已修复:改为 embed_module = unwrap_lora_layer(target_model.get_input_embeddings()),并在 1xH200 上验证 DFLASH + 2 适配器仍可服务所有路由(base 2.857、fact 3.333、guard 2.857 accept length,输出互相区分)。 · 已解决

DSPARK 草稿路径中 embedding 获取点遗漏与 hasattr 检查顺序 正确性

Qiaolin-Yu 指出 dspark_draft.py 也独立获取 embedding,建议一并修改。作者确认只在 attach_shared_modules 解包仍会留下包裹路径;追查中还发现 same file 的 hasattr(lm_head, "weight") 检查在解包前执行,而 BaseLayerWithLoRA 不透传 weight 属性,会误报 "DSpark requires the target model to expose lm_head with weight"。

结论:已修复:dspark_draft.py 的 embed 获取点同样使用 unwrap_lora_layer,并将 weight 检查移到解包之后;rebase 后 attach_shared_modules 移入 else 分支,unwrap 只应用一次。 · 已解决

风险与影响

核心风险集中在共享草稿设计的固有代价:适配器移动输出分布越远,接受率下降越明显(实验数据:NEXTN 私有适配器 -19~-31%,DFLASH -34%,EAGLE3 -41%),且高并发下 LoRA + spec 吞吐可能低于无 spec(c=32 时 1419.7 vs 2975.4 tok/s)。triton_backend.py 的宽度断言是 fail-fast 设计,若运行期 draft_token_num 变化会直接报错而非静默降级,属于有意为之但需注意的行为变化。两个已知未修复问题被明确排除在门外:--enable-two-batch-overlapfilter_batch 切片 lora_ids 但不重建 LoRA batch info)和 --enable-lora-overlap-loading(观察到适配器静默不生效),它们是通用 LoRA 问题而非投机解码问题。兼容性校验的拒绝列表硬编码在 server_args.py,未来新增投机算法时容易遗漏。embedding 适配器只告警不拒绝,用户忽略告警可能导致接受率意外下降。此外 e2e 贪心解码不可逐 bit 复现,正确性保障依赖手动矩阵工具,CI 覆盖的是“服务属性”而非逐 token 等价。

对用户:服务端现在可以直接给 EAGLE / NEXTN / EAGLE3、DFLASH、DSPARK 开启多适配器 LoRA 共批,无需退回 NGRAM 或关闭投机,单并发下 LoRA + spec 相比无 spec 有 2.12x 提升,但量化结果显示高并发下该组合收益为负。对系统:改动触及 model executor、LoRA manager、三个 LoRA 后端和多个 speculative worker,TARGET_VERIFY 的 token 计数语义被统一收敛到 get_batch_token_counts。对团队:新增的 run_spec_lora_matrix.pycheck_spec_baseline_divergence.py 成为 LoRA + 投机解码回归验证的专用工具,验证方法论(spec-on vs spec-off、噪声地板测量、adapter 身份前缀检查)值得沉淀为通用实践。

核心路径变更 TARGET_VERIFY 段布局风险 共享草稿接受率下降 已知 LoRA 问题未修复 e2e 不可逐 bit 复现

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论