Skip to content

Commit 0deb900

Browse files
[CI][Feature] Add nightly auto-bisect tool for E2E test failures (vllm-project#10564)
### What this PR does / why we need it? This PR introduces an automated bisect tool (`auto_bisect`) for nightly E2E test failures in `vllm-ascend`. It binary-searches the commit history between the last known-good commit (tracked in a CSV table) and the failing commit to identify the first bad commit and its associated PR. It supports both single-node and multi-node environments, reuses existing nightly launch entries, and optimizes the build process by only rebuilding when native or build-definition files are modified. ### Does this PR introduce _any_ user-facing change? No. This is a developer/CI tool and does not introduce any user-facing changes. ### How was this patch tested? Tested locally by running the auto-bisect tool on single-node and multi-node scenarios. - vLLM version: v0.23.0 - vLLM main: vllm-project/vllm@1f486d9 Signed-off-by: DreamerLeader <88812830+DreamerLeader@users.noreply.github.com>
1 parent 7217766 commit 0deb900

33 files changed

Lines changed: 3382 additions & 4 deletions

.github/workflows/scripts/select_tests.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@
4343
the broad regression triggered by ``optional: false`` modules when the
4444
intent of the PR is purely to add or adjust tests.
4545
46+
Bisect-tool optimization:
47+
If a PR is scoped to ``tools/bisect`` and its paired UT/config/format files,
48+
the always-on modules are skipped and only modules whose dependencies match
49+
the changed files are selected. This keeps maintenance-only tool changes
50+
from triggering the full CPU and NPU regression suite.
51+
4652
Routing is driven by ``test_config.yaml`` ``runner_mapping:`` (regex patterns).
4753
Partition sizing by ``partition:`` config block.
4854
See ``test_config.yaml`` for details.
@@ -90,6 +96,15 @@ class RunnerInfo:
9096
# is selected for UT runs (along with the changed test files).
9197
DEFAULT_CPU_UT_MODULE = "default_cpu_ut"
9298

99+
_BISECT_TOOL_ROOTS = ("tools/bisect", "tests/ut/tools/bisect")
100+
_BISECT_TOOL_SUPPORT_FILES = {
101+
".github/workflows/scripts/select_tests.py",
102+
".github/workflows/scripts/test_config.yaml",
103+
".github/workflows/scripts/test_select_tests.py",
104+
"csrc/build.sh",
105+
"tests/ut/tools/__init__.py",
106+
}
107+
93108
# Populated by _load_runner_mapping(). Ordered list of (regex, {key: RunnerKey}).
94109
_RUNNER_MAPPING: list[tuple[re.Pattern, dict[str, RunnerKey]]] = []
95110

@@ -249,12 +264,14 @@ def resolve(name: str) -> dict:
249264
def _match_modules(
250265
changed_files: list[str],
251266
config: list[dict],
267+
*,
268+
include_always: bool = True,
252269
) -> list[str]:
253270
if not changed_files:
254271
return []
255272
matched: list[str] = []
256273
for module in config:
257-
if not module.get("optional", True):
274+
if include_always and not module.get("optional", True):
258275
matched.append(module["name"])
259276
continue
260277
deps = module.get("source_file_dependencies", [])
@@ -348,6 +365,20 @@ def _is_test_only_change(changed_files: list[str]) -> bool:
348365
return bool(changed_files) and all(_is_test_path(f) for f in changed_files)
349366

350367

368+
def _is_bisect_tool_scoped_path(file_path: str) -> bool:
369+
return file_path in _BISECT_TOOL_SUPPORT_FILES or any(
370+
_matches_path_dependency(file_path, root) for root in _BISECT_TOOL_ROOTS
371+
)
372+
373+
374+
def _is_bisect_tool_scoped_change(changed_files: list[str]) -> bool:
375+
return (
376+
bool(changed_files)
377+
and any(_matches_path_dependency(f, root) for f in changed_files for root in _BISECT_TOOL_ROOTS)
378+
and all(_is_bisect_tool_scoped_path(f) for f in changed_files)
379+
)
380+
381+
351382
def _scan_ut_test_dir(
352383
dir_path: str,
353384
groups: dict[RunnerKey, list[str]],
@@ -731,15 +762,23 @@ def main():
731762
_scan_e2e_test_dir(path, all_groups)
732763
else:
733764
changed_files = _get_changed_files(args.diff_base) if args.diff_base else args.changed_files
765+
bisect_tool_scoped_change = _is_bisect_tool_scoped_change(changed_files)
734766
test_only_change = _is_test_only_change(changed_files)
735-
if test_only_change:
767+
if bisect_tool_scoped_change:
768+
print(
769+
"Detected bisect tool-scoped change: running only matching tool modules (skipping always-on modules).",
770+
file=sys.stderr,
771+
)
772+
elif test_only_change:
736773
print(
737774
"Detected test-only change: running only default_cpu_ut"
738775
" and the changed test files (skipping source-driven modules).",
739776
file=sys.stderr,
740777
)
741778
if args.run_all_modules:
742779
matched_modules = [module["name"] for module in config]
780+
elif bisect_tool_scoped_change:
781+
matched_modules = _match_modules(changed_files, config, include_always=False)
743782
elif test_only_change:
744783
matched_modules = [m["name"] for m in config if m["name"] == DEFAULT_CPU_UT_MODULE]
745784
else:

.github/workflows/scripts/test_config.yaml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,15 @@
647647
- tests/e2e/pull_request/two_card/test_qwen3_5_35b_a3b_w8a8.py
648648

649649
# === Others ===
650+
- name: nightly_bisect
651+
optional: true
652+
cpu_only: true
653+
source_file_dependencies:
654+
- tools/bisect
655+
- tests/ut/tools/bisect
656+
tests:
657+
- tests/ut/tools/bisect
658+
650659
- name: _tools
651660
optional: false
652661
source_file_dependencies:
@@ -824,4 +833,3 @@ partition:
824833

825834

826835

827-

.github/workflows/scripts/test_select_tests.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,99 @@ def test_main_end_to_end_changed_files_options_and_skip(tmp_path, monkeypatch, c
468468
assert selected_tests == {"tests/e2e/pull_request/two_card/test_two_card.py::test_specific_case"}
469469

470470

471+
def test_bisect_tool_scoped_change_skips_global_modules(tmp_path, monkeypatch, capsys):
472+
test_root = tmp_path / "tests"
473+
default_cpu_dir = test_root / "ut" / "default"
474+
bisect_dir = test_root / "ut" / "tools" / "bisect"
475+
tools_dir = test_root / "ut" / "_tools"
476+
e2e_dir = test_root / "e2e" / "pull_request" / "one_card"
477+
for path in (default_cpu_dir, bisect_dir, tools_dir, e2e_dir):
478+
path.mkdir(parents=True)
479+
480+
default_cpu_test = default_cpu_dir / "test_default.py"
481+
bisect_test = bisect_dir / "test_auto_bisect.py"
482+
tools_test = tools_dir / "test_base_tool.py"
483+
e2e_test = e2e_dir / "test_model.py"
484+
for path in (default_cpu_test, bisect_test, tools_test, e2e_test):
485+
path.write_text("")
486+
487+
config = [
488+
{
489+
"name": "default_cpu_ut",
490+
"optional": False,
491+
"cpu_only": True,
492+
"tests": ["tests/ut"],
493+
},
494+
{
495+
"name": "always_e2e",
496+
"optional": False,
497+
"tests": ["tests/e2e/pull_request/one_card"],
498+
},
499+
{
500+
"name": "nightly_bisect",
501+
"optional": True,
502+
"cpu_only": True,
503+
"source_file_dependencies": ["tools/bisect", "tests/ut/tools/bisect"],
504+
"tests": ["tests/ut/tools/bisect"],
505+
},
506+
{
507+
"name": "_tools",
508+
"optional": False,
509+
"source_file_dependencies": ["tools/"],
510+
"tests": ["tests/ut/_tools"],
511+
},
512+
]
513+
config_path = tmp_path / "config.yaml"
514+
runner_mapping = {"tests/e2e/pull_request/one_card": {"default": "a2_x1"}}
515+
_write_two_doc_config(config_path, config, {"runner_mapping": runner_mapping})
516+
runner_file = tmp_path / "runner_label.json"
517+
runner_file.write_text(
518+
json.dumps(
519+
{
520+
"cpu-runner": {"chip": "cpu", "npu_num": 0},
521+
"a2-runner": {"chip": "a2", "npu_num": 1},
522+
}
523+
)
524+
)
525+
monkeypatch.setattr(select_tests, "_RUNNER_LABEL_PATH", runner_file)
526+
monkeypatch.chdir(tmp_path)
527+
528+
changed_files = [
529+
"tools/bisect/auto_bisect.py",
530+
"tests/ut/tools/bisect/test_auto_bisect.py",
531+
".github/workflows/scripts/select_tests.py",
532+
".github/workflows/scripts/test_config.yaml",
533+
"csrc/build.sh",
534+
]
535+
assert select_tests._is_bisect_tool_scoped_change(changed_files)
536+
assert not select_tests._is_bisect_tool_scoped_change([*changed_files, "vllm_ascend/envs.py"])
537+
monkeypatch.setattr(
538+
sys,
539+
"argv",
540+
[
541+
"select_tests.py",
542+
"--config",
543+
str(config_path),
544+
"--changed-files",
545+
*changed_files,
546+
],
547+
)
548+
549+
select_tests.main()
550+
captured = capsys.readouterr()
551+
assert "Detected bisect tool-scoped change" in captured.err
552+
assert "matched_modules=nightly_bisect,_tools" in captured.out
553+
groups_line = next(line for line in captured.out.splitlines() if line.startswith("test_groups="))
554+
test_groups = json.loads(groups_line.removeprefix("test_groups="))
555+
selected_tests = {test for group in test_groups for test in group["tests"].split()}
556+
assert selected_tests == {
557+
"tests/ut/_tools/test_base_tool.py",
558+
"tests/ut/tools/bisect/test_auto_bisect.py",
559+
}
560+
assert "tests/ut/default/test_default.py" not in selected_tests
561+
assert "tests/e2e/pull_request/one_card/test_model.py" not in selected_tests
562+
563+
471564
def test_default_cpu_ut_always_runs(tmp_path, monkeypatch, capsys):
472565
test_root = tmp_path / "tests"
473566
cpu_dir = test_root / "ut" / "cpu"

csrc/build.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -521,7 +521,7 @@ function gen_bisheng(){
521521
fi
522522

523523
pushd ${gen_bisheng_dir}
524-
$(> bisheng)
524+
: > bisheng
525525
echo "#!/bin/bash" >> bisheng
526526
echo "ccache_args=""\"""${ccache_program} ${BISHENG_REAL_PATH}""\"" >> bisheng
527527
echo "args=""$""@" >> bisheng

tests/ut/tools/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+

tests/ut/tools/bisect/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+

tests/ut/tools/bisect/conftest.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import importlib.util
2+
import sys
3+
import types
4+
from unittest.mock import MagicMock
5+
6+
if importlib.util.find_spec("psutil") is None:
7+
psutil = types.ModuleType("psutil")
8+
psutil.__spec__ = importlib.util.spec_from_loader("psutil", loader=None)
9+
psutil.Error = RuntimeError # type: ignore[attr-defined]
10+
psutil.Process = MagicMock() # type: ignore[attr-defined]
11+
psutil.process_iter = MagicMock(return_value=[]) # type: ignore[attr-defined]
12+
sys.modules["psutil"] = psutil
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import argparse
2+
from pathlib import Path
3+
4+
import pytest
5+
6+
from tools.bisect.auto_bisect import Bisector, _parse_args, _resolve_num_nodes
7+
from tools.bisect.config import SCENE_MULTI
8+
9+
10+
def test_pick_mid_prefers_midpoint_then_nearest_unskipped_index():
11+
assert Bisector._pick_mid(0, 8, skipped=set()) == 4
12+
assert Bisector._pick_mid(0, 8, skipped={4}) == 5
13+
assert Bisector._pick_mid(0, 8, skipped={4, 5}) == 3
14+
assert Bisector._pick_mid(0, 3, skipped={0, 1, 2}) is None
15+
16+
17+
def test_parse_args_maps_no_assume_built_head_flag():
18+
args = _parse_args(
19+
[
20+
"--scene",
21+
"single_node",
22+
"--config-yaml",
23+
"case.yaml",
24+
"--good-commit",
25+
"good",
26+
"--no-assume-built-head",
27+
"--native-check",
28+
"since-build",
29+
]
30+
)
31+
32+
assert args.scene == "single_node"
33+
assert args.config_yaml == "case.yaml"
34+
assert args.good_commit == "good"
35+
assert args.no_assume_built_head is True
36+
assert args.native_check == "since-build"
37+
38+
39+
def test_resolve_num_nodes_prefers_explicit_value(tmp_path: Path):
40+
args = argparse.Namespace(
41+
num_nodes=4,
42+
scene=SCENE_MULTI,
43+
config_base_path=None,
44+
config_yaml="missing.yaml",
45+
)
46+
47+
assert _resolve_num_nodes(args, tmp_path) == 4
48+
49+
50+
def test_resolve_num_nodes_reads_multi_node_yaml(tmp_path: Path):
51+
config = tmp_path / "configs" / "case.yaml"
52+
config.parent.mkdir()
53+
config.write_text("num_nodes: 2\n", encoding="utf-8")
54+
args = argparse.Namespace(
55+
num_nodes=None,
56+
scene=SCENE_MULTI,
57+
config_base_path="configs",
58+
config_yaml="case.yaml",
59+
)
60+
61+
assert _resolve_num_nodes(args, tmp_path) == 2
62+
63+
64+
def test_resolve_num_nodes_fails_when_multi_node_yaml_has_no_node_count(tmp_path: Path):
65+
config = tmp_path / "configs" / "case.yaml"
66+
config.parent.mkdir()
67+
config.write_text("test_cases: []\n", encoding="utf-8")
68+
args = argparse.Namespace(
69+
num_nodes=None,
70+
scene=SCENE_MULTI,
71+
config_base_path="configs",
72+
config_yaml="case.yaml",
73+
)
74+
75+
with pytest.raises(SystemExit, match="Could not determine --num-nodes"):
76+
_resolve_num_nodes(args, tmp_path)

0 commit comments

Comments
 (0)