Prhub

#50713 [CI] Solidify speculative decoding E2E coverage

原始 PR 作者 AndreasKaratzas 合并时间 2026-08-11 09:56 文件变更 18 提交数 12 评论 17 代码增减 +749 / -471

执行摘要

统一 spec-decode 测试基建,补 AMD CI 与失败诊断

PR body 明确说明这是 #50330 的 follow-up,目的是“apply some NITS across the new spec-decode area while hardening deterministic setup and actionable failure reporting”。结合代码变更可还原出三个具体动机:一是重组后的 spec-decode 测试仍以手写 LLM(...) + del llm + cleanup_dist_env_and_memory() 方式管理生命周期,各文件 setup、默认值、清理逻辑不一致(ROCm 上还需要 wait_for_rocm_memory_to_settle 等临时变通);二是 prompt 生成依赖 random.seed(0),匹配断言是各文件手写循环,失败时只输出零星 print、缺少可操作的诊断信息;三是原测试覆盖面偏 CUDA,MoE 后端断言硬编码 flashinfer 后端名导致 ROCm 无法运行,DFlash/DSpark 在 AMD CI 上也缺少镜像覆盖。

值得精读。该 PR 是“大规模测试基建统一”的范本:vllm_runner 迁移中如何处理默认值语义差异(block_size/enable_chunked_prefill 显式传 None)、如何用共享工具函数收敛重复断言逻辑、以及如何在测试代码中做平台差异化(ROCm 的 AITER/FlashAttention 限制)都值得后续新增 spec-decode 测试时直接复用。重点文件是 tests/v1/e2e/spec_decode/utils.py 与 test_draft_model.py;若负责 CI 维护,还应关注 spec_decode.yaml 的 shard 平衡与新增 AMD 镜像。

讨论亮点

Review 由 mgoin 主导并最终 APPROVED,核心讨论集中在 dspark 测试的 VllmRunner 迁移参数上:

1) mgoin 质疑为何要显式设置 block_size 与 enable_chunked_prefill(认为默认值即可),作者解释 VllmRunner 与直接 LLM 构造的默认值不同(block_size=16、enable_chunked_prefill=False),移除会改变行为并禁用 Gemma4 支持的 chunked prefill,故必须保留显式传参以保持语义等价;
2) mgoin 提出 limit_mm_per_prompt 的写法可以用 language_model_only=True 简化,作者采纳。整体无未解决的争议。

实现拆解

  1. 统一测试生命周期到 vllm_runner:将 tests/v1/e2e/spec_decode 下 15+ 个测试文件(test_draft_model.py、test_lora.py、test_mtp_parallel_load.py、test_mtp.py、test_ngram_suffix.py、test_async.py、test_synthetic.py、test_dflash.py、test_dspark.py、test_speculators.py、test_medusa.py、eagle/utils.py、eagle/test_eagle_correctness.py、acceptance_rates/utils.py 等)从手写 LLM 构造 + 手动释放迁移到 with vllm_runner(...) as runner 上下文。vllm_runner 提供一致的清理与默认值管理,消除了 ROCm 上手工 wait_for_rocm_memory_to_settle 的必要。关键点在于 vllm_runner 与直接 LLM 构造的默认值不同(如 block_size 默认 16、enable_chunked_prefill 默认 False),因此多数调用点显式传入 block_size=None、enable_chunked_prefill=None、compilation_config=CompilationConfig() 以保持与 LLM 语义等价。
  2. 抽取共享断言与指标工具:在 tests/v1/e2e/spec_decode/utils.py 新增 assert_request_outputs_match(带 required_matches 阈值的精确文本匹配,失败时输出最多 3 个 mismatch 的 text 与 token_ids 截断样本)和 get_spec_decode_metric_value(metric 缺失时列出可用 spec-decode 指标,给出可操作错误)。compute_acceptance_rate 与 compute_acceptance_len 转而基于该工具读取指标,消除了原先 KeyError 式失败。各测试文件(test_lora.py、test_mtp.py、eagle/utils.py 等)的手写匹配循环全部替换为 assert_request_outputs_match。
  3. 平台感知与确定性处理:get_test_prompts 改用 set_random_seed(0) 同时覆盖 Python 与 Torch 随机状态;新增 _platform_moe_backend(ROCm 先设置 VLLM_ROCM_USE_AITER 与 VLLM_ROCM_USE_AITER_MOE 再返回 aiter,CUDA 返回 flashinfer_trtllm,其余返回 auto),使 MoE 后端断言可在 ROCm 上运行;新增 _enable_batch_invariance(ROCm 上显式关闭 VLLM_BATCH_INVARIANT,原因是 ROCm FlashAttention varlen API 缺少 num_splits 参数);test_lora.py 的跳过条件从 is_cuda() 放宽为 is_cuda_alike(),CUBLAS_WORKSPACE_CONFIG 仅在 CUDA 上设置;EAGLE/MTP/dspark 测试通过 limit_mm_per_prompt(或 review 建议的 language_model_only)避免对未使用的多模态塔做 profiling。
  4. 强化验收阈值与失败诊断:ArgsTest.num_prompts 从 100 提升到 200(注释称可将采样标准误降低约 29%);匹配阈值从 matches > int(0.6 * len(ref_outputs)) 收紧为 required_matches=int(0.6 * len(ref_outputs)) + 1;pytest.raises(ValueError) 增加 match="draft_tensor_parallel_size" 精确匹配;test_mtp_parallel_load.py 对生成 token 数量与 MTP drafts 非零性增加断言(非空泛验证);test_ngram_suffix.py 在断言失败时输出首末轮 accepted/drafted 摘要;acceptance_rates 相关测试移除 tqdm 进度输出。
  5. CI 配置与配套:.buildkite/test_areas/spec_decode.yaml 新增 AMD CI 镜像(DFlash/DSpark,包括通过 ROCm 模拟的 NVFP4 目标)并平衡 Eagle shard;vllm/config/speculative.py 有 1 行微调(上下文未给出具体内容,属测试配置契约层面的配套调整)。
文件 模块 状态 重要度
tests/v1/e2e/spec_decode/utils.py 测试工具 modified 6.5
tests/v1/e2e/spec_decode/draft_model/test_draft_model.py 草稿模型 modified 7.24
.buildkite/test_areas/spec_decode.yaml CI 配置 modified 5.11
tests/v1/e2e/spec_decode/test_mtp_parallel_load.py MTP 加载 modified 5.71
tests/v1/e2e/spec_decode/draft_model/test_lora.py LoRA 测试 modified 5.34

关键符号

assert_request_outputs_match get_spec_decode_metric_value compute_acceptance_rate compute_acceptance_len _platform_moe_backend _enable_batch_invariance test_draft_model_correctness test_draft_model_moe_backend_override test_draft_model_moe_backend_inherits_target test_batch_inference_correctness test_deepseek_mtp_load_inline test_synthetic_acceptance_rate _run_eagle_correctness

关键源码片段

tests/v1/e2e/spec_decode/utils.py test-coverage

新增 assert_request_outputs_match 与 get_spec_decode_metric_value 两个共享工具,是整个测试加固的地基;compute_acceptance_rate / compute_acceptance_len 也重构为基于新工具,统一了失败诊断方式。

# 带阈值的精确文本匹配断言,失败时输出有界的诊断信息,
# 用于对比参考引擎与 spec-decode 引擎的输出是否一致。
def assert_request_outputs_match(
    ref_outputs: Sequence[RequestOutput],
    spec_outputs: Sequence[RequestOutput],
    *,
    required_matches: int,
    context: str,
    max_mismatches: int = 3,
) -> None:
    assert ref_outputs, f"{context}: no reference outputs"
    assert len(ref_outputs) == len(spec_outputs), (
        f"{context}: output count differs: "
        f"reference={len(ref_outputs)}, speculative={len(spec_outputs)}"
    )
    assert 0 <= required_matches <= len(ref_outputs), (
        f"{context}: invalid required_matches={required_matches} for "
        f"{len(ref_outputs)} outputs"
    )
​
    mismatches: list[str] = []
    matches = 0
    for index, (ref_output, spec_output) in enumerate(zip(ref_outputs, spec_outputs)):
        assert ref_output.outputs, (
            f"{context}: reference output {index} has no candidate"
        )
        assert spec_output.outputs, (
            f"{context}: speculative output {index} has no candidate"
        )
        ref_candidate = ref_output.outputs[0]
        spec_candidate = spec_output.outputs[0]
        if ref_candidate.text == spec_candidate.text:
            matches += 1
        elif len(mismatches) < max_mismatches:
            # 只记录前 max_mismatches 个不匹配,避免失败日志过长;
            # 同时截断文本与 token id,足够定位漂移点。
            mismatches.append(
                f"[{index}] ref_text={ref_candidate.text[:240]!r}, "
                f"spec_text={spec_candidate.text[:240]!r}\n"
                f"    ref_token_ids={list(ref_candidate.token_ids)[:64]}\n"
                f"    spec_token_ids={list(spec_candidate.token_ids)[:64]}"
            )
​
    print(
        f"{context}: exact text matches={matches}/{len(ref_outputs)} "
        f"(required={required_matches})"
    )
    mismatch_summary = "\n".join(mismatches) or "no mismatches captured"
    assert matches >= required_matches, (
        f"{context}: only {matches}/{len(ref_outputs)} outputs matched; "
        f"required at least {required_matches}. First mismatches:\n"
        f"{mismatch_summary}"
    )
​
​
# 读取 spec-decode 统计指标,缺失时给出可操作的错误信息,
# 避免原先的 KeyError 式失败。
def get_spec_decode_metric_value(metrics: Sequence[Metric], metric_name: str) -> float:
    name2metric = {metric.name: metric for metric in metrics}
    metric = name2metric.get(metric_name)
    assert metric is not None, (
        f"Missing metric {metric_name!r}. Ensure disable_log_stats=False. "
        "Available spec-decode metrics: "
        f"{sorted(name for name in name2metric if 'spec_decode' in name) or ['<none>']}"
    )
    return float(metric.value)
tests/v1/e2e/spec_decode/draft_model/test_draft_model.py test-coverage

draft_model 测试主文件:整体迁移到 vllm_runner,并将 MoE 后端断言平台化(_platform_moe_backend),使原本仅 CUDA 可跑的测试覆盖 ROCm;num_prompts 100→200 降低采样标准误。

# 按当前平台选择可用的 MoE 后端名,并完成 ROCm 所需的
# 环境变量设置,保证后端相关断言在 ROCm CI 上也能运行。
def _platform_moe_backend(monkeypatch: pytest.MonkeyPatch) -> MoEBackend:
    if current_platform.is_rocm():
        monkeypatch.setenv("VLLM_ROCM_USE_AITER", "1")
        monkeypatch.setenv("VLLM_ROCM_USE_AITER_MOE", "1")
        return "aiter"
    if current_platform.is_cuda():
        return "flashinfer_trtllm"
    return "auto"
​
​
# 当 speculative_config 显式指定 moe_backend 时,草稿配置应使用它,
# 而目标模型保留自己的后端设置,互不影响。
def test_draft_model_moe_backend_override(monkeypatch: pytest.MonkeyPatch):
    target_moe_backend = _platform_moe_backend(monkeypatch)
    engine_args = EngineArgs(
        model="Qwen/Qwen3-1.7B",
        tensor_parallel_size=1,
        moe_backend=target_moe_backend, # 目标侧后端按平台选择
        speculative_config={
            "model": "Qwen/Qwen3-0.6B",
            "method": "draft_model",
            "num_speculative_tokens": 3,
            "moe_backend": "triton", # 草稿侧显式指定
        },
    )
    tgt_config: VllmConfig = engine_args.create_engine_config()
    assert tgt_config.kernel_config.moe_backend == target_moe_backend
    assert tgt_config.speculative_config.moe_backend == "triton"
    draft_config = _apply_draft_moe_backend(tgt_config)
    assert draft_config.kernel_config.moe_backend == "triton"
    # 应用草稿配置后,目标配置必须保持原状不受影响。
    assert tgt_config.kernel_config.moe_backend == target_moe_backend
tests/v1/e2e/spec_decode/test_mtp_parallel_load.py test-coverage

MTP 并行加载测试:新增 _enable_batch_invariance 按平台显式开关 batch invariance(ROCm FlashAttention varlen API 缺 num_splits 参数),并迁移 vllm_runner;增加 token 数量与 drafts 非零断言防止空泛验证。

# DeepSeek MLA prefill 的 batch invariance 依赖 FlashAttention 的
# num_splits 参数,ROCm 上游 varlen API 不支持该参数,因此按平台
# 显式开关,避免环境变量被外部设置污染测试结果。
def _enable_batch_invariance(monkeypatch: pytest.MonkeyPatch) -> None:
    if current_platform.is_cuda():
        monkeypatch.setenv("VLLM_BATCH_INVARIANT", "1")
    else:
        monkeypatch.setenv("VLLM_BATCH_INVARIANT", "0")

评论区精华

VllmRunner 默认值与 LLM 不一致的语义风险 question

mgoin 在 tests/v1/e2e/spec_decode/acceptance_rates/dspark/test_dspark.py 询问:"Why do we need to set block_size and enable_chunked_prefill? These should be fine as their defaults"。作者回复 VllmRunner 默认 block_size=16、enable_chunked_prefill=False,与 LLM 构造不同,移除会改变行为并禁用 Gemma4 支持的 chunked prefill。

结论:保留显式传参以保持与 LLM 构造语义等价,这是迁移时必须记录的关键约定。 · 已解决

limit_mm_per_prompt 可替换为 language_model_only 设计

mgoin 提出 nit:`limit_mm_per_prompt={"image": 0, "audio": 0, "video": 0}` 可以直接用 language_model_only=True 更简洁。作者回复 Done 采纳。

结论:采纳建议,使用更简洁的 language_model_only 表达。 · 已解决

风险与影响

  1. 大范围迁移的语义漂移风险:VllmRunner 与直接 LLM 构造的默认值不同(block_size、enable_chunked_prefill、trust_remote_code 等),虽然本 PR 多数入口显式传参,但 15+ 文件迁移中任何遗漏都会静默改变测试行为,可能造成假通过或假失败,后续维护需警惕。
  2. 平台分支增多:_platform_moe_backend、_enable_batch_invariance 等函数在 CUDA/ROCm 上行为不同,未来新平台(如 XPU)会落入 else 分支,语义可能不达预期。
  3. 阈值与统计收紧可能引发 flaky:num_prompts 100→200 增加测试时长;required_matches 从 >x 改为 >=floor(x)+1;synthetic acceptance 的 tolerance 0.15 在 ROCm 上可能因数值差异偶发失败。
  4. CI 资源与外部依赖:spec_decode.yaml 新增 AMD 镜像后 CI 时间与 GPU 资源占用上升;测试依赖 HuggingFace 外网模型下载,存在网络抖动导致的偶发失败。

影响范围集中在测试与 CI:tests/v1/e2e/spec_decode 下所有测试的写法成为后续新增 spec-decode 测试的样板;AMD ROCm CI 首次获得 DFlash/DSpark 及 NVFP4 模拟覆盖,跨平台回归检测能力显著增强。对生产推理路径无影响(vllm/config/speculative.py 仅 1 行改动)。对团队而言,失败诊断从“零星 print + 手写阈值”升级为“带上下文与样本截断的结构化断言”,定位 flaky 的成本降低。对用户无行为影响。

测试基建全量迁移 VllmRunner 默认值差异带来语义变化 阈值收紧可能引发跨平台 flaky CI 资源与镜像依赖增加

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论