From a3c6646c6f4cb56ce14747b5c09426033106e372 Mon Sep 17 00:00:00 2001 From: functionstackx <47992694+functionstackx@users.noreply.github.com> Date: Sun, 1 Nov 2026 08:05:06 +0000 Subject: [PATCH 01/14] feat(speedbench): collect GLM-5.3-Flash natural MTP acceptance --- .github/workflows/speedbench-al.yml | 17 +- .../speedbench/glm53flash_fp8_b300_sglang.sh | 29 ++ inferencex-e2e/configs/runners.yaml | 1 + inferencex-e2e/infx/bench/__main__.py | 1 + inferencex-e2e/infx/bench/speedbench_al.py | 303 ++++++++++++++++++ .../infx/tests/bench/test_speedbench_al.py | 95 ++++++ 6 files changed, 441 insertions(+), 5 deletions(-) create mode 100644 inferencex-e2e/benchmarks/single_node/speedbench/glm53flash_fp8_b300_sglang.sh create mode 100644 inferencex-e2e/infx/bench/speedbench_al.py create mode 100644 inferencex-e2e/infx/tests/bench/test_speedbench_al.py diff --git a/.github/workflows/speedbench-al.yml b/.github/workflows/speedbench-al.yml index e036c0c98c..16936d196d 100644 --- a/.github/workflows/speedbench-al.yml +++ b/.github/workflows/speedbench-al.yml @@ -19,13 +19,19 @@ on: # zizmor: ignore[concurrency-limits] type: string default: 'dsv4' framework: - description: "Serving framework; selects the _fp4_b300_[_].sh collector" + description: "Serving framework; selects the __b300_[_].sh collector" required: false type: choice options: [vllm, sglang] default: 'vllm' + precision: + description: "Checkpoint precision; selects the collector and queue metadata" + required: false + type: choice + options: [fp4, fp8, bf16] + default: 'fp4' collector-variant: - description: "Collector variant suffix; non-empty selects _fp4_b300__.sh (e.g. probabilistic_sample_method_block_rejection_sample_method)" + description: "Collector variant suffix; non-empty selects __b300__.sh" required: false type: string default: '' @@ -97,7 +103,7 @@ env: HF_HUB_CACHE: '/mnt/hf_hub_cache/' MODEL: ${{ inputs.model }} MODEL_PREFIX: ${{ inputs.model-prefix }} - PRECISION: fp4 + PRECISION: ${{ inputs.precision }} FRAMEWORK: ${{ inputs.framework }} EXP_NAME: ${{ inputs.model-prefix }}_speedbench IMAGE: ${{ inputs.image }} @@ -155,11 +161,12 @@ jobs: --arg runner "$RUNNER" \ --arg model_prefix "$MODEL_PREFIX" \ --arg framework "$FRAMEWORK" \ + --arg precision "$PRECISION" \ '[{ runner: $runner, framework: $framework, "model-prefix": $model_prefix, - precision: "fp4", + precision: $precision, "spec-decoding": "mtp" }]') scored=$(printf '%s' "$entry" | @@ -250,7 +257,7 @@ jobs: run: | if [[ -f inferencex-e2e/configs/runners.yaml ]]; then cd inferencex-e2e; fi collector_dir=benchmarks/single_node/speedbench - collector="${MODEL_PREFIX}_fp4_b300_${FRAMEWORK}" + collector="${MODEL_PREFIX}_${PRECISION}_b300_${FRAMEWORK}" if [[ -n "$COLLECTOR_VARIANT" ]]; then collector+="_${COLLECTOR_VARIANT}" fi diff --git a/inferencex-e2e/benchmarks/single_node/speedbench/glm53flash_fp8_b300_sglang.sh b/inferencex-e2e/benchmarks/single_node/speedbench/glm53flash_fp8_b300_sglang.sh new file mode 100644 index 0000000000..7439d60583 --- /dev/null +++ b/inferencex-e2e/benchmarks/single_node/speedbench/glm53flash_fp8_b300_sglang.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -eo pipefail +repo_root="$(cd "$(dirname "$0")/../../.." && pwd)" +source "$repo_root/benchmarks/check_env.sh" +check_env_vars \ + CATEGORY CHAT_TEMPLATE_KWARGS_ON EP_SIZE IMAGE MODEL MODEL_PATH MTP_LIST \ + OUT_YAML PORT SPEEDBENCH_OUTPUT_LEN THINKING_MODES TP + +export PYTHONPATH="$repo_root${PYTHONPATH:+:$PYTHONPATH}" +exec python3 -m infx.bench speedbench-al \ + --concurrency 32 \ + --temperature 1.0 \ + --top-p 0.95 \ + -- \ + --tp-size "$TP" --ep-size "$EP_SIZE" \ + --trust-remote-code \ + --mem-fraction-static 0.80 \ + --context-length 32768 \ + --chunked-prefill-size 8192 \ + --max-prefill-tokens 8192 \ + --max-running-requests 32 \ + --cuda-graph-max-bs 32 \ + --kv-cache-dtype fp8_e4m3 \ + --dsa-prefill-backend trtllm \ + --dsa-decode-backend trtllm \ + --moe-runner-backend flashinfer_trtllm \ + --reasoning-parser glm45 \ + --tool-call-parser glm47 \ + --disable-radix-cache diff --git a/inferencex-e2e/configs/runners.yaml b/inferencex-e2e/configs/runners.yaml index c54e232edb..ff16164590 100644 --- a/inferencex-e2e/configs/runners.yaml +++ b/inferencex-e2e/configs/runners.yaml @@ -521,6 +521,7 @@ clusters: GLM-5.2-FP8: {root: scratch, dir: GLM-5.2-FP8} GLM-5.2-NVFP4: {root: scratch, dir: GLM-5.2-NVFP4} GLM-5.3-NVFP4: {root: scratch, dir: GLM-5.3-NVFP4} + GLM-5.3-Flash: {root: data-models, dir: GLM-5.3-Flash} Kimi-K3: {root: scratch, dir: Kimi-K3} MiniMax-M3: {root: scratch, dir: MiniMax-M3} MiniMax-M3-MXFP8: {root: scratch, dir: MiniMax-M3-MXFP8} diff --git a/inferencex-e2e/infx/bench/__main__.py b/inferencex-e2e/infx/bench/__main__.py index cb33a7ea8e..6ef5583aa1 100644 --- a/inferencex-e2e/infx/bench/__main__.py +++ b/inferencex-e2e/infx/bench/__main__.py @@ -13,6 +13,7 @@ "fixed-seq": "infx.bench.fixed_seq", "agentic": "infx.bench.agentic.run", "eval": "infx.bench.eval", + "speedbench-al": "infx.bench.speedbench_al", } diff --git a/inferencex-e2e/infx/bench/speedbench_al.py b/inferencex-e2e/infx/bench/speedbench_al.py new file mode 100644 index 0000000000..7dd1c0acdd --- /dev/null +++ b/inferencex-e2e/infx/bench/speedbench_al.py @@ -0,0 +1,303 @@ +from __future__ import annotations + +import argparse +import contextlib +import hashlib +import importlib.metadata +import json +import math +import os +import signal +import socket +import subprocess +import sys +import tempfile +import urllib.request +from collections.abc import Mapping, Sequence +from pathlib import Path + +from infx.bench import env, proc, server + +PREPARE_URL = ( + "https://raw.githubusercontent.com/NVIDIA-NeMo/Skills/" + "bcf059af55c20a89f797724598f9908d126153e6/" + "nemo_skills/dataset/speed-bench/prepare.py" +) + + +def levels(value: str) -> list[int]: + parsed = [env.parse_positive_int("MTP_LIST", item) for item in value.split()] + if not parsed or len(parsed) != len(set(parsed)): + raise env.InputError("MTP_LIST must contain distinct positive integers") + return parsed + + +def select_category(source: Path, destination: Path, category: str) -> int: + count = 0 + with source.open(encoding="utf-8") as rows, destination.open("w", encoding="utf-8") as out: + for line in rows: + row = json.loads(line) + if row.get("category") != category: + continue + messages = row["messages"] + # SGLang's loader consumes only the first user turn; reject lossy conversion. + if ( + len(messages) != 1 + or messages[0]["role"] != "user" + or not isinstance(messages[0]["content"], str) + or not messages[0]["content"] + ): + raise env.BenchError("SPEED-Bench row must contain exactly one nonempty user turn") + out.write(json.dumps({"turns": [messages[0]["content"]]}) + "\n") + count += 1 + if count == 0: + raise env.BenchError(f"No SPEED-Bench rows for category {category!r}") + return count + + +def read_acceptance(path: Path, *, expected: int, mtp: int) -> float: + rows = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line] + if len(rows) != 1: + raise env.BenchError(f"Expected one benchmark result in {path}, got {len(rows)}") + result = rows[0] + if result.get("completed") != expected or result.get("total_output_tokens", 0) <= 0: + raise env.BenchError(f"Incomplete SPEED-Bench result: {result.get('completed')}/{expected}") + value = result.get("accept_length") + if ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not math.isfinite(value) + or not 1 <= value <= mtp + 1 + ): + raise env.BenchError(f"Invalid acceptance length for MTP={mtp}: {value!r}") + return float(value) + + +def prepare_dataset(directory: Path, category: str) -> tuple[Path, int]: + constraints = directory / "constraints.txt" + constraints.write_text( + "".join( + f"{name}=={importlib.metadata.version(name)}\n" + for name in ("huggingface-hub", "transformers", "tokenizers") + ), + encoding="utf-8", + ) + run( + [ + sys.executable, + "-m", + "pip", + "install", + "-q", + "-c", + str(constraints), + "datasets", + "tiktoken", + ] + ) + prepare = directory / "prepare.py" + with urllib.request.urlopen(PREPARE_URL, timeout=60) as response: + prepare.write_bytes(response.read()) + run([sys.executable, str(prepare), "--config", "qualitative", "--output_dir", str(directory)]) + selected = directory / "selected.jsonl" + return selected, select_category(directory / "qualitative.jsonl", selected, category) + + +def run(command: Sequence[str]) -> None: + code = proc.call(command) + if code: + raise env.BenchError(f"Command exited with status {code}: {command[0]}") + + +def free_port(requested: int) -> int: + with socket.socket() as sock: + try: + sock.bind(("127.0.0.1", requested)) + except OSError: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def stop(child: subprocess.Popen[bytes]) -> None: + with contextlib.suppress(ProcessLookupError): + os.killpg(child.pid, signal.SIGTERM) + try: + child.wait(timeout=30) + except subprocess.TimeoutExpired: + pass + finally: + # The launcher can exit before its GPU workers. + with contextlib.suppress(ProcessLookupError): + os.killpg(child.pid, signal.SIGKILL) + child.wait() + + +def collect_cell( + *, + model_path: str, + mtp: int, + port: int, + dataset: Path, + count: int, + output_len: int, + results: Path, + args: argparse.Namespace, +) -> float: + command = [ + sys.executable, + "-m", + "sglang.launch_server", + "--model-path", + model_path, + "--host", + "127.0.0.1", + "--port", + str(port), + *args.server_args, + "--speculative-algorithm", + "EAGLE", + "--speculative-num-steps", + str(mtp), + "--speculative-eagle-topk", + "1", + "--speculative-num-draft-tokens", + str(mtp + 1), + ] + log = results / f"server_on_mtp{mtp}.log" + result = results / f"speedbench_on_mtp{mtp}.jsonl" + result.unlink(missing_ok=True) + proc.echo(command) + with log.open("wb") as sink: + child = subprocess.Popen( + command, stdout=sink, stderr=subprocess.STDOUT, start_new_session=True + ) + try: + server.wait_ready(f"http://127.0.0.1:{port}/health", pid=child.pid, log=log) + run( + [ + sys.executable, + "-m", + "sglang.bench_serving", + "--backend", + "sglang", + "--host", + "127.0.0.1", + "--port", + str(port), + "--model", + model_path, + "--dataset-name", + "speed-bench", + "--dataset-path", + str(dataset), + "--speed-bench-output-len", + str(output_len), + "--num-prompts", + str(count), + "--max-concurrency", + str(args.concurrency), + "--temperature", + str(args.temperature), + "--top-p", + str(args.top_p), + # No warmup generations may contaminate server-wide acceptance counters. + "--warmup-requests", + "0", + "--seed", + "42", + "--disable-ignore-eos", + "--output-file", + str(result), + "--output-details", + ] + ) + return read_acceptance(result, expected=count, mtp=mtp) + finally: + stop(child) + + +def write_matrix(path: Path, model: str, values: Mapping[int, float], metadata: Path) -> None: + text = f"# Collection provenance: {metadata.name}\n{json.dumps(model.rsplit('/', 1)[-1].lower())}:\n" + text += " thinking_on:\n" + text += "".join(f" {level}: {value:.6f}\n" for level, value in values.items()) + path.write_text(text, encoding="utf-8") + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--concurrency", type=int, required=True) + parser.add_argument("--temperature", type=float, required=True) + parser.add_argument("--top-p", type=float, required=True) + parser.add_argument("server_args", nargs=argparse.REMAINDER) + args = parser.parse_args(argv) + if args.server_args[:1] == ["--"]: + args.server_args = args.server_args[1:] + if args.concurrency < 1 or not args.temperature > 0 or not 0 < args.top_p <= 1: + raise env.InputError("Invalid concurrency or sampling parameters") + inputs = env.require( + "CATEGORY", + "CHAT_TEMPLATE_KWARGS_ON", + "IMAGE", + "MODEL", + "MODEL_PATH", + "MTP_LIST", + "OUT_YAML", + "PORT", + "SPEEDBENCH_OUTPUT_LEN", + "THINKING_MODES", + ) + # The upstream SPEED-Bench loader cannot forward chat-template kwargs. + if inputs["THINKING_MODES"] != "on" or json.loads(inputs["CHAT_TEMPLATE_KWARGS_ON"]) != {}: + raise env.InputError( + "SGLang SPEED-Bench requires THINKING_MODES=on and CHAT_TEMPLATE_KWARGS_ON={}" + ) + mtp_levels = levels(inputs["MTP_LIST"]) + output_len = env.positive_int("SPEEDBENCH_OUTPUT_LEN") + requested_port = env.positive_int("PORT") + model = Path(inputs["MODEL_PATH"]) + if not (model / "config.json").is_file(): + raise env.BenchError( + f"Staged checkpoint missing: {model / 'config.json'}; no download attempted" + ) + out = Path(inputs["OUT_YAML"]) + out.unlink(missing_ok=True) + results = out.parent / "speedbench_results" + results.mkdir(exist_ok=True) + metadata = results / "speedbench_provenance.json" + with tempfile.TemporaryDirectory(prefix="infx-speedbench-") as temp: + dataset, count = prepare_dataset(Path(temp), inputs["CATEGORY"]) + provenance = { + "inputs": inputs, + "server_args": args.server_args, + "concurrency": args.concurrency, + "temperature": args.temperature, + "top_p": args.top_p, + "thinking": "chat template default; reasoning_effort=max for GLM-5.3-Flash", + "draft": "embedded MTP, upstream checkpoint-default precision", + "verification": "standard (no block or synthetic acceptance flags)", + "prepare_url": PREPARE_URL, + "num_prompts": count, + "dataset_sha256": hashlib.sha256(dataset.read_bytes()).hexdigest(), + "config_sha256": hashlib.sha256((model / "config.json").read_bytes()).hexdigest(), + "sglang_version": importlib.metadata.version("sglang"), + "complete": False, + } + metadata.write_text(json.dumps(provenance, indent=2) + "\n", encoding="utf-8") + values: dict[int, float] = {} + for mtp in mtp_levels: + print(f"Collecting thinking=on MTP={mtp}, {count} prompts", flush=True) + values[mtp] = collect_cell( + model_path=str(model), + mtp=mtp, + port=free_port(requested_port), + dataset=dataset, + count=count, + output_len=output_len, + results=results, + args=args, + ) + write_matrix(out, inputs["MODEL"], values, metadata) + print(f"MTP={mtp}: AL={values[mtp]:.6f}", flush=True) + provenance["complete"] = True + metadata.write_text(json.dumps(provenance, indent=2) + "\n", encoding="utf-8") + return 0 diff --git a/inferencex-e2e/infx/tests/bench/test_speedbench_al.py b/inferencex-e2e/infx/tests/bench/test_speedbench_al.py new file mode 100644 index 0000000000..a1afcd8a75 --- /dev/null +++ b/inferencex-e2e/infx/tests/bench/test_speedbench_al.py @@ -0,0 +1,95 @@ +import json + +import pytest +import yaml + +from infx.bench.env import BenchError, InputError +from infx.bench.speedbench_al import levels, read_acceptance, select_category, write_matrix + + +def test_select_category_preserves_all_matching_prompts(tmp_path): + source = tmp_path / "source.jsonl" + source.write_text( + "\n".join( + json.dumps( + { + "category": category, + "messages": [{"role": "user", "content": content}], + } + ) + for category, content in [("coding", "one"), ("math", "two"), ("coding", "three")] + ) + ) + destination = tmp_path / "selected.jsonl" + assert select_category(source, destination, "coding") == 2 + assert [json.loads(row) for row in destination.read_text().splitlines()] == [ + {"turns": ["one"]}, + {"turns": ["three"]}, + ] + with pytest.raises(BenchError, match="No SPEED-Bench"): + select_category(source, destination, "missing") + + +@pytest.mark.parametrize( + "messages", + [ + [], + [{"role": "system", "content": "instruction"}, {"role": "user", "content": "question"}], + [{"role": "user", "content": ""}], + [{"role": "user", "content": ["multimodal"]}], + ], +) +def test_select_category_rejects_lossy_conversion(tmp_path, messages): + source = tmp_path / "source.jsonl" + source.write_text(json.dumps({"category": "coding", "messages": messages})) + with pytest.raises(BenchError, match="exactly one"): + select_category(source, tmp_path / "selected.jsonl", "coding") + + +def test_acceptance_and_matrix(tmp_path): + result = tmp_path / "result.jsonl" + result.write_text( + json.dumps({"completed": 20, "total_output_tokens": 100, "accept_length": 2.3456789}) + ) + value = read_acceptance(result, expected=20, mtp=3) + matrix = tmp_path / "matrix.yaml" + write_matrix(matrix, "zai-org/GLM-5.3-Flash", {3: value}, tmp_path / "provenance.json") + assert yaml.safe_load(matrix.read_text()) == {"glm-5.3-flash": {"thinking_on": {3: 2.345679}}} + + +@pytest.mark.parametrize( + "updates", + [ + {"completed": 19}, + {"total_output_tokens": 0}, + {"accept_length": None}, + {"accept_length": "N/A"}, + {"accept_length": float("nan")}, + {"accept_length": float("inf")}, + {"accept_length": True}, + {"accept_length": 0.99}, + {"accept_length": 4.01}, + ], +) +def test_reject_incomplete_or_invalid_measurements(tmp_path, updates): + result = tmp_path / "result.jsonl" + result.write_text( + json.dumps({"completed": 20, "total_output_tokens": 100, "accept_length": 2.5} | updates) + ) + with pytest.raises(BenchError): + read_acceptance(result, expected=20, mtp=3) + + +@pytest.mark.parametrize("content", ["", "{}\n{}\n"]) +def test_reject_missing_or_stale_results(tmp_path, content): + result = tmp_path / "result.jsonl" + result.write_text(content) + with pytest.raises(BenchError, match="Expected one"): + read_acceptance(result, expected=20, mtp=3) + + +def test_levels(): + assert levels("1 3 8") == [1, 3, 8] + for invalid in ("", "1 1", "0", "-1", "a"): + with pytest.raises(InputError): + levels(invalid) From 8ea3013a89b3f5fdd4de7c58c31d484ec948798c Mon Sep 17 00:00:00 2001 From: functionstackx <47992694+functionstackx@users.noreply.github.com> Date: Sun, 1 Nov 2026 08:09:31 +0000 Subject: [PATCH 02/14] fix(speedbench): use vLLM B300 for GLM-5.3-Flash collection --- .../speedbench/glm53flash_fp8_b300_sglang.sh | 29 ----- .../speedbench/glm53flash_fp8_b300_vllm.sh | 27 +++++ inferencex-e2e/infx/bench/speedbench_al.py | 100 +++++++++++------- .../infx/tests/bench/test_speedbench_al.py | 48 ++++++--- 4 files changed, 122 insertions(+), 82 deletions(-) delete mode 100644 inferencex-e2e/benchmarks/single_node/speedbench/glm53flash_fp8_b300_sglang.sh create mode 100644 inferencex-e2e/benchmarks/single_node/speedbench/glm53flash_fp8_b300_vllm.sh diff --git a/inferencex-e2e/benchmarks/single_node/speedbench/glm53flash_fp8_b300_sglang.sh b/inferencex-e2e/benchmarks/single_node/speedbench/glm53flash_fp8_b300_sglang.sh deleted file mode 100644 index 7439d60583..0000000000 --- a/inferencex-e2e/benchmarks/single_node/speedbench/glm53flash_fp8_b300_sglang.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash -set -eo pipefail -repo_root="$(cd "$(dirname "$0")/../../.." && pwd)" -source "$repo_root/benchmarks/check_env.sh" -check_env_vars \ - CATEGORY CHAT_TEMPLATE_KWARGS_ON EP_SIZE IMAGE MODEL MODEL_PATH MTP_LIST \ - OUT_YAML PORT SPEEDBENCH_OUTPUT_LEN THINKING_MODES TP - -export PYTHONPATH="$repo_root${PYTHONPATH:+:$PYTHONPATH}" -exec python3 -m infx.bench speedbench-al \ - --concurrency 32 \ - --temperature 1.0 \ - --top-p 0.95 \ - -- \ - --tp-size "$TP" --ep-size "$EP_SIZE" \ - --trust-remote-code \ - --mem-fraction-static 0.80 \ - --context-length 32768 \ - --chunked-prefill-size 8192 \ - --max-prefill-tokens 8192 \ - --max-running-requests 32 \ - --cuda-graph-max-bs 32 \ - --kv-cache-dtype fp8_e4m3 \ - --dsa-prefill-backend trtllm \ - --dsa-decode-backend trtllm \ - --moe-runner-backend flashinfer_trtllm \ - --reasoning-parser glm45 \ - --tool-call-parser glm47 \ - --disable-radix-cache diff --git a/inferencex-e2e/benchmarks/single_node/speedbench/glm53flash_fp8_b300_vllm.sh b/inferencex-e2e/benchmarks/single_node/speedbench/glm53flash_fp8_b300_vllm.sh new file mode 100644 index 0000000000..088c65a168 --- /dev/null +++ b/inferencex-e2e/benchmarks/single_node/speedbench/glm53flash_fp8_b300_vllm.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -eo pipefail +repo_root="$(cd "$(dirname "$0")/../../.." && pwd)" +source "$repo_root/benchmarks/check_env.sh" +check_env_vars \ + CATEGORY CHAT_TEMPLATE_KWARGS_ON IMAGE MODEL MODEL_PATH MTP_LIST \ + OUT_YAML PORT SPEEDBENCH_OUTPUT_LEN THINKING_MODES TP + +export PYTHONPATH="$repo_root${PYTHONPATH:+:$PYTHONPATH}" +export VLLM_ENGINE_READY_TIMEOUT_S=3600 +export VLLM_USE_RUST_FRONTEND=0 +exec python3 -m infx.bench speedbench-al \ + --concurrency 1 \ + --temperature 1.0 \ + --top-p 0.95 \ + -- \ + --tensor-parallel-size "$TP" --data-parallel-size 1 \ + --trust-remote-code \ + --gpu-memory-utilization 0.80 \ + --max-model-len 32768 \ + --max-num-batched-tokens 8192 \ + --max-num-seqs 32 \ + --kv-cache-dtype fp8 \ + --tokenizer-mode auto \ + --reasoning-parser glm47 \ + --chat-template-content-format string \ + --no-enable-prefix-caching diff --git a/inferencex-e2e/infx/bench/speedbench_al.py b/inferencex-e2e/infx/bench/speedbench_al.py index 7dd1c0acdd..e1dd7dff41 100644 --- a/inferencex-e2e/infx/bench/speedbench_al.py +++ b/inferencex-e2e/infx/bench/speedbench_al.py @@ -40,7 +40,7 @@ def select_category(source: Path, destination: Path, category: str) -> int: if row.get("category") != category: continue messages = row["messages"] - # SGLang's loader consumes only the first user turn; reject lossy conversion. + # Coding collection is single-turn; do not silently flatten conversations. if ( len(messages) != 1 or messages[0]["role"] != "user" @@ -48,7 +48,7 @@ def select_category(source: Path, destination: Path, category: str) -> int: or not messages[0]["content"] ): raise env.BenchError("SPEED-Bench row must contain exactly one nonempty user turn") - out.write(json.dumps({"turns": [messages[0]["content"]]}) + "\n") + out.write(json.dumps(row) + "\n") count += 1 if count == 0: raise env.BenchError(f"No SPEED-Bench rows for category {category!r}") @@ -56,13 +56,17 @@ def select_category(source: Path, destination: Path, category: str) -> int: def read_acceptance(path: Path, *, expected: int, mtp: int) -> float: - rows = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line] - if len(rows) != 1: - raise env.BenchError(f"Expected one benchmark result in {path}, got {len(rows)}") - result = rows[0] - if result.get("completed") != expected or result.get("total_output_tokens", 0) <= 0: + try: + result = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as error: + raise env.BenchError(f"Expected one benchmark result in {path}") from error + if ( + result.get("completed") != expected + or result.get("failed", 0) != 0 + or result.get("total_output_tokens", 0) <= 0 + ): raise env.BenchError(f"Incomplete SPEED-Bench result: {result.get('completed')}/{expected}") - value = result.get("accept_length") + value = result.get("spec_decode_acceptance_length") if ( isinstance(value, bool) or not isinstance(value, (int, float)) @@ -70,6 +74,16 @@ def read_acceptance(path: Path, *, expected: int, mtp: int) -> float: or not 1 <= value <= mtp + 1 ): raise env.BenchError(f"Invalid acceptance length for MTP={mtp}: {value!r}") + drafts = result.get("spec_decode_num_drafts") + accepted = result.get("spec_decode_accepted_tokens") + if ( + type(drafts) is not int + or drafts <= 0 + or type(accepted) is not int + or not 0 <= accepted <= mtp * drafts + or not math.isclose(value, 1 + accepted / drafts, rel_tol=1e-9) + ): + raise env.BenchError("Missing or inconsistent speculative decoding counter deltas") return float(value) @@ -100,7 +114,10 @@ def prepare_dataset(directory: Path, category: str) -> tuple[Path, int]: prepare.write_bytes(response.read()) run([sys.executable, str(prepare), "--config", "qualitative", "--output_dir", str(directory)]) selected = directory / "selected.jsonl" - return selected, select_category(directory / "qualitative.jsonl", selected, category) + dataset = directory / "qualitative.jsonl" + count = select_category(dataset, selected, category) + selected.replace(dataset) + return dataset, count def run(command: Sequence[str]) -> None: @@ -144,27 +161,19 @@ def collect_cell( args: argparse.Namespace, ) -> float: command = [ - sys.executable, - "-m", - "sglang.launch_server", - "--model-path", + "vllm", + "serve", model_path, "--host", "127.0.0.1", "--port", str(port), *args.server_args, - "--speculative-algorithm", - "EAGLE", - "--speculative-num-steps", - str(mtp), - "--speculative-eagle-topk", - "1", - "--speculative-num-draft-tokens", - str(mtp + 1), + "--speculative-config", + json.dumps({"method": "mtp", "num_speculative_tokens": mtp}), ] log = results / f"server_on_mtp{mtp}.log" - result = results / f"speedbench_on_mtp{mtp}.jsonl" + result = results / f"speedbench_on_mtp{mtp}.json" result.unlink(missing_ok=True) proc.echo(command) with log.open("wb") as sink: @@ -175,11 +184,11 @@ def collect_cell( server.wait_ready(f"http://127.0.0.1:{port}/health", pid=child.pid, log=log) run( [ - sys.executable, - "-m", - "sglang.bench_serving", + "vllm", + "bench", + "serve", "--backend", - "sglang", + "vllm", "--host", "127.0.0.1", "--port", @@ -187,28 +196,35 @@ def collect_cell( "--model", model_path, "--dataset-name", - "speed-bench", + "speed_bench", "--dataset-path", - str(dataset), + str(dataset.parent), "--speed-bench-output-len", str(output_len), "--num-prompts", - str(count), + "-1", "--max-concurrency", str(args.concurrency), "--temperature", str(args.temperature), "--top-p", str(args.top_p), - # No warmup generations may contaminate server-wide acceptance counters. - "--warmup-requests", + "--chat-template-kwargs", + os.environ["CHAT_TEMPLATE_KWARGS_ON"], + "--trust-remote-code", + # vLLM records counter deltas around the formal request window. + "--num-warmups", + "0", + "--ready-check-timeout-sec", "0", "--seed", "42", - "--disable-ignore-eos", - "--output-file", - str(result), - "--output-details", + "--save-result", + "--save-detailed", + "--result-dir", + str(results), + "--result-filename", + result.name, ] ) return read_acceptance(result, expected=count, mtp=mtp) @@ -246,10 +262,13 @@ def main(argv: list[str]) -> int: "SPEEDBENCH_OUTPUT_LEN", "THINKING_MODES", ) - # The upstream SPEED-Bench loader cannot forward chat-template kwargs. - if inputs["THINKING_MODES"] != "on" or json.loads(inputs["CHAT_TEMPLATE_KWARGS_ON"]) != {}: + # GLM-5.3-Flash always thinks; enable_thinking=false does not disable it. + if inputs["THINKING_MODES"] != "on" or json.loads(inputs["CHAT_TEMPLATE_KWARGS_ON"]) not in ( + {}, + {"reasoning_effort": "max"}, + ): raise env.InputError( - "SGLang SPEED-Bench requires THINKING_MODES=on and CHAT_TEMPLATE_KWARGS_ON={}" + "GLM-5.3-Flash collection requires THINKING_MODES=on and max reasoning effort" ) mtp_levels = levels(inputs["MTP_LIST"]) output_len = env.positive_int("SPEEDBENCH_OUTPUT_LEN") @@ -272,14 +291,15 @@ def main(argv: list[str]) -> int: "concurrency": args.concurrency, "temperature": args.temperature, "top_p": args.top_p, - "thinking": "chat template default; reasoning_effort=max for GLM-5.3-Flash", + "thinking": "reasoning_effort=max for GLM-5.3-Flash", "draft": "embedded MTP, upstream checkpoint-default precision", "verification": "standard (no block or synthetic acceptance flags)", "prepare_url": PREPARE_URL, "num_prompts": count, "dataset_sha256": hashlib.sha256(dataset.read_bytes()).hexdigest(), "config_sha256": hashlib.sha256((model / "config.json").read_bytes()).hexdigest(), - "sglang_version": importlib.metadata.version("sglang"), + "vllm_version": importlib.metadata.version("vllm"), + "frontend": os.environ.get("VLLM_USE_RUST_FRONTEND"), "complete": False, } metadata.write_text(json.dumps(provenance, indent=2) + "\n", encoding="utf-8") diff --git a/inferencex-e2e/infx/tests/bench/test_speedbench_al.py b/inferencex-e2e/infx/tests/bench/test_speedbench_al.py index a1afcd8a75..1d868ddeea 100644 --- a/inferencex-e2e/infx/tests/bench/test_speedbench_al.py +++ b/inferencex-e2e/infx/tests/bench/test_speedbench_al.py @@ -23,8 +23,8 @@ def test_select_category_preserves_all_matching_prompts(tmp_path): destination = tmp_path / "selected.jsonl" assert select_category(source, destination, "coding") == 2 assert [json.loads(row) for row in destination.read_text().splitlines()] == [ - {"turns": ["one"]}, - {"turns": ["three"]}, + {"category": "coding", "messages": [{"role": "user", "content": "one"}]}, + {"category": "coding", "messages": [{"role": "user", "content": "three"}]}, ] with pytest.raises(BenchError, match="No SPEED-Bench"): select_category(source, destination, "missing") @@ -47,9 +47,17 @@ def test_select_category_rejects_lossy_conversion(tmp_path, messages): def test_acceptance_and_matrix(tmp_path): - result = tmp_path / "result.jsonl" + result = tmp_path / "result.json" result.write_text( - json.dumps({"completed": 20, "total_output_tokens": 100, "accept_length": 2.3456789}) + json.dumps( + { + "completed": 20, + "total_output_tokens": 100, + "spec_decode_acceptance_length": 2.3456789, + "spec_decode_num_drafts": 10000000, + "spec_decode_accepted_tokens": 13456789, + } + ) ) value = read_acceptance(result, expected=20, mtp=3) matrix = tmp_path / "matrix.yaml" @@ -62,19 +70,33 @@ def test_acceptance_and_matrix(tmp_path): [ {"completed": 19}, {"total_output_tokens": 0}, - {"accept_length": None}, - {"accept_length": "N/A"}, - {"accept_length": float("nan")}, - {"accept_length": float("inf")}, - {"accept_length": True}, - {"accept_length": 0.99}, - {"accept_length": 4.01}, + {"failed": 1}, + {"spec_decode_acceptance_length": None}, + {"spec_decode_acceptance_length": "N/A"}, + {"spec_decode_acceptance_length": float("nan")}, + {"spec_decode_acceptance_length": float("inf")}, + {"spec_decode_acceptance_length": True}, + {"spec_decode_acceptance_length": 0.99}, + {"spec_decode_acceptance_length": 4.01}, + {"spec_decode_num_drafts": 0}, + {"spec_decode_num_drafts": None}, + {"spec_decode_accepted_tokens": -1}, + {"spec_decode_accepted_tokens": 301}, ], ) def test_reject_incomplete_or_invalid_measurements(tmp_path, updates): - result = tmp_path / "result.jsonl" + result = tmp_path / "result.json" result.write_text( - json.dumps({"completed": 20, "total_output_tokens": 100, "accept_length": 2.5} | updates) + json.dumps( + { + "completed": 20, + "total_output_tokens": 100, + "spec_decode_acceptance_length": 2.5, + "spec_decode_num_drafts": 200, + "spec_decode_accepted_tokens": 300, + } + | updates + ) ) with pytest.raises(BenchError): read_acceptance(result, expected=20, mtp=3) From 7e8ca71fa27bc2a70324b9fe978f8deefed8c332 Mon Sep 17 00:00:00 2001 From: functionstackx <47992694+functionstackx@users.noreply.github.com> Date: Sun, 1 Nov 2026 08:20:48 +0000 Subject: [PATCH 03/14] fix(speedbench): preserve multi-turn rows and drain GPU workers --- inferencex-e2e/infx/bench/speedbench_al.py | 29 ++++++++++++-- .../infx/tests/bench/test_speedbench_al.py | 38 ++++++++++++++++++- 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/inferencex-e2e/infx/bench/speedbench_al.py b/inferencex-e2e/infx/bench/speedbench_al.py index e1dd7dff41..291fe9eed6 100644 --- a/inferencex-e2e/infx/bench/speedbench_al.py +++ b/inferencex-e2e/infx/bench/speedbench_al.py @@ -12,6 +12,7 @@ import subprocess import sys import tempfile +import time import urllib.request from collections.abc import Mapping, Sequence from pathlib import Path @@ -40,14 +41,14 @@ def select_category(source: Path, destination: Path, category: str) -> int: if row.get("category") != category: continue messages = row["messages"] - # Coding collection is single-turn; do not silently flatten conversations. + # vLLM samples the first turn of each row, including multi-turn rows. if ( - len(messages) != 1 + not messages or messages[0]["role"] != "user" or not isinstance(messages[0]["content"], str) or not messages[0]["content"] ): - raise env.BenchError("SPEED-Bench row must contain exactly one nonempty user turn") + raise env.BenchError("SPEED-Bench row must start with a nonempty text user turn") out.write(json.dumps(row) + "\n") count += 1 if count == 0: @@ -147,6 +148,24 @@ def stop(child: subprocess.Popen[bytes]) -> None: with contextlib.suppress(ProcessLookupError): os.killpg(child.pid, signal.SIGKILL) child.wait() + wait_gpu_idle() + + +def wait_gpu_idle() -> None: + # Reaping the launcher does not wait for TP workers to release their CUDA contexts. + deadline = time.monotonic() + 120 + while True: + output = subprocess.check_output( + ["nvidia-smi", "--query-gpu=memory.used", "--format=csv,noheader,nounits"], + text=True, + timeout=10, + ) + used = [int(line.strip()) for line in output.splitlines() if line.strip()] + if used and max(used) < 2000: + return + if time.monotonic() >= deadline: + raise env.BenchError(f"GPU memory did not drain after server shutdown: {used} MiB") + time.sleep(3) def collect_cell( @@ -296,6 +315,10 @@ def main(argv: list[str]) -> int: "verification": "standard (no block or synthetic acceptance flags)", "prepare_url": PREPARE_URL, "num_prompts": count, + "prompt_selection": "upstream vLLM SPEED-Bench: first user turn of each category row", + "rows_with_followup_turns": sum( + len(json.loads(line)["messages"]) > 1 for line in dataset.read_text().splitlines() + ), "dataset_sha256": hashlib.sha256(dataset.read_bytes()).hexdigest(), "config_sha256": hashlib.sha256((model / "config.json").read_bytes()).hexdigest(), "vllm_version": importlib.metadata.version("vllm"), diff --git a/inferencex-e2e/infx/tests/bench/test_speedbench_al.py b/inferencex-e2e/infx/tests/bench/test_speedbench_al.py index 1d868ddeea..e1a8279550 100644 --- a/inferencex-e2e/infx/tests/bench/test_speedbench_al.py +++ b/inferencex-e2e/infx/tests/bench/test_speedbench_al.py @@ -3,6 +3,7 @@ import pytest import yaml +from infx.bench import speedbench_al from infx.bench.env import BenchError, InputError from infx.bench.speedbench_al import levels, read_acceptance, select_category, write_matrix @@ -39,13 +40,46 @@ def test_select_category_preserves_all_matching_prompts(tmp_path): [{"role": "user", "content": ["multimodal"]}], ], ) -def test_select_category_rejects_lossy_conversion(tmp_path, messages): +def test_select_category_rejects_invalid_first_turn(tmp_path, messages): source = tmp_path / "source.jsonl" source.write_text(json.dumps({"category": "coding", "messages": messages})) - with pytest.raises(BenchError, match="exactly one"): + with pytest.raises(BenchError, match="start with"): select_category(source, tmp_path / "selected.jsonl", "coding") +def test_select_category_preserves_multiturn_rows_for_native_vllm_loader(tmp_path): + source = tmp_path / "source.jsonl" + row = { + "category": "coding", + "messages": [ + {"role": "user", "content": "Write a parser"}, + {"role": "user", "content": "Add error handling"}, + ], + } + source.write_text(json.dumps(row)) + destination = tmp_path / "selected.jsonl" + assert select_category(source, destination, "coding") == 1 + assert json.loads(destination.read_text()) == row + + +def test_wait_gpu_idle_waits_for_workers(monkeypatch): + readings = iter(["15000\n16000\n", "1000\n1900\n"]) + monkeypatch.setattr(speedbench_al.subprocess, "check_output", lambda *a, **kw: next(readings)) + waits = [] + monkeypatch.setattr(speedbench_al.time, "sleep", waits.append) + speedbench_al.wait_gpu_idle() + assert waits == [3] + + +@pytest.mark.parametrize("output", ["", "3000\n4000\n"]) +def test_wait_gpu_idle_fails_closed(monkeypatch, output): + monkeypatch.setattr(speedbench_al.subprocess, "check_output", lambda *a, **kw: output) + clock = iter([0, 121]) + monkeypatch.setattr(speedbench_al.time, "monotonic", lambda: next(clock)) + with pytest.raises(BenchError, match="did not drain"): + speedbench_al.wait_gpu_idle() + + def test_acceptance_and_matrix(tmp_path): result = tmp_path / "result.json" result.write_text( From 48627c4a3b8157789edff95972247e39716aacc2 Mon Sep 17 00:00:00 2001 From: functionstackx <47992694+functionstackx@users.noreply.github.com> Date: Sun, 1 Nov 2026 11:05:02 +0000 Subject: [PATCH 04/14] feat(golden-al): commit measured GLM-5.3-Flash MTP curve --- .../golden_al_distribution/glm5.3flash_mtp.yaml | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 inferencex-e2e/infx/golden_al_distribution/glm5.3flash_mtp.yaml diff --git a/inferencex-e2e/infx/golden_al_distribution/glm5.3flash_mtp.yaml b/inferencex-e2e/infx/golden_al_distribution/glm5.3flash_mtp.yaml new file mode 100644 index 0000000000..8216550753 --- /dev/null +++ b/inferencex-e2e/infx/golden_al_distribution/glm5.3flash_mtp.yaml @@ -0,0 +1,13 @@ +# Source GitHub Actions run: https://github.com/SemiAnalysisAI/InferenceX/actions/runs/41288481245 +# SPEED-Bench coding, 80 first-user-turn prompts, concurrency 1, max reasoning effort. +# vLLM B300 TP8, temperature 1.0, top_p 0.95, natural EOS, output cap 4096. +glm-5.3-flash: + thinking_on: + 1: 1.858382 + 2: 2.518506 + 3: 3.004589 + 4: 3.349344 + 5: 3.603536 + 6: 3.790803 + 7: 3.925358 + 8: 3.992497 From 12eea95ef965295bd2dc0baf0cbb06f018a486fd Mon Sep 17 00:00:00 2001 From: functionstackx <47992694+functionstackx@users.noreply.github.com> Date: Sun, 1 Nov 2026 11:09:10 +0000 Subject: [PATCH 05/14] fix(ci): allow authorized sweeps on stacked pull requests --- .github/workflows/README.md | 2 +- .github/workflows/run-sweep.yml | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 1822672c27..a3b9d45166 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -162,7 +162,7 @@ test-config --config-keys dsr1-fp8-h200-sglang --evals-only --all-evals --config ## PR Sweep Labels -`run-sweep.yml` sweeps only same-repository PRs that change +`run-sweep.yml` sweeps only same-repository PRs, including stacked PRs, that change `inferencex-e2e/perf-changelog.yaml`, using the appended entries as the matrix. Apply exactly one primary label, normally `full-sweep-fail-fast`. Fork PRs use the [trusted dispatch](#trusted-external-fork-sweep-dispatch-poc). diff --git a/.github/workflows/run-sweep.yml b/.github/workflows/run-sweep.yml index 19f6d4d2c6..03842620bf 100644 --- a/.github/workflows/run-sweep.yml +++ b/.github/workflows/run-sweep.yml @@ -18,8 +18,6 @@ concurrency: on: pull_request: - branches: - - main types: - synchronize - labeled From d49bdf1d416b420b4a3b45e029b75955c3fce53f Mon Sep 17 00:00:00 2001 From: functionstackx <47992694+functionstackx@users.noreply.github.com> Date: Sun, 1 Nov 2026 11:10:32 +0000 Subject: [PATCH 06/14] fix(bench): preserve golden acceptance precision in native overrides --- .../infx/srt_slurm/synthetic_acceptance.py | 8 ++--- .../srt_slurm/test_synthetic_acceptance.py | 36 +++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/inferencex-e2e/infx/srt_slurm/synthetic_acceptance.py b/inferencex-e2e/infx/srt_slurm/synthetic_acceptance.py index e7da9b72bb..7ae0f9f9ae 100644 --- a/inferencex-e2e/infx/srt_slurm/synthetic_acceptance.py +++ b/inferencex-e2e/infx/srt_slurm/synthetic_acceptance.py @@ -248,16 +248,16 @@ def build_overrides( elif worker_engine == "atom": key = "spec-decode-acceptance-length" if worker_al is not None and worker_spec: - overrides += ["--set", f"{prefix}.args.{key}={worker_al:g}"] + overrides += ["--set", f"{prefix}.args.{key}={worker_al:.15g}"] elif key in (role.get("args") or {}): overrides += ["--unset", f"{prefix}.args.{key}"] elif worker_al is not None and worker_spec: values = ( - (f"{worker_al:g}", "match-expected", "real-draft-token") + (f"{worker_al:.15g}", "match-expected", "real-draft-token") if engine == "sglang" - else (f"{worker_al:g}", "match-expected") + else (f"{worker_al:.15g}", "match-expected") if engine == "tilert" - else (f"{worker_al - 1:g}",) + else (f"{worker_al - 1:.15g}",) ) for key, value in zip(variables, values, strict=True): overrides += ["--set", f"{prefix}.env.{key}={json.dumps(value)}"] diff --git a/inferencex-e2e/infx/tests/srt_slurm/test_synthetic_acceptance.py b/inferencex-e2e/infx/tests/srt_slurm/test_synthetic_acceptance.py index e687fc922c..0a309f4047 100644 --- a/inferencex-e2e/infx/tests/srt_slurm/test_synthetic_acceptance.py +++ b/inferencex-e2e/infx/tests/srt_slurm/test_synthetic_acceptance.py @@ -762,6 +762,42 @@ def test_sglang_abbreviated_algorithm_selects_golden( } +@pytest.mark.parametrize( + ("engine", "args", "key", "expected"), + [ + ( + "sglang", + {"speculative-algorithm": "EAGLE", "speculative-num-steps": 3}, + "SGLANG_SIMULATE_ACC_LEN", + "3.603536", + ), + ( + "tilert", + {"with-mtp": True, "num-mtp": 3}, + "TILERT_SIMULATE_ACC_LEN", + "3.603536", + ), + ( + "trt", + {"speculative_config": {"decoding_type": "mtp", "max_draft_len": 3}}, + "TLLM_SPEC_DECODE_FORCE_NUM_ACCEPTED_TOKENS", + "2.603536", + ), + ], +) +def test_precise_golden_length_survives_native_overrides( + golden_dir: Path, engine: str, args: dict[str, Any], key: str, expected: str +) -> None: + (golden_dir / "dsv4_mtp.yaml").write_text( + yaml.safe_dump({"deepseek-v4-pro": {"thinking_on": {3: 3.603536}}}) + ) + recipe = {"roles": {"decode": {"args": args}}} + result = apply_native( + recipe, build_overrides(recipe, engine, ENV, golden_dir=golden_dir) + ) + assert result["roles"]["decode"]["env"][key] == expected + + def test_sglang_conflicting_algorithm_aliases_fail(golden_dir: Path) -> None: recipe = { "roles": { From 32d6423628e59003178b35404ddd985e08ed90ee Mon Sep 17 00:00:00 2001 From: functionstackx <47992694+functionstackx@users.noreply.github.com> Date: Sun, 1 Nov 2026 11:14:57 +0000 Subject: [PATCH 07/14] feat(bench): add native GLM-5.3-Flash FP8 GB300 AgentX lane --- .../sglang/gb300-fp8-mtp/agentic.yaml | 51 +++++++++++++++++++ inferencex-e2e/configs/nvidia-master.yaml | 15 +++++- inferencex-e2e/configs/runners.yaml | 1 + inferencex-e2e/docs/MODELS.md | 2 + 4 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/glm5.3flash/sglang/gb300-fp8-mtp/agentic.yaml diff --git a/inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/glm5.3flash/sglang/gb300-fp8-mtp/agentic.yaml b/inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/glm5.3flash/sglang/gb300-fp8-mtp/agentic.yaml new file mode 100644 index 0000000000..52d703fd43 --- /dev/null +++ b/inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/glm5.3flash/sglang/gb300-fp8-mtp/agentic.yaml @@ -0,0 +1,51 @@ +schema: 2 +name: glm5.3flash-fp8-gb300-sglang-agentic-mtp +resources: + gpu_type: gb300 + gpus_per_node: 4 +slurm: + time_limit: '4:00:00' +frontend: + type: sglang + enable_multiple_frontends: false +observability: + enabled: false + tachometer: + enabled: false +engine: sglang +roles: + agg: + nodes: 1 + workers: 1 + gpus: 4 + args: + served-model-name: zai-org/GLM-5.3-Flash + trust-remote-code: true + tensor-parallel-size: 4 + expert-parallel-size: 1 + context-length: 1048576 + dsa-prefill-backend: trtllm + dsa-decode-backend: trtllm + kv-cache-dtype: fp8_e4m3 + moe-runner-backend: flashinfer_trtllm + speculative-algorithm: EAGLE + speculative-num-steps: 5 + speculative-eagle-topk: 1 + speculative-num-draft-tokens: 6 + reasoning-parser: auto + tool-call-parser: auto + watchdog-timeout: 3600 + enable-metrics: true + env: + PYTHONNOUSERSITE: '1' + PYTHONUNBUFFERED: '1' + HF_HUB_OFFLINE: '1' + SGLANG_TIMEOUT_KEEP_ALIVE: '900' +health_check: + max_attempts: 1440 + interval_seconds: 5 +benchmark: + env: + TOKENIZER: /model + AIPERF_HTTP_TCP_USER_TIMEOUT: '900000' + AIPERF_REQUIRED_SERVER_METRIC_PREFIX: 'sglang:' diff --git a/inferencex-e2e/configs/nvidia-master.yaml b/inferencex-e2e/configs/nvidia-master.yaml index ca18213d0c..bbb6d3fed4 100644 --- a/inferencex-e2e/configs/nvidia-master.yaml +++ b/inferencex-e2e/configs/nvidia-master.yaml @@ -8820,6 +8820,20 @@ kimik3-fp4-gb200-dynamo-vllm-agentic-dspark-mooncake-dcp16-disagg: dp-attn: false decode: { num-worker: 1, tp: 16, dcp-size: 16, ep: 1, dp-attn: false } +glm5.3flash-fp8-gb300-sglang-agentic-mtp: + image: lmsysorg/sglang:v0.5.22-cu130@sha256:e3a4f06001a29542fc4a8638536d9ffc5eb9c9942b08603c09b19a56eae4a1ae + model: zai-org/GLM-5.3-Flash + model-prefix: glm5.3flash + runner: cluster:gb300-nv + precision: fp8 + framework: sglang + multinode: false + srt-recipe-dir: glm5.3flash/sglang/gb300-fp8-mtp + scenarios: + agentic-coding: + - search-space: + - { tp: 4, ep: 1, spec-decoding: mtp, kv-offloading: none, conc-list: [1, 2, 4, 8, 12, 16, 24, 32, 48, 64], srt-recipe: agentic.yaml } + glm5.3-fp4-b300-sglang-agentic-mtp: image: lmsysorg/sglang:v0.5.20-cu130@sha256:b27fce60bc5494c118c4910702812bcfa8cee67abcdd1ff8b0902f21647552f4 model: nvidia/GLM-5.3-NVFP4 @@ -9071,4 +9085,3 @@ minimaxm31flash-fp4-gb300-dynamo-vllm-agentic-dspark-disagg: srt-recipe: disagg-mooncake.yaml:override_3p4d16 prefill: { num-worker: 6, tp: 2, ep: 2, dp-attn: true } decode: { num-worker: 1, tp: 16, ep: 16, dp-attn: true } - diff --git a/inferencex-e2e/configs/runners.yaml b/inferencex-e2e/configs/runners.yaml index ff16164590..564ae8d930 100644 --- a/inferencex-e2e/configs/runners.yaml +++ b/inferencex-e2e/configs/runners.yaml @@ -634,6 +634,7 @@ clusters: DeepSeek-R1-0528: {root: scratch, dir: DeepSeek-R1-0528} DeepSeek-R1-0528-NVFP4-v2: {root: scratch, dir: DeepSeek-R1-0528-NVFP4-v2} GLM-5.2-NVFP4: {root: scratch, dir: GLM-5.2-NVFP4} + GLM-5.3-Flash: {root: data-models, dir: GLM-5.3-Flash} Kimi-K3: {root: scratch, dir: Kimi-K3} MiniMax-M3.1-Flash: {root: data-models, dir: MiniMax-M3.1-Flash} Muse-Spark-Open-816B-A42B-NVFP4: {root: data-models, dir: Muse-Spark-Open-816B-A42B-NVFP4} diff --git a/inferencex-e2e/docs/MODELS.md b/inferencex-e2e/docs/MODELS.md index e0a6d55d7b..afa4031486 100644 --- a/inferencex-e2e/docs/MODELS.md +++ b/inferencex-e2e/docs/MODELS.md @@ -98,6 +98,7 @@ The table also records both the agreed plan-of-record (PoR) draft-model mapping |---|---|---|---|---| | Kimi-K3 (`kimik3`) | native/upstream vLLM engine | `Inferact/Kimi-K3-DSpark` | None | Additional non-vLLM/SGLang engines under the ordering guideline and exceptions above | | GLM-5.2 (`glm5.2`) | native/upstream SGLang engine | native MTP | None | Additional non-vLLM/SGLang engines under the ordering guideline and exceptions above | +| GLM-5.3-Flash (`glm5.3flash`) | native/upstream SGLang engine | native MTP | None | Additional non-vLLM/SGLang engines under the ordering guideline and exceptions above | | Qwen3.5-397B-A17B (`qwen3.5`) | native/upstream SGLang engine | native MTP | None | Additional non-vLLM/SGLang engines under the ordering guideline and exceptions above | | Qwen3.8-Flash-Next (`qwen3.8next`) | native/upstream SGLang engine | TBD | native MTP (built-in 4B multi-step module; golden AL collector: [`qwen3.8next_fp4_b300_vllm.sh`](../benchmarks/single_node/speedbench/qwen3.8next_fp4_b300_vllm.sh)) | Additional non-vLLM/SGLang engines under the ordering guideline and exceptions above | @@ -124,6 +125,7 @@ Other offloading tiers, including NVMe KV cache offloading, are outside the init | Model architecture class | Prefix | Date added | Active scenarios | Deprecated scenarios | |---|---|---|---|---| +| GLM-5.3-Flash | `glm5.3flash` | 2026-11-01 | Agentic coding (native SGLang FP8 on GB300, embedded MTP; golden AL curve `glm5.3flash_mtp.yaml`) | | | MiniMax-M3.1-Flash | `minimaxm31flash` | 2026-10-23 | Agentic coding (vLLM DSpark; golden AL curve `minimaxm31flash_dspark.yaml`) | | | Muse Spark Open 816B-A42B | `musespark` | 2026-10-15 | Agentic coding (SGLang and GB300 TensorRT-LLM with DFlash2 draft `RadixArk/Muse-Spark-Open-DFlash2`; golden AL curve `musespark_dflash.yaml`) | | | DeepSeek-V4.1-Flash | `dsv41flash` | 2026-09-10 | Agentic coding (vLLM: DSpark, Engram UVA offload; SGLang: DSpark arms added per SKU from 2026-09-17; ATOM: MI355X TP2/TP4 DSpark added 2026-09-23, removed in #3463, restored on srt-slurm 2026-09-30; GPU validation pending) | | From 505ac3a89485ce9a82458c160122c366f94573ba Mon Sep 17 00:00:00 2001 From: functionstackx <47992694+functionstackx@users.noreply.github.com> Date: Sun, 1 Nov 2026 11:15:38 +0000 Subject: [PATCH 08/14] chore(bench): register GLM-5.3-Flash FP8 GB300 sweep --- inferencex-e2e/perf-changelog.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/inferencex-e2e/perf-changelog.yaml b/inferencex-e2e/perf-changelog.yaml index 88ea87392c..51fbda91ac 100644 --- a/inferencex-e2e/perf-changelog.yaml +++ b/inferencex-e2e/perf-changelog.yaml @@ -11769,3 +11769,11 @@ - "6 DEP2 prefill workers with a DEP8 decode at conc 280-400 or a DEP16 decode at conc 300; MegaMoE, DSpark block 7 with fp8 draft KV, Dynamo KV router with fastokens for prefill and least-loaded routing across decode DP ranks." - "Prefill: shortest-prefill-first, chunk 8192, mem-fraction-static 0.85, HiCache 200 GB per rank write_back." pr-link: https://github.com/SemiAnalysisAI/InferenceX/pull/4686 + +- config-keys: + - glm5.3flash-fp8-gb300-sglang-agentic-mtp + scenario-type: [agentic-coding] + description: + - "Add native SGLang GLM-5.3-Flash FP8 AgentX on GB300 with the official ARM64-capable v0.5.22-cu130 image, TP4/EP1, TRT-LLM DSA, FP8 E4M3 KV cache, and embedded EAGLE MTP 5/1/6." + - "Run the full AgentX corpus and 1048576-token context at concurrency 1, 2, 4, 8, 12, 16, 24, 32, 48, and 64 with shared measured golden acceptance and canonical real evals." + pr-link: https://github.com/SemiAnalysisAI/InferenceX/pull/4715 From 86f215a33bfc8b4326d6c3900ec18bd8d5c9d6c5 Mon Sep 17 00:00:00 2001 From: functionstackx <47992694+functionstackx@users.noreply.github.com> Date: Sun, 1 Nov 2026 11:22:19 +0000 Subject: [PATCH 09/14] fix(ci): preserve canary gates after skipped reuse checks --- .github/workflows/run-sweep.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/run-sweep.yml b/.github/workflows/run-sweep.yml index 03842620bf..acf9c1995c 100644 --- a/.github/workflows/run-sweep.yml +++ b/.github/workflows/run-sweep.yml @@ -316,6 +316,7 @@ jobs: name: canary-select needs: setup if: >- + !cancelled() && needs.setup.result == 'success' && ( contains(github.event.pull_request.labels.*.name, 'full-sweep-enabled') || contains(github.event.pull_request.labels.*.name, 'full-sweep-fail-fast') @@ -366,7 +367,7 @@ jobs: canary-sweep: needs: canary-select - if: ${{ needs.canary-select.outputs.canary-config != '' && needs.canary-select.outputs.canary-config != '[]' }} + if: ${{ !cancelled() && needs.canary-select.result == 'success' && needs.canary-select.outputs.canary-config != '' && needs.canary-select.outputs.canary-config != '[]' }} uses: $/.github/workflows/benchmark-tmpl.yml name: canary / strategy: @@ -390,7 +391,7 @@ jobs: canary-multi-node-sweep: needs: canary-select - if: ${{ needs.canary-select.outputs.multi-node-canary-config != '' && needs.canary-select.outputs.multi-node-canary-config != '[]' }} + if: ${{ !cancelled() && needs.canary-select.result == 'success' && needs.canary-select.outputs.multi-node-canary-config != '' && needs.canary-select.outputs.multi-node-canary-config != '[]' }} uses: $/.github/workflows/benchmark-multinode-tmpl.yml name: multi-node canary / strategy: From c611580c42f9f8975d260d1a0a567c38e188d7d8 Mon Sep 17 00:00:00 2001 From: functionstackx <47992694+functionstackx@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:45:34 +0000 Subject: [PATCH 10/14] fix(bench): use canonical Flash tokenizer ID for AgentX --- .../glm5.3flash/sglang/gb300-fp8-mtp/agentic.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/glm5.3flash/sglang/gb300-fp8-mtp/agentic.yaml b/inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/glm5.3flash/sglang/gb300-fp8-mtp/agentic.yaml index 52d703fd43..7f4253b855 100644 --- a/inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/glm5.3flash/sglang/gb300-fp8-mtp/agentic.yaml +++ b/inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/glm5.3flash/sglang/gb300-fp8-mtp/agentic.yaml @@ -46,6 +46,5 @@ health_check: interval_seconds: 5 benchmark: env: - TOKENIZER: /model AIPERF_HTTP_TCP_USER_TIMEOUT: '900000' AIPERF_REQUIRED_SERVER_METRIC_PREFIX: 'sglang:' From ecb4205af236dca66ae63b5c9af0538b123878f9 Mon Sep 17 00:00:00 2001 From: Oseltamivir <58582368+Oseltamivir@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:46:45 +0800 Subject: [PATCH 11/14] feat(bench): add GLM-5.3-Flash FP8 and NVFP4 AgentX on GB300 Single-node SGLang v0.5.22 TP4 MTP 5/1/6 lanes for FP8 and NVFP4 using the SGLang cookbook GB300 recipe: GPU-resident KV at low concurrency and an auto-sized HiCache host tier (host memory fraction 0.90) at high concurrency. --- .github/AGENT_OPERATIONS.md | 12 +- .github/actions/classify-priority/action.yml | 174 + .github/codeowner-signoff-verify-prompt.md | 8 +- .github/workflows/README.md | 18 +- .github/workflows/al-gate-prompts.yml | 104 + .../workflows/benchmark-multinode-tmpl.yml | 14 + .github/workflows/benchmark-tmpl.yml | 14 + .github/workflows/collect-evals.yml | 6 + .github/workflows/e2e-tests.yml | 44 +- .github/workflows/profile.yml | 12 +- .github/workflows/run-sweep.yml | 111 +- .github/workflows/speedbench-al.yml | 36 +- AGENTS.md | 1 + collectivex/bench/ep_backend.py | 9 + collectivex/bench/ep_deepep_v2.py | 99 +- collectivex/bench/ep_flashinfer.py | 4 - collectivex/bench/ep_legacy_ll.py | 102 +- collectivex/bench/ep_mori.py | 5 +- collectivex/bench/ep_nccl.py | 6 - collectivex/bench/ep_uccl.py | 69 +- collectivex/configs/platform_config.json | 3 +- collectivex/tests/test_backends.py | 373 +- collectivex/tests/test_deepep_v2_contract.py | 47 + collectivex/tests/test_nccl_ep_contract.py | 308 +- .../multi_node/srt-slurm-recipes/RECIPES.md | 44 +- .../configs/kimik3-atom-dspark-draft.sh | 13 - .../configs/kimik3-b300-mooncake.sh | 36 - .../configs/kimik3-dspark-config-compat.sh | 49 - .../configs/tokenspeed-nightly.sh | 15 + .../gb300-fp4/agentx/disagg-variants.yaml | 2 - .../b300-fp4/agentx/agg-tp4-variants.yaml | 2 +- .../vllm/b200-fp4/agentx/agg-variants.yaml | 201 - .../agg-dcp16-dspark4-maxseq2-mooncake.yaml | 147 - .../agentx/agg-dcp16-nospec-mooncake.yaml | 143 - .../gb200-fp4/agentx/agg-tp8pp2-variants.yaml | 173 - .../vllm/gb200-fp4/agentx/agg-variants.yaml | 205 - .../gb200-fp4/agentx/disagg-variants.yaml | 268 -- .../agentx/agg-megakernel-variants.yaml | 89 - .../gb300-fp4/agentx/disagg-tp8-dep16.yaml | 218 - .../gb300-fp4/agentx/disagg-variants.yaml | 367 -- .../agentx/agg-tp16dp2ep32-latency.yaml | 79 - .../agentx/agg-tp8dp4ep32-balanced.yaml | 80 - .../agentx/agg-tp8dp4ep32-vllm-simple.yaml | 81 - .../mi355x-fp4/agentx/disagg-variants.yaml | 212 - .../sglang/gb300-fp4/agentx/agg-variants.yaml | 216 + .../gb300-fp4/agentx/disagg-variants.yaml | 16 +- .../8k1k/mtp/1p1d-tp4-tp4-c1.yaml | 155 - .../8k1k/mtp/1p1d-tp4-tp4-c4.yaml | 155 - .../8k1k/mtp/1p1d-tp4-tp4-c8.yaml | 155 - .../8k1k/mtp/2p2d-tp2-tp2-c512.yaml | 158 - .../8k1k/mtp/2p3d-tp2-tp2-c768.yaml | 154 - .../8k1k/mtp/3p5d-tp4-tp4-c128.yaml | 155 - .../8k1k/mtp/7p1d-dep4-dep16-c5120.yaml | 165 - .../8k1k/mtp/7p1d-dep4-dep16-c8192.yaml | 165 - .../8k1k/mtp/7p4d-dep4-dep4-c12288.yaml | 178 - ...sagg-1p1d-p-tp4-d-tp4-b128-c1x2x8-mtp.yaml | 130 - ...tp8-ep8-d-tp8-ep8-b1024-c32x48x80-mtp.yaml | 142 - .../8k1k/disagg-1p1d-tp4-tp4-stp.yaml | 110 - ...4-dp4-d-tp16-ep16-dp16-b1024-c480-mtp.yaml | 157 - .../8k1k/disagg-4p1d-dep4-dep16-stp.yaml | 146 - ...4-dp4-d-tp16-ep16-dp16-b1024-c768-mtp.yaml | 157 - ...-dp4-d-tp16-ep16-dp16-b1024-c1280-mtp.yaml | 157 - ...-dp4-d-tp16-ep16-dp16-b2048-c1344-mtp.yaml | 157 - .../8k1k/disagg-8p1d-dep4-dep16-stp.yaml | 155 - ...d-tp16-ep16-dp16-b2048-c1920x2304-mtp.yaml | 157 - ...p4-d-tp4-b128-c1x4x8x16x32x64x256-stp.yaml | 157 - ...-dp4-d-tp16-ep16-dp16-b4096-c2048-stp.yaml | 157 - ...-dp4-d-tp16-ep16-dp16-b5120-c5120-stp.yaml | 155 - ...-dp4-d-tp16-ep16-dp16-b5120-c5120-stp.yaml | 155 - ...agg-1p1d-p-tp4-d-tp4-b1024-c1x2x8-mtp.yaml | 130 - ...tp8-ep8-d-tp8-ep8-b1024-c32x48x80-mtp.yaml | 142 - .../8k1k/disagg-1p1d-tp4-tp4-stp.yaml | 110 - ...4-dp4-d-tp16-ep16-dp16-b1024-c480-mtp.yaml | 158 - .../8k1k/disagg-4p1d-dep4-dep16-stp.yaml | 145 - ...4-dp4-d-tp16-ep16-dp16-b1024-c768-mtp.yaml | 158 - ...-dp4-d-tp16-ep16-dp16-b1024-c1280-mtp.yaml | 158 - ...-dp4-d-tp16-ep16-dp16-b2048-c1344-mtp.yaml | 158 - .../8k1k/disagg-8p1d-dep4-dep16-stp.yaml | 154 - ...d-tp16-ep16-dp16-b2048-c1920x2304-mtp.yaml | 158 - .../mi355x-fp4/8k1k/disagg-1p1d-tp8.yaml | 100 - .../8k1k/disagg-1p1d-p-tp4-d-tp8.yaml | 105 - .../gb300-fp4/8k1k/disagg-mtp-variants.yaml | 796 ---- .../gb300-fp4/8k1k/disagg-stp-variants.yaml | 681 --- .../benchmarks/multi_node/srt_eval.sh | 5 + .../speedbench/dsv4_fp4_b300_vllm.sh | 13 +- .../speedbench/dsv4dspark_fp4_b300_vllm.sh | 23 +- ...le_method_block_rejection_sample_method.sh | 4 + .../speedbench/glm5.3_fp4_b300_sglang.sh | 203 + .../speedbench/glm52_fp4_b300_vllm.sh | 11 +- .../speedbench/glm53flash_fp8_b300_vllm.sh | 27 - .../speedbench/kimik3.1_fp4_b300_sglang.sh | 276 ++ .../speedbench/kimik3_fp4_b300_vllm.sh | 19 +- ...4_b300_vllm_probabilistic_sample_method.sh | 4 + ...le_method_block_rejection_sample_method.sh | 11 +- .../minimaxm31flash_fp4_b300_vllm.sh | 11 +- .../speedbench/minimaxm3_fp4_b300_vllm.sh | 23 +- ..._vllm_gqa_block_rejection_sample_method.sh | 5 + .../speedbench/musespark_fp4_b300_sglang.sh | 11 +- .../speedbench/qwen3.5_fp4_b300_vllm.sh | 11 +- .../speedbench/qwen3.8next_fp4_b300_vllm.sh | 11 +- .../atom/mi355x-fp4-mtp/agentic.yaml | 2 +- .../sglang/gb300-fp4-mtp/agentic.yaml | 34 +- .../vllm/mi355x-fp4-mtp/agentic.yaml | 20 +- .../glm5.2/sglang/mi355x-fp4-mtp/agentic.yaml | 61 + .../glm5.3/sglang/b300-fp4-mtp/agentic.yaml | 2 +- .../sglang/gb300-fp4-mtp/agentic.yaml | 71 + .../sglang/gb300-fp8-mtp/agentic.yaml | 112 +- .../kimik3.1/sglang/b300-fp4-mtp/agentic.yaml | 230 + .../sglang/mi355x-fp4-mtp/agentic.yaml | 193 + .../kimik3.1/vllm/b300-fp4-mtp/agentic.yaml | 182 + .../kimik3/atom/mi355x-fp4-mtp/agentic.yaml | 217 - .../kimik3/vllm/b300-fp4-mtp/agentic.yaml | 233 - .../kimik3/vllm/mi355x-fp4-mtp/agentic.yaml | 323 -- .../b300-fp4-mtp/agentic-tp2-spf-extra.yaml | 117 + .../b300-fp4-mtp/agentic-tp4-spf-extra.yaml | 95 + .../sglang/mi355x-fp4-mtp/agentic.yaml | 30 +- .../vllm/gb300-fp4-mtp/agentic.yaml | 129 +- .../vllm/h100-fp4-mtp/agentic.yaml | 2 +- .../vllm/h200-fp4-mtp/agentic.yaml | 2 +- .../vllm/mi355x-fp4/agentic.yaml | 38 +- .../sglang/b300-fp4-mtp/agentic.yaml | 133 +- .../sglang/gb300-fp4-mtp/agentic.yaml | 79 +- .../qwen3.5/atom/mi355x-fp4/8k1k.yaml | 40 - .../qwen3.5/atom/mi355x-fp8-mtp/8k1k.yaml | 43 - .../qwen3.5/atom/mi355x-fp8/8k1k.yaml | 47 - .../qwen3.5/sglang/b200-fp4-mtp/8k1k.yaml | 82 - .../qwen3.5/sglang/b200-fp4/8k1k.yaml | 59 - .../qwen3.5/sglang/b200-fp8-mtp/8k1k.yaml | 68 - .../qwen3.5/sglang/b200-fp8/8k1k.yaml | 64 - .../qwen3.5/sglang/b300-fp4-mtp/8k1k.yaml | 77 - .../qwen3.5/sglang/b300-fp4/8k1k.yaml | 73 - .../qwen3.5/sglang/b300-fp8-mtp/8k1k.yaml | 60 - .../qwen3.5/sglang/b300-fp8/8k1k.yaml | 55 - .../qwen3.5/sglang/h100-fp8-mtp/8k1k.yaml | 56 - .../qwen3.5/sglang/h100-fp8/8k1k.yaml | 63 - .../qwen3.5/sglang/h200-fp8-mtp/8k1k.yaml | 55 - .../qwen3.5/sglang/h200-fp8/8k1k.yaml | 50 - .../qwen3.5/sglang/mi300x-fp8/8k1k.yaml | 43 - .../qwen3.5/sglang/mi325x-fp8-mtp/8k1k.yaml | 47 - .../qwen3.5/sglang/mi325x-fp8/8k1k.yaml | 43 - .../qwen3.5/sglang/mi355x-fp4-mtp/8k1k.yaml | 62 - .../qwen3.5/sglang/mi355x-fp4/8k1k.yaml | 58 - .../qwen3.5/sglang/mi355x-fp8-mtp/8k1k.yaml | 54 - .../qwen3.5/sglang/mi355x-fp8/8k1k.yaml | 50 - .../qwen3.5/trtllm/b200-fp4-mtp/8k1k.yaml | 141 - .../qwen3.5/trtllm/b200-fp4/8k1k.yaml | 156 - .../sglang/b300-fp4-mtp/agentic.yaml | 208 +- .../sglang/mi355x-fp4-mtp/agentic.yaml | 45 +- .../benchmarks/single_node/srt_eval.sh | 5 + inferencex-e2e/benchmarks/srt_al_gate.sh | 12 + inferencex-e2e/configs/CONFIGS.md | 21 +- inferencex-e2e/configs/amd-master.yaml | 537 +-- inferencex-e2e/configs/ci-priority.yaml | 2 +- inferencex-e2e/configs/nvidia-master.yaml | 3744 ++++------------- inferencex-e2e/configs/runners.yaml | 28 +- .../configs/srt-recipes/telemetry-dcgm.yaml | 6 +- .../configs/srt-recipes/telemetry-native.yaml | 2 - inferencex-e2e/docs/MODELS.md | 41 +- inferencex-e2e/docs/PRINCIPLES.md | 3 +- inferencex-e2e/docs/agentx-standalone.md | 8 +- inferencex-e2e/infx/bench/__main__.py | 2 +- inferencex-e2e/infx/bench/agentic/replay.py | 15 +- .../infx/bench/agentic/requirements.txt | 2 +- inferencex-e2e/infx/bench/agentic/venv.py | 5 + inferencex-e2e/infx/bench/al_gate.py | 247 ++ inferencex-e2e/infx/bench/fixed_seq.py | 2 +- inferencex-e2e/infx/bench/speedbench.py | 89 + inferencex-e2e/infx/bench/speedbench_al.py | 346 -- .../infx/bench_serving/encoding_dsv41.py | 548 +++ inferencex-e2e/infx/clusters/slurm.py | 36 +- inferencex-e2e/infx/evals/EVALS.md | 50 +- .../infx/golden_al_distribution/README.md | 102 +- .../infx/golden_al_distribution/README_zh.md | 99 +- .../infx/golden_al_distribution/__init__.py | 8 +- .../infx/golden_al_distribution/__main__.py | 15 +- .../infx/golden_al_distribution/curves.py | 72 +- ...spark__native__probabilistic-default.yaml} | 11 +- ...v4-pro__mtp__native__default-default.yaml} | 14 +- ..._dspark__native__probabilistic-block.yaml} | 12 +- ....2-fp8__mtp__native__default-default.yaml} | 11 +- ...3-flash__mtp__native__default-default.yaml | 26 + ...3-nvfp4__mtp__native__default-default.yaml | 19 + .../golden_al_distribution/glm5.3_mtp.yaml | 19 - .../glm5.3flash_mtp.yaml | 13 - .../golden_al_distribution/identities.yaml | 233 + .../infx/golden_al_distribution/identity.py | 285 ++ ...i-k3.1-dflash2-preview__default-block.yaml | 28 + ...o-dspark-preview__probabilistic-block.yaml | 28 + ...ark__kimi-k3-dspark__default-default.yaml} | 11 +- ..._kimi-k3-dspark__probabilistic-block.yaml} | 11 +- ...kimi-k3-dspark__probabilistic-default.yaml | 41 + ..._dspark__native__probabilistic-block.yaml} | 9 +- ..._minimax-m3-eagle3-gqa__default-block.yaml | 42 + ...nimax-m3-eagle3-gqa__default-default.yaml} | 14 +- ...__minimax-m3-eagle3__default-default.yaml} | 14 +- ...-spark-open-dflash2__default-default.yaml} | 9 +- ...-nvfp4__mtp__native__default-default.yaml} | 14 +- ...xt-fp8__mtp__native__default-default.yaml} | 11 +- inferencex-e2e/infx/launch/artifacts.py | 27 +- .../infx/launch/drivers/srt/__init__.py | 49 +- .../infx/launch/drivers/srt/checkout.py | 12 +- .../infx/launch/drivers/srt/collect.py | 21 +- .../infx/launch/drivers/srt/config.py | 88 +- .../infx/launch/drivers/srt/lanes.py | 6 +- .../infx/launch/drivers/srt/models.py | 1 - .../infx/launch/drivers/srt/power.py | 121 +- .../infx/launch/drivers/srt/submit.py | 68 +- inferencex-e2e/infx/launch/event.py | 3 + inferencex-e2e/infx/launch/policy.py | 2 +- inferencex-e2e/infx/launch/request.py | 1 + inferencex-e2e/infx/matrix/fingerprint.py | 6 +- inferencex-e2e/infx/matrix/generate.py | 19 +- inferencex-e2e/infx/matrix/validation.py | 23 + .../infx/results/agentic/__init__.py | 3 +- .../infx/results/agentic/backends/__init__.py | 2 + .../results/agentic/backends/tokenspeed.py | 27 + .../infx/results/agentic/power_adapter.py | 4 + .../infx/results/agentic/spec_decode.py | 101 + inferencex-e2e/infx/results/fixed_sequence.py | 1 + inferencex-e2e/infx/results/metadata.py | 6 + inferencex-e2e/infx/results/power/__init__.py | 38 +- inferencex-e2e/infx/results/power/audit.py | 39 +- inferencex-e2e/infx/results/power/common.py | 40 +- inferencex-e2e/infx/results/power/cpu_side.py | 334 ++ .../infx/results/power/multinode.py | 189 +- inferencex-e2e/infx/srt_slurm/al_gate.py | 193 + inferencex-e2e/infx/srt_slurm/generate.py | 87 +- inferencex-e2e/infx/srt_slurm/single_node.py | 28 +- .../infx/srt_slurm/spec_identity.py | 222 + .../infx/srt_slurm/synthetic_acceptance.py | 132 +- inferencex-e2e/infx/srt_slurm/workload.py | 166 +- inferencex-e2e/infx/tests/bench/conftest.py | 10 +- .../infx/tests/bench/test_al_gate.py | 305 ++ .../infx/tests/bench/test_fixed_seq.py | 3 +- .../infx/tests/bench/test_speedbench.py | 95 + .../infx/tests/bench/test_speedbench_al.py | 151 - .../test_golden_al_distribution.py | 127 +- .../test_spec_identity.py | 178 + .../infx/tests/launch/fake_slurm.py | 7 +- .../tests/launch/test_launch_artifacts.py | 66 + .../infx/tests/launch/test_launch_policy.py | 115 +- .../tests/launch/test_srt_collect_staging.py | 2 +- .../infx/tests/launch/test_srt_config.py | 106 +- .../infx/tests/launch/test_srt_driver.py | 248 +- .../matrix/test_generate_sweep_configs.py | 50 + .../infx/tests/matrix/test_validation.py | 15 + .../results/agentic/test_power_adapter.py | 2 +- .../agentic/test_process_agentic_result.py | 43 + .../tests/results/agentic/test_spec_decode.py | 125 + .../results/power/test_aggregate_power.py | 1 + .../power/test_aggregate_power_multinode.py | 335 ++ .../infx/tests/srt_slurm/test_al_gate_plan.py | 219 + .../infx/tests/srt_slurm/test_generate.py | 112 +- .../tests/srt_slurm/test_setup_scripts.py | 21 + .../tests/srt_slurm/test_srt_single_node.py | 37 + .../srt_slurm/test_synthetic_acceptance.py | 210 +- .../infx/tests/srt_slurm/test_workload.py | 59 +- .../infx/tests/workflows/test_ci_priority.py | 24 - .../workflows/test_find_reusable_sweep_run.py | 2 +- .../tests/workflows/test_launch_layout.py | 4 +- .../infx/tests/workflows/test_pr_checklist.py | 44 + .../tests/workflows/test_signoff_facts.py | 4 +- inferencex-e2e/infx/workflows/ci_priority.py | 8 + inferencex-e2e/infx/workflows/pr_checklist.py | 42 + .../infx/workflows/signoff_facts.py | 16 +- inferencex-e2e/perf-changelog.yaml | 283 +- .../runners/srt-slurm/hooks/common.sh | 10 +- .../srt-slurm/hooks/mi355x-amds/setup.sh | 6 +- inferencex-e2e/utils/srt-slurm | 2 +- operatorx/perf-changelog.yaml | 9 + operatorx/testlists/moe_small.json | 15 - 271 files changed, 10977 insertions(+), 16807 deletions(-) create mode 100644 .github/actions/classify-priority/action.yml create mode 100644 .github/workflows/al-gate-prompts.yml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/configs/kimik3-atom-dspark-draft.sh delete mode 100755 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/configs/kimik3-b300-mooncake.sh delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/configs/kimik3-dspark-config-compat.sh create mode 100755 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/configs/tokenspeed-nightly.sh delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/kimik3/vllm/b200-fp4/agentx/agg-variants.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/kimik3/vllm/gb200-fp4/agentx/agg-dcp16-dspark4-maxseq2-mooncake.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/kimik3/vllm/gb200-fp4/agentx/agg-dcp16-nospec-mooncake.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/kimik3/vllm/gb200-fp4/agentx/agg-tp8pp2-variants.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/kimik3/vllm/gb200-fp4/agentx/agg-variants.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/kimik3/vllm/gb200-fp4/agentx/disagg-variants.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/kimik3/vllm/gb300-fp4/agentx/agg-megakernel-variants.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/kimik3/vllm/gb300-fp4/agentx/disagg-tp8-dep16.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/kimik3/vllm/gb300-fp4/agentx/disagg-variants.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/kimik3/vllm/h200-fp4/agentx/agg-tp16dp2ep32-latency.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/kimik3/vllm/h200-fp4/agentx/agg-tp8dp4ep32-balanced.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/kimik3/vllm/h200-fp4/agentx/agg-tp8dp4ep32-vllm-simple.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/kimik3/vllm/mi355x-fp4/agentx/disagg-variants.yaml create mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/minimaxm31flash/sglang/gb300-fp4/agentx/agg-variants.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/sglang/gb200-fp4-v2/8k1k/mtp/1p1d-tp4-tp4-c1.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/sglang/gb200-fp4-v2/8k1k/mtp/1p1d-tp4-tp4-c4.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/sglang/gb200-fp4-v2/8k1k/mtp/1p1d-tp4-tp4-c8.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/sglang/gb200-fp4-v2/8k1k/mtp/2p2d-tp2-tp2-c512.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/sglang/gb200-fp4-v2/8k1k/mtp/2p3d-tp2-tp2-c768.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/sglang/gb200-fp4-v2/8k1k/mtp/3p5d-tp4-tp4-c128.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/sglang/gb200-fp4-v2/8k1k/mtp/7p1d-dep4-dep16-c5120.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/sglang/gb200-fp4-v2/8k1k/mtp/7p1d-dep4-dep16-c8192.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/sglang/gb200-fp4-v2/8k1k/mtp/7p4d-dep4-dep4-c12288.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/sglang/gb200-fp8/8k1k/disagg-1p1d-p-tp4-d-tp4-b128-c1x2x8-mtp.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/sglang/gb200-fp8/8k1k/disagg-1p1d-p-tp8-ep8-d-tp8-ep8-b1024-c32x48x80-mtp.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/sglang/gb200-fp8/8k1k/disagg-1p1d-tp4-tp4-stp.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/sglang/gb200-fp8/8k1k/disagg-3p1d-p-tp4-ep4-dp4-d-tp16-ep16-dp16-b1024-c480-mtp.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/sglang/gb200-fp8/8k1k/disagg-4p1d-dep4-dep16-stp.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/sglang/gb200-fp8/8k1k/disagg-4p1d-p-tp4-ep4-dp4-d-tp16-ep16-dp16-b1024-c768-mtp.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/sglang/gb200-fp8/8k1k/disagg-6p1d-p-tp4-ep4-dp4-d-tp16-ep16-dp16-b1024-c1280-mtp.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/sglang/gb200-fp8/8k1k/disagg-7p1d-p-tp4-ep4-dp4-d-tp16-ep16-dp16-b2048-c1344-mtp.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/sglang/gb200-fp8/8k1k/disagg-8p1d-dep4-dep16-stp.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/sglang/gb200-fp8/8k1k/disagg-8p1d-p-tp4-ep4-dp4-d-tp16-ep16-dp16-b2048-c1920x2304-mtp.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/sglang/gb300-fp4/8k1k/disagg-1p1d-p-tp4-d-tp4-b128-c1x4x8x16x32x64x256-stp.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/sglang/gb300-fp4/8k1k/disagg-5p1d-p-tp4-ep4-dp4-d-tp16-ep16-dp16-b4096-c2048-stp.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/sglang/gb300-fp4/8k1k/disagg-6p1d-p-tp4-ep4-dp4-d-tp16-ep16-dp16-b5120-c5120-stp.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/sglang/gb300-fp4/8k1k/disagg-7p1d-p-tp4-ep4-dp4-d-tp16-ep16-dp16-b5120-c5120-stp.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/sglang/gb300-fp8/8k1k/disagg-1p1d-p-tp4-d-tp4-b1024-c1x2x8-mtp.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/sglang/gb300-fp8/8k1k/disagg-1p1d-p-tp8-ep8-d-tp8-ep8-b1024-c32x48x80-mtp.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/sglang/gb300-fp8/8k1k/disagg-1p1d-tp4-tp4-stp.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/sglang/gb300-fp8/8k1k/disagg-3p1d-p-tp4-ep4-dp4-d-tp16-ep16-dp16-b1024-c480-mtp.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/sglang/gb300-fp8/8k1k/disagg-4p1d-dep4-dep16-stp.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/sglang/gb300-fp8/8k1k/disagg-4p1d-p-tp4-ep4-dp4-d-tp16-ep16-dp16-b1024-c768-mtp.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/sglang/gb300-fp8/8k1k/disagg-6p1d-p-tp4-ep4-dp4-d-tp16-ep16-dp16-b1024-c1280-mtp.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/sglang/gb300-fp8/8k1k/disagg-7p1d-p-tp4-ep4-dp4-d-tp16-ep16-dp16-b2048-c1344-mtp.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/sglang/gb300-fp8/8k1k/disagg-8p1d-dep4-dep16-stp.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/sglang/gb300-fp8/8k1k/disagg-8p1d-p-tp4-ep4-dp4-d-tp16-ep16-dp16-b2048-c1920x2304-mtp.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/sglang/mi355x-fp4/8k1k/disagg-1p1d-tp8.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/sglang/mi355x-fp8/8k1k/disagg-1p1d-p-tp4-d-tp8.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/trtllm/gb300-fp4/8k1k/disagg-mtp-variants.yaml delete mode 100644 inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/qwen3.5/trtllm/gb300-fp4/8k1k/disagg-stp-variants.yaml create mode 100755 inferencex-e2e/benchmarks/single_node/speedbench/dsv4dspark_fp4_b300_vllm_probabilistic_sample_method_block_rejection_sample_method.sh create mode 100755 inferencex-e2e/benchmarks/single_node/speedbench/glm5.3_fp4_b300_sglang.sh delete mode 100644 inferencex-e2e/benchmarks/single_node/speedbench/glm53flash_fp8_b300_vllm.sh create mode 100755 inferencex-e2e/benchmarks/single_node/speedbench/kimik3.1_fp4_b300_sglang.sh create mode 100755 inferencex-e2e/benchmarks/single_node/speedbench/kimik3_fp4_b300_vllm_probabilistic_sample_method.sh create mode 100755 inferencex-e2e/benchmarks/single_node/speedbench/minimaxm3_fp4_b300_vllm_gqa_block_rejection_sample_method.sh create mode 100644 inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/glm5.3flash/sglang/gb300-fp4-mtp/agentic.yaml create mode 100644 inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/kimik3.1/sglang/b300-fp4-mtp/agentic.yaml create mode 100644 inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/kimik3.1/sglang/mi355x-fp4-mtp/agentic.yaml create mode 100644 inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/kimik3.1/vllm/b300-fp4-mtp/agentic.yaml delete mode 100644 inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/kimik3/atom/mi355x-fp4-mtp/agentic.yaml delete mode 100644 inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/kimik3/vllm/b300-fp4-mtp/agentic.yaml delete mode 100644 inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/kimik3/vllm/mi355x-fp4-mtp/agentic.yaml create mode 100644 inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/minimaxm31flash/sglang/b300-fp4-mtp/agentic-tp2-spf-extra.yaml create mode 100644 inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/minimaxm31flash/sglang/b300-fp4-mtp/agentic-tp4-spf-extra.yaml delete mode 100644 inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/qwen3.5/atom/mi355x-fp4/8k1k.yaml delete mode 100644 inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/qwen3.5/atom/mi355x-fp8-mtp/8k1k.yaml delete mode 100644 inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/qwen3.5/atom/mi355x-fp8/8k1k.yaml delete mode 100644 inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/qwen3.5/sglang/b200-fp4-mtp/8k1k.yaml delete mode 100644 inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/qwen3.5/sglang/b200-fp4/8k1k.yaml delete mode 100644 inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/qwen3.5/sglang/b200-fp8-mtp/8k1k.yaml delete mode 100644 inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/qwen3.5/sglang/b200-fp8/8k1k.yaml delete mode 100644 inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/qwen3.5/sglang/b300-fp4-mtp/8k1k.yaml delete mode 100644 inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/qwen3.5/sglang/b300-fp4/8k1k.yaml delete mode 100644 inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/qwen3.5/sglang/b300-fp8-mtp/8k1k.yaml delete mode 100644 inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/qwen3.5/sglang/b300-fp8/8k1k.yaml delete mode 100644 inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/qwen3.5/sglang/h100-fp8-mtp/8k1k.yaml delete mode 100644 inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/qwen3.5/sglang/h100-fp8/8k1k.yaml delete mode 100644 inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/qwen3.5/sglang/h200-fp8-mtp/8k1k.yaml delete mode 100644 inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/qwen3.5/sglang/h200-fp8/8k1k.yaml delete mode 100644 inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/qwen3.5/sglang/mi300x-fp8/8k1k.yaml delete mode 100644 inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/qwen3.5/sglang/mi325x-fp8-mtp/8k1k.yaml delete mode 100644 inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/qwen3.5/sglang/mi325x-fp8/8k1k.yaml delete mode 100644 inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/qwen3.5/sglang/mi355x-fp4-mtp/8k1k.yaml delete mode 100644 inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/qwen3.5/sglang/mi355x-fp4/8k1k.yaml delete mode 100644 inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/qwen3.5/sglang/mi355x-fp8-mtp/8k1k.yaml delete mode 100644 inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/qwen3.5/sglang/mi355x-fp8/8k1k.yaml delete mode 100644 inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/qwen3.5/trtllm/b200-fp4-mtp/8k1k.yaml delete mode 100644 inferencex-e2e/benchmarks/single_node/srt-slurm-recipes/qwen3.5/trtllm/b200-fp4/8k1k.yaml create mode 100644 inferencex-e2e/benchmarks/srt_al_gate.sh create mode 100644 inferencex-e2e/infx/bench/al_gate.py create mode 100644 inferencex-e2e/infx/bench/speedbench.py delete mode 100644 inferencex-e2e/infx/bench/speedbench_al.py create mode 100644 inferencex-e2e/infx/bench_serving/encoding_dsv41.py rename inferencex-e2e/infx/golden_al_distribution/{dsv4-pro-0813-dspark.yaml => deepseek-v4-pro-0813__dspark__native__probabilistic-default.yaml} (62%) rename inferencex-e2e/infx/golden_al_distribution/{dsv4_mtp.yaml => deepseek-v4-pro__mtp__native__default-default.yaml} (57%) rename inferencex-e2e/infx/golden_al_distribution/{dsv41flash_dspark.yaml => deepseek-v4.1-flash__dspark__native__probabilistic-block.yaml} (68%) rename inferencex-e2e/infx/golden_al_distribution/{glm5.2_mtp.yaml => glm-5.2-fp8__mtp__native__default-default.yaml} (61%) create mode 100644 inferencex-e2e/infx/golden_al_distribution/glm-5.3-flash__mtp__native__default-default.yaml create mode 100644 inferencex-e2e/infx/golden_al_distribution/glm-5.3-nvfp4__mtp__native__default-default.yaml delete mode 100644 inferencex-e2e/infx/golden_al_distribution/glm5.3_mtp.yaml delete mode 100644 inferencex-e2e/infx/golden_al_distribution/glm5.3flash_mtp.yaml create mode 100644 inferencex-e2e/infx/golden_al_distribution/identities.yaml create mode 100644 inferencex-e2e/infx/golden_al_distribution/identity.py create mode 100644 inferencex-e2e/infx/golden_al_distribution/kimi-k3.1-pro__dflash__kimi-k3.1-dflash2-preview__default-block.yaml create mode 100644 inferencex-e2e/infx/golden_al_distribution/kimi-k3.1-pro__dspark__kimi-k3.1-pro-dspark-preview__probabilistic-block.yaml rename inferencex-e2e/infx/golden_al_distribution/{kimik3_dspark.yaml => kimi-k3__dspark__kimi-k3-dspark__default-default.yaml} (59%) rename inferencex-e2e/infx/golden_al_distribution/{kimik3_dspark_probabilistic_sample_method_block_rejection_sample_method.yaml => kimi-k3__dspark__kimi-k3-dspark__probabilistic-block.yaml} (65%) create mode 100644 inferencex-e2e/infx/golden_al_distribution/kimi-k3__dspark__kimi-k3-dspark__probabilistic-default.yaml rename inferencex-e2e/infx/golden_al_distribution/{minimaxm31flash_dspark.yaml => minimax-m3.1-flash__dspark__native__probabilistic-block.yaml} (71%) create mode 100644 inferencex-e2e/infx/golden_al_distribution/minimax-m3__eagle3__minimax-m3-eagle3-gqa__default-block.yaml rename inferencex-e2e/infx/golden_al_distribution/{minimaxm3_eagle3_gqa.yaml => minimax-m3__eagle3__minimax-m3-eagle3-gqa__default-default.yaml} (57%) rename inferencex-e2e/infx/golden_al_distribution/{minimaxm3_eagle3.yaml => minimax-m3__eagle3__minimax-m3-eagle3__default-default.yaml} (57%) rename inferencex-e2e/infx/golden_al_distribution/{musespark_dflash.yaml => muse-spark-open-816b-a42b-nvfp4__dflash__muse-spark-open-dflash2__default-default.yaml} (74%) rename inferencex-e2e/infx/golden_al_distribution/{qwen3.5_mtp.yaml => qwen3.5-397b-a17b-nvfp4__mtp__native__default-default.yaml} (57%) rename inferencex-e2e/infx/golden_al_distribution/{qwen3.8next_mtp.yaml => qwen3.8-flash-next-fp8__mtp__native__default-default.yaml} (62%) create mode 100644 inferencex-e2e/infx/results/agentic/backends/tokenspeed.py create mode 100644 inferencex-e2e/infx/results/agentic/spec_decode.py create mode 100644 inferencex-e2e/infx/results/power/cpu_side.py create mode 100644 inferencex-e2e/infx/srt_slurm/al_gate.py create mode 100644 inferencex-e2e/infx/srt_slurm/spec_identity.py create mode 100644 inferencex-e2e/infx/tests/bench/test_al_gate.py create mode 100644 inferencex-e2e/infx/tests/bench/test_speedbench.py delete mode 100644 inferencex-e2e/infx/tests/bench/test_speedbench_al.py create mode 100644 inferencex-e2e/infx/tests/golden_al_distribution/test_spec_identity.py create mode 100644 inferencex-e2e/infx/tests/results/agentic/test_spec_decode.py create mode 100644 inferencex-e2e/infx/tests/srt_slurm/test_al_gate_plan.py create mode 100644 inferencex-e2e/infx/tests/workflows/test_pr_checklist.py create mode 100644 inferencex-e2e/infx/workflows/pr_checklist.py diff --git a/.github/AGENT_OPERATIONS.md b/.github/AGENT_OPERATIONS.md index 69f653c531..6a5cf1ce93 100644 --- a/.github/AGENT_OPERATIONS.md +++ b/.github/AGENT_OPERATIONS.md @@ -70,13 +70,21 @@ Eval selection, the `--no-evals` / `--evals-only` / `--all-evals` flags, and cha ## Power telemetry -Multinode srt-slurm results, and single-node fixed-sequence and AgentX results with a retained native telemetry package, may include `power_valid`, `avg_power_w`, `avg_total_gpu_power_w`, `total_gpu_energy_j`, and joules per query/input/output/total token. Invalid telemetry records `power_valid: 0` without energy metrics and fails only with `REQUIRE_POWER=1`. +Multinode srt-slurm results, and single-node fixed-sequence and AgentX results with a retained native telemetry package, may include `power_valid`, `avg_power_w`, `avg_total_gpu_power_w`, `total_gpu_energy_j`, and joules per query/input/output/total token. Invalid telemetry records `power_valid: 0` without energy metrics and fails only with `REQUIRE_POWER=1`, or always on AgentX lanes whose launcher validates power. Multinode disaggregated results add `prefill_gpu_energy_j`, `decode_gpu_energy_j`, `prefill_avg_power_w`, `decode_avg_power_w`, `prefill_joules_per_input_token`, and `decode_joules_per_output_token`. Role energy covers the full formal benchmark window, not kernel-level phases, and the role watts are that energy divided by the same window and by the role's GPU count. +A multinode master row with `cpu-power: true` beside `power: true` adds srt-slurm's CPU leg; every multinode benchmark point on a cluster that declares a CPU exporter binds the leg, and `cpu-power: true` makes it expected. The binder merges `telemetry.cpu_power_exporter` into the bound recipe from the cluster's `srt-slurm.cpu-power-exporter` record in `inferencex-e2e/configs/runners.yaml` (ACPI on `gb200-nv` and `gb300-nv`, RAPL on the AMD clusters), the same way it merges the GPU telemetry block, so recipe fragments and `additional-settings` never carry it; `make setup` pins the exporter release from `CPU_POWER_EXPORTER_RELEASE` in `infx/launch/drivers/srt/checkout.py` for jobs that bind it, and the workflow exports `CPU_POWER=1` once for both the launcher and the result processor. + +The package then carries `power/cpu/samples.csv` in srt-slurm's wide format (one row per scrape, host, and Grace socket; two sockets per worker host in the manifest topology), and the aggregate adds `cpu_power_valid`, `avg_cpu_socket_power_w`, `avg_total_cpu_power_w`, and `total_cpu_energy_j` from the `CPU:cpuSidePowerUsageW` series, plus `avg_total_cpu_rail_power_w` / `total_cpu_rail_energy_j` (ACPI `CPU Power Socket N`, cores only) and `avg_total_cpu_sysio_power_w` / `total_cpu_sysio_energy_j` (ACPI `SysIO Power Socket N`), each pair only when every socket reports that rail without gaps. The meters nest ([NVIDIA Grace power guide](https://docs.nvidia.com/dccpu/grace-perf-tuning-guide/power-thermals.html)): `Grace Power Socket N` is the whole socket (CPU rail, SysIO rail, LPDDR5X, and regulator loss) and the two rails are a breakdown inside it, so never add them to or subtract them from the total; a GB300 socket reads about 97 W while its rails sum to about 55 W. The DRAM rail is blank on the CI NVL72 firmware. srt-slurm v2.59.0+ writes NVML superchip module power as `module_w` (CPU samples schema v3) when its exporter can load NVML (the aarch64 glibc build, srt-slurm #609), and the aggregate then publishes `avg_total_module_power_w` / `total_module_energy_j` under the same every-socket, gap-free rule; the module includes both GPUs, so never add it to the CPU or GPU totals. Every series is integrated over the bound formal GPU window with the GPU leg's trapezoid, boundary interpolation, and 3.0 s gap limit; the sidecar's `cpu` block, mirrored as `power_audit.cpu`, records `sensor_kind: grace_socket`, `source`, socket and row counts, and reason codes. A valid GPU leg also publishes `workers[]`: one entry per worker per host (`role`, the manifest's `worker_idx`, `hosts` holding that one host, `num_gpus`, `avg_power_w`), so a worker spanning trays repeats its index once per host and can be modeled per tray; it is omitted when a GPU belongs to two workers. + +A native single-node fixed-sequence row with `cpu-power: true` adds the same leg. Single-node rows have no `power` field because their native points always measure GPU power; eval-only and acceptance-gate jobs bind no CPU exporter. RAPL clusters (`mi300x-amd`, `mi325x-amd`, `mi355x-amds`) read each AMD EPYC package as `CPU:packagePowerUsageW` and publish the three headline CPU keys with `sensor_kind: rapl_package` and no rail keys. RAPL `energy_uj` counters are root-only, so a job that binds a RAPL exporter adds a host-setup command that gives the job user each top-level counter and a teardown that returns them to root. A failed grant only warns, so the CPU leg is then judged by the verdict below; a cancelled job skips teardown, and a failed restore shows up only in the job's `logs/host_teardown_.out`. + +The CPU verdict is independent of `power_valid`: a failed leg records `cpu_power_valid: 0` with no CPU keys and leaves every GPU field unchanged. It fails the job only when power is required (`REQUIRE_POWER=1`, or on AgentX lanes whose launcher validates power, except multinode points whose row omits `power`) and the row set `cpu-power`; missing CPU artifacts are then a verdict (`cpu_artifacts_missing`) rather than silence. Other reason codes: `cpu_samples_missing`, `cpu_samples_header_mismatch`, `cpu_samples_malformed`, `cpu_socket_count_mismatch` (also when a host reports only the DCGM CPU rail), `cpu_sample_gap_exceeded`, `cpu_window_not_bracketed`, `cpu_window_unavailable`, and the shared `non_finite_power_metric`. + Every power result, valid or invalid, carries `power_metric_schema_version`. Version 2 defines each unprefixed `joules_per_*` field as whole-deployment GPU-board energy over the named denominator; role-scoped energy uses the explicit `prefill_*` / `decode_*` keys. Rows without the field predate the whole-deployment switch and their unprefixed joules are not comparable across topologies. -A multinode master row with `power: true` enables official energy collection through the shared telemetry fragment; native single-node throughput and AgentX jobs enable telemetry through their bound recipe. Native single-node jobs inherit the exporter image, labels and metrics from the cluster's `default_gpu_exporter` in `inferencex-e2e/configs/runners.yaml`: DCGM on NVIDIA, the `kind: custom` AMD device-metrics-exporter on AMD. Multi-node power rows keep their shared DCGM telemetry block. Eval-only jobs omit collection. The Git submodule pointer at `inferencex-e2e/utils/srt-slurm` is the source of truth for every srt-slurm job, including TileRT. CI derives `POWER_PRODUCER_SHA` from the launcher stamp. The aggregate-power and AgentX power tests validate telemetry and provenance. These local tests do not prove hardware power collection. Eligible `dynamo-sglang` dcgm-power lanes are validated. +A multinode master row with `power: true` enables official energy collection through the shared telemetry fragment. Every multinode benchmark point on a cluster with a GPU exporter measures GPU power, and CPU power where the cluster declares a CPU exporter, whether or not its row sets `power`; invalid telemetry then records `power_valid: 0` and keeps the result, while `power: true` keeps invalid AgentX power fatal. Native single-node throughput and AgentX jobs enable telemetry through their bound recipe. Native single-node jobs inherit the exporter image, labels and metrics from the cluster's `default_gpu_exporter` in `inferencex-e2e/configs/runners.yaml`: DCGM on NVIDIA, the `kind: custom` AMD device-metrics-exporter on AMD. Multi-node points take the shared DCGM telemetry block on a cluster with `power-exporter-port` and the cluster's `default_gpu_exporter` elsewhere. Eval-only jobs omit collection. The Git submodule pointer at `inferencex-e2e/utils/srt-slurm` is the source of truth for every srt-slurm job, including TileRT. CI derives `POWER_PRODUCER_SHA` from the launcher stamp. The aggregate-power and AgentX power tests validate telemetry and provenance. These local tests do not prove hardware power collection. Eligible `dynamo-sglang` dcgm-power lanes are validated. Power audit artifacts are named `power_audit_` and contain the `power_validation*.json` sidecars plus, for native telemetry jobs, `LOGS/power/` (`samples.csv` v3 with `temperature_c`, `manifest.json`, `windows/`). They are uploaded even when validation fails. diff --git a/.github/actions/classify-priority/action.yml b/.github/actions/classify-priority/action.yml new file mode 100644 index 0000000000..4fee106105 --- /dev/null +++ b/.github/actions/classify-priority/action.yml @@ -0,0 +1,174 @@ +name: Classify CI priority criteria +description: >- + Classify an unmerged diff into CI priority criteria. Emits no criteria when + head is already on the default branch, and falls back to patchwork when + classification fails. + +inputs: + head-ref: + description: Revision under test + required: true + base-ref: + description: Diff base; empty uses the merge base with the default branch + required: false + default: "" + scope: + description: "full returns every criterion; patchwork returns only the patchwork verdict" + required: false + default: full + pr-number: + description: Pull request whose description decides checklist-complete; empty never returns it + required: false + default: "" + anthropic-api-key: + description: Anthropic API key for the classifier + required: true + github-token: + description: GitHub token for claude-code-action and reading the PR description + required: true + +outputs: + criteria: + description: JSON array for ci_priority --criteria-json, or empty for unscoped scoring + value: ${{ steps.normalize.outputs.criteria }} + +runs: + using: composite + steps: + - name: Resolve classification range + id: range + shell: bash + env: + HEAD_REF: ${{ inputs.head-ref }} + BASE_REF: ${{ inputs.base-ref }} + SCOPE: ${{ inputs.scope }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + if [ "$SCOPE" != "full" ] && [ "$SCOPE" != "patchwork" ]; then + echo "::error::scope must be full or patchwork, got '$SCOPE'" >&2 + exit 1 + fi + head=$(git rev-parse --verify "${HEAD_REF}^{commit}") + base="$BASE_REF" + if [ -z "$base" ]; then + if [ "$(git rev-parse --is-shallow-repository)" = "true" ] || + ! git rev-parse --verify -q "origin/${DEFAULT_BRANCH}" >/dev/null; then + echo "::error::classify-priority needs a full-history checkout (fetch-depth: 0) with origin/${DEFAULT_BRANCH}" >&2 + exit 1 + fi + if git merge-base --is-ancestor "$head" "origin/${DEFAULT_BRANCH}"; then + echo "Head $head is already on ${DEFAULT_BRANCH}; skipping priority classification." + exit 0 + fi + base=$(git merge-base "$head" "origin/${DEFAULT_BRANCH}") + fi + { + echo "base=$base" + echo "head=$head" + } >> "$GITHUB_OUTPUT" + + - name: Load supported criteria + id: schema + if: steps.range.outputs.head != '' + shell: bash + env: + E2E_ROOT: ${{ github.action_path }}/../../../inferencex-e2e + run: | + criteria=$(env PYTHONPATH="$E2E_ROOT" \ + uv run --no-project --exclude-newer PT12H --python 3.12 --with pyyaml \ + python -P -m infx.workflows.ci_priority \ + --policy "$E2E_ROOT/configs/ci-priority.yaml" --list-criteria) + schema=$(jq -c 'map(select(. != "checklist-complete")) | {type:"object",properties:{criteria:{type:"array",items:{type:"string",enum:.},uniqueItems:true},reason:{type:"string"}},required:["criteria","reason"]}' <<<"$criteria") + echo "json=$schema" >> "$GITHUB_OUTPUT" + + - name: Check PR checklist + id: checklist + if: steps.range.outputs.head != '' && inputs.pr-number != '' + continue-on-error: true + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + PR_NUMBER: ${{ inputs.pr-number }} + E2E_ROOT: ${{ github.action_path }}/../../../inferencex-e2e + run: | + complete=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.body // ""' | + env PYTHONPATH="$E2E_ROOT" uv run --no-project --exclude-newer PT12H --python 3.12 \ + python -P -m infx.workflows.pr_checklist) + echo "complete=$complete" >> "$GITHUB_OUTPUT" + + - name: Install Claude Code 2.1.282 + id: claude_cli + if: steps.range.outputs.head != '' + shell: bash + run: | # zizmor: ignore[adhoc-packages] Claude CLI and its native packages are pinned to 2.1.282 + npm install --prefix "$RUNNER_TEMP/claude-code" --no-audit --no-fund @anthropic-ai/claude-code@2.1.282 + claude_cli="$RUNNER_TEMP/claude-code/node_modules/.bin/claude" + test "$("$claude_cli" --version)" = "2.1.282 (Claude Code)" + echo "path=$claude_cli" >> "$GITHUB_OUTPUT" + + - name: Classify priority criteria + id: classify + if: steps.range.outputs.head != '' + continue-on-error: true + uses: anthropics/claude-code-action@9171db3e57d6a3140a37ddc2ba92788584e0ead6 # v1.0.234 + with: + path_to_claude_code_executable: ${{ steps.claude_cli.outputs.path }} + github_token: ${{ inputs.github-token }} + anthropic_api_key: ${{ inputs.anthropic-api-key }} + track_progress: false + settings: | + {"fastMode": true} + claude_args: | + --model 'claude-opus-5-5' + --effort low + --max-turns 8 + --allowedTools "Read,Glob,Grep,Bash(git diff:*)" + --json-schema '${{ steps.schema.outputs.json }}' + prompt: | + Inspect the diff `git diff ${{ steps.range.outputs.base }}...${{ steps.range.outputs.head }}`. + + Return every matching priority criterion: + + - multi-node: separate prefill and decode workers + - agentic: agentic-coding or AgentX workload + - eval-only: evaluation without throughput measurement + - fp4: FP4 precision + - mtp, draft_model: speculative decoding method + - sglang, vllm, dynamo-sglang, dynamo-vllm: runtime framework + - model criterion: the config model-prefix itself (minimaxm31flash, not minimaxm3) + - patchwork: modified upstream engine or runtime source + + Patchwork includes patch files, git apply, source mutation, + monkey-patching, local engine builds, forks, or images + containing unmerged engine changes. + + Return a concise reason grounded in the diff. + + - name: Normalize priority classification + id: normalize + if: always() && steps.range.outputs.head != '' + shell: bash + env: + OUTPUT: ${{ steps.classify.outputs.structured_output || '{}' }} + SCOPE: ${{ inputs.scope }} + CHECKLIST_COMPLETE: ${{ steps.checklist.outputs.complete }} + run: | + if jq -e 'type == "object" and (.criteria | type == "array")' \ + >/dev/null <<<"$OUTPUT"; then + criteria=$(jq -c '.criteria' <<<"$OUTPUT") + else + criteria='["patchwork"]' + fi + criteria=$(jq -c 'map(select(. != "checklist-complete"))' <<<"$criteria") + if [ "$CHECKLIST_COMPLETE" = "true" ]; then + criteria=$(jq -c '. + ["checklist-complete"]' <<<"$criteria") + fi + if [ "$SCOPE" = "patchwork" ]; then + if jq -e 'index("patchwork")' >/dev/null <<<"$criteria"; then + criteria='["patchwork"]' + else + criteria='' + fi + fi + echo "criteria=$criteria" >> "$GITHUB_OUTPUT" + echo "Priority criteria: ${criteria:-}" diff --git a/.github/codeowner-signoff-verify-prompt.md b/.github/codeowner-signoff-verify-prompt.md index 868c764afe..fecab0f8ba 100644 --- a/.github/codeowner-signoff-verify-prompt.md +++ b/.github/codeowner-signoff-verify-prompt.md @@ -559,10 +559,14 @@ Verify BOTH: Name the config/script and line. - (b) AL VALUE MATCHES THE GOLDEN CURVE. Read the committed golden AL YAML for the model in `inferencex-e2e/infx/golden_al_distribution/` on the default-branch checkout. Examples include - `qwen3.5_mtp.yaml` and `minimaxm3_eagle3.yaml`. Confirm the pinned AL equals the golden value for that + `qwen3.5-397b-a17b-nvfp4__mtp__native__default-default.yaml` and `minimax-m3__eagle3__minimax-m3-eagle3__default-default.yaml`. Confirm the pinned AL equals the golden value for that model, thinking mode, and the config's `num_speculative_tokens` / MTP level (e.g. qwen3.5 thinking_on with 3 speculative tokens -> 3.39; `signoff_facts golden-al` returns - the expected value and setting). For TRT-LLM configs, compare + the expected value and setting; pass the generation role's verifier, as `identities.yaml` may select on it: + `--verify block` for vLLM `rejection_sample_method: block` or `synthetic` and SGLang + `speculative-use-block-verification`, `--verify relaxed` for TRT-LLM + `use_relaxed_acceptance_for_thinking`, `--verify standard` for vLLM, SGLang and TRT-LLM roles + that set none, and no `--verify` for ATOM and TileRT). For TRT-LLM configs, compare the pinned `TLLM_SPEC_DECODE_FORCE_NUM_ACCEPTED_TOKENS` value PLUS 1 against the golden AL (the env var excludes the bonus token). A submission may choose any supported draft length, but it may NOT substitute a different acceptance target. diff --git a/.github/workflows/README.md b/.github/workflows/README.md index a3b9d45166..befe37537b 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -162,7 +162,7 @@ test-config --config-keys dsr1-fp8-h200-sglang --evals-only --all-evals --config ## PR Sweep Labels -`run-sweep.yml` sweeps only same-repository PRs, including stacked PRs, that change +`run-sweep.yml` sweeps only same-repository PRs that change `inferencex-e2e/perf-changelog.yaml`, using the appended entries as the matrix. Apply exactly one primary label, normally `full-sweep-fail-fast`. Fork PRs use the [trusted dispatch](#trusted-external-fork-sweep-dispatch-poc). @@ -186,8 +186,16 @@ Labels authorize same-repository sweeps on draft or ready PRs. Marking ready does not dispatch a sweep. Changing a recognized primary or modifier label cancels the active sweep; another starts only if a primary label remains. To retrigger an already-labeled PR, remove and reapply its primary label. -Merge conflicts block sweeps. Queue priority comes from the PR diff and -review state, not priority labels. +Merge conflicts block sweeps. Queue priority comes from the PR diff and the +PR description's checklist, not priority labels. A PR gets a small bonus when +every item under its `Checklist` heading is checked, except conditional items +("If ...", "Before merging ...") that do not apply yet. The checklist is read +when the sweep starts, so later edits count from the next sweep. Trusted +external sweeps classify the approved PR diff the same way. Manual +`e2e-tests.yml` and `profile.yml` dispatches of a revision not yet on `main` +classify its diff from the merge +base only for patchwork, so engine-patching branches still run last. Klaud runs +and revisions already on `main` keep unclassified scoring. The canary is the lowest-concurrency non-eval candidate among single-node fixed-sequence 1k/1k, 8k/1k, or AgentX entries, falling back to multi-node AgentX. @@ -197,6 +205,10 @@ cancels siblings within each matrix family, not every independent family. Find the first actual failure before treating a cancelled run as a flake. Manual `e2e-tests.yml` has no canary and defaults `fail-fast` to false. +## Speculative acceptance gate + +AgentX throughput forces speculative acceptance to the committed golden acceptance length (AL). The acceptance gate, milestone M1 of [#1651](https://github.com/SemiAnalysisAI/InferenceX/issues/1651), measures each agentic speculative eval's real AL on the eval's own server, in the same Slurm job, before the eval runs. `run-sweep.yml` and `e2e-tests.yml` call [`al-gate-prompts.yml`](al-gate-prompts.yml) for the SPEED-Bench prompts before their agentic eval jobs, and `collect-evals.yml` tabulates the verdicts. The gate is report-only: a verdict, a skipped gate or a failed measurement never fails the eval or the run. See [the speculative acceptance gate](../../inferencex-e2e/infx/evals/EVALS.md#speculative-acceptance-gate) for how it is planned, run, and skipped. + ## Changelog Conflict Recovery Run from `inferencex-e2e/`. After fetching and merging `origin/main`, if the diff --git a/.github/workflows/al-gate-prompts.yml b/.github/workflows/al-gate-prompts.yml new file mode 100644 index 0000000000..86b72ecc35 --- /dev/null +++ b/.github/workflows/al-gate-prompts.yml @@ -0,0 +1,104 @@ +name: AL Gate Prompts Cache + +# Prepares the 80 SPEED-Bench coding prompts that agentic speculative evals replay before their +# eval, on the same server (inferencex-e2e/infx/evals/EVALS.md#speculative-acceptance-gate). +# Pushes to main and the weekly schedule keep the cache warm; sweeps call it with upload: true +# to hand the prompts to their eval jobs. Report-only: callers' evals run without the gate if it +# fails, and the gate records why. + +on: + push: + branches: + - main + paths: + - inferencex-e2e/infx/bench/speedbench.py + - .github/workflows/al-gate-prompts.yml + schedule: + - cron: '41 5 * * 1' + workflow_dispatch: + workflow_call: + inputs: + upload: + description: "Upload the prompts as the al_gate_dataset artifact" + type: boolean + default: false + secrets: + INFERENCEX_OFFICIAL_RO_HF_TOKEN: + required: false + +permissions: + contents: read + +jobs: + warm: + name: warm + runs-on: ubuntu-latest + # A sweep's evals wait for this job: a stall ends it and they run without the gate. + timeout-minutes: ${{ inputs.upload && 30 || 360 }} + continue-on-error: ${{ inputs.upload == true }} + # Cache warming runs one at a time; a sweep's call prepares its own prompts on a miss. + concurrency: + group: ${{ inputs.upload && format('al-gate-prompts-{0}-{1}', github.run_id, github.run_attempt) || 'al-gate-prompts' }} + cancel-in-progress: false + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + continue-on-error: ${{ inputs.upload == true }} # A sweep's evals run without the gate. + with: + persist-credentials: false + + - name: Restore cached prompts + continue-on-error: ${{ inputs.upload == true }} # A sweep's evals run without the gate. + id: cached + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ runner.temp }}/al_gate_dataset + key: al-gate-speedbench-${{ hashFiles('inferencex-e2e/infx/bench/speedbench.py') }} + lookup-only: ${{ !inputs.upload }} + + - name: Free hosted-runner disk for SPEED-Bench + continue-on-error: ${{ inputs.upload == true }} # A sweep's evals run without the gate. + if: ${{ steps.cached.outputs.cache-hit != 'true' }} + run: | + # The pinned resolver materializes about 35 GB of coding source datasets. + # These preinstalled SDKs are unused by this hosted Python-only job. + sudo rm -rf -- /usr/local/lib/android /usr/share/dotnet /opt/ghc \ + /usr/local/.ghcup /usr/local/swift /opt/hostedtoolcache/CodeQL + df -h / /mnt + + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + continue-on-error: ${{ inputs.upload == true }} # A sweep's evals run without the gate. + if: ${{ steps.cached.outputs.cache-hit != 'true' }} + with: + enable-cache: false + + - name: Prepare gate prompts + continue-on-error: ${{ inputs.upload == true }} # A sweep's evals run without the gate. + id: prepare + if: ${{ steps.cached.outputs.cache-hit != 'true' }} + env: + # Read-only Hugging Face credential; it only lifts anonymous Hub rate limits. + HF_TOKEN: ${{ secrets.INFERENCEX_OFFICIAL_RO_HF_TOKEN }} # zizmor: ignore[secrets-outside-env] + HF_HOME: /mnt/al-gate-hf-home # The ~35 GB of SPEED-Bench sources exceed the root disk's free space. + run: | + cd "$GITHUB_WORKSPACE/inferencex-e2e" + sudo install -d -o "$(id -u)" -g "$(id -g)" "$HF_HOME" + uv run --no-project --exclude-newer 2026-10-27T00:00:00Z --python 3.12 \ + --with datasets --with numpy --with pandas --with tiktoken \ + python -m infx.bench.speedbench --output-dir "$RUNNER_TEMP/al_gate_dataset" + + - name: Cache gate prompts + continue-on-error: ${{ inputs.upload == true }} # A sweep's evals run without the gate. + if: ${{ steps.prepare.outcome == 'success' }} + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ runner.temp }}/al_gate_dataset + key: al-gate-speedbench-${{ hashFiles('inferencex-e2e/infx/bench/speedbench.py') }} + + - name: Upload gate prompts + continue-on-error: ${{ inputs.upload == true }} # A sweep's evals run without the gate. + if: ${{ inputs.upload }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: al_gate_dataset + path: ${{ runner.temp }}/al_gate_dataset/speedbench_coding.jsonl + if-no-files-found: error diff --git a/.github/workflows/benchmark-multinode-tmpl.yml b/.github/workflows/benchmark-multinode-tmpl.yml index 9f61b9ecd7..fa011ff0ca 100644 --- a/.github/workflows/benchmark-multinode-tmpl.yml +++ b/.github/workflows/benchmark-multinode-tmpl.yml @@ -143,6 +143,8 @@ env: EVAL_CONC: ${{ inputs.eval-conc }} EVAL_LIMIT: ${{ inputs.eval-limit }} POWER: ${{ fromJSON(inputs.config).power && '1' || '0' }} + CPU_POWER: ${{ fromJSON(inputs.config)['cpu-power'] && '1' || '0' }} + AL_GATE_DATASET: ${{ inputs.eval-only && inputs.scenario-type == 'agentic-coding' && 'al_gate_dataset/speedbench_coding.jsonl' || '' }} REQUIRE_POWER: ${{ inputs.require-power && '1' || '0' }} POWER_PRODUCER_SHA: ${{ inputs.power-producer-sha }} SCENARIO_TYPE: ${{ inputs.scenario-type }} @@ -289,6 +291,14 @@ jobs: run: &launcher-cleanup | "$INFERENCEX_LAUNCH_PYTHON" -P -m infx.launch cleanup + - name: Download acceptance gate prompts + if: ${{ inputs.eval-only && inputs.scenario-type == 'agentic-coding' && env.SPEC_DECODING != 'none' }} + continue-on-error: true # Without them the eval runs alone and the gate records why. + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: al_gate_dataset + path: ${{ github.workspace }}/inferencex-e2e/al_gate_dataset + - name: Launch multi-node job script env: PREFILL_ADDITIONAL_SETTINGS: ${{ toJSON(fromJSON(inputs.config).prefill.additional-settings) }} @@ -537,6 +547,7 @@ jobs: path: | ${{ env.INFERENCEX_E2E_ROOT }}/meta_env.json ${{ env.INFERENCEX_E2E_ROOT }}/results*.json + ${{ env.INFERENCEX_E2E_ROOT }}/al_gate_*.json ${{ env.INFERENCEX_E2E_ROOT }}/*_report.json ${{ env.INFERENCEX_E2E_ROOT }}/*_results.jsonl ${{ env.INFERENCEX_E2E_ROOT }}/sample*.jsonl @@ -562,6 +573,7 @@ jobs: path: | ${{ env.INFERENCEX_E2E_ROOT }}/meta_env.json ${{ env.INFERENCEX_E2E_ROOT }}/results*.json + ${{ env.INFERENCEX_E2E_ROOT }}/al_gate_*.json ${{ env.INFERENCEX_E2E_ROOT }}/*_report.json ${{ env.INFERENCEX_E2E_ROOT }}/*_results.jsonl ${{ env.INFERENCEX_E2E_ROOT }}/sample*.jsonl @@ -585,6 +597,7 @@ jobs: path: | ${{ env.INFERENCEX_E2E_ROOT }}/meta_env.json ${{ env.INFERENCEX_E2E_ROOT }}/results*.json + ${{ env.INFERENCEX_E2E_ROOT }}/al_gate_*.json ${{ env.INFERENCEX_E2E_ROOT }}/*_report.json ${{ env.INFERENCEX_E2E_ROOT }}/*_results.jsonl ${{ env.INFERENCEX_E2E_ROOT }}/sample*.jsonl @@ -611,6 +624,7 @@ jobs: if [[ -f inferencex-e2e/configs/runners.yaml ]]; then cd inferencex-e2e; fi rm -f meta_env.json || true rm -f results*.json || true + rm -f al_gate_*.json || true rm -f -- ./*_report.json || true rm -f -- ./*_results.jsonl || true rm -f -- ./*_artifacts.tar.gz || true diff --git a/.github/workflows/benchmark-tmpl.yml b/.github/workflows/benchmark-tmpl.yml index adb39e1962..5b8d8bf862 100644 --- a/.github/workflows/benchmark-tmpl.yml +++ b/.github/workflows/benchmark-tmpl.yml @@ -98,6 +98,7 @@ env: SALLOC_TIME_LIMIT: '480' IS_MULTINODE: 'false' REQUIRE_POWER: ${{ inputs.require-power && '1' || '0' }} + CPU_POWER: ${{ fromJSON(inputs.config)['cpu-power'] && '1' || '0' }} RANDOM_RANGE_RATIO: 0.8 HF_TOKEN: ${{ secrets.INFERENCEX_OFFICIAL_RO_HF_TOKEN }} HF_HUB_CACHE: '/mnt/hf_hub_cache/' @@ -137,6 +138,7 @@ env: DURATION: ${{ inputs.duration }} AIPERF_EXPERIMENTAL_FAST: ${{ inputs.agentx-fast && '1' || '0' }} EVAL_LIMIT: ${{ inputs.eval-limit }} + AL_GATE_DATASET: ${{ inputs.eval-only && inputs.scenario-type == 'agentic-coding' && 'al_gate_dataset/speedbench_coding.jsonl' || '' }} AIPERF_FAILED_REQUEST_THRESHOLD: '0.10' RESULT_DIR: /workspace/results PYTHONDONTWRITEBYTECODE: '1' @@ -257,6 +259,14 @@ jobs: run: &launcher-cleanup | "$INFERENCEX_LAUNCH_PYTHON" -P -m infx.launch cleanup + - name: Download acceptance gate prompts + if: ${{ inputs.eval-only && inputs.scenario-type == 'agentic-coding' && env.SPEC_DECODING != 'none' }} + continue-on-error: true # Without them the eval runs alone and the gate records why. + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: al_gate_dataset + path: ${{ github.workspace }}/inferencex-e2e/al_gate_dataset + - name: Launch job script env: RUNNER_NAME: ${{ runner.name }} @@ -500,6 +510,7 @@ jobs: path: | ${{ env.INFERENCEX_E2E_ROOT }}/meta_env.json ${{ env.INFERENCEX_E2E_ROOT }}/results*.json + ${{ env.INFERENCEX_E2E_ROOT }}/al_gate_*.json ${{ env.INFERENCEX_E2E_ROOT }}/*_report.json ${{ env.INFERENCEX_E2E_ROOT }}/*_results.jsonl ${{ env.INFERENCEX_E2E_ROOT }}/*_artifacts.tar.gz @@ -521,6 +532,7 @@ jobs: path: | ${{ env.INFERENCEX_E2E_ROOT }}/meta_env.json ${{ env.INFERENCEX_E2E_ROOT }}/results*.json + ${{ env.INFERENCEX_E2E_ROOT }}/al_gate_*.json ${{ env.INFERENCEX_E2E_ROOT }}/*_report.json ${{ env.INFERENCEX_E2E_ROOT }}/*_results.jsonl ${{ env.INFERENCEX_E2E_ROOT }}/*_artifacts.tar.gz @@ -544,6 +556,7 @@ jobs: path: | ${{ env.INFERENCEX_E2E_ROOT }}/meta_env.json ${{ env.INFERENCEX_E2E_ROOT }}/results*.json + ${{ env.INFERENCEX_E2E_ROOT }}/al_gate_*.json ${{ env.INFERENCEX_E2E_ROOT }}/*_report.json ${{ env.INFERENCEX_E2E_ROOT }}/*_results.jsonl ${{ env.INFERENCEX_E2E_ROOT }}/*_artifacts.tar.gz @@ -570,6 +583,7 @@ jobs: if [[ -f inferencex-e2e/configs/runners.yaml ]]; then cd inferencex-e2e; fi rm -f meta_env.json || true rm -f results*.json || true + rm -f al_gate_*.json || true rm -f -- ./*_report.json || true rm -f -- ./*_results.jsonl || true rm -f sample*.jsonl || true diff --git a/.github/workflows/collect-evals.yml b/.github/workflows/collect-evals.yml index 3b3512a64c..9e2c284c43 100644 --- a/.github/workflows/collect-evals.yml +++ b/.github/workflows/collect-evals.yml @@ -58,6 +58,12 @@ jobs: uv run --project "$INFERENCEX_PYTHON_PROJECT" --locked --extra results \ python -m infx.results.collect_eval_results eval_results/ "${RESULT_PREFIX}" >> $GITHUB_STEP_SUMMARY + - name: Summarize acceptance gates + continue-on-error: true # Report-only. + run: | + uv run --project "$INFERENCEX_PYTHON_PROJECT" --locked \ + python -m infx.srt_slurm.al_gate summarize eval_results/ >> "$GITHUB_STEP_SUMMARY" + - name: Upload aggregated evals uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index e80395e5f3..22fb27f796 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -99,6 +99,8 @@ on: # zizmor: ignore[concurrency-limits] secrets: INFERENCEX_OFFICIAL_RO_HF_TOKEN: required: true + ANTHROPIC_API_KEY: + required: false inputs: generate-cli-command: description: "Command passed to generate matrix script" @@ -196,6 +198,9 @@ jobs: get-jobs: name: get-jobs runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read # Read the external PR description for checklist-complete. outputs: benchmark-ref: ${{ steps.checkout-ref.outputs.commit || steps.checkout-default.outputs.commit }} tooling-ref: ${{ steps.tooling.outputs.result }} @@ -223,6 +228,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.sha }} + fetch-depth: 0 persist-credentials: false - name: Resolve workflow tooling id: tooling @@ -249,6 +255,17 @@ jobs: persist-credentials: false - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + - name: Classify priority criteria + id: priority-criteria + if: vars.PRIORITY_SCHEDULER_ENABLED == 'true' && !inputs.klaud-run + uses: $/.github/actions/classify-priority + with: + base-ref: ${{ inputs.changelog-base-ref }} + head-ref: ${{ inputs.changelog-head-ref || 'HEAD' }} + scope: ${{ inputs.changelog-head-ref != '' && 'full' || 'patchwork' }} + pr-number: ${{ inputs.source-pr-number }} + anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }} # zizmor: ignore[secrets-outside-env] + github-token: ${{ github.token }} - id: get-jobs env: GENERATE_COMMAND: ${{ inputs.generate-cli-command || github.event.inputs.generate-cli-command }} @@ -257,6 +274,7 @@ jobs: TRIM_CONC: ${{ inputs.trim-conc }} ALL_EVALS: ${{ inputs.all-evals }} EVALS_ONLY: ${{ inputs.evals-only }} + PRIORITY_CRITERIA: ${{ steps.priority-criteria.outputs.criteria }} run: | if [[ -f inferencex-e2e/configs/runners.yaml ]]; then cd inferencex-e2e; fi MEASURED_ROOT="$PWD" @@ -322,7 +340,8 @@ jobs: python -P -m infx.workflows.ci_priority \ --policy "${PRIORITY_ROOT}/configs/ci-priority.yaml" \ --event-name "${GITHUB_EVENT_NAME}" \ - --queue-namespace "${GITHUB_RUN_ID}:${GITHUB_RUN_ATTEMPT}:${family}" + --queue-namespace "${GITHUB_RUN_ID}:${GITHUB_RUN_ATTEMPT}:${family}" \ + --criteria-json "$PRIORITY_CRITERIA" } AGENTIC=$(echo "$CONFIG_JSON" | python3 -c "import sys,json; d=json.load(sys.stdin); print(json.dumps([x for x in d if x.get('scenario-type') == 'agentic-coding' and 'prefill' not in x and not x.get('eval-only', False)]))" | score_matrix agentic) AGENTIC_EVAL=$(echo "$CONFIG_JSON" | python3 -c "import sys,json; d=json.load(sys.stdin); print(json.dumps([x for x in d if x.get('scenario-type') == 'agentic-coding' and 'prefill' not in x and x.get('run-eval', False)]))" | score_matrix agentic-eval) @@ -465,9 +484,24 @@ jobs: ref: ${{ needs.get-jobs.outputs.benchmark-ref }} test-name: ${{ inputs.test-name }} - test-sweep-agentic-evals: + # Prompts for the report-only acceptance gate that agentic speculative evals run before their eval. + al-gate-prompts: needs: get-jobs - if: ${{ needs.get-jobs.outputs.agentic-eval-config != '' && needs.get-jobs.outputs.agentic-eval-config != '[]' }} + if: >- + ${{ + (needs.get-jobs.outputs.agentic-eval-config != '' && needs.get-jobs.outputs.agentic-eval-config != '[]') || + (needs.get-jobs.outputs.multi-node-agentic-eval-config != '' && needs.get-jobs.outputs.multi-node-agentic-eval-config != '[]') + }} + uses: $/.github/workflows/al-gate-prompts.yml + secrets: + INFERENCEX_OFFICIAL_RO_HF_TOKEN: ${{ secrets.INFERENCEX_OFFICIAL_RO_HF_TOKEN }} + with: + upload: true + + test-sweep-agentic-evals: + needs: [get-jobs, al-gate-prompts] + # The acceptance gate is report-only: the evals run whether or not its prompts were prepared. + if: ${{ !cancelled() && needs.get-jobs.result == 'success' && needs.get-jobs.outputs.agentic-eval-config != '' && needs.get-jobs.outputs.agentic-eval-config != '[]' }} uses: $/.github/workflows/benchmark-tmpl.yml name: agentic eval / strategy: @@ -524,8 +558,8 @@ jobs: test-name: ${{ inputs.test-name }} test-sweep-multi-node-agentic-evals: - needs: get-jobs - if: ${{ needs.get-jobs.outputs.multi-node-agentic-eval-config != '' && needs.get-jobs.outputs.multi-node-agentic-eval-config != '[]' }} + needs: [get-jobs, al-gate-prompts] + if: ${{ !cancelled() && needs.get-jobs.result == 'success' && needs.get-jobs.outputs.multi-node-agentic-eval-config != '' && needs.get-jobs.outputs.multi-node-agentic-eval-config != '[]' }} uses: $/.github/workflows/benchmark-multinode-tmpl.yml name: multi-node agentic eval / strategy: diff --git a/.github/workflows/profile.yml b/.github/workflows/profile.yml index ce9feb53bb..55bb372554 100644 --- a/.github/workflows/profile.yml +++ b/.github/workflows/profile.yml @@ -65,6 +65,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.ref || github.sha }} + fetch-depth: 0 persist-credentials: false - name: Checkout priority scheduler tooling if: ${{ inputs.ref && inputs.ref != '' }} @@ -75,6 +76,15 @@ jobs: persist-credentials: false - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + - name: Classify priority criteria + id: priority-criteria + if: vars.PRIORITY_SCHEDULER_ENABLED == 'true' && !inputs.moe-debug + uses: $/.github/actions/classify-priority + with: + head-ref: HEAD + scope: patchwork + anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }} # zizmor: ignore[secrets-outside-env] + github-token: ${{ github.token }} - id: gen name: Generate matrix via script run: | @@ -113,7 +123,7 @@ jobs: echo "raw=$CONFIG_JSON" >> "$GITHUB_OUTPUT" env: - PRIORITY_CRITERIA: ${{ inputs.moe-debug && '["patchwork"]' || '' }} + PRIORITY_CRITERIA: ${{ inputs.moe-debug && '["patchwork"]' || steps.priority-criteria.outputs.criteria || '' }} INPUTS_CONFIG_FILE: ${{ inputs.config-file }} INPUTS_CONFIG_KEY: ${{ inputs.config-key }} INPUTS_CONC: ${{ inputs.conc }} diff --git a/.github/workflows/run-sweep.yml b/.github/workflows/run-sweep.yml index acf9c1995c..6bad8a4c09 100644 --- a/.github/workflows/run-sweep.yml +++ b/.github/workflows/run-sweep.yml @@ -18,6 +18,8 @@ concurrency: on: pull_request: + branches: + - main types: - synchronize - labeled @@ -191,6 +193,8 @@ jobs: outputs: tooling-ref: ${{ steps.tooling.outputs.result }} search-space-config: ${{ steps.setup.outputs.search-space-config }} + # Empty when the priority scheduler is disabled, as for setup's own scoring. + priority-criteria: ${{ steps.priority-criteria.outputs.criteria }} steps: - name: Checkout code id: checkout @@ -216,67 +220,16 @@ jobs: ]).write(); return data.sha; - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - - name: Install Claude Code 2.1.282 - id: claude_cli - if: vars.PRIORITY_SCHEDULER_ENABLED == 'true' - run: | # zizmor: ignore[adhoc-packages] Claude CLI and its native packages are pinned to 2.1.282 - npm install --prefix "$RUNNER_TEMP/claude-code" --no-audit --no-fund @anthropic-ai/claude-code@2.1.282 - claude_cli="$RUNNER_TEMP/claude-code/node_modules/.bin/claude" - test "$("$claude_cli" --version)" = "2.1.282 (Claude Code)" - echo "path=$claude_cli" >> "$GITHUB_OUTPUT" - name: Classify priority criteria - id: classify + id: priority-criteria if: vars.PRIORITY_SCHEDULER_ENABLED == 'true' - continue-on-error: true - uses: anthropics/claude-code-action@9171db3e57d6a3140a37ddc2ba92788584e0ead6 # v1.0.234 + uses: $/.github/actions/classify-priority with: - path_to_claude_code_executable: ${{ steps.claude_cli.outputs.path }} - github_token: ${{ github.token }} - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # zizmor: ignore[secrets-outside-env] - track_progress: false - settings: | - {"fastMode": true} - claude_args: | - --model 'claude-opus-5-5' - --effort low - --max-turns 8 - --allowedTools "Read,Glob,Grep,Bash(git diff:*)" - --json-schema '{"type":"object","properties":{"criteria":{"type":"array","items":{"type":"string","enum":["multi-node","agentic","eval-only","fp4","mtp","draft_model","sglang","vllm","dynamo-sglang","dynamo-vllm","musespark","dsv41flash","kimik3","glm5.3","glm5.2","dsv4","minimaxm31flash","minimaxm3","qwen3.5","qwen3.8next","dsr1","checklist-complete","patchwork"]},"uniqueItems":true},"reason":{"type":"string"}},"required":["criteria","reason"]}' - prompt: | - Inspect this pull request's diff against its base branch. - - Return every matching priority criterion: - - - multi-node: separate prefill and decode workers - - agentic: agentic-coding or AgentX workload - - eval-only: evaluation without throughput measurement - - fp4: FP4 precision - - mtp, draft_model: speculative decoding method - - sglang, vllm, dynamo-sglang, dynamo-vllm: runtime framework - - model criterion: the config model-prefix itself (minimaxm31flash, not minimaxm3) - - checklist-complete: PR checklist is satisfied - - patchwork: modified upstream engine or runtime source - - Patchwork includes patch files, git apply, source mutation, - monkey-patching, local engine builds, forks, or images - containing unmerged engine changes. - - Return a concise reason grounded in the diff. - - - name: Normalize priority classification - id: priority-criteria - if: vars.PRIORITY_SCHEDULER_ENABLED == 'true' && always() - env: - OUTPUT: ${{ steps.classify.outputs.structured_output || '{}' }} - run: | - if jq -e 'type == "object" and (.criteria | type == "array")' \ - >/dev/null <<<"$OUTPUT"; then - criteria=$(jq -c '.criteria' <<<"$OUTPUT") - else - criteria='["patchwork"]' - fi - echo "criteria=$criteria" >> "$GITHUB_OUTPUT" - echo "Priority criteria: $criteria" + base-ref: origin/${{ github.base_ref }} + head-ref: ${{ github.event.pull_request.head.sha }} + pr-number: ${{ github.event.pull_request.number }} + anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }} # zizmor: ignore[secrets-outside-env] + github-token: ${{ github.token }} - id: setup env: @@ -316,11 +269,14 @@ jobs: name: canary-select needs: setup if: >- - !cancelled() && needs.setup.result == 'success' && - ( - contains(github.event.pull_request.labels.*.name, 'full-sweep-enabled') || - contains(github.event.pull_request.labels.*.name, 'full-sweep-fail-fast') - ) + ${{ + !cancelled() && + needs.setup.result == 'success' && + ( + contains(github.event.pull_request.labels.*.name, 'full-sweep-enabled') || + contains(github.event.pull_request.labels.*.name, 'full-sweep-fail-fast') + ) + }} runs-on: ubuntu-latest outputs: canary-config: ${{ steps.pick.outputs.canary-config }} @@ -385,6 +341,7 @@ jobs: queue-token: ${{ matrix.config['queue-token'] }} dp-attn: ${{ matrix.config.dp-attn }} run-eval: false + require-power: ${{ matrix.config['require-power'] == true }} scenario-type: ${{ matrix.config['scenario-type'] || 'fixed-seq-len' }} duration: ${{ matrix.config.duration || '3600' }} agentx-fast: ${{ contains(github.event.pull_request.labels.*.name, 'agentx-fast') }} @@ -412,9 +369,29 @@ jobs: conc: ${{ matrix.config.conc[0] }} duration: ${{ matrix.config.duration }} run-eval: false + require-power: ${{ matrix.config['require-power'] == true }} scenario-type: agentic-coding agentx-fast: ${{ contains(github.event.pull_request.labels.*.name, 'agentx-fast') }} + # Prompts for the report-only acceptance gate that agentic speculative evals run before their + # eval, on the same server (inferencex-e2e/infx/evals/EVALS.md#speculative-acceptance-gate). + al-gate-prompts: + needs: setup + if: >- + ${{ + !cancelled() && + needs.setup.result == 'success' && + ( + (toJson(fromJson(needs.setup.outputs.search-space-config).agentic_evals) != '[]' && toJson(fromJson(needs.setup.outputs.search-space-config).agentic_evals) != 'null') || + (toJson(fromJson(needs.setup.outputs.search-space-config).multinode_agentic_evals) != '[]' && toJson(fromJson(needs.setup.outputs.search-space-config).multinode_agentic_evals) != 'null') + ) + }} + uses: $/.github/workflows/al-gate-prompts.yml + secrets: + INFERENCEX_OFFICIAL_RO_HF_TOKEN: ${{ secrets.INFERENCEX_OFFICIAL_RO_HF_TOKEN }} + with: + upload: true + sweep-multi-node-1k1k: needs: [setup, canary-select, canary-sweep, canary-multi-node-sweep] if: >- @@ -544,6 +521,7 @@ jobs: dp-attn: ${{ matrix.config.dp-attn }} duration: ${{ matrix.config.duration }} run-eval: false + require-power: ${{ matrix.config['require-power'] == true }} scenario-type: agentic-coding agentx-fast: ${{ contains(github.event.pull_request.labels.*.name, 'agentx-fast') }} @@ -578,6 +556,7 @@ jobs: conc: ${{ matrix.config.conc[0] }} duration: ${{ matrix.config.duration }} run-eval: false + require-power: ${{ matrix.config['require-power'] == true }} scenario-type: agentic-coding agentx-fast: ${{ contains(github.event.pull_request.labels.*.name, 'agentx-fast') }} @@ -614,7 +593,8 @@ jobs: eval-only: true sweep-agentic-evals: - needs: [setup, canary-select, canary-sweep, canary-multi-node-sweep] + # The acceptance gate is report-only: the evals run whether or not its prompts were prepared. + needs: [setup, canary-select, canary-sweep, canary-multi-node-sweep, al-gate-prompts] if: >- ${{ !cancelled() && @@ -682,7 +662,8 @@ jobs: eval-conc: ${{ (matrix.config['eval-framework'] || 'lm-eval') == 'lm-eval' && matrix.config['eval-all-concs'] && join(matrix.config.conc, ' ') || matrix.config['eval-conc'] }} sweep-multi-node-agentic-evals: - needs: [setup, canary-select, canary-sweep, canary-multi-node-sweep] + # The acceptance gate is report-only: the evals run whether or not its prompts were prepared. + needs: [setup, canary-select, canary-sweep, canary-multi-node-sweep, al-gate-prompts] if: >- ${{ !cancelled() && diff --git a/.github/workflows/speedbench-al.yml b/.github/workflows/speedbench-al.yml index 16936d196d..ca0b0f3a36 100644 --- a/.github/workflows/speedbench-al.yml +++ b/.github/workflows/speedbench-al.yml @@ -19,19 +19,13 @@ on: # zizmor: ignore[concurrency-limits] type: string default: 'dsv4' framework: - description: "Serving framework; selects the __b300_[_].sh collector" + description: "Serving framework; selects the _fp4_b300_[_].sh collector" required: false type: choice options: [vllm, sglang] default: 'vllm' - precision: - description: "Checkpoint precision; selects the collector and queue metadata" - required: false - type: choice - options: [fp4, fp8, bf16] - default: 'fp4' collector-variant: - description: "Collector variant suffix; non-empty selects __b300__.sh" + description: "Collector variant suffix; non-empty selects _fp4_b300__.sh (e.g. probabilistic_sample_method_block_rejection_sample_method)" required: false type: string default: '' @@ -76,7 +70,7 @@ on: # zizmor: ignore[concurrency-limits] type: boolean default: false golden-curve: - description: "Golden curve file stem that open-pr writes, e.g. dsv4_mtp or kimik3_dspark (required with open-pr)" + description: "Golden curve file stem that open-pr writes, e.g. deepseek-v4-pro__mtp__native__default-default or kimi-k3__dspark__kimi-k3-dspark__default-default (required with open-pr)" required: false type: string default: '' @@ -103,7 +97,7 @@ env: HF_HUB_CACHE: '/mnt/hf_hub_cache/' MODEL: ${{ inputs.model }} MODEL_PREFIX: ${{ inputs.model-prefix }} - PRECISION: ${{ inputs.precision }} + PRECISION: fp4 FRAMEWORK: ${{ inputs.framework }} EXP_NAME: ${{ inputs.model-prefix }}_speedbench IMAGE: ${{ inputs.image }} @@ -134,8 +128,8 @@ jobs: env: GOLDEN_CURVE: ${{ inputs.golden-curve }} run: | - if [[ ! "$GOLDEN_CURVE" =~ ^[a-z0-9_.-]+$ ]]; then - echo "::error::open-pr needs golden-curve, a curve file stem such as dsv4_mtp (got '$GOLDEN_CURVE')." >&2 + if [[ ! "$GOLDEN_CURVE" =~ ^[a-z0-9_.-]+$ || "$GOLDEN_CURVE" == identities ]]; then + echo "::error::open-pr needs golden-curve, a curve file stem such as deepseek-v4-pro__mtp__native__default-default (got '$GOLDEN_CURVE')." >&2 exit 1 fi - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -161,12 +155,11 @@ jobs: --arg runner "$RUNNER" \ --arg model_prefix "$MODEL_PREFIX" \ --arg framework "$FRAMEWORK" \ - --arg precision "$PRECISION" \ '[{ runner: $runner, framework: $framework, "model-prefix": $model_prefix, - precision: $precision, + precision: "fp4", "spec-decoding": "mtp" }]') scored=$(printf '%s' "$entry" | @@ -257,7 +250,7 @@ jobs: run: | if [[ -f inferencex-e2e/configs/runners.yaml ]]; then cd inferencex-e2e; fi collector_dir=benchmarks/single_node/speedbench - collector="${MODEL_PREFIX}_${PRECISION}_b300_${FRAMEWORK}" + collector="${MODEL_PREFIX}_fp4_b300_${FRAMEWORK}" if [[ -n "$COLLECTOR_VARIANT" ]]; then collector+="_${COLLECTOR_VARIANT}" fi @@ -314,6 +307,10 @@ jobs: echo "AL collection failed: speedbench-reference-al.yaml not produced." >&2 exit 1 fi + if ! grep -qE '^ +[0-9]+: [0-9]' speedbench-reference-al.yaml; then + echo "AL collection failed: no cell was measured (every value is N/A)." >&2 + exit 1 + fi echo "### SpeedBench AL matrix" >> "$GITHUB_STEP_SUMMARY" echo '```yaml' >> "$GITHUB_STEP_SUMMARY" cat speedbench-reference-al.yaml >> "$GITHUB_STEP_SUMMARY" @@ -339,9 +336,14 @@ jobs: check_env_vars GOLDEN_CURVE INFERENCEX_LAUNCH_PYTHON BENCH_SCRIPT_OVERRIDE run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" curve_file="infx/golden_al_distribution/${GOLDEN_CURVE}.yaml" + collection="" + if [[ -f "$curve_file" ]]; then + collection=$(PYTHONPATH="$PWD" "$INFERENCEX_LAUNCH_PYTHON" -P -c 'import sys, yaml; from infx.golden_al_distribution import load_curve; c = load_curve(sys.argv[1]).collection; print(yaml.safe_dump({"collection": c}, sort_keys=False) if c else "", end="")' "$GOLDEN_CURVE") + fi { - echo "# Source GitHub Actions run: ${run_url}" + echo "source_run: ${run_url}" cat speedbench-reference-al.yaml + if [[ -n "$collection" ]]; then printf '\n%s\n' "$collection"; fi } > "$curve_file" PYTHONPATH="$PWD" "$INFERENCEX_LAUNCH_PYTHON" -P -m infx.golden_al_distribution show "$GOLDEN_CURVE" @@ -358,7 +360,7 @@ jobs: git -c credential.helper= -c 'credential.helper=!gh auth git-credential' push -u origin "$BRANCH" gh pr create \ --title "Update golden AL curve ${GOLDEN_CURVE} (auto)" \ - --body "Auto-generated by the SpeedBench AL Collection workflow (${run_url}). Curve: \`${curve_file}\`, collector: \`${BENCH_SCRIPT_OVERRIDE}\`, model: \`${MODEL}\`, category: \`${CATEGORY}\`, MTP: \`${MTP_LIST}\`, thinking: \`${THINKING_MODES}\`, output_len: \`${SPEEDBENCH_OUTPUT_LEN}\`. Review the measured values against the checklist in infx/golden_al_distribution/README.md before merging. A new curve also needs its row in that README's curve table." \ + --body "Auto-generated by the SpeedBench AL Collection workflow (${run_url}). Curve: \`${curve_file}\`, collector: \`${BENCH_SCRIPT_OVERRIDE}\`, model: \`${MODEL}\`, category: \`${CATEGORY}\`, MTP: \`${MTP_LIST}\`, thinking: \`${THINKING_MODES}\`, output_len: \`${SPEEDBENCH_OUTPUT_LEN}\`. Review the measured values against the checklist in infx/golden_al_distribution/README.md before merging. The curve keeps its previous \`collection\` block; confirm it still matches the collector's client settings, and add one for a new curve. A new curve also needs its row in that README's curve table and its identity in identities.yaml, named as the README's naming rule says." \ --base main \ --head "$BRANCH" diff --git a/AGENTS.md b/AGENTS.md index 4afbc6acb0..a02a1f1563 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,6 +96,7 @@ Run the focused test path first. Use `--extra workflows` for GitHub integrations - Launch only through `python -m infx.launch`; add no shell launchers. Cluster facts go in the cluster's `clusters:` record, workload rules in named policy tables; drivers never branch on a cluster id. Retiring a cluster removes its label, record, and policy rows in one PR. - srt-slurm host-setup hooks only check or prepare hosts: they never run benchmarks, tune engines, patch containers, or hide failures. - Never hard-code synthetic acceptance lengths; the srt driver picks measured values from `infx/golden_al_distribution/` ([details](inferencex-e2e/infx/golden_al_distribution/README.md#using-golden-curves-in-srt-slurm-runs)). +- Agentic speculative evals also run the report-only [speculative acceptance gate](inferencex-e2e/infx/evals/EVALS.md#speculative-acceptance-gate) on the eval's own server. New golden curves need a `collection` block with the SPEED-Bench client settings they were measured with. ## Deprecating benchmark configs diff --git a/collectivex/bench/ep_backend.py b/collectivex/bench/ep_backend.py index 5ffc774cbf..abe248abb4 100644 --- a/collectivex/bench/ep_backend.py +++ b/collectivex/bench/ep_backend.py @@ -149,6 +149,9 @@ class EPBackend(abc.ABC): dispatch_scale_bytes_per_copy = 0 # (eager quantize, dequantize) of an adopted blockwise FP8 codec; see `_enable_fp8`. _fp8_codec = None + # Whether this run dispatches FP8; set from args.precision in __init__ (the class default + # covers partial adapters that tests build without __init__). + _fp8 = False # The low-latency dispatch quantises FP8 in-kernel from BF16, so its caller-side quantize only # models those bits for the oracle. False where low-latency is caller-prequantized like normal. ll_quantizes_in_kernel = True @@ -307,6 +310,12 @@ def __init__(self, args, rank, world_size, local_rank, device): raise ValueError( f"{self.name} does not support precision {self.precision!r}" ) + self._fp8 = self.precision == "fp8" + self.experts_per_rank = args.experts // world_size + # Every adapter's FP8 stage() dequantizes the received (fp8, scale) payload to the BF16 + # combine input -- real device work, and so a separately-timed component -- while its BF16 + # stage() is a pointer hand-off. + self.stage_device_work = self._fp8 @staticmethod def init_process_group(dist, rank, world_size, device): diff --git a/collectivex/bench/ep_deepep_v2.py b/collectivex/bench/ep_deepep_v2.py index 99be7845a5..2ef3be2f9a 100644 --- a/collectivex/bench/ep_deepep_v2.py +++ b/collectivex/bench/ep_deepep_v2.py @@ -6,7 +6,6 @@ import inspect import os import sys -import types from importlib import metadata from pathlib import Path @@ -47,7 +46,8 @@ # Low-latency receive sizing, deliberately two numbers: _LL_BUFFER_CAP sizes the pre-allocated # receive (and so the transport footprint and fp8 dequant volume), _LL_LADDER_CAP bounds which # token counts are measured. Equal today, but kept separate so the ladder can be clamped around a -# kernel defect at one rung without moving the footprint (see `create_buffer`). +# kernel defect at one rung without moving the footprint (see `_create_ll_buffer`). The adapter +# carries them as LL_BUFFER_CAP and LL_LADDER_CAP. _LL_BUFFER_CAP = 256 _LL_LADDER_CAP = 256 assert _LL_LADDER_CAP <= _LL_BUFFER_CAP <= 511, ( @@ -141,6 +141,12 @@ class DeepEPV2Backend(LegacyBufferLL, EPBackend): CUDA_GRAPH_MODES = ("normal", "low-latency") # vLLM's low-latency send/receive split (see LegacyBufferLL). ll_recv_hook = True + # LL pre-allocates a fixed [num_local_experts, cap * num_ranks, hidden] receive buffer, so the + # ladder cap is a hard per-rank dispatch-slot bound; the harness clamps the ladder to it and + # records any dropped point in the artifact. Clamping LL_LADDER_CAP is the containment lever if + # a kernel defect reds the top rung (check the DeepEP pin first). + LL_LADDER_CAP = _LL_LADDER_CAP + LL_BUFFER_CAP = _LL_BUFFER_CAP # The num_sms the timed dispatch passes: 0 hands the count to DeepEP's model, as vLLM does; # create_buffer overrides it where the registry sets rdma_gbs. _dispatch_num_sms = 0 @@ -156,12 +162,8 @@ def __init__(self, args, rank, world_size, local_rank, device): # Buffer); base SUPPORTED_MODES enforces the allowed set. super().__init__(args, rank, world_size, local_rank, device) self.group = dist.group.WORLD - self._fp8 = self.precision == "fp8" - # FP8 dispatch dequantizes the received (e4m3fn, per-128-block FP32 scale) payload - # back to the BF16 combine sends, which is real device work and so a separately- - # timed component. (Normal mode quantizes inside the timed dispatch; low-latency casts - # inside the dispatch kernel — either way the dequant lands in stage().) - self.stage_device_work = self._fp8 + # Under FP8, normal mode quantizes inside the timed dispatch and low-latency casts inside + # the dispatch kernel; either way the dequant lands in stage() (stage_device_work). if self._fp8: # Resolve the pinned cast pair once (lazily, so a BF16-only run never imports # deep_ep.utils.math) so the timed stage() does no module lookup in the @@ -207,31 +209,9 @@ def _dispatch_capacity(self, tokens): """ return min(self.max_tokens, 1 << max(0, int(tokens) - 1).bit_length()) - def buffer_cap(self, args): - if self.mode == "low-latency": - # LL pre-allocates a fixed [num_local_experts, cap * num_ranks, hidden] receive - # buffer, so the cap is a hard per-rank dispatch-slot bound; the harness clamps the - # ladder to it and records any dropped point in the artifact. Clamping here is the - # containment lever if a kernel defect reds the top rung (check the DeepEP pin first). - return _LL_LADDER_CAP - return None - - def create_buffer(self, spec): + def _normal_create_buffer(self, spec): args, world_size = self.args, self.world_size self.max_tokens = spec.max_tokens_per_rank - if self.mode == "low-latency": - # Size the LL buffer from the fixed cap, not from the clamped ladder: the receive - # footprint sets both the transport's memory traffic and the fp8 dequant volume - # (`_ll_recv_bf16` converts the whole padded receive), so following the ladder would - # shift every retained rung and break comparability with the published series. - if spec.max_tokens_per_rank > _LL_BUFFER_CAP: - raise RuntimeError( - f"low-latency ladder maximum {spec.max_tokens_per_rank} exceeds the LL " - f"buffer cap {_LL_BUFFER_CAP}" - ) - self.max_tokens = _LL_BUFFER_CAP - self._create_ll_buffer(spec) - return _require_runtime() jit_root = Path(os.environ["EP_JIT_CACHE_DIR"]) # Hybrid exactly where vLLM's VLLM_DEEPEP_V2_ALLOW_HYBRID_MODE=1 autoselects it: DeepEP @@ -331,19 +311,14 @@ def _create_ll_buffer(self, spec): buffer and forces IBGDA even for single-node EP8, so there is no NVLink-only fallback. `allow_nvlink_for_low_latency_mode` lets NVLink carry intranode traffic alongside IBGDA; it does not remove the IBGDA requirement. + + The buffer is sized from LL_BUFFER_CAP, not from the clamped ladder: the receive footprint + sets both the transport's memory traffic and the fp8 dequant volume (`_ll_recv_bf16` + converts the whole padded receive), so following the ladder would shift every retained + rung and break comparability with the published series. """ - args, world_size = self.args, self.world_size - assert args.experts % world_size == 0, ( - "low-latency EP requires num_experts divisible by the EP size" - ) - self.num_local_experts = args.experts // world_size - if not hasattr(deep_ep.Buffer, "low_latency_dispatch"): - raise RuntimeError( - "invalid DeepEP LL runtime: deep_ep.Buffer.low_latency_dispatch is absent" - ) - num_rdma_bytes = deep_ep.Buffer.get_low_latency_rdma_size_hint( - self.max_tokens, args.hidden, world_size, args.experts - ) + args = self.args + num_rdma_bytes = self._ll_geometry(spec, deep_ep.Buffer, "DeepEP") kwargs = {} # On an MNNVL rack the scale-up fabric is NVLink across trays, but the legacy Buffer # defaults `allow_mnnvl=False` and a False there self-sets NVSHMEM_DISABLE_MNNVL, so @@ -378,9 +353,9 @@ def _topk_idx_dtype(self): # DeepEP V2's kernels key routing indices on deep_ep.topk_idx_t, not int64. return deep_ep.topk_idx_t - def dispatch(self, p): - if self.mode == "low-latency": - return self._ll_dispatch(p) + # ---- normal-mode transport (LegacyBufferLL routes by mode; low-latency lives there) ---- + + def _normal_dispatch(self, p): # Quantise here, not in make_problem: production runs one fused bf16->fp8 kernel per # forward pass immediately before this collective, so the timed window must contain it. dispatch_x = self._quant(p.dispatch_x) if self._fp8 else p.dispatch_x @@ -397,20 +372,9 @@ def dispatch(self, p): do_cpu_sync=False, do_expand=False, ) - return types.SimpleNamespace( - recv_x=recv_x, - recv_topk_idx=recv_topk_idx, - recv_topk_weights=recv_topk_weights, - handle=handle, - ) + return self._normal_handle(recv_x, recv_topk_idx, recv_topk_weights, handle) - def stage(self, p, h): - if self.mode == "low-latency": - # The timed combine sends the padded per-expert receive back as BF16 (dequant - # under FP8). Value correctness is exercised by the oracle's combine_transformed - # path; this only has to move the right shape for timing. - h.combine_input = self._ll_recv_bf16(h.recv_x) - return + def _normal_stage(self, p, h): if self._fp8: # Dequantize the received (fp8, scale) tuple to the BF16 combine sends. Without the # host sync that is the whole worst-case plane: combine requires its input to match @@ -420,10 +384,7 @@ def stage(self, p, h): # BF16: the received buffer is already the semantic payload to combine. h.combine_input = h.recv_x - def combine(self, p, h): - if self.mode == "low-latency": - # The kernel applies topk_weights internally (weighted). - return self._ll_combine(p, h, h.combine_input) + def _normal_combine(self, p, h): # SM and QP counts default as in vLLM: the dispatch handle's SMs, DeepEP's QP model. combined_x, _, _ = self.buffer.combine( h.combine_input, @@ -432,9 +393,7 @@ def combine(self, p, h): ) return combined_x - def inspect_dispatch(self, p, h): - if self.mode == "low-latency": - return self._ll_inspect_dispatch(p, h) + def _normal_inspect_dispatch(self, p, h): count = self.recv_tokens(h) if self._fp8: # Dequantize the sliced (fp8, scale) recv tuple so the payload the oracle @@ -447,9 +406,7 @@ def inspect_dispatch(self, p, h): self.args.experts // self.world_size, ) - def combine_transformed(self, p, h, transformed): - if self.mode == "low-latency": - return self._ll_combine_transformed(p, h, transformed) + def _normal_combine_transformed(self, p, h, transformed): # Combine always sends BF16. Under FP8, recv_x is an (fp8, scale) tuple, so the # BF16 combine buffer is shaped from the fp8 payload rather than zeros_like it. if self._fp8: @@ -464,7 +421,5 @@ def combine_transformed(self, p, h, transformed): ) return combined - def recv_tokens(self, h): - if self.mode == "low-latency": - return int(h.recv_count.sum().item()) + def _normal_recv_tokens(self, h): return int(h.handle.psum_num_recv_tokens_per_scaleup_rank[-1].item()) diff --git a/collectivex/bench/ep_flashinfer.py b/collectivex/bench/ep_flashinfer.py index dafae987ca..cdbf6d9788 100644 --- a/collectivex/bench/ep_flashinfer.py +++ b/collectivex/bench/ep_flashinfer.py @@ -134,18 +134,14 @@ def cuda_graph_supported(self) -> bool: def __init__(self, args, rank, world_size, local_rank, device): super().__init__(args, rank, world_size, local_rank, device) - self._fp8 = self.precision == "fp8" if self._fp8: # The codec, bytes and block size of deepep-v2/uccl-ep, so the same label. self._enable_fp8("fp8-e4m3fn", _blockwise_cast_to_fp8, _blockwise_cast_back) - # BF16 stage() is a pointer hand-off, so only the FP8 dequant is device work to hoist. - self.stage_device_work = self._fp8 self._a2a = None self._max_tokens = None self._workspace_sizing = {} # Which of vLLM's two combine calls this wheel takes; resolved in create_buffer. self._combine_takes_output = False - self.experts_per_rank = args.experts // world_size # ---- setup ------------------------------------------------------------------------------- diff --git a/collectivex/bench/ep_legacy_ll.py b/collectivex/bench/ep_legacy_ll.py index 80bf67bf12..34e044cf94 100644 --- a/collectivex/bench/ep_legacy_ll.py +++ b/collectivex/bench/ep_legacy_ll.py @@ -105,10 +105,13 @@ def _ll_dequant_static(fp8, scales): class LegacyBufferLL: - """Mixin (listed before EPBackend) for the legacy `Buffer` low-latency kernels. The adapter - keeps the mode branches of its timed methods inline and calls these `_ll_*` pieces; it provides - `_fp8`, `buffer` (from its `_create_ll_buffer`), `max_tokens` and `num_local_experts`, and calls - `_enable_ll` from `__init__`.""" + """Mixin (listed before EPBackend) for the legacy `Buffer` low-latency kernels. It owns the + transport contract's public methods and routes each by mode: low-latency to the shared `_ll_*` + pieces here, normal to the adapter's `_normal_*` methods (`_normal_create_buffer`, + `_normal_dispatch`, `_normal_stage`, `_normal_combine`, `_normal_inspect_dispatch`, + `_normal_combine_transformed`, `_normal_recv_tokens`). The adapter builds its low-latency + `buffer` in `_create_ll_buffer` (sized by `_ll_geometry`) and calls `_enable_ll` from + `__init__`.""" # vLLM's default (non-DBO) path issues each collective as a send kernel plus a receive hook # (deepep_ll.py: dispatch always passes return_recv_hook=True; finalize_async, the path the @@ -118,6 +121,9 @@ class LegacyBufferLL: # `_ll_combine` for dispatch and combine to share the contract. ll_recv_hook = False _ll_ue8m0 = False + # Per-rank slots the low-latency receive is pre-allocated with; None sizes it at the ladder cap + # (LL_LADDER_CAP). An adapter that keeps the two apart sets it (see deepep-v2). + LL_BUFFER_CAP: "int | None" = None def _enable_ll(self, kernel_generation): # A distinct kernel family whose combine multiplies by the gate at the source (weighted), @@ -153,6 +159,91 @@ def implementation_details(self): }) return details + # ---- transport contract, routed by mode ---------------------------------------------- + + def create_buffer(self, spec): + if self.mode == "low-latency": + return self._create_ll_buffer(spec) + return self._normal_create_buffer(spec) + + def dispatch(self, p): + if self.mode == "low-latency": + return self._ll_dispatch(p) + return self._normal_dispatch(p) + + def stage(self, p, h): + if self.mode == "low-latency": + # The timed combine sends the padded per-expert receive back as BF16 (dequant under + # FP8). Value correctness is exercised by the oracle's combine_transformed path; this + # only has to move the right shape for timing. + h.combine_input = self._ll_recv_bf16(h.recv_x) + return + self._normal_stage(p, h) + + def combine(self, p, h): + if self.mode == "low-latency": + # The kernel applies topk_weights internally (weighted). + return self._ll_combine(p, h, h.combine_input) + return self._normal_combine(p, h) + + def inspect_dispatch(self, p, h): + if self.mode == "low-latency": + return self._expert_major_view(h, self._ll_recv_bf16(h.recv_x), h.recv_count) + return self._normal_inspect_dispatch(p, h) + + def combine_transformed(self, p, h, transformed): + if self.mode == "low-latency": + return self._ll_combine_transformed(p, h, transformed) + return self._normal_combine_transformed(p, h, transformed) + + def recv_tokens(self, h): + if self.mode == "low-latency": + return int(h.recv_count.sum().item()) + return self._normal_recv_tokens(h) + + @staticmethod + def _normal_handle(recv_x, recv_topk_idx, recv_topk_weights, handle): + """The per-call handle a normal-mode dispatch returns.""" + return types.SimpleNamespace( + recv_x=recv_x, + recv_topk_idx=recv_topk_idx, + recv_topk_weights=recv_topk_weights, + handle=handle, + ) + + # ---- shared low-latency pieces -------------------------------------------------------- + + def _ll_geometry(self, spec, buffer_cls, label): + """Fix the low-latency receive geometry and return the Buffer's RDMA byte count. + + The receive is pre-allocated at a fixed per-rank slot count (LL_BUFFER_CAP, else the ladder + cap) and never follows the ladder, so a partial ladder runs the same receive plane as the + full one; a ladder that does not fit fails closed. Sets `max_tokens` and + `num_local_experts` (LL requires the QP-per-rank count to equal the latter). + """ + args, world_size = self.args, self.world_size + cap = self.LL_BUFFER_CAP if self.LL_BUFFER_CAP is not None else self.LL_LADDER_CAP + if spec.max_tokens_per_rank > cap: + raise RuntimeError( + f"low-latency ladder maximum {spec.max_tokens_per_rank} exceeds the LL " + f"buffer cap {cap}" + ) + assert args.experts % world_size == 0, ( + "low-latency EP requires num_experts divisible by the EP size" + ) + self.max_tokens = cap + self.num_local_experts = args.experts // world_size + if not hasattr(buffer_cls, "low_latency_dispatch") or not hasattr( + buffer_cls, "get_low_latency_rdma_size_hint" + ): + raise RuntimeError( + f"invalid {label} LL runtime: Buffer.low_latency_dispatch / " + "get_low_latency_rdma_size_hint absent" + ) + return buffer_cls.get_low_latency_rdma_size_hint( + self.max_tokens, args.hidden, world_size, args.experts + ) + def _ll_recv_bf16(self, recv_x): """The padded per-expert receive as BF16 `[num_local_experts, cap*num_ranks, hidden]`. @@ -206,9 +297,6 @@ def _ll_combine(self, p, h, x): hook() return combined_x[: p.T] - def _ll_inspect_dispatch(self, p, h): - return self._expert_major_view(h, self._ll_recv_bf16(h.recv_x), h.recv_count) - def _ll_combine_transformed(self, p, h, transformed): """Scatter the oracle-transformed rows back into a zeroed padded combine buffer at the exact `(expert, slot)` coordinates inspect_dispatch read them from, then run the weighted diff --git a/collectivex/bench/ep_mori.py b/collectivex/bench/ep_mori.py index 9d331e9a9f..d354e18e1c 100644 --- a/collectivex/bench/ep_mori.py +++ b/collectivex/bench/ep_mori.py @@ -126,8 +126,6 @@ def library_version(self) -> str: def __init__(self, args, rank, world_size, local_rank, device): super().__init__(args, rank, world_size, local_rank, device) - self._fp8 = self.precision == "fp8" - self.experts_per_rank = args.experts // world_size arch = _gfx_arch(device) scale_out = args.scope == "scale-out" if self.mode == "low-latency": @@ -159,8 +157,7 @@ def __init__(self, args, rank, world_size, local_rank, device): ) self._enable_fp8(label, *blockwise_fp8_codec(self._fp8_dtype, fp8_max)) # External-input kernels consume the dispatch output directly, so stage has no copy of its - # own; under FP8 it dequantizes the received payload with its scales. - self.stage_device_work = self._fp8 + # own; under FP8 it dequantizes the received payload with its scales (stage_device_work). self._library_config = {} def create_buffer(self, spec): diff --git a/collectivex/bench/ep_nccl.py b/collectivex/bench/ep_nccl.py index 6d31ff2b55..23abe5fa07 100644 --- a/collectivex/bench/ep_nccl.py +++ b/collectivex/bench/ep_nccl.py @@ -192,14 +192,11 @@ def __init__(self, args, rank, world_size, local_rank, device): # fails (error 5); present -> device_api_support=True and HT+LL groups create. os.environ.setdefault("NCCL_CUMEM_ENABLE", "1") self.group = dist.group.WORLD - self.experts_per_rank = args.experts // world_size self.num_local_experts = self.experts_per_rank self._internode = world_size > int(args.scale_up_domain) self._ll = self.mode == "low-latency" - self._fp8 = getattr(self, "precision", "bf16") == "fp8" if self._fp8 and not self._ll: raise ValueError("NCCL EP FP8 is enabled for low-latency mode only") - self.stage_device_work = self._fp8 # LL layout: rank-major (TensorRT-LLM's NCCL EP contract, the default) or expert-major # (DeepEP LL's contract, as vLLM/SGLang decode consume it). Both are native LL layouts. layout = os.environ.get("COLLX_NCCL_LL_LAYOUT", "rank-major") @@ -359,8 +356,6 @@ def _comm_config(): def create_buffer(self, spec): """Bootstrap the communicator, create the EP group sized from the ladder maximum, and allocate the persistent receive/combine buffers reused across every ladder shape.""" - # Unit tests construct a deliberately partial adapter without __init__. - self._fp8 = getattr(self, "_fp8", False) # Token capacity is the largest step the ladder runs. LL follows TensorRT-LLM, which sizes # its one NcclEP group to min(max_num_tokens, moe_max_num_tokens) (nccl_ep.py:98-103) and # rejects any step above it (124-129): the smallest such engine serving every rung has @@ -526,7 +521,6 @@ def _ensure_handle(self, p): the oracle passes), never inside a timed window. Re-entering with the already-bound problem returns immediately without a collective or a sync. """ - self._fp8 = getattr(self, "_fp8", False) cached = getattr(p, "_nccl", None) if cached is not None: if self._bound is not cached: diff --git a/collectivex/bench/ep_uccl.py b/collectivex/bench/ep_uccl.py index f05151079f..b84125b9ed 100644 --- a/collectivex/bench/ep_uccl.py +++ b/collectivex/bench/ep_uccl.py @@ -37,7 +37,6 @@ import json import os import sys -import types from importlib import metadata from pathlib import Path @@ -178,14 +177,9 @@ def cuda_graph_supported(self) -> bool: def __init__(self, args, rank, world_size, local_rank, device): super().__init__(args, rank, world_size, local_rank, device) self.group = dist.group.WORLD - self.experts_per_rank = args.experts // world_size self._internode = world_size > int(args.scale_up_domain) - self._fp8 = self.precision == "fp8" self.library_version = _library_version() self._library_config = {} - # FP8 dispatch dequantizes the received (e4m3, per-128-block scale) payload back to the - # BF16 combine sends — real device work, hence a separately-timed stage component. - self.stage_device_work = self._fp8 if self._fp8: fnuz = _fp8_e4m3_dtype() == torch.float8_e4m3fnuz quantize = per_token_cast_to_fp8 @@ -204,10 +198,7 @@ def __init__(self, args, rank, world_size, local_rank, device): # ---- buffer construction --------------------------------------------------------------- - def create_buffer(self, spec): - if self.mode == "low-latency": - self._create_ll_buffer(spec) - return + def _normal_create_buffer(self, spec): self.max_tokens = spec.max_tokens_per_rank # vLLM's deepep_ht resolves both configs from the EP size alone, on Buffer.num_sms left at # UCCL's default: one 20-SM budget at EP8 and EP16, on CUDA and HIP alike (the default is @@ -260,29 +251,9 @@ def _create_ll_buffer(self, spec): same receive plane as the full one. It does NOT force the proxy path: `is_intranode` is passed below, so at EP8 UCCL leaves the proxies stopped and the kernel takes its IPC branch. """ - args, world_size = self.args, self.world_size - assert args.experts % world_size == 0, ( - "low-latency EP requires num_experts divisible by the EP size" - ) - if spec.max_tokens_per_rank > self.LL_LADDER_CAP: - raise RuntimeError( - f"low-latency ladder maximum {spec.max_tokens_per_rank} exceeds the LL " - f"buffer cap {self.LL_LADDER_CAP}" - ) - self.max_tokens = self.LL_LADDER_CAP - self.num_local_experts = args.experts // world_size + num_rdma_bytes = self._ll_geometry(spec, Buffer, "UCCL-EP") # LL requires the QP-per-rank count to equal the number of local experts. num_qps_per_rank = self.num_local_experts - if not hasattr(Buffer, "low_latency_dispatch") or not hasattr( - Buffer, "get_low_latency_rdma_size_hint" - ): - raise RuntimeError( - "invalid UCCL-EP LL runtime: Buffer.low_latency_dispatch / " - "get_low_latency_rdma_size_hint absent" - ) - num_rdma_bytes = Buffer.get_low_latency_rdma_size_hint( - self.max_tokens, args.hidden, world_size, args.experts - ) # vLLM also passes enable_shrink=False, which this pin's Buffer does not accept (UCCL #1043 # adds it); False is UCCL's behaviour either way. self.buffer = Buffer( @@ -314,11 +285,9 @@ def implementation_details(self) -> dict: # On top of the shared low-latency keys (ep_legacy_ll), which name the same values the same. return {**super().implementation_details(), **self._library_config} - # ---- transport contract (the shared low-latency pieces live in ep_legacy_ll) --------- + # ---- normal-mode transport (LegacyBufferLL routes by mode; low-latency lives there) ---- - def dispatch(self, p): - if self.mode == "low-latency": - return self._ll_dispatch(p) + def _normal_dispatch(self, p): # Legacy normal dispatch, as vLLM's deepep_ht prepare runs it: compute the layout, then # scatter tokens to their experts. num_tokens_per_rdma_rank is None intranode (EP8) and # populated internode (EP16); pass it through so the same call serves both scopes. @@ -339,27 +308,15 @@ def dispatch(self, p): config=self.dispatch_config, async_finish=False, ) - return types.SimpleNamespace( - recv_x=recv_x, - recv_topk_idx=recv_topk_idx, - recv_topk_weights=recv_topk_weights, - handle=handle, - ) + return self._normal_handle(recv_x, recv_topk_idx, recv_topk_weights, handle) - def stage(self, p, h): - if self.mode == "low-latency": - # The timed combine sends the padded per-expert receive back as BF16 (dequant under - # FP8). Value correctness is exercised by the oracle's combine_transformed path. - h.combine_input = self._ll_recv_bf16(h.recv_x) - return + def _normal_stage(self, p, h): if self._fp8: h.combine_input = per_token_cast_back(h.recv_x[0], h.recv_x[1]) else: h.combine_input = h.recv_x - def combine(self, p, h): - if self.mode == "low-latency": - return self._ll_combine(p, h, h.combine_input) + def _normal_combine(self, p, h): # Normal combine is the activation-only unweighted rank-sum: topk_weights are intentionally # NOT passed so the kernel sums the per-token expert aggregates across ranks without # applying the gate (matches combine_weight_semantics and the two-level oracle). @@ -373,9 +330,7 @@ def combine(self, p, h): # ---- correctness-oracle views ---------------------------------------------------------- - def inspect_dispatch(self, p, h): - if self.mode == "low-latency": - return self._ll_inspect_dispatch(p, h) + def _normal_inspect_dispatch(self, p, h): # Legacy normal recv: recv_x is [num_recv, hidden] (each received token once) and # recv_topk_idx/recv_topk_weights are [num_recv, topk] — the oracle's per-received-token # 2-D contract (it sorts each row over the topk axis and sums the per-expert transforms, @@ -387,9 +342,7 @@ def inspect_dispatch(self, p, h): payload, h.recv_topk_idx, h.recv_topk_weights, self.experts_per_rank ) - def combine_transformed(self, p, h, transformed): - if self.mode == "low-latency": - return self._ll_combine_transformed(p, h, transformed) + def _normal_combine_transformed(self, p, h, transformed): # `transformed` is the oracle's per-received-token combine input [num_recv, hidden] # (already summed over the top-k axis) — exactly the per-token buffer legacy combine # consumes; combine then sums those per-token aggregates across ranks (unweighted). @@ -401,8 +354,6 @@ def combine_transformed(self, p, h, transformed): ) return combined - def recv_tokens(self, h): - if self.mode == "low-latency": - return int(h.recv_count.sum().item()) + def _normal_recv_tokens(self, h): recv = h.recv_x[0] if self._fp8 else h.recv_x return int(recv.shape[0]) diff --git a/collectivex/configs/platform_config.json b/collectivex/configs/platform_config.json index 6dd9977ab6..53888e0c2e 100644 --- a/collectivex/configs/platform_config.json +++ b/collectivex/configs/platform_config.json @@ -146,8 +146,7 @@ "partition": "batch_1", "account": "benchmark", "squash_dir": "/data/home/sa-shared/collectivex/containers", - "enroot_cache_path": "/data/home/sa-shared/collectivex/enroot-cache", - "exclude_nodes": "im-gb300-r01-c003,im-gb300-r01-c005" + "enroot_cache_path": "/data/home/sa-shared/collectivex/enroot-cache" }, "network": { "socket_ifname": "enP22s22f0np0", diff --git a/collectivex/tests/test_backends.py b/collectivex/tests/test_backends.py index 2abafb97bc..5343313166 100644 --- a/collectivex/tests/test_backends.py +++ b/collectivex/tests/test_backends.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""EPBackend contracts: ladder/spec construction, the staging-vs-roundtrip gate, and the NCCL EP handle.""" +"""EPBackend contracts: ladder/spec construction, the staging-vs-roundtrip gate, and graph defaults.""" from __future__ import annotations import importlib @@ -126,234 +126,6 @@ def test_an_unreadable_version_falls_back_to_the_rounding_model(self): self.assertFalse(gate(version), f"{version!r} must fall back to the safe model") -class NcclLowLatencyLadderSizing(unittest.TestCase): - """LL keeps every requested point and allocates receive space for the full ladder.""" - - def _module(self): - return _import_stubbed("ep_nccl") - - @staticmethod - def _construct(module, world_size, mode="low-latency", **env): - """NCCLEPBackend.__init__ over a base __init__ reduced to the two fields it reads.""" - def base_init(instance, options, rank, world_size, local_rank, device): - instance.args = options - instance.mode = options.mode - - options = types.SimpleNamespace( - mode=mode, experts=384, hidden=7168, topk=6, scale_up_domain=8 - ) - with mock.patch.object(module.EPBackend, "__init__", base_init), \ - mock.patch.dict(os.environ, env): - return module.NCCLEPBackend(options, 0, world_size, 0, "cuda:0") - - def _backend(self, module, low_latency): - """A backend far enough along to run create_buffer against the stubs.""" - backend = module.NCCLEPBackend.__new__(module.NCCLEPBackend) - backend._ll = low_latency - backend.zero_copy = True - backend.world_size, backend.num_local_experts, backend.device = 8, 4, "cuda:0" - backend.args = types.SimpleNamespace(hidden=7168, experts=256, topk=8, phase="decode") - backend._algorithm = "LL" if low_latency else "HT" - backend._layout = module.Layout.RANK_MAJOR if low_latency else module.Layout.FLAT - backend._bootstrap_comm = lambda: None - # create_buffer stamps the installed wheel into kernel_generation. - module.metadata = types.SimpleNamespace(version=lambda name: "0.2.0.dev20261013") - backend._recv_window_for_test = types.SimpleNamespace( - is_valid=True, close=lambda: None - ) - backend._comm = types.SimpleNamespace( - register_window=mock.Mock(return_value=backend._recv_window_for_test) - ) - backend.created_configs = [] - - def group_config(**kwargs): - config = types.SimpleNamespace(**kwargs) - backend.created_configs.append(config) - return config - - module.GroupConfig = group_config - module.nccl_ep.Group = types.SimpleNamespace(create=lambda *a, **k: object()) - return backend - - def test_the_communicator_runs_graph_usage_mode_one(self): - module = self._module() - with mock.patch.object(module.nccl_core, "NCCLConfig", lambda **kw: kw, create=True): - self.assertEqual(module.NCCLEPBackend._comm_config(), {"graph_usage_mode": 1}) - - def test_ll_layout_selector_restores_the_expert_major_contract(self): - module = self._module() - em = self._construct(module, 8, COLLX_NCCL_LL_LAYOUT="expert-major") - rm = self._construct(module, 8) - ht = self._construct(module, 16, mode="normal") - with self.assertRaisesRegex(ValueError, "COLLX_NCCL_LL_LAYOUT"): - self._construct(module, 8, COLLX_NCCL_LL_LAYOUT="flat") - - self.assertEqual(em._layout, module.Layout.EXPERT_MAJOR) - self.assertEqual( - (em.kernel_generation, em.receive_layout, em.combine_weight_semantics), - ("nccl-ep-v02-ll-em", "token-expert", "weighted-kernel-sum"), - ) - self.assertFalse(em.zero_copy) - self.assertEqual(rm._layout, module.Layout.RANK_MAJOR) - # Only LL rank-major selects NCCL's FP32 reduction. - self.assertEqual((rm.combine_reduction, em.combine_reduction, ht.combine_reduction), - ("rank-fp32", "domain-fp32", "domain-fp32")) - - def test_ll_ladder_keeps_512_and_larger_points(self): - module = self._module() - backend = self._backend(module, low_latency=True) - backend.mode = "low-latency" - backend.args.tokens_ladder = "32 256 512 1024" - backend._build_rank_inputs = mock.Mock(return_value=None) - spec = backend.make_inputs(backend.args) - self.assertEqual(spec.ladder, [32, 256, 512, 1024]) - self.assertEqual(spec.dropped, []) - - def test_fp8_ll_accepts_both_phases_and_keeps_ht_disabled(self): - module = self._module() - module.nccl_ep.DispatchQuantizationRecipe = types.SimpleNamespace(DS_FP8E3M4="ds-fp8") - module.DispatchConfig = lambda **kw: types.SimpleNamespace(**kw) - with mock.patch.dict(os.environ, {"COLLX_NCCL_LL_LAYOUT": "rank-major"}): - for phase in ("decode", "prefill"): - with self.subTest(phase=phase): - options = args(phase=phase, precision="fp8", mode="low-latency", - hidden=7168, experts=384, topk=6, scale_up_domain=72) - backend = module.NCCLEPBackend(options, 0, 8, 0, "cuda:0") - self.assertEqual(backend._dispatch_cfg.quantization_recipe, "ds-fp8") - self.assertEqual(backend.dispatch_dtype, "fp8-e4m3fn") - self.assertTrue(backend.zero_copy) - self.assertEqual(backend.cuda_graph_supported, phase == "decode") - options.mode = "normal" - with self.assertRaisesRegex(ValueError, "low-latency mode only"): - module.NCCLEPBackend(options, 0, 8, 0, "cuda:0") - - def test_ll_prefill_uses_the_full_ladder_receive_without_graphs(self): - module = self._module() - backend = self._backend(module, low_latency=True) - backend.mode = "low-latency" - backend.args.phase = "prefill" - self.assertIsNone(backend.buffer_cap(backend.args)) - backend.create_buffer(types.SimpleNamespace(max_tokens_per_rank=8192)) - self.assertEqual(backend._recv_x.shape, (8, 8192, 7168)) - self.assertEqual(backend.created_configs[0].max_dispatch_tokens_per_rank, 8192) - self.assertFalse(backend.cuda_graph_supported) - - def test_ht_registers_a_symmetric_zero_copy_receive_window(self): - module = self._module() - backend = self._backend(module, low_latency=False) - backend.create_buffer(types.SimpleNamespace(max_tokens_per_rank=99)) - - self.assertEqual(backend.created_configs[0].zero_copy, module.ZeroCopyMode.ON) - backend._comm.register_window.assert_called_once_with( - backend._recv_x, flags=module.nccl_core.WindowFlag.COLL_SYMMETRIC - ) - self.assertIs(backend._recv_x_t.window, backend._recv_window_for_test) - self.assertEqual(backend._recv_x_t.window_offset, 0) - - def test_ll_rank_major_registers_a_direct_receive_window(self): - module = self._module() - backend = self._backend(module, low_latency=True) - backend.create_buffer(types.SimpleNamespace(ladder=[1, 64], max_tokens_per_rank=64)) - - self.assertTrue(backend.zero_copy) - self.assertEqual(backend.created_configs[0].zero_copy, module.ZeroCopyMode.ON) - backend._comm.register_window.assert_called_once_with( - backend._recv_x, flags=module.nccl_core.WindowFlag.COLL_SYMMETRIC - ) - self.assertEqual(backend._recv_x.shape, (8, 64, 7168)) - self.assertEqual(backend._recv_count.shape, (8,)) - self.assertIs(backend._recv_x_t.window, backend._recv_window) - - def test_fp8_buffer_binding_follows_selected_policy_in_both_phases(self): - for phase in ("decode", "prefill"): - for zero_copy in (False, True): - with self.subTest(phase=phase, zero_copy=zero_copy): - module = self._module() - backend = self._backend(module, low_latency=True) - backend.args.phase = phase - backend._fp8 = True - backend.zero_copy = zero_copy - windows = [types.SimpleNamespace(is_valid=True, close=mock.Mock()) - for _ in range(2)] - backend._comm.register_window.side_effect = windows - backend.create_buffer(types.SimpleNamespace(max_tokens_per_rank=1024)) - self.assertEqual(backend._recv_x.shape, (8, 1024, 7168)) - self.assertEqual(backend._recv_scales.shape, (8, 1024, 56)) - self.assertIs(backend._recv_x_t.window, windows[0] if zero_copy else None) - self.assertIs(backend._recv_scales_t.window, - windows[1] if zero_copy else None) - self.assertIs(backend._recv_scales_t.buffer, backend._recv_scales) - self.assertIs(backend._combine_x_t.buffer, backend._combine_scratch) - self.assertIsNone(backend._combine_x_t.window) - backend._handle = None - backend._bound = None - backend._ep_group = types.SimpleNamespace(destroy=lambda: None) - backend._comm.destroy = lambda: None - module.dist.barrier = lambda: None - module.dist.destroy_process_group = lambda: None - self.assertEqual(backend.finalize(0), 0) - for window in windows: - self.assertEqual(window.close.call_count, int(zero_copy)) - - def test_fp8_scale_registration_failure_releases_token_window_and_group(self): - module = self._module() - backend = self._backend(module, low_latency=True) - backend._fp8 = True - windows = [types.SimpleNamespace(is_valid=valid, close=mock.Mock()) - for valid in (True, False)] - backend._comm.register_window.side_effect = windows - group = types.SimpleNamespace(destroy=mock.Mock()) - module.nccl_ep.Group.create = lambda *a, **k: group - with self.assertRaisesRegex(RuntimeError, "scale window registration failed"): - backend.create_buffer(types.SimpleNamespace(max_tokens_per_rank=1024)) - for window in windows: - window.close.assert_called_once() - group.destroy.assert_called_once() - self.assertIsNone(backend._recv_window) - self.assertIsNone(backend._recv_scales_window) - self.assertIsNone(backend._ep_group) - - def test_ll_dispatch_uses_rank_major_metadata_and_returns_expert_inputs(self): - module = self._module() - backend = self._backend(module, low_latency=True) - backend.create_buffer(types.SimpleNamespace(ladder=[1, 64], max_tokens_per_rank=64)) - captured = {} - - class DispatchInputs: - def __init__(self, **kwargs): - captured["inputs"] = kwargs - - class DispatchOutputs: - def __init__(self, **kwargs): - captured["outputs"] = kwargs - - class LayoutInfo: - def __init__(self, **kwargs): - captured["layout"] = kwargs - - handle = types.SimpleNamespace(dispatch=mock.Mock()) - module.DispatchInputs = DispatchInputs - module.DispatchOutputs = DispatchOutputs - module.LayoutInfo = LayoutInfo - backend._handle = None - backend._bound = None - backend._ep_group = types.SimpleNamespace(create_handle=mock.Mock(return_value=handle)) - backend._dispatch_cfg = object() - backend._stream = lambda: 0 - - p = types.SimpleNamespace(T=1, dispatch_x="tokens", topk_idx="indices", topk_weights="weights") - backend.dispatch(p) - - self.assertIs(backend._layout, module.Layout.RANK_MAJOR) - self.assertEqual(captured["inputs"]["tokens"].buffer, "tokens") - self.assertEqual(captured["inputs"]["topk_weights"].buffer, "weights") - self.assertEqual(captured["outputs"]["tokens"].window, backend._recv_window) - self.assertIs(captured["outputs"]["topk_weights"], backend._recv_w_t) - self.assertIs(captured["outputs"]["topk_idx"], backend._recv_idx_t) - self.assertIs(captured["layout"]["src_rank_counters"], backend._recv_count_t) - handle.dispatch.assert_called_once() - - class RoundtripStagingGate(unittest.TestCase): """`roundtrip` must mean dispatch -> combine in every row, or it is not comparable: the gate is `stage_device_work` alone, with `CX_FP8_CONSUME=dequant` as the sole opt-out.""" @@ -441,96 +213,6 @@ def _import_stubbed(name, **vendor): return _fakes.fresh_import(name, {**_stub_modules(), **vendor}) -ep_nccl = _import_stubbed("ep_nccl") - - -class FakeHandle: - """Records every rebind so the tests can assert on the collective call pattern.""" - - def __init__(self): - self.updates = [] - - def update(self, topk_idx, *, layout_info=None, stream=None): - self.updates.append((topk_idx, layout_info)) - - -class FakeGroup: - def __init__(self): - self.created = 0 - self.handle = FakeHandle() - - def create_handle(self, layout, topk_idx, *, layout_info=None, config=None, stream=None): - self.created += 1 - return self.handle - - -def backend(ll=True): - """An NCCLEPBackend with just the fields _ensure_handle touches (no __init__, no GPU).""" - b = object.__new__(ep_nccl.NCCLEPBackend) - b._ll = ll - b._layout = "EM" if ll else "FLAT" - b._handle = None - b._bound = None - b._ep_group = FakeGroup() - b.device = "cuda:0" - b.num_local_experts = 4 - b.args = types.SimpleNamespace(hidden=16) - b._t = lambda x: x - b._window_t = lambda x: x - b._stream = lambda: 0 - # create_buffer always runs before the first _ensure_handle, so the HT receive plane exists - # by then; a list stands in for the tensor because `_t` is identity here. - b._recv_x = list(range(64)) - b._recv_x_t = ("window", "full-plane") - return b - - -def problem(T): - return types.SimpleNamespace( - T=T, dispatch_x=f"x{T}", topk_idx=f"idx{T}", topk_weights=f"w{T}" - ) - - -class TestSingleHandle(unittest.TestCase): - def test_one_handle_across_many_shapes(self): - """Nine ladder rungs produce exactly one create_handle, rebound to each later routing.""" - b = backend() - for T in (1, 2, 4, 8, 16, 32, 64, 128, 256): - b._ensure_handle(problem(T)) - self.assertEqual(b._ep_group.created, 1) - self.assertEqual([idx for idx, _ in b._ep_group.handle.updates], - [f"idx{T}" for T in (2, 4, 8, 16, 32, 64, 128, 256)]) - - def test_ll_rank_major_weight_wrapper_is_built_once_per_handle(self): - """LL caches its dispatch-weight wrapper with the handle.""" - ll = backend(ll=True) - ll._t = mock.Mock(side_effect=lambda value: types.SimpleNamespace(value=value)) - pa = problem(1) - h = ll._ensure_handle(pa) - first_weights = h.in_weights_t - self.assertEqual(first_weights.value, "w1") - # Re-entering the same problem reuses the handle and therefore the wrapper. - ll._t.reset_mock() - self.assertIs(ll._ensure_handle(pa).in_weights_t, first_weights) - ll._t.assert_not_called() - - def test_ht_combine_input_is_the_full_static_receive_plane(self): - """The FLAT contract gives combine the dispatch output's static [num_recv_slots, hidden] - shape (required under graph capture); a count-sized slice needs an AUTO-sized group.""" - b = backend(ll=False) - h = b._ensure_handle(problem(1)) - # 7 is what the stubbed `torch.zeros(...).item()` reports as the received count. - self.assertEqual(h.count, 7) - self.assertIs(h.combine_in_t, b._recv_x_t) - - -def _deep_ep(*classes): - module = types.ModuleType("deep_ep") - for cls in classes: - setattr(module, cls, type(cls, (), {})) - return module - - def _gate(cls, mode, phase): """An adapter carrying only what `cuda_graph_supported` reads.""" backend = object.__new__(cls) @@ -550,58 +232,5 @@ def test_flashinfer_and_nccl_ht_graph_decode_only(self): self.assertTrue(_gate(nccl, "low-latency", phase="decode").cuda_graph_supported) -class GinWindowOrdering(unittest.TestCase): - """Relaxed-ordering rows must not merge into the strict-ordered series they would replace.""" - - def test_only_relaxed_scale_out_rows_carry_the_suffix(self): - module = _import_stubbed("ep_deepep_v2", deep_ep=_deep_ep("ElasticBuffer", "Buffer")) - relaxed = {"EP_WIN_RELAXED_ORDERING": "1"} - for world_size, env, suffixed in ((16, relaxed, True), (16, {}, False), (8, relaxed, False)): - with self.subTest(world_size=world_size, env=env), \ - mock.patch.dict(os.environ, env, clear=True): - backend = module.DeepEPV2Backend( - args(scale_up_domain=8, runner="h200-dgxc"), 0, world_size, 0, "cpu") - self.assertEqual(backend.kernel_generation.endswith("-relaxed-ordering"), suffixed) - - -class LowLatencyCapDecoupling(unittest.TestCase): - """The LL receive size and the measured ladder must stay two numbers -- sizing the receive - from `max(ladder)` would shift every rung.""" - - @staticmethod - def _adapter(): - # The adapter imports ElasticBuffer by name and fails closed without it. - return _import_stubbed("ep_deepep_v2", deep_ep=_deep_ep("Buffer", "ElasticBuffer")) - - def test_ladder_cap_drops_only_oversized_measurement_points(self): - module = self._adapter() - backend = module.DeepEPV2Backend.__new__(module.DeepEPV2Backend) - backend.mode = "low-latency" - backend.world_size = 8 - backend._build_rank_inputs = mock.Mock(return_value=None) - args = types.SimpleNamespace(experts=256, tokens_ladder="32 64 128") - with mock.patch.object(module, "_LL_LADDER_CAP", 64): - spec = backend.make_inputs(args) - self.assertEqual(spec.ladder, [32, 64]) - self.assertEqual(spec.dropped, [128]) - - def test_the_receive_is_sized_from_the_buffer_cap_not_the_ladder(self): - module = self._adapter() - backend = module.DeepEPV2Backend.__new__(module.DeepEPV2Backend) - backend.mode, backend.world_size, backend.group = "low-latency", 8, object() - backend.args = types.SimpleNamespace(experts=256, hidden=16) - vendor_buffer = mock.Mock() - vendor_buffer.get_low_latency_rdma_size_hint.return_value = 4096 - with mock.patch.object(module.deep_ep, "Buffer", vendor_buffer), \ - mock.patch.object(module, "_LL_BUFFER_CAP", 128), \ - mock.patch.object(module, "_LL_LADDER_CAP", 64): - for ladder_max in (16, 64): - backend.create_buffer(types.SimpleNamespace(max_tokens_per_rank=ladder_max)) - vendor_buffer.get_low_latency_rdma_size_hint.assert_called_with(128, 16, 8, 256) - self.assertEqual(vendor_buffer.call_args.kwargs["num_rdma_bytes"], 4096) - with self.assertRaisesRegex(RuntimeError, "exceeds"): - backend.create_buffer(types.SimpleNamespace(max_tokens_per_rank=129)) - - if __name__ == "__main__": unittest.main() diff --git a/collectivex/tests/test_deepep_v2_contract.py b/collectivex/tests/test_deepep_v2_contract.py index c3892d1030..7c9fce2c46 100644 --- a/collectivex/tests/test_deepep_v2_contract.py +++ b/collectivex/tests/test_deepep_v2_contract.py @@ -349,5 +349,52 @@ def test_one_domain_models_agree_up_to_the_final_cast(self): self.assertEqual(torch.tensor(single).to(torch.bfloat16).item(), domain) +class GinWindowOrdering(unittest.TestCase): + """Relaxed-ordering rows must not merge into the strict-ordered series they would replace.""" + + def test_only_relaxed_scale_out_rows_carry_the_suffix(self): + module = _adapter() + relaxed = {"EP_WIN_RELAXED_ORDERING": "1"} + for world_size, env, suffixed in ((16, relaxed, True), (16, {}, False), (8, relaxed, False)): + with self.subTest(world_size=world_size, env=env), \ + mock.patch.dict(os.environ, env, clear=True): + backend = module.DeepEPV2Backend(_args(), 0, world_size, 0, "cpu") + self.assertEqual(backend.kernel_generation.endswith("-relaxed-ordering"), suffixed) + + +class LowLatencyCapDecoupling(unittest.TestCase): + """The LL receive size and the measured ladder must stay two numbers -- sizing the receive + from `max(ladder)` would shift every rung.""" + + def test_ladder_cap_drops_only_oversized_measurement_points(self): + module = _adapter() + backend = module.DeepEPV2Backend.__new__(module.DeepEPV2Backend) + backend.mode = "low-latency" + backend.world_size = 8 + backend._build_rank_inputs = mock.Mock(return_value=None) + args = types.SimpleNamespace(experts=256, tokens_ladder="32 64 128") + with mock.patch.object(module.DeepEPV2Backend, "LL_LADDER_CAP", 64): + spec = backend.make_inputs(args) + self.assertEqual(spec.ladder, [32, 64]) + self.assertEqual(spec.dropped, [128]) + + def test_the_receive_is_sized_from_the_buffer_cap_not_the_ladder(self): + module = _adapter() + backend = module.DeepEPV2Backend.__new__(module.DeepEPV2Backend) + backend.mode, backend.world_size, backend.group = "low-latency", 8, object() + backend.args = types.SimpleNamespace(experts=256, hidden=16) + vendor_buffer = mock.Mock() + vendor_buffer.get_low_latency_rdma_size_hint.return_value = 4096 + with mock.patch.object(module.deep_ep, "Buffer", vendor_buffer), \ + mock.patch.object(module.DeepEPV2Backend, "LL_BUFFER_CAP", 128), \ + mock.patch.object(module.DeepEPV2Backend, "LL_LADDER_CAP", 64): + for ladder_max in (16, 64): + backend.create_buffer(types.SimpleNamespace(max_tokens_per_rank=ladder_max)) + vendor_buffer.get_low_latency_rdma_size_hint.assert_called_with(128, 16, 8, 256) + self.assertEqual(vendor_buffer.call_args.kwargs["num_rdma_bytes"], 4096) + with self.assertRaisesRegex(RuntimeError, "exceeds"): + backend.create_buffer(types.SimpleNamespace(max_tokens_per_rank=129)) + + if __name__ == "__main__": unittest.main() diff --git a/collectivex/tests/test_nccl_ep_contract.py b/collectivex/tests/test_nccl_ep_contract.py index b1bd8b5d92..ad69aa0ae3 100644 --- a/collectivex/tests/test_nccl_ep_contract.py +++ b/collectivex/tests/test_nccl_ep_contract.py @@ -13,7 +13,7 @@ ROOT = Path(__file__).resolve().parents[1] sys.path[:0] = [str(ROOT / "bench"), str(ROOT / "tests")] -from test_backends import _import_stubbed # noqa: E402 +from test_backends import _import_stubbed, args # noqa: E402 VERSION = "0.2.0.dev20261013" DEVICE_SMS = 132 @@ -296,5 +296,311 @@ def test_the_artifact_reads_the_environment_the_group_was_created_under(self): self.assertEqual(details["num_sms"], {"dispatch": 64, "combine": DEVICE_SMS}) +class NcclLowLatencyLadderSizing(unittest.TestCase): + """LL keeps every requested point and allocates receive space for the full ladder.""" + + def _module(self): + return _import_stubbed("ep_nccl") + + @staticmethod + def _construct(module, world_size, mode="low-latency", **env): + """NCCLEPBackend.__init__ for a BF16 case under `env`.""" + options = types.SimpleNamespace( + mode=mode, precision="bf16", experts=384, hidden=7168, topk=6, scale_up_domain=8 + ) + with mock.patch.dict(os.environ, env): + return module.NCCLEPBackend(options, 0, world_size, 0, "cuda:0") + + def _backend(self, module, low_latency): + """A backend far enough along to run create_buffer against the stubs.""" + backend = module.NCCLEPBackend.__new__(module.NCCLEPBackend) + backend._ll = low_latency + backend.zero_copy = True + backend.world_size, backend.num_local_experts, backend.device = 8, 4, "cuda:0" + backend.args = types.SimpleNamespace(hidden=7168, experts=256, topk=8, phase="decode") + backend._algorithm = "LL" if low_latency else "HT" + backend._layout = module.Layout.RANK_MAJOR if low_latency else module.Layout.FLAT + backend._bootstrap_comm = lambda: None + # create_buffer stamps the installed wheel into kernel_generation. + module.metadata = types.SimpleNamespace(version=lambda name: "0.2.0.dev20261013") + backend._recv_window_for_test = types.SimpleNamespace( + is_valid=True, close=lambda: None + ) + backend._comm = types.SimpleNamespace( + register_window=mock.Mock(return_value=backend._recv_window_for_test) + ) + backend.created_configs = [] + + def group_config(**kwargs): + config = types.SimpleNamespace(**kwargs) + backend.created_configs.append(config) + return config + + module.GroupConfig = group_config + module.nccl_ep.Group = types.SimpleNamespace(create=lambda *a, **k: object()) + return backend + + def test_the_communicator_runs_graph_usage_mode_one(self): + module = self._module() + with mock.patch.object(module.nccl_core, "NCCLConfig", lambda **kw: kw, create=True): + self.assertEqual(module.NCCLEPBackend._comm_config(), {"graph_usage_mode": 1}) + + def test_ll_layout_selector_restores_the_expert_major_contract(self): + module = self._module() + em = self._construct(module, 8, COLLX_NCCL_LL_LAYOUT="expert-major") + rm = self._construct(module, 8) + ht = self._construct(module, 16, mode="normal") + with self.assertRaisesRegex(ValueError, "COLLX_NCCL_LL_LAYOUT"): + self._construct(module, 8, COLLX_NCCL_LL_LAYOUT="flat") + + self.assertEqual(em._layout, module.Layout.EXPERT_MAJOR) + self.assertEqual( + (em.kernel_generation, em.receive_layout, em.combine_weight_semantics), + ("nccl-ep-v02-ll-em", "token-expert", "weighted-kernel-sum"), + ) + self.assertFalse(em.zero_copy) + self.assertEqual(rm._layout, module.Layout.RANK_MAJOR) + # Only LL rank-major selects NCCL's FP32 reduction. + self.assertEqual((rm.combine_reduction, em.combine_reduction, ht.combine_reduction), + ("rank-fp32", "domain-fp32", "domain-fp32")) + + def test_ll_ladder_keeps_512_and_larger_points(self): + module = self._module() + backend = self._backend(module, low_latency=True) + backend.mode = "low-latency" + backend.args.tokens_ladder = "32 256 512 1024" + backend._build_rank_inputs = mock.Mock(return_value=None) + spec = backend.make_inputs(backend.args) + self.assertEqual(spec.ladder, [32, 256, 512, 1024]) + self.assertEqual(spec.dropped, []) + + def test_fp8_ll_accepts_both_phases_and_keeps_ht_disabled(self): + module = self._module() + module.nccl_ep.DispatchQuantizationRecipe = types.SimpleNamespace(DS_FP8E3M4="ds-fp8") + module.DispatchConfig = lambda **kw: types.SimpleNamespace(**kw) + with mock.patch.dict(os.environ, {"COLLX_NCCL_LL_LAYOUT": "rank-major"}): + for phase in ("decode", "prefill"): + with self.subTest(phase=phase): + options = args(phase=phase, precision="fp8", mode="low-latency", + hidden=7168, experts=384, topk=6, scale_up_domain=72) + backend = module.NCCLEPBackend(options, 0, 8, 0, "cuda:0") + self.assertEqual(backend._dispatch_cfg.quantization_recipe, "ds-fp8") + self.assertEqual(backend.dispatch_dtype, "fp8-e4m3fn") + self.assertTrue(backend.zero_copy) + self.assertEqual(backend.cuda_graph_supported, phase == "decode") + options.mode = "normal" + with self.assertRaisesRegex(ValueError, "low-latency mode only"): + module.NCCLEPBackend(options, 0, 8, 0, "cuda:0") + + def test_ll_prefill_uses_the_full_ladder_receive_without_graphs(self): + module = self._module() + backend = self._backend(module, low_latency=True) + backend.mode = "low-latency" + backend.args.phase = "prefill" + self.assertIsNone(backend.buffer_cap(backend.args)) + backend.create_buffer(types.SimpleNamespace(max_tokens_per_rank=8192)) + self.assertEqual(backend._recv_x.shape, (8, 8192, 7168)) + self.assertEqual(backend.created_configs[0].max_dispatch_tokens_per_rank, 8192) + self.assertFalse(backend.cuda_graph_supported) + + def test_ht_registers_a_symmetric_zero_copy_receive_window(self): + module = self._module() + backend = self._backend(module, low_latency=False) + backend.create_buffer(types.SimpleNamespace(max_tokens_per_rank=99)) + + self.assertEqual(backend.created_configs[0].zero_copy, module.ZeroCopyMode.ON) + backend._comm.register_window.assert_called_once_with( + backend._recv_x, flags=module.nccl_core.WindowFlag.COLL_SYMMETRIC + ) + self.assertIs(backend._recv_x_t.window, backend._recv_window_for_test) + self.assertEqual(backend._recv_x_t.window_offset, 0) + + def test_ll_rank_major_registers_a_direct_receive_window(self): + module = self._module() + backend = self._backend(module, low_latency=True) + backend.create_buffer(types.SimpleNamespace(ladder=[1, 64], max_tokens_per_rank=64)) + + self.assertTrue(backend.zero_copy) + self.assertEqual(backend.created_configs[0].zero_copy, module.ZeroCopyMode.ON) + backend._comm.register_window.assert_called_once_with( + backend._recv_x, flags=module.nccl_core.WindowFlag.COLL_SYMMETRIC + ) + self.assertEqual(backend._recv_x.shape, (8, 64, 7168)) + self.assertEqual(backend._recv_count.shape, (8,)) + self.assertIs(backend._recv_x_t.window, backend._recv_window) + + def test_fp8_buffer_binding_follows_selected_policy_in_both_phases(self): + for phase in ("decode", "prefill"): + for zero_copy in (False, True): + with self.subTest(phase=phase, zero_copy=zero_copy): + module = self._module() + backend = self._backend(module, low_latency=True) + backend.args.phase = phase + backend._fp8 = True + backend.zero_copy = zero_copy + windows = [types.SimpleNamespace(is_valid=True, close=mock.Mock()) + for _ in range(2)] + backend._comm.register_window.side_effect = windows + backend.create_buffer(types.SimpleNamespace(max_tokens_per_rank=1024)) + self.assertEqual(backend._recv_x.shape, (8, 1024, 7168)) + self.assertEqual(backend._recv_scales.shape, (8, 1024, 56)) + self.assertIs(backend._recv_x_t.window, windows[0] if zero_copy else None) + self.assertIs(backend._recv_scales_t.window, + windows[1] if zero_copy else None) + self.assertIs(backend._recv_scales_t.buffer, backend._recv_scales) + self.assertIs(backend._combine_x_t.buffer, backend._combine_scratch) + self.assertIsNone(backend._combine_x_t.window) + backend._handle = None + backend._bound = None + backend._ep_group = types.SimpleNamespace(destroy=lambda: None) + backend._comm.destroy = lambda: None + module.dist.barrier = lambda: None + module.dist.destroy_process_group = lambda: None + self.assertEqual(backend.finalize(0), 0) + for window in windows: + self.assertEqual(window.close.call_count, int(zero_copy)) + + def test_fp8_scale_registration_failure_releases_token_window_and_group(self): + module = self._module() + backend = self._backend(module, low_latency=True) + backend._fp8 = True + windows = [types.SimpleNamespace(is_valid=valid, close=mock.Mock()) + for valid in (True, False)] + backend._comm.register_window.side_effect = windows + group = types.SimpleNamespace(destroy=mock.Mock()) + module.nccl_ep.Group.create = lambda *a, **k: group + with self.assertRaisesRegex(RuntimeError, "scale window registration failed"): + backend.create_buffer(types.SimpleNamespace(max_tokens_per_rank=1024)) + for window in windows: + window.close.assert_called_once() + group.destroy.assert_called_once() + self.assertIsNone(backend._recv_window) + self.assertIsNone(backend._recv_scales_window) + self.assertIsNone(backend._ep_group) + + def test_ll_dispatch_uses_rank_major_metadata_and_returns_expert_inputs(self): + module = self._module() + backend = self._backend(module, low_latency=True) + backend.create_buffer(types.SimpleNamespace(ladder=[1, 64], max_tokens_per_rank=64)) + captured = {} + + class DispatchInputs: + def __init__(self, **kwargs): + captured["inputs"] = kwargs + + class DispatchOutputs: + def __init__(self, **kwargs): + captured["outputs"] = kwargs + + class LayoutInfo: + def __init__(self, **kwargs): + captured["layout"] = kwargs + + handle = types.SimpleNamespace(dispatch=mock.Mock()) + module.DispatchInputs = DispatchInputs + module.DispatchOutputs = DispatchOutputs + module.LayoutInfo = LayoutInfo + backend._handle = None + backend._bound = None + backend._ep_group = types.SimpleNamespace(create_handle=mock.Mock(return_value=handle)) + backend._dispatch_cfg = object() + backend._stream = lambda: 0 + + p = types.SimpleNamespace(T=1, dispatch_x="tokens", topk_idx="indices", topk_weights="weights") + backend.dispatch(p) + + self.assertIs(backend._layout, module.Layout.RANK_MAJOR) + self.assertEqual(captured["inputs"]["tokens"].buffer, "tokens") + self.assertEqual(captured["inputs"]["topk_weights"].buffer, "weights") + self.assertEqual(captured["outputs"]["tokens"].window, backend._recv_window) + self.assertIs(captured["outputs"]["topk_weights"], backend._recv_w_t) + self.assertIs(captured["outputs"]["topk_idx"], backend._recv_idx_t) + self.assertIs(captured["layout"]["src_rank_counters"], backend._recv_count_t) + handle.dispatch.assert_called_once() + + +_ep_nccl = _import_stubbed("ep_nccl") + + +class RebindRecordingHandle: + """Records every rebind so the tests can assert on the collective call pattern.""" + + def __init__(self): + self.updates = [] + + def update(self, topk_idx, *, layout_info=None, stream=None): + self.updates.append((topk_idx, layout_info)) + + +class RebindRecordingGroup: + def __init__(self): + self.created = 0 + self.handle = RebindRecordingHandle() + + def create_handle(self, layout, topk_idx, *, layout_info=None, config=None, stream=None): + self.created += 1 + return self.handle + + +def _handle_backend(ll=True): + """An NCCLEPBackend with just the fields _ensure_handle touches (no __init__, no GPU).""" + b = object.__new__(_ep_nccl.NCCLEPBackend) + b._ll = ll + b._layout = "EM" if ll else "FLAT" + b._handle = None + b._bound = None + b._ep_group = RebindRecordingGroup() + b.device = "cuda:0" + b.num_local_experts = 4 + b.args = types.SimpleNamespace(hidden=16) + b._t = lambda x: x + b._window_t = lambda x: x + b._stream = lambda: 0 + # create_buffer always runs before the first _ensure_handle, so the HT receive plane exists + # by then; a list stands in for the tensor because `_t` is identity here. + b._recv_x = list(range(64)) + b._recv_x_t = ("window", "full-plane") + return b + + +def _ladder_problem(T): + return types.SimpleNamespace( + T=T, dispatch_x=f"x{T}", topk_idx=f"idx{T}", topk_weights=f"w{T}" + ) + + +class TestSingleHandle(unittest.TestCase): + def test_one_handle_across_many_shapes(self): + """Nine ladder rungs produce exactly one create_handle, rebound to each later routing.""" + b = _handle_backend() + for T in (1, 2, 4, 8, 16, 32, 64, 128, 256): + b._ensure_handle(_ladder_problem(T)) + self.assertEqual(b._ep_group.created, 1) + self.assertEqual([idx for idx, _ in b._ep_group.handle.updates], + [f"idx{T}" for T in (2, 4, 8, 16, 32, 64, 128, 256)]) + + def test_ll_rank_major_weight_wrapper_is_built_once_per_handle(self): + """LL caches its dispatch-weight wrapper with the handle.""" + ll = _handle_backend(ll=True) + ll._t = mock.Mock(side_effect=lambda value: types.SimpleNamespace(value=value)) + pa = _ladder_problem(1) + h = ll._ensure_handle(pa) + first_weights = h.in_weights_t + self.assertEqual(first_weights.value, "w1") + # Re-entering the same problem reuses the handle and therefore the wrapper. + ll._t.reset_mock() + self.assertIs(ll._ensure_handle(pa).in_weights_t, first_weights) + ll._t.assert_not_called() + + def test_ht_combine_input_is_the_full_static_receive_plane(self): + """The FLAT contract gives combine the dispatch output's static [num_recv_slots, hidden] + shape (required under graph capture); a count-sized slice needs an AUTO-sized group.""" + b = _handle_backend(ll=False) + h = b._ensure_handle(_ladder_problem(1)) + # 7 is what the stubbed `torch.zeros(...).item()` reports as the received count. + self.assertEqual(h.count, 7) + self.assertIs(h.combine_in_t, b._recv_x_t) + + if __name__ == "__main__": unittest.main() diff --git a/inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/RECIPES.md b/inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/RECIPES.md index a97d8f0c9a..d3c19b21e3 100644 --- a/inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/RECIPES.md +++ b/inferencex-e2e/benchmarks/multi_node/srt-slurm-recipes/RECIPES.md @@ -2,7 +2,7 @@ InferenceX owns the recipes in this directory. For every NVIDIA srt-slurm launch, the srt driver ([`infx/launch/drivers/srt/checkout.py`](../../../infx/launch/drivers/srt/checkout.py)) makes a job-local Git clone of the pinned submodule and copies this entire tree into `recipes/`. It records the actual revision in `srt-slurm-sha.txt`; power lanes copy that revision into `power-producer-sha.txt` for result validation. -The shared version is the Git submodule pointer at [`utils/srt-slurm`](../../../utils/srt-slurm), currently [v2.65.2](https://github.com/NVIDIA/srt-slurm/releases/tag/v2.65.2) (`59920ea16a13017a990e449d034a495c0b05bc57`). Update that submodule pointer when upgrading, then run the recipe and integration checks. Do not add model-specific checkout branches to launchers. +The shared version is the Git submodule pointer at [`utils/srt-slurm`](../../../utils/srt-slurm), currently [v2.66.2](https://github.com/NVIDIA/srt-slurm/releases/tag/v2.66.2) (`acfb4dcc3bd48fd6a1e7b615e849e7954c0c3852`). Update that submodule pointer when upgrading, then run the recipe and integration checks. Do not add model-specific checkout branches to launchers. InferenceX requires srt-slurm 2.0 or newer and `schema: 2` recipes. Legacy recipe layouts are unsupported; migrate them before adding them to this tree. @@ -49,13 +49,47 @@ Keep the recipe and master configuration synchronized. The launcher executes the All referenced recipes must be checked in: srt-slurm 2 ships curated examples instead of the historical `recipes/` archive. The initial migration restores 204 previously external recipes and two still-referenced AgentX recipes from InferenceX history. Master-config paths follow the layout above; existing override selectors are preserved. +## Single-node parallelism validation + +The master search-space fields `tp`, `ep`, and `dp-attn` become `TP`, `EP_SIZE`, +and `DP_ATTENTION` in the benchmark environment. Each engine's recipe retains +its native argument names and parallelism conventions. +[`parallelism_constraints` and `validate_recipe`](../../../infx/srt_slurm/single_node.py) +compare the recipe with that metadata. The former returns named +`(actual, expected)` pairs; the latter raises `ValueError` on a mismatch. +Variant selection requires exactly one matching recipe before launch. +These checks never rewrite the recipe's parallelism settings or launch flags. + +| Engine | Recipe value compared with `tp` | Check against `ep` | Check against `dp-attn` | +|---|---|---|---| +| TokenSpeed | `attn-tp-size * data-parallel-size` | `ep-size == ep` | `(data-parallel-size > 1) == dp-attn` | +| SGLang | `tensor-parallel-size` | `expert-parallel-size`, or `ep-size` | `enable-dp-attention == dp-attn`; `data-parallel-size` must equal `tp` when enabled, otherwise 1 | +| TensorRT-LLM (`trt` in the master) | `tensor_parallel_size` | `moe_expert_parallel_size == ep` | `enable_attention_dp == dp-attn` | +| vLLM | `tensor-parallel-size * data-parallel-size` | `enable-expert-parallel == (ep > 1)` | `(data-parallel-size > 1) == dp-attn` | +| ATOM | `roles.agg.gpus` | `enable-expert-parallel == (ep > 1)`; `ep` must be 1 or `tp` | `enable-dp-attention == dp-attn` | + +For example, a master row with `tp: 4`, `ep: 4`, and `dp-attn: true` matches +TokenSpeed arguments `attn-tp-size: 1`, `data-parallel-size: 4`, and `ep-size: 4`: +one GPU per attention group times four groups gives the four-GPU width. +The matching SGLang arguments are `tensor-parallel-size: 4`, +`data-parallel-size: 4`, `expert-parallel-size: 4`, and +`enable-dp-attention: true`. This validator compares SGLang's TP directly; +it does not multiply that TP by DP. Comparing literal TP flags across engines +would incorrectly reject the TokenSpeed recipe because its attention TP is 1. + +The single-node launcher requires pipeline parallelism 1. vLLM's EP check +verifies the enable flag, not an exact numerical expert group size. `dp-attn` +is a boolean, not a count, so these checks do not encode every possible engine +topology. They prevent running and labeling a benchmark with inconsistent +recipe and master settings; the recipe still owns the native configuration. + ## Recipe fragments Active recipes, fixed-sequence and AgentX, single- and multi-node, are fragments: native srt-slurm YAML holding only recipe-specific settings. At launch the fragment is composed and bound: -1. The lane's shared block in [`configs/srt-recipes/`](../../../configs/srt-recipes) (`fixed-sequence-{single,multi}.yaml` or `agentic.yaml`) is merged under the fragment (under `base` for bundles). A multi-node row with `power: true` also gets [`telemetry-dcgm.yaml`](../../../configs/srt-recipes/telemetry-dcgm.yaml), its exporter on the cluster's `srt-slurm.power-exporter-port`; the lane must allow power (`POWER_LANES`). The fragment wins: mappings merge and lists replace, so a fragment may keep telemetry tunables such as `collector_join_timeout_seconds`. Native single-node benchmarks also get [`telemetry-native.yaml`](../../../configs/srt-recipes/telemetry-native.yaml) before variant selection. That block enables best-effort power collection with the cluster's `default_gpu_exporter`; eval-only points omit collection. The binder sets `telemetry.required` from `REQUIRE_POWER`, so a single-node fragment may not set it, and gives AgentX clients matching power switches. +1. The lane's shared block in [`configs/srt-recipes/`](../../../configs/srt-recipes) (`fixed-sequence-{single,multi}.yaml` or `agentic.yaml`) is merged under the fragment (under `base` for bundles). Every multi-node benchmark point on a cluster with a GPU exporter also gets a telemetry block, with the cluster's CPU exporter where it declares one, unless its recipe runs etcd and NATS on a dedicated node: [`telemetry-dcgm.yaml`](../../../configs/srt-recipes/telemetry-dcgm.yaml) where the cluster sets `srt-slurm.power-exporter-port`, else [`telemetry-native.yaml`](../../../configs/srt-recipes/telemetry-native.yaml). The binder sets `telemetry.required` only for a row with `power: true`. The fragment wins: mappings merge and lists replace, so a fragment may keep telemetry tunables such as `collector_join_timeout_seconds`. Native single-node benchmarks also get [`telemetry-native.yaml`](../../../configs/srt-recipes/telemetry-native.yaml) before variant selection. That block enables best-effort power collection with the cluster's `default_gpu_exporter`; eval-only points omit collection. A native single-node point on a cluster that declares `srt-slurm.cpu-power-exporter`, and a `cpu-power: true` row, also gets `telemetry.cpu_power_exporter` with that source. The binder sets `telemetry.required` from `REQUIRE_POWER`, so a single-node fragment may not set it, and gives AgentX clients matching power switches. 2. The row's selector picks the variant. A single-node variant may name its `benchmark.env.CONC`, which keeps that concurrency paired with its tuning. -3. The binder ([`workload.py`](../../../infx/srt_slurm/workload.py)) writes the matrix point into the selected recipe: `model.path: hf:`, `model.container: `, `model.precision`, `identity.container.image` (as a registry reference) and `identity.model.repo` when the fragment declares `identity.container`/`identity.model`, and `benchmark.concurrencies` when telemetry is enabled. Fixed-sequence recipes also get `benchmark.env.ISL`/`OSL`; single-node recipes get `MODEL` and `CONC`, and fixed-sequence ones `RANDOM_RANGE_RATIO` and `USE_CHAT_TEMPLATE` (`true` exactly when the recipe speculates). Multi-node AgentX recipes get the client's `RESULT_DIR`, `AIPERF_DATASET_MMAP_CACHE_DIR` and `HF_HUB_CACHE` from the launcher, which derives them from the volumes it mounts (`volume-mounts`, `agentic-volume-mounts`, lane mounts); a fragment that sets `HF_HOME` keeps its own cache. They also get `IS_MULTINODE: "true"` and `ENABLE_AGENTX_POWER`, `"1"` exactly when the launcher collects the point's AgentX power (a `power: true` row whose lane rule collects AgentX or adapter power) and `"0"` otherwise, and `REQUIRE_POWER`, `"1"` when the workflow requires power or the lane rule marks the point `require` (GB200 and GB300 Kimi-K3 AgentX), so the client fails a point whose power is invalid. The multi-node client reads `CONC_LIST`, `CONC`, `MODEL` and the other matrix inputs, prefill and decode topology included, from the job environment, so fragments do not copy them (`TP`, `PP_SIZE`, `PCP_SIZE` and `EP_SIZE` describe single-node points only); single-node AgentX points get them as runtime arguments. +3. The binder ([`workload.py`](../../../infx/srt_slurm/workload.py)) writes the matrix point into the selected recipe: `model.path: hf:`, `model.container: `, `model.precision`, `identity.container.image` (as a registry reference) and `identity.model.repo` when the fragment declares `identity.container`/`identity.model`, and `benchmark.concurrencies` when telemetry is enabled. Fixed-sequence recipes also get `benchmark.env.ISL`/`OSL`; single-node recipes get `MODEL` and `CONC`, and fixed-sequence ones `RANDOM_RANGE_RATIO` and `USE_CHAT_TEMPLATE` (`true` exactly when the recipe speculates). Multi-node AgentX recipes get the client's `RESULT_DIR`, `AIPERF_DATASET_MMAP_CACHE_DIR` and `HF_HUB_CACHE` from the launcher, which derives them from the volumes it mounts (`volume-mounts`, `agentic-volume-mounts`, lane mounts); a fragment that sets `HF_HOME` keeps its own cache. They also get `IS_MULTINODE: "true"` and `ENABLE_AGENTX_POWER`, `"1"` exactly when the launcher collects the point's AgentX power (a `power: true` row whose lane rule collects AgentX or adapter power, or any GB200 or GB300 AgentX point the launcher measures by default) and `"0"` otherwise, and `REQUIRE_POWER`, `"1"` when the workflow requires power or the lane rule marks the point `require`, so the client fails a point whose power is invalid. The multi-node client reads `CONC_LIST`, `CONC`, `MODEL` and the other matrix inputs, prefill and decode topology included, from the job environment, so fragments do not copy them (`TP`, `PP_SIZE`, `PCP_SIZE` and `EP_SIZE` describe single-node points only); single-node AgentX points get them as runtime arguments. 4. A component a repository setup script installs gets the master config's version. When `setup_script`, or a service `preamble` that runs `/configs/