diff --git a/.agents/skills/run-e2e/SKILL.md b/.agents/skills/run-e2e/SKILL.md index 9229a422..252f4143 100644 --- a/.agents/skills/run-e2e/SKILL.md +++ b/.agents/skills/run-e2e/SKILL.md @@ -1,6 +1,6 @@ --- name: run-e2e -description: Use when the user asks to run, validate, or diagnose the AFD plugin's DeepSeek-V2-Lite GPU/NPU, Qwen3 MoE GPU, or Qwen3.6 MoE CUDA end-to-end tests through the Qwen3.5/3.6 adapter family, including PR-gate E2E, GSM8K-7 accuracy, graph, eager, DBO, or 2A2F scenarios. +description: Use when the user asks to run, validate, or diagnose the AFD plugin's DeepSeek-V2-Lite GPU/NPU, Qwen3 MoE GPU, Qwen3.6 MoE CUDA, or opt-in Qwen3.5-122B CUDA end-to-end tests, including PR-gate E2E, GSM8K accuracy, graph, eager, DBO, or AFD topology scenarios. --- # Run AFD E2E Tests @@ -13,6 +13,8 @@ Run one of the model suites: - `tests/e2e/models/qwen3_moe/test_qwen3_moe.py` on GPU - `tests/e2e/models/qwen3_6/test_qwen3_6.py` on CUDA (text-only Qwen3.6 evidence for the Qwen3.5/3.6 adapter family) +- `tests/e2e/models/qwen3_5/test_qwen3_5_122b.py` on eight CUDA devices + (manual, eager-only large-model profile) Each suite contains four gate scenarios: @@ -51,8 +53,9 @@ or uv.lock. Fail before pytest when a prerequisite is missing; never turn it into a skip. -Set HF_HOME before every run. The pytest entrypoint downloads/caches GSM8K and -the model when the backend model env var is unset. +Set HF_HOME before every run. Default pytest entrypoints download/cache GSM8K +and the model when the backend model env var is unset. The Qwen3.5-122B +profile only caches GSM8K and never downloads its checkpoint. ### 3. Configure the run @@ -120,6 +123,26 @@ Multimodal, NPU, `compute_gate_on_attention=true`, asynchronous, pipeline-parallel, and multi-node execution are not covered; quantization is unverified. +For the Qwen3.5-122B large-model profile, run: + +~~~bash +export AFD_E2E_BACKEND=gpu +export AFD_E2E_LARGE_MODEL=1 +export AFD_GPU_E2E_MODEL=/path/to/Qwen3.5-122B-A10B +export AFD_E2E_DEVICES=0,2,4,6,1,3,5,7 +python -m pytest -q -s \ + tests/e2e/models/qwen3_5/test_qwen3_5_122b.py +~~~ + +Verify the model path and eight unique devices before pytest. Device order is +part of the profile contract: the first four run native DP4 or AFD Attention +DP4, and the last four run AFD FFN DP4/EP4. Both cases are BF16, text-only, +vLLM V1, eager, and natural-routing GSM8K-7 checks. Do not enable graph, DBO, +or benchmark-only forced routing. The profile disables the FlashInfer sampler +because vLLM 0.26.0 rejects Blackwell SM12 during its capability check. Report +`baseline-eager` and `afd-eager-4a4f` separately, including cleanup and +released GPU memory. + Do not add backend markers or run scenarios in parallel; they share devices. For the local DeepSeek-V2-Lite 2A1F cases, run the same pytest entrypoint with @@ -129,9 +152,9 @@ On cancellation, forward SIGTERM and allow over 90 seconds for cleanup. ### 5. Report -Success means the selected suite reports 4 passed and 0 skipped. Report the -failed scenario, first actionable error, and cleanup status. Any skip is a -gate failure. +Success means a default suite reports 4 passed and 0 skipped; the Qwen3.5-122B +profile reports 2 passed and 0 skipped. Report the failed scenario, first +actionable error, and cleanup status. Any skip is a gate failure. ## Environment reference @@ -139,7 +162,8 @@ gate failure. |---|---|---| | AFD_E2E_BACKEND | both | yes: gpu or npu | | AFD_E2E_DEVICES | both | yes: four unique IDs for the default suite | -| AFD_GPU_E2E_MODEL | GPU | no; downloads the selected suite's model when unset | +| AFD_E2E_LARGE_MODEL | GPU | Qwen3.5-122B only: must equal 1 | +| AFD_GPU_E2E_MODEL | GPU | Qwen3.5-122B: yes; default suites download when unset | | AFD_GPU_E2E_VLLM_BIN | GPU | no; defaults to vllm | | AFD_NPU_E2E_MODEL | NPU | no; downloads the selected suite's model when unset | | AFD_NPU_E2E_VLLM_BIN | NPU | no; defaults to vllm | diff --git a/docs/design/module/e2e_testing.md b/docs/design/module/e2e_testing.md index 7cf19462..50f36237 100644 --- a/docs/design/module/e2e_testing.md +++ b/docs/design/module/e2e_testing.md @@ -27,6 +27,7 @@ validation_paths: - "tests/e2e/models/deepseek_v2_lite/test_async_cam_npu.py" - "tests/e2e/models/qwen3_moe/test_qwen3_moe.py" - "tests/e2e/models/qwen3_6/test_qwen3_6.py" + - "tests/e2e/models/qwen3_5/test_qwen3_5_122b.py" upstream_refs: - "vLLM 0.26.0 serving and shutdown interfaces" - "lm-evaluation-harness GSM8K task and local-completions API" @@ -36,6 +37,7 @@ verified_platform_refs: - "Ascend NPU DeepSeek-V2-Lite" - "CUDA Qwen3 MoE" - "CUDA Qwen3.6 MoE" + - "CUDA Qwen3.5-122B-A10B" related_issues: [] last_reviewed: 2026-08-19 --- @@ -68,9 +70,11 @@ cleanup. Production code does not depend on the E2E harness. - `E2E-INV-001` — A case **MUST** have a stable lower-kebab-case ID and cover behavior not already covered by an existing case. -- `E2E-INV-002` — A PR case **MUST NOT** use more than four unique devices. - Gate AFD cases **MUST** use 2 Attention ranks and 2 FFN ranks; 2A1F cases - are local-only. +- `E2E-INV-002` — A default PR-gate case **MUST NOT** use more than four + unique devices. Gate AFD cases **MUST** use 2 Attention ranks and 2 FFN + ranks; 2A1F cases are local-only. Larger hardware profiles **MUST** be + explicit opt-ins selected by exact pytest node ID, require an explicit model + path, and **MUST NOT** enter a gate without matching hardware. - `E2E-INV-003` — Cases sharing devices or ports **MUST** run sequentially, remain order-independent, and release owned process groups before the next case. @@ -120,6 +124,14 @@ Multimodal, NPU, `compute_gate_on_attention=true`, pipeline-parallel, asynchronous, and multi-node execution are outside this case; quantization is unverified. +The opt-in Qwen3.5-122B-A10B profile covers a checkpoint size and 256-expert +configuration that the default family case does not. It adds two eager-only +cases: native DP4/TP1/EP4 and synchronous AFD 4A4F, with Attention DP4/TP1 and +FFN DP4/TP1/EP4. The profile requires eight explicit devices, +`AFD_E2E_LARGE_MODEL=1`, and an existing `AFD_GPU_E2E_MODEL`; it never +downloads the checkpoint. It is manual hardware coverage, not a default PR or +merge gate. Graph and DBO are outside this profile. + ## Accuracy gate | Setting | PR | Weekly | diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 5c2576a9..a2d5fdf4 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -30,9 +30,11 @@ Run from the repository root. The environment needs `vllm`, `pytest`, `afd_plugin`, `lm_eval`, `datasets`, and `huggingface_hub`. NPU also needs `torch_npu`. -The selected test downloads/caches `openai/gsm8k` and its Hugging Face model -when the backend model env var is unset. Point `HF_HOME` at a persistent cache -if you want to reuse downloads across runs. +The default model suites download/cache `openai/gsm8k` and their Hugging Face +model when the backend model env var is unset. Point `HF_HOME` at a persistent +cache if you want to reuse downloads across runs. Large-model profiles are an +exception: they require an explicit local model path and never download a +checkpoint. GPU: @@ -89,6 +91,34 @@ by AFD scenarios. The suite uses the same GSM8K-7, eight-shot, 4096-token, `compute_gate_on_attention=true`, pipeline-parallel, asynchronous, and multi-node execution are not covered; quantization is unverified. +### Qwen3.5-122B large-model CUDA profile + +`Qwen/Qwen3.5-122B-A10B` has a separate, manual hardware profile. It is not a +default PR or merge gate and never downloads the roughly 234 GiB checkpoint. +Running it requires an explicit opt-in, an existing model path, and exactly +eight unique CUDA device IDs: + +```bash +export AFD_E2E_BACKEND=gpu +export AFD_E2E_LARGE_MODEL=1 +export AFD_GPU_E2E_MODEL=/path/to/Qwen3.5-122B-A10B +# First four: Native or AFD Attention. Last four: AFD FFN. +export AFD_E2E_DEVICES=0,2,4,6,1,3,5,7 +python -m pytest -q -s \ + tests/e2e/models/qwen3_5/test_qwen3_5_122b.py +``` + +The two sequential cases are `baseline-eager` (native DP4/TP1/EP4 on the +first four devices) and `afd-eager-4a4f` (Attention DP4/TP1 on the first four, +FFN DP4/TP1/EP4 on the last four). Both use BF16, text-only vLLM V1, natural +routing, GSM8K-7 with eight-shot prompting, and a 4096-token model length. +The profile removes benchmark-only forced-routing variables from child server +environments. It also disables the optional FlashInfer sampler because vLLM +0.26.0 rejects Blackwell SM12 during that sampler's capability check; greedy +GSM8K does not require it. Graph, DBO, asynchronous, multi-node, quantized, +and multimodal coverage are out of scope; graph coverage remains excluded +while #261 is unresolved. + ### Local 2A1F cases The 2 Attention + 1 FFN scenarios are local-only cases; CI gates do not run @@ -155,10 +185,11 @@ Use run-e2e to run the Qwen3 MoE GPU E2E tests with HF_HOME /data/huggingface. ``` -Provide `HF_HOME` and `AFD_E2E_BACKEND`. `AFD_E2E_DEVICES` is optional; when -unset, the test module picks the defaults above. The model path is optional -when Hugging Face download is available. The skill checks prerequisites, runs -the same four tests, and reports failures and process cleanup. +Provide `HF_HOME` and `AFD_E2E_BACKEND`. For default suites, +`AFD_E2E_DEVICES` is optional and the model path is optional when Hugging Face +download is available. The Qwen3.5-122B profile instead requires the explicit +large-model variables documented above. The skill checks prerequisites, runs +the selected cases, and reports failures and process cleanup. ## NPU async CAM smoke test diff --git a/tests/e2e/models/qwen3_5/test_qwen3_5_122b.py b/tests/e2e/models/qwen3_5/test_qwen3_5_122b.py new file mode 100644 index 00000000..8d90911c --- /dev/null +++ b/tests/e2e/models/qwen3_5/test_qwen3_5_122b.py @@ -0,0 +1,133 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the AFD plugin project +"""Opt-in CUDA Qwen3.5-122B-A10B E2E coverage.""" + +from __future__ import annotations + +import os +import sys +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from tests.conftest import download_dataset, run_runner +from tests.e2e.runner import AFD_EAGER_4A4F_SCENARIO, BASELINE_EAGER_SCENARIO + +GSM8K_DATASET_ID = "openai/gsm8k" +GSM8K_DATASET_CONFIG = "main" +LARGE_MODEL_OPT_IN_ENV = "AFD_E2E_LARGE_MODEL" +DEVICE_COUNT = 8 +ROLE_DEVICE_COUNT = 4 +SCENARIOS = (BASELINE_EAGER_SCENARIO, AFD_EAGER_4A4F_SCENARIO) +COMMON_VLLM_ARGS = ( + "--dtype=bfloat16", + "--language-model-only", + "--max-model-len=4096", + "--max-num-seqs=1", + "--max-num-batched-tokens=4096", + "--mamba-cache-mode=align", + "--all2all-backend=allgather_reducescatter", + "--seed=0", +) +CONTROLLED_ROUTING_ENV_VARS = ( + "VLLM_MOE_ROUTING_SIMULATION_STRATEGY", + "AFD_BENCHMARK_FORCE_LB_TOPN_PER_RANK", +) +FLASHINFER_SAMPLER_ENV = "VLLM_USE_FLASHINFER_SAMPLER" + + +def _required_env(name: str) -> str: + value = os.environ.get(name) + if not value: + raise RuntimeError(f"{name} must be set") + return value + + +def _devices() -> list[str]: + raw_devices = _required_env("AFD_E2E_DEVICES") + devices = [item.strip() for item in raw_devices.split(",") if item.strip()] + if len(devices) != DEVICE_COUNT: + raise RuntimeError( + f"AFD_E2E_DEVICES must contain exactly {DEVICE_COUNT} devices", + ) + if len(devices) != len(set(devices)): + raise RuntimeError("AFD_E2E_DEVICES must contain unique devices") + return devices + + +def prepare_e2e_assets() -> None: + """Validate the explicit large-model contract, then cache GSM8K.""" + if _required_env("AFD_E2E_BACKEND") != "gpu": + raise RuntimeError("Qwen3.5-122B E2E supports only the 'gpu' backend") + if _required_env(LARGE_MODEL_OPT_IN_ENV) != "1": + raise RuntimeError(f"{LARGE_MODEL_OPT_IN_ENV} must be set to 1") + model_path = Path(_required_env("AFD_GPU_E2E_MODEL")).expanduser() + if not model_path.is_dir(): + raise RuntimeError( + f"AFD_GPU_E2E_MODEL must be an existing directory: {model_path}" + ) + _devices() + download_dataset(GSM8K_DATASET_ID, GSM8K_DATASET_CONFIG) + + +def build_runner_command(scenario: str, gsm8k_output_path: Path) -> list[str]: + if scenario not in SCENARIOS: + raise ValueError(f"unsupported Qwen3.5-122B scenario: {scenario}") + if _required_env("AFD_E2E_BACKEND") != "gpu": + raise RuntimeError("Qwen3.5-122B E2E supports only the 'gpu' backend") + + devices = _devices() + command = [ + sys.executable, + "-m", + "tests.e2e.runner", + "--model", + _required_env("AFD_GPU_E2E_MODEL"), + "--vllm-bin", + os.environ.get("AFD_GPU_E2E_VLLM_BIN", "vllm"), + "--device-backend", + "gpu", + "--attention-devices", + ",".join(devices[:ROLE_DEVICE_COUNT]), + ] + command.extend(f"--common-vllm-arg={arg}" for arg in COMMON_VLLM_ARGS) + if scenario == AFD_EAGER_4A4F_SCENARIO: + command.extend( + ["--ffn-devices", ",".join(devices[ROLE_DEVICE_COUNT:])], + ) + command.extend( + [ + "--scenario", + scenario, + "--gsm8k-output-path", + str(gsm8k_output_path), + "--served-model-name-prefix", + "qwen3-5-122b-afd", + ], + ) + return command + + +def natural_routing_env() -> dict[str, str]: + """Return the natural-routing environment validated by this profile.""" + env = os.environ.copy() + for name in CONTROLLED_ROUTING_ENV_VARS: + env.pop(name, None) + # vLLM 0.26.0's FlashInfer sampler rejects Blackwell SM12 during device + # capability detection. Greedy GSM8K does not require that sampler. + env[FLASHINFER_SAMPLER_ENV] = "0" + return env + + +@pytest.fixture(scope="module", autouse=True) +def _prepare_e2e_assets() -> Iterator[None]: + prepare_e2e_assets() + yield + + +@pytest.mark.e2e +@pytest.mark.parametrize("scenario", SCENARIOS, ids=SCENARIOS) +def test_qwen3_5_122b(scenario: str, tmp_path: Path) -> None: + command = build_runner_command(scenario, tmp_path / scenario) + run_runner(command, env=natural_routing_env()) diff --git a/tests/e2e/runner.py b/tests/e2e/runner.py index 98dd1bb4..eb4cbbbe 100644 --- a/tests/e2e/runner.py +++ b/tests/e2e/runner.py @@ -38,6 +38,8 @@ ASYNC_UBATCH_NUM_STAGES = 2 ASYNC_UBATCH_BATCH_SIZE = 2 V2_SYNC_CONNECTOR = "P2pNcclAFDConnector" +BASELINE_EAGER_SCENARIO = "baseline-eager" +AFD_EAGER_4A4F_SCENARIO = "afd-eager-4a4f" V2_SCENARIOS = ( "afd-v2-eager-1a1f", "afd-v2-eager-dp2", @@ -104,6 +106,7 @@ def main() -> int: previous_handlers = {signum: signal.getsignal(signum) for signum in handled_signals} received_signal: int | None = None cleanup_in_progress = False + launch_order: tuple[tuple[str, str], ...] def exit_after_cleanup(signum: int, _frame: Any) -> None: nonlocal received_signal @@ -210,6 +213,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "--scenario", choices=[ + BASELINE_EAGER_SCENARIO, "baseline-graph", "afd-eager-2a1f", "afd-graph-2a1f", @@ -219,6 +223,7 @@ def parse_args() -> argparse.Namespace: "afd-graph-dbo-2a2f", ASYNC_CAM_SCENARIO, ASYNC_UBATCH_SCENARIO, + AFD_EAGER_4A4F_SCENARIO, *V2_SCENARIOS, ], required=True, @@ -323,6 +328,7 @@ def configure_scenario(args: argparse.Namespace) -> None: is_async_cam = args.scenario == ASYNC_CAM_SCENARIO is_async_ubatch = args.scenario == ASYNC_UBATCH_SCENARIO scenario_settings = { + BASELINE_EAGER_SCENARIO: (True, False, False, 4, 0), "baseline-graph": (True, True, False, 4, 0), "afd-eager-2a1f": (False, False, False, 2, 1), "afd-graph-2a1f": (False, True, False, 2, 1), @@ -344,6 +350,7 @@ def configure_scenario(args: argparse.Namespace) -> None: ASYNC_UBATCH_ATTENTION_RANKS, ASYNC_UBATCH_FFN_RANKS, ), + AFD_EAGER_4A4F_SCENARIO: (False, False, False, 4, 4), "afd-v2-eager-1a1f": (False, False, False, 1, 1), "afd-v2-eager-dp2": (False, False, False, 2, 2), "afd-v2-eager-tp2": (False, False, False, 2, 2), diff --git a/tests/unit/test_e2e_runner.py b/tests/unit/test_e2e_runner.py index 02e073b0..5eb3e02b 100644 --- a/tests/unit/test_e2e_runner.py +++ b/tests/unit/test_e2e_runner.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the AFD plugin project + from __future__ import annotations import argparse @@ -7,6 +10,7 @@ import signal import subprocess import sys +from collections.abc import Callable from pathlib import Path import pytest @@ -18,6 +22,7 @@ from tests.e2e.models.deepseek_v2_lite import ( test_deepseek_v2_lite as deepseek_v2_lite_e2e, ) +from tests.e2e.models.qwen3_5 import test_qwen3_5_122b as qwen3_5_122b_e2e from tests.e2e.models.qwen3_6 import test_qwen3_6 as qwen3_6_e2e from tests.e2e.models.qwen3_moe import test_qwen3_moe as qwen3_moe_e2e @@ -159,6 +164,126 @@ def test_qwen3_6_entrypoint_rejects_non_gpu_backends(monkeypatch, tmp_path): qwen3_6_e2e.build_runner_command("afd-eager", tmp_path) +@pytest.mark.parametrize("scenario", qwen3_5_122b_e2e.SCENARIOS) +def test_qwen3_5_122b_entrypoint_builds_fixed_eager_topologies( + monkeypatch, + tmp_path, + scenario, +): + monkeypatch.setenv("AFD_E2E_BACKEND", "gpu") + monkeypatch.setenv("AFD_E2E_DEVICES", "0,2,4,6,1,3,5,7") + monkeypatch.setenv("AFD_GPU_E2E_MODEL", "/models/qwen3.5-122b") + + command = qwen3_5_122b_e2e.build_runner_command(scenario, tmp_path) + + assert command[command.index("--attention-devices") + 1] == "0,2,4,6" + assert command[command.index("--model") + 1] == "/models/qwen3.5-122b" + assert ( + command[command.index("--served-model-name-prefix") + 1] == "qwen3-5-122b-afd" + ) + for arg in qwen3_5_122b_e2e.COMMON_VLLM_ARGS: + assert f"--common-vllm-arg={arg}" in command + if scenario == runner.AFD_EAGER_4A4F_SCENARIO: + assert command[command.index("--ffn-devices") + 1] == "1,3,5,7" + else: + assert "--ffn-devices" not in command + + +@pytest.mark.parametrize( + ("env_name", "env_value", "error_message"), + [ + ("AFD_E2E_LARGE_MODEL", None, "AFD_E2E_LARGE_MODEL must be set"), + ("AFD_E2E_LARGE_MODEL", "0", "AFD_E2E_LARGE_MODEL must be set to 1"), + ("AFD_GPU_E2E_MODEL", None, "AFD_GPU_E2E_MODEL must be set"), + ("AFD_E2E_DEVICES", None, "AFD_E2E_DEVICES must be set"), + ( + "AFD_E2E_DEVICES", + "0,1,2,3", + "AFD_E2E_DEVICES must contain exactly 8 devices", + ), + ( + "AFD_E2E_DEVICES", + "0,1,2,3,4,5,6,6", + "AFD_E2E_DEVICES must contain unique devices", + ), + ], +) +def test_qwen3_5_122b_preflight_fails_before_dataset_download( + monkeypatch, + tmp_path, + env_name, + env_value, + error_message, +): + monkeypatch.setenv("AFD_E2E_BACKEND", "gpu") + monkeypatch.setenv("AFD_E2E_LARGE_MODEL", "1") + monkeypatch.setenv("AFD_GPU_E2E_MODEL", str(tmp_path)) + monkeypatch.setenv("AFD_E2E_DEVICES", "0,1,2,3,4,5,6,7") + if env_value is None: + monkeypatch.delenv(env_name, raising=False) + else: + monkeypatch.setenv(env_name, env_value) + downloaded = False + + def record_download(*_args): + nonlocal downloaded + downloaded = True + + monkeypatch.setattr(qwen3_5_122b_e2e, "download_dataset", record_download) + + with pytest.raises(RuntimeError, match=error_message): + qwen3_5_122b_e2e.prepare_e2e_assets() + + assert downloaded is False + + +def test_qwen3_5_122b_preflight_requires_existing_model_directory( + monkeypatch, + tmp_path, +): + monkeypatch.setenv("AFD_E2E_BACKEND", "gpu") + monkeypatch.setenv("AFD_E2E_LARGE_MODEL", "1") + monkeypatch.setenv("AFD_GPU_E2E_MODEL", str(tmp_path / "missing")) + monkeypatch.setenv("AFD_E2E_DEVICES", "0,1,2,3,4,5,6,7") + monkeypatch.setattr( + qwen3_5_122b_e2e, + "download_dataset", + lambda *_args: pytest.fail("dataset download must not run"), + ) + + with pytest.raises(RuntimeError, match="must be an existing directory"): + qwen3_5_122b_e2e.prepare_e2e_assets() + + +def test_qwen3_5_122b_preflight_rejects_non_gpu_before_download( + monkeypatch, +): + monkeypatch.setenv("AFD_E2E_BACKEND", "npu") + downloaded = False + + def record_download(*_args): + nonlocal downloaded + downloaded = True + + monkeypatch.setattr(qwen3_5_122b_e2e, "download_dataset", record_download) + + with pytest.raises(RuntimeError, match="supports only the 'gpu' backend"): + qwen3_5_122b_e2e.prepare_e2e_assets() + + assert downloaded is False + + +def test_qwen3_5_122b_correctness_clears_controlled_routing(monkeypatch): + for name in qwen3_5_122b_e2e.CONTROLLED_ROUTING_ENV_VARS: + monkeypatch.setenv(name, "benchmark-value") + + env = qwen3_5_122b_e2e.natural_routing_env() + + for name in qwen3_5_122b_e2e.CONTROLLED_ROUTING_ENV_VARS: + assert name not in env + assert env[qwen3_5_122b_e2e.FLASHINFER_SAMPLER_ENV] == "0" + + @pytest.mark.parametrize( ("first_suite", "first_model"), [ @@ -248,10 +373,10 @@ def test_run_runner_forwards_cancellation_and_reaps(monkeypatch): signal.SIGTERM: object(), signal.SIGINT: object(), } - installed_handlers = {} - signal_calls = [] - kill_calls = [] - popen_calls = [] + installed_handlers: dict[int, Callable[[int, object], None]] = {} + signal_calls: list[tuple[int, Callable[[int, object], None]]] = [] + kill_calls: list[tuple[int, int]] = [] + popen_calls: list[tuple[list[str], dict[str, object]]] = [] class FakeProcess: pid = 321 @@ -317,7 +442,7 @@ def fake_signal(signum, handler): def test_run_runner_forwards_signal_received_during_spawn(monkeypatch): - handlers = {} + handlers: dict[int, Callable[[int, object], None]] = {} forwarded = [] class FakeProcess: @@ -459,6 +584,7 @@ def test_parse_args_rejects_legacy_fixed_scenario_options(monkeypatch, legacy_ar @pytest.mark.parametrize( ("scenario", "expected"), [ + ("baseline-eager", (True, False, False, 4, 0, 1, 1, 1, False)), ("baseline-graph", (True, True, False, 4, 0, 1, 1, 1, False)), ("afd-eager-2a1f", (False, False, False, 2, 1, 1, 1, 1, False)), ("afd-graph-2a1f", (False, True, False, 2, 1, 1, 1, 1, False)), @@ -468,6 +594,7 @@ def test_parse_args_rejects_legacy_fixed_scenario_options(monkeypatch, legacy_ar ("afd-graph-dbo-2a2f", (False, True, True, 2, 2, 1, 1, 1, False)), ("afd-eager-async-cam", (False, False, False, 2, 2, 1, 2, 1, False)), ("afd-async-ubatch", (False, False, False, 2, 1, 1, 2, 1, False)), + ("afd-eager-4a4f", (False, False, False, 4, 4, 1, 1, 1, False)), ("afd-v2-eager-1a1f", (False, False, False, 1, 1, 1, 1, 1, True)), ("afd-v2-eager-dp2", (False, False, False, 2, 2, 1, 1, 1, True)), ("afd-v2-eager-tp2", (False, False, False, 2, 2, 1, 2, 2, True)), @@ -649,6 +776,48 @@ def test_build_baseline_command_uses_native_dp4_graph_server(): } +def test_build_baseline_eager_command_uses_native_dp4_without_graph(): + args = _args() + args.scenario = runner.BASELINE_EAGER_SCENARIO + runner.configure_scenario(args) + + command = runner.build_baseline_command(args) + + assert command[command.index("--data-parallel-size") + 1] == "4" + assert command[command.index("--tensor-parallel-size") + 1] == "1" + assert "--enable-expert-parallel" in command + assert "--enforce-eager" in command + assert "--compilation-config" not in command + assert "--additional-config" not in command + + +@pytest.mark.parametrize("role", ["attention", "ffn"]) +def test_build_afd_eager_4a4f_command_uses_dp4_tp1(role): + args = _args() + args.scenario = runner.AFD_EAGER_4A4F_SCENARIO + runner.configure_scenario(args) + + command = runner.build_vllm_command(args, role=role) + afd_config = json.loads( + command[command.index("--additional-config") + 1], + )["afd"] + + assert command[command.index("--data-parallel-size") + 1] == "4" + assert command[command.index("--tensor-parallel-size") + 1] == "1" + assert "--enable-expert-parallel" in command + assert "--enforce-eager" in command + assert "--compilation-config" not in command + assert "--enable-dbo" not in command + assert afd_config == { + "role": role, + "connector": "P2pNcclAFDConnector", + "host": "127.0.0.1", + "port": 1239, + "num_attention_ranks": 4, + "num_ffn_ranks": 4, + } + + def test_build_baseline_command_configures_graceful_shutdown_timeout(): args = _args() args.scenario = "baseline-graph" @@ -695,6 +864,18 @@ def test_validate_topology_accepts_four_baseline_ranks_without_ffn_ranks(): runner.validate_topology(args, ["0", "1", "2", "3"], []) +def test_validate_topology_accepts_afd_eager_4a4f(): + args = _args() + args.scenario = runner.AFD_EAGER_4A4F_SCENARIO + runner.configure_scenario(args) + + runner.validate_topology( + args, + ["0", "2", "4", "6"], + ["1", "3", "5", "7"], + ) + + @pytest.mark.parametrize( "scenario", [runner.ASYNC_CAM_SCENARIO, runner.ASYNC_UBATCH_SCENARIO], @@ -1149,7 +1330,7 @@ def fake_killpg(_pid, sig): def test_run_lm_eval_handles_signal_received_during_spawn(monkeypatch, tmp_path): - handlers = {} + handlers: dict[int, Callable[[int, object], None]] = {} cleaned = [] class FakeProcess: @@ -1394,7 +1575,7 @@ def test_ensure_processes_alive_reports_exited_process_returncode(): process = argparse.Namespace(poll=lambda: 17) with pytest.raises(RuntimeError, match="returncode=17"): - runner.ensure_processes_alive([process]) + runner.ensure_processes_alive([process]) # type: ignore[list-item] def test_terminate_processes_rejects_cleanup_failure(monkeypatch):