diff --git a/.github/workflows/benchmark-multinode-tmpl.yml b/.github/workflows/benchmark-multinode-tmpl.yml index 0a666a6e90..7004faed99 100644 --- a/.github/workflows/benchmark-multinode-tmpl.yml +++ b/.github/workflows/benchmark-multinode-tmpl.yml @@ -133,6 +133,16 @@ on: type: boolean required: false default: false + eval-framework: + description: "Eval runner (lm-eval, swebench, or kimi-vendor)" + type: string + required: false + default: "lm-eval" + eval-suite: + description: "Kimi Vendor Verifier suite; leave empty for other eval runners" + type: string + required: false + default: "" eval-conc: description: "Concurrency value or space-separated list for eval requests (overrides default max-of-conc-list)" type: string @@ -232,6 +242,8 @@ env: DECODE_HARDWARE: ${{ inputs.decode-hardware }} RUN_EVAL: ${{ inputs.run-eval }} EVAL_ONLY: ${{ inputs.eval-only }} + EVAL_FRAMEWORK: ${{ inputs.eval-framework }} + EVAL_SUITE: ${{ inputs.eval-suite }} EVAL_CONC: ${{ inputs.eval-conc }} EVAL_LIMIT: ${{ inputs.eval-limit }} SWEBENCH_GEN_MODE: ${{ inputs.swebench-gen-mode }} @@ -467,10 +479,11 @@ jobs: if: ${{ always() && (env.RUN_EVAL == 'true' || inputs.eval-only) }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: eval_${{ env.RESULT_FILENAME }} + name: eval_${{ env.EXP_NAME }}_${{ env.PRECISION }}_${{ env.FRAMEWORK }}_p${{ env.PREFILL_NUM_WORKERS }}x${{ env.PREFILL_TP }}p${{ env.PREFILL_PP_SIZE }}c${{ env.PREFILL_DCP_SIZE }}k${{ env.PREFILL_PCP_SIZE }}e${{ env.PREFILL_EP }}d${{ env.PREFILL_DP_ATTN }}_d${{ env.DECODE_NUM_WORKERS }}x${{ env.DECODE_TP }}p${{ env.DECODE_PP_SIZE }}c${{ env.DECODE_DCP_SIZE }}k${{ env.DECODE_PCP_SIZE }}e${{ env.DECODE_EP }}d${{ env.DECODE_DP_ATTN }}_kv${{ env.KV_OFFLOADING }}-${{ env.KV_OFFLOAD_BACKEND }}_spec${{ env.SPEC_DECODING }}_c${{ join(fromJson(inputs.conc-list), 'x') }}_${{ runner.name }} path: | meta_env.json results*.json + *_vendor_report.json sample*.jsonl agent_preds.json predictions.jsonl @@ -492,6 +505,7 @@ jobs: run: | rm -f meta_env.json || true rm -f results*.json || true + rm -f *_vendor_report.json || true rm -f sample*.jsonl || true rm -f agent_preds.json predictions.jsonl swebench_report_*.json *.traj* || true diff --git a/.github/workflows/benchmark-tmpl.yml b/.github/workflows/benchmark-tmpl.yml index 6c4fe50fe5..27f933dec5 100644 --- a/.github/workflows/benchmark-tmpl.yml +++ b/.github/workflows/benchmark-tmpl.yml @@ -85,6 +85,16 @@ on: type: boolean required: false default: false + eval-framework: + description: "Eval runner (lm-eval, swebench, or kimi-vendor)" + type: string + required: false + default: "lm-eval" + eval-suite: + description: "Kimi Vendor Verifier suite; leave empty for other eval runners" + type: string + required: false + default: "" random-range-ratio: required: false type: string @@ -173,6 +183,8 @@ env: DISAGG: ${{ inputs.disagg }} RUN_EVAL: ${{ inputs.run-eval }} EVAL_ONLY: ${{ inputs.eval-only }} + EVAL_FRAMEWORK: ${{ inputs.eval-framework }} + EVAL_SUITE: ${{ inputs.eval-suite }} # Agentic-coding env. Fixed-seq-len jobs leave these empty. SCENARIO_TYPE: ${{ inputs.scenario-type }} SCENARIO_SUBDIR: ${{ inputs.scenario-type == 'agentic-coding' && 'agentic/' || 'fixed_seq_len/' }} @@ -197,7 +209,7 @@ env: MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} # These b300 nodes are currently broken. - SALLOC_EXCLUDE: 'b300-005,b300-006' + SALLOC_EXCLUDE: 'b300-005,b300-006,b300-017' permissions: contents: read @@ -392,6 +404,7 @@ jobs: path: | meta_env.json results*.json + *_vendor_report.json sample*.jsonl agent_preds.json predictions.jsonl @@ -409,6 +422,7 @@ jobs: rm -f meta_env.json || true # Remove any eval results JSONs that were moved into workspace rm -f results*.json || true + rm -f -- ./*_vendor_report.json || true rm -f sample*.jsonl || true rm -f agent_preds.json predictions.jsonl swebench_report_*.json *.traj* || true diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index c973df94e6..dc4bbb665a 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -45,6 +45,16 @@ on: required: false type: string default: "" + eval-framework: + description: "Agentic eval runner (lm-eval, swebench, or kimi-vendor)" + required: false + type: string + default: "lm-eval" + eval-suite: + description: "Kimi Vendor Verifier suite; empty for other runners" + required: false + type: string + default: "" swebench-gen-mode: description: "SWE-bench generation mode (single-shot | agentic). Empty = agentic (single-shot is an explicit debugging escape hatch)." required: false @@ -125,6 +135,16 @@ on: required: false type: string default: "" + eval-framework: + description: "Agentic eval runner (lm-eval, swebench, or kimi-vendor)" + required: false + type: string + default: "lm-eval" + eval-suite: + description: "Kimi Vendor Verifier suite; empty for other runners" + required: false + type: string + default: "" swebench-gen-mode: description: "SWE-bench generation mode (single-shot | agentic). Empty = agentic (single-shot is an explicit debugging escape hatch)." required: false @@ -236,7 +256,7 @@ jobs: CMD+=(--evals-only) fi RAW_CONFIG_JSON=$("${CMD[@]}") - CONFIG_JSON=$(python3 -c 'import json,sys; data=json.load(sys.stdin); rows=[row for family in ("single_node","multi_node") for group in data.get(family,{}).values() for row in group]; rows.extend(row for family in ("evals","agentic_evals","multinode_evals") for row in data.get(family,[])); print(json.dumps(rows))' <<<"$RAW_CONFIG_JSON") + CONFIG_JSON=$(python3 -c 'import json,sys; data=json.load(sys.stdin); rows=[row for family in ("single_node","multi_node") for group in data.get(family,{}).values() for row in group]; rows.extend(row for family in ("evals","agentic_evals","multinode_evals","multinode_agentic_evals") for row in data.get(family,[])); print(json.dumps(rows))' <<<"$RAW_CONFIG_JSON") else GENERATE_COMMAND="${{ inputs.generate-cli-command || github.event.inputs.generate-cli-command }}" if [ -z "$GENERATE_COMMAND" ]; then @@ -260,6 +280,7 @@ jobs: MULTI_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' in x and not x.get('run-eval', False)]))" | score_matrix multi-agentic) MULTI_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' in x and x.get('run-eval', False)]))" | score_matrix multi-agentic-eval) SINGLE=$(echo "$CONFIG_JSON" | python3 -c "import sys,json; d=json.load(sys.stdin); print(json.dumps([x for x in d if 'prefill' not in x and x.get('scenario-type') != 'agentic-coding' and not x.get('eval-only', False)]))" | score_matrix single) + EVALS=$(echo "$CONFIG_JSON" | python3 -c "import sys,json; d=json.load(sys.stdin); print(json.dumps([x for x in d if 'prefill' not in x and x.get('scenario-type') != 'agentic-coding' and x.get('run-eval', False)]))" | score_matrix eval) MULTI=$(echo "$CONFIG_JSON" | python3 -c "import sys,json; d=json.load(sys.stdin); print(json.dumps([x for x in d if 'prefill' in x and x.get('scenario-type') != 'agentic-coding' and not x.get('eval-only', False)]))" | score_matrix multi) MULTI_EVAL=$(echo "$CONFIG_JSON" | python3 -c "import sys,json; d=json.load(sys.stdin); print(json.dumps([x for x in d if 'prefill' in x and x.get('scenario-type') != 'agentic-coding' and x.get('run-eval', False)]))" | score_matrix multi-eval) { @@ -377,6 +398,8 @@ jobs: run-eval: true eval-only: true eval-conc: ${{ matrix.config['eval-all-concs'] && join(matrix.config.conc, ' ') || matrix.config['eval-conc'] }} + eval-framework: ${{ inputs.eval-framework }} + eval-suite: ${{ inputs.eval-suite }} ref: ${{ inputs.ref }} test-sweep-agentic: @@ -455,12 +478,14 @@ jobs: isl: '0' osl: '0' max-model-len: '0' - spec-decoding: 'none' + spec-decoding: ${{ matrix.config.spec-decoding }} disagg: ${{ 'false' }} run-eval: true eval-only: true eval-limit: ${{ inputs.eval-limit }} swebench-gen-mode: ${{ inputs.swebench-gen-mode }} + eval-framework: ${{ inputs.eval-framework }} + eval-suite: ${{ inputs.eval-suite }} scenario-type: agentic-coding ref: ${{ inputs.ref }} @@ -576,6 +601,8 @@ jobs: eval-conc: ${{ matrix.config['eval-conc'] }} eval-limit: ${{ inputs.eval-limit }} swebench-gen-mode: ${{ inputs.swebench-gen-mode }} + eval-framework: ${{ inputs.eval-framework }} + eval-suite: ${{ inputs.eval-suite }} scenario-type: agentic-coding ref: ${{ inputs.ref }} @@ -654,6 +681,8 @@ jobs: run-eval: true eval-only: true eval-limit: ${{ inputs.eval-limit }} + eval-framework: ${{ inputs.eval-framework }} + eval-suite: ${{ inputs.eval-suite }} ref: ${{ inputs.ref }} collect-results: diff --git a/.github/workflows/run-sweep.yml b/.github/workflows/run-sweep.yml index b8e95f0806..62d013934b 100644 --- a/.github/workflows/run-sweep.yml +++ b/.github/workflows/run-sweep.yml @@ -853,12 +853,12 @@ jobs: eval-only: true eval-conc: ${{ matrix.config['eval-all-concs'] && join(matrix.config.conc, ' ') || matrix.config['eval-conc'] }} - # Multi-node agentic (SWE-bench) eval rows carry the agentic input shape, - # so they are dispatched with sweep-multi-node-agentic's inputs rather - # than sweep-multi-node-evals' fixed-seq-len inputs (isl/osl/max-model-len, - # which agentic rows don't have). SWE-bench doesn't support batched - # concurrencies (unlike lm-eval), so eval-conc is always a single value, - # never the joined-list form sweep-multi-node-evals uses. + # Multi-node agentic GSM8K eval rows carry the agentic input shape, so + # they are dispatched with sweep-multi-node-agentic's inputs rather than + # sweep-multi-node-evals' fixed-seq-len inputs (isl/osl/max-model-len, + # which agentic rows don't have). Agentic selection uses one highest + # eval-conc per topology; fixed-sequence --all-evals rows may instead pass + # a joined concurrency list to lm-eval. sweep-multi-node-agentic-evals: needs: [setup, canary-select, canary-sweep] if: >- diff --git a/benchmarks/benchmark_lib.sh b/benchmarks/benchmark_lib.sh index 8cc894940f..fe81bccc4b 100644 --- a/benchmarks/benchmark_lib.sh +++ b/benchmarks/benchmark_lib.sh @@ -8,6 +8,12 @@ export PYTHONDONTWRITEBYTECODE=1 export PYTHONPYCACHEPREFIX="${PYTHONPYCACHEPREFIX:-/tmp/inferencex-pycache}" mkdir -p "$PYTHONPYCACHEPREFIX" 2>/dev/null || true +INFERENCEX_BENCHMARK_LIB_DIR="$( + cd "$(dirname "${BASH_SOURCE[0]}")" && pwd +)" +INFERENCEX_REPO_ROOT="$( + cd "$INFERENCEX_BENCHMARK_LIB_DIR/.." && pwd +)" # Inference server port shared by every benchmark recipe. Launchers that need # a non-default value (e.g. launch_mi355x-amds.sh derives PORT from RUNNER_NAME @@ -886,8 +892,396 @@ _install_lm_eval_deps() { fi } +_require_kimi_vendor_python() { + if python3 -c 'import sys; raise SystemExit(sys.version_info < (3, 12))'; then + return 0 + fi + + local python_version + python_version="$(python3 -c 'import platform; print(platform.python_version())' 2>/dev/null || printf 'unavailable')" + echo "ERROR: Kimi Vendor Verifier requires Python >=3.12 (python3 is ${python_version})" >&2 + return 2 +} + +_install_kimi_vendor_eval_deps() { + local target_dir="$1" + python3 -m pip install -q --no-cache-dir --target "$target_dir" \ + "httpx[http2]==0.28.1" \ + "openai==2.14.0" \ + "jsonschema==4.25.1" \ + "pytest==8.4.2" \ + "pytest-rerunfailures==16.4" +} + +_prepare_kimi_vendor_runtime() { + local runtime_dir install_rc=0 + runtime_dir="$(mktemp -d /tmp/kimi-vendor-runtime-XXXXXX)" || return $? + _install_kimi_vendor_eval_deps "$runtime_dir" >&2 || install_rc=$? + if [ "$install_rc" -ne 0 ]; then + rm -rf "$runtime_dir" + return "$install_rc" + fi + printf '%s\n' "$runtime_dir" +} + +_prepare_kimi_vendor_verifier() { + local repo_url="$1" + local verifier_ref="$2" + local checkout_dir prepare_rc=0 + + checkout_dir="$(mktemp -d /tmp/kimi-vendor-verifier-XXXXXX)" || { + echo "ERROR: could not create a temporary directory for Kimi-Vendor-Verifier" >&2 + return 1 + } + + python3 - "$repo_url" "$verifier_ref" "$checkout_dir" <<'PY' || prepare_rc=$? +from pathlib import Path +import re +import socket +import sys +import tarfile +import tempfile +import time +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urlsplit, urlunsplit +from urllib.request import Request, urlopen + + +repo_url, verifier_ref, checkout_dir_arg = sys.argv[1:] +checkout_dir = Path(checkout_dir_arg) +stage = "derive the pinned archive URL" + + +def archive_member_parts(name): + if not name or "\x00" in name or "\\" in name or name.startswith("/"): + raise ValueError(f"unsafe archive member path: {name!r}") + normalized = name.rstrip("/") + parts = normalized.split("/") + if not normalized or any(part in ("", ".", "..") for part in parts): + raise ValueError(f"unsafe archive member path: {name!r}") + return tuple(parts) + + +try: + if not re.fullmatch(r"[0-9a-fA-F]{40}", verifier_ref): + raise ValueError(f"expected a 40-character commit SHA, got {verifier_ref!r}") + + parsed_repo_url = urlsplit(repo_url) + if parsed_repo_url.scheme not in ("http", "https") or not parsed_repo_url.netloc: + raise ValueError(f"unsupported repository URL: {repo_url!r}") + if parsed_repo_url.query or parsed_repo_url.fragment: + raise ValueError(f"repository URL must not contain a query or fragment: {repo_url!r}") + repo_path = parsed_repo_url.path.rstrip("/") + if repo_path.endswith(".git"): + repo_path = repo_path[:-4] + if not repo_path: + raise ValueError(f"repository URL has no repository path: {repo_url!r}") + archive_path = f"{repo_path}/archive/{quote(verifier_ref, safe='')}.tar.gz" + archive_url = urlunsplit( + (parsed_repo_url.scheme, parsed_repo_url.netloc, archive_path, "", "") + ) + + stage = f"download {archive_url}" + request = Request( + archive_url, + headers={"User-Agent": "InferenceX-Kimi-Vendor-Verifier"}, + ) + with tempfile.TemporaryFile() as archive_file: + downloaded = 0 + for attempt in range(1, 4): + archive_file.seek(0) + archive_file.truncate() + downloaded = 0 + deadline = time.monotonic() + 60 + try: + with urlopen(request, timeout=60) as response: + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError( + "archive download exceeded the 60-second deadline" + ) + sock = getattr(getattr(response, "fp", None), "raw", None) + sock = getattr(sock, "_sock", None) + if sock is not None: + sock.settimeout(max(0.001, remaining)) + try: + chunk = response.read(1024 * 1024) + except socket.timeout as error: + raise TimeoutError( + "archive download exceeded the 60-second deadline" + ) from error + if not chunk: + break + downloaded += len(chunk) + if downloaded > 128 * 1024 * 1024: + raise ValueError( + "archive download exceeds the 128 MiB safety limit" + ) + archive_file.write(chunk) + break + except HTTPError as error: + if error.code not in (408, 429) and not 500 <= error.code < 600: + raise + if attempt == 3: + raise + print( + f"WARN: Kimi-Vendor-Verifier archive download attempt " + f"{attempt}/3 failed: {error}; retrying", + file=sys.stderr, + ) + time.sleep(attempt) + except (TimeoutError, URLError, ConnectionError) as error: + if attempt == 3: + raise + print( + f"WARN: Kimi-Vendor-Verifier archive download attempt " + f"{attempt}/3 failed: {error}; retrying", + file=sys.stderr, + ) + time.sleep(attempt) + if downloaded == 0: + raise ValueError("downloaded archive is empty") + archive_file.seek(0) + + stage = "validate the downloaded archive" + required_files = { + "pyproject.toml", + "tests/conftest.py", + "tests/__init__.py", + "tests/tool_call_json_schema/conftest.py", + "tests/tool_call_json_schema/__init__.py", + "tests/tool_call_json_schema/test_tool_call_json_schema.py", + "tests/tool_call_json_schema/validator.py", + "testdata/walle_validator_cases/validator_cases/TestAdditionalProperties/valid.jsonl", + "testdata/walle_validator_cases/validator_cases/TestAnyOf/valid.jsonl", + "testdata/walle_validator_cases/validator_cases/TestBasicTypes/valid.jsonl", + "testdata/walle_validator_cases/validator_cases/TestDefs/valid.jsonl", + "testdata/walle_validator_cases/validator_cases/TestDescription/valid.jsonl", + "testdata/walle_validator_cases/validator_cases/TestEnforcerCases/valid.jsonl", + "testdata/walle_validator_cases/validator_cases/TestID/valid.jsonl", + "testdata/walle_validator_cases/validator_cases/TestKeywordsValidation/valid.jsonl", + "testdata/walle_validator_cases/validator_cases/TestNestedDefsDepth/valid.jsonl", + "testdata/walle_validator_cases/validator_cases/TestNumberFormat/valid.jsonl", + "testdata/walle_validator_cases/validator_cases/TestRangeConstraints/valid.jsonl", + "testdata/walle_validator_cases/validator_cases/TestRefInProperties/valid.jsonl", + "testdata/walle_validator_cases/validator_cases/TestReferences/valid.jsonl", + "testdata/walle_validator_cases/validator_cases/TestRequired/valid.jsonl", + "testdata/walle_validator_cases/validator_cases/TestSingleTypeInArray/valid.jsonl", + "testdata/walle_validator_cases/validator_cases/TestTypeLocation/valid.jsonl", + } + selected_files = {} + archive_roots = set() + member_count = 0 + archive_size = 0 + selected_size = 0 + + with tarfile.open(fileobj=archive_file, mode="r|gz") as archive: + for member in archive: + member_count += 1 + if member_count > 100_000: + raise ValueError("archive contains more than 100000 members") + if member.size < 0: + raise ValueError( + f"archive member has a negative size: {member.name!r}" + ) + archive_size += member.size + if archive_size > 512 * 1024 * 1024: + raise ValueError("expanded archive exceeds the 512 MiB safety limit") + + parts = archive_member_parts(member.name) + archive_roots.add(parts[0]) + if len(archive_roots) > 1: + roots = ", ".join(sorted(archive_roots)) + raise ValueError(f"archive has multiple roots: {roots}") + if not (member.isdir() or member.isfile()): + raise ValueError( + f"archive member has unsafe type: {member.name!r}" + ) + if len(parts) == 1: + continue + + relative_path = "/".join(parts[1:]) + if relative_path not in required_files: + continue + if relative_path in selected_files: + raise ValueError( + f"archive contains duplicate selected path: {relative_path!r}" + ) + if not member.isfile(): + raise ValueError( + f"required path is not a regular file: {relative_path}" + ) + selected_size += member.size + if selected_size > 256 * 1024 * 1024: + raise ValueError( + "selected archive subset exceeds the 256 MiB safety limit" + ) + source = archive.extractfile(member) + if source is None: + raise ValueError(f"could not read archive member: {member.name!r}") + with source: + content = source.read(member.size + 1) + if len(content) != member.size: + raise ValueError( + f"archive member size mismatch: {member.name!r}" + ) + selected_files[relative_path] = content + + if member_count == 0: + raise ValueError("archive contains no members") + if len(archive_roots) != 1: + raise ValueError("archive does not have exactly one root") + missing_files = sorted(required_files - selected_files.keys()) + if missing_files: + raise ValueError( + "archive is missing required files: " + ", ".join(missing_files) + ) + + stage = "extract the verified archive subset" + if any(checkout_dir.iterdir()): + raise ValueError(f"checkout directory is not empty: {checkout_dir}") + for relative_path, content in selected_files.items(): + destination = checkout_dir.joinpath(*relative_path.split("/")) + destination.parent.mkdir(parents=True, exist_ok=True) + with destination.open("xb") as output: + output.write(content) +except Exception as error: + print( + f"ERROR: failed to {stage} for Kimi-Vendor-Verifier " + f"at {verifier_ref}: {error}", + file=sys.stderr, + ) + raise SystemExit(1) +PY + + if [ "$prepare_rc" -ne 0 ]; then + if ! rm -rf "$checkout_dir"; then + echo "ERROR: failed to remove partial Kimi-Vendor-Verifier directory ${checkout_dir}" >&2 + fi + return "$prepare_rc" + fi + + printf '%s\n' "$checkout_dir" +} + +_cleanup_kimi_vendor_eval() { + local path + for path in "$@"; do + [ -z "$path" ] || rm -rf "$path" || true + done +} + +_write_kimi_vendor_integration_error() { + local adapter_path="$1" + local model_name="$2" + local results_dir="$3" + local message="$4" + + python3 "$adapter_path" \ + --model "$model_name" \ + --output-dir "$results_dir" \ + --integration-error "$message" +} + +_run_kimi_tool_call_schema_eval() { + local port="${PORT:-8888}" + local results_dir="${EVAL_RESULT_DIR:-$(mktemp -d /tmp/eval_out-XXXXXX)}" + local verifier_repo="https://github.com/MoonshotAI/Kimi-Vendor-Verifier.git" + local verifier_ref="b9ed3a6665bdff2c943246f7d2903cd003d6ddd6" + + while [[ $# -gt 0 ]]; do + case "$1" in + --port|--results-dir) + if [[ $# -lt 2 || -z "${2:-}" || "${2:-}" == --* ]]; then + echo "ERROR: $1 requires a value" >&2 + return 2 + fi + case "$1" in + --port) port="$2" ;; + --results-dir) results_dir="$2" ;; + esac + shift 2 + ;; + *) + echo "Unknown parameter: $1" >&2 + return 2 + ;; + esac + done + + + local model_name="${MODEL_NAME:-${MODEL:-}}" + local adapter_path="${INFERENCEX_REPO_ROOT}/utils/evals/kimi_vendor_eval.py" + local runtime_dir="" + local checkout_dir="" + + mkdir -p "$results_dir" || return $? + export EVAL_RESULT_DIR="$results_dir" + + local setup_rc=0 integration_error="" + _require_kimi_vendor_python || { + setup_rc=$? + integration_error="Kimi Vendor Verifier Python version check failed with exit code ${setup_rc}" + } + if [ "$setup_rc" -eq 0 ]; then + runtime_dir=$(_prepare_kimi_vendor_runtime) || { + setup_rc=$? + integration_error="Kimi Vendor Verifier dependency installation failed with exit code ${setup_rc}" + } + fi + if [ "$setup_rc" -eq 0 ]; then + checkout_dir=$( + _prepare_kimi_vendor_verifier "$verifier_repo" "$verifier_ref" + ) || { + setup_rc=$? + integration_error="Kimi Vendor Verifier checkout failed with exit code ${setup_rc}" + } + fi + if [ "$setup_rc" -ne 0 ]; then + _cleanup_kimi_vendor_eval "$runtime_dir" "$checkout_dir" + echo "ERROR: ${integration_error}" >&2 + local artifact_rc=0 + _write_kimi_vendor_integration_error \ + "$adapter_path" "$model_name" "$results_dir" "$integration_error" \ + || artifact_rc=$? + if [ "$artifact_rc" -ne 0 ]; then + echo "ERROR: failed to write Kimi verifier failure artifact (exit code ${artifact_rc})" >&2 + fi + return "$setup_rc" + fi + + local eval_rc=0 + PYTHONPATH="${runtime_dir}${PYTHONPATH:+:${PYTHONPATH}}" \ + python3 "$adapter_path" \ + --verifier-dir "$checkout_dir" \ + --base-url "http://127.0.0.1:${port}/v1" \ + --api-key EMPTY \ + --model "$model_name" \ + --output-dir "$results_dir" \ + || eval_rc=$? + _cleanup_kimi_vendor_eval "$runtime_dir" "$checkout_dir" + return "$eval_rc" +} + +run_kimi_vendor_eval() { + local eval_suite="${EVAL_SUITE:-kimi_tool_call_schema}" + export EVAL_SUITE="$eval_suite" + + case "$eval_suite" in + kimi_tool_call_schema) + _run_kimi_tool_call_schema_eval "$@" + ;; + *) + echo "ERROR: unsupported Kimi Vendor Verifier suite '${eval_suite}'" >&2 + export EVAL_RESULT_DIR="" + return 2 + ;; + esac +} + _eval_patches_dir() { - cd "$(dirname "${BASH_SOURCE[0]}")/../utils/evals/patches" && pwd + printf '%s\n' "${INFERENCEX_REPO_ROOT}/utils/evals/patches" } _patch_lm_eval() { @@ -996,14 +1390,15 @@ run_lm_eval() { done # Serving images may use a different WORKDIR. - local _repo_root - _repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + local _repo_root="$INFERENCEX_REPO_ROOT" if [[ "$tasks_dir" == *.yaml && "$tasks_dir" != /* \ && ! -f "$tasks_dir" && -f "$_repo_root/$tasks_dir" ]]; then echo "run_lm_eval: anchoring relative task '$tasks_dir' to repo root -> $_repo_root/$tasks_dir" tasks_dir="$_repo_root/$tasks_dir" fi + export EVAL_TASKS_DIR="$tasks_dir" + if [ "${INFERENCEX_LM_EVAL_RUNTIME_READY:-false}" != "true" ]; then _install_lm_eval_deps _patch_lm_eval @@ -1119,8 +1514,8 @@ _eval_concs_to_json() { } _env_is_true() { - case "${1,,}" in - 1|true|yes|on) return 0 ;; + case "${1:-}" in + 1|[Tt][Rr][Uu][Ee]|[Yy][Ee][Ss]|[Oo][Nn]) return 0 ;; *) return 1 ;; esac } @@ -1213,6 +1608,13 @@ _write_lm_eval_meta_json() { fi fi fi + local eval_suite="${EVAL_COMPLETED_SUITE:-${EVAL_SUITE:-}}" + if [ -z "$eval_suite" ] && [ -n "${EVAL_TASKS_DIR:-}" ]; then + eval_suite="$(basename "${EVAL_TASKS_DIR}")" + eval_suite="${eval_suite%.yaml}" + eval_suite="${eval_suite%.yml}" + fi + eval_suite="${eval_suite:-gsm8k}" cat > "${meta_json}" <&2 + return 2 + ;; + esac + + if [ -n "${EVAL_SUITE:-}" ] && [ "$framework" != "kimi-vendor" ]; then + echo "ERROR: EVAL_SUITE is only supported with EVAL_FRAMEWORK=kimi-vendor" >&2 + return 2 + fi + + # Kimi Vendor Verifier uses a fixed request budget and does not consume + # EVAL_MAX_MODEL_LEN, so avoid loading model configuration for that path. + if [ "$framework" != "kimi-vendor" ] && [ -z "${EVAL_MAX_MODEL_LEN:-}" ]; then compute_eval_context_length "$MODEL" "${MAX_MODEL_LEN:-0}" > /dev/null fi @@ -1757,9 +2181,14 @@ run_eval() { case "$framework" in lm-eval|lm_eval) run_lm_eval "${forwarded[@]}" || eval_rc=$? ;; swebench) run_swebench_eval "${forwarded[@]}" || eval_rc=$? ;; + kimi-vendor) run_kimi_vendor_eval "${forwarded[@]}" || eval_rc=$? ;; *) echo "Unknown framework '${framework}'"; eval_rc=1 ;; esac + if [ "$framework" = "kimi-vendor" ]; then + export EVAL_COMPLETED_SUITE="$EVAL_SUITE" + fi + # Agentic eval-only recipes have no separate staging step. if [ "${EVAL_ONLY:-false}" = "true" ] && [ "$scenario_is_agentic" = "1" ]; then append_lm_eval_summary || true @@ -1767,7 +2196,7 @@ run_eval() { if [ "$eval_rc" -ne 0 ]; then echo "ERROR: run_eval failed with exit code $eval_rc" >&2 - if [ "${EVAL_ONLY}" = "true" ]; then + if [ "${EVAL_ONLY:-false}" = "true" ]; then echo "Eval-only mode: failing after artifact collection" >&2 return "$eval_rc" fi diff --git a/benchmarks/multi_node/agentic_srt.sh b/benchmarks/multi_node/agentic_srt.sh index 79a36da524..dea0881327 100644 --- a/benchmarks/multi_node/agentic_srt.sh +++ b/benchmarks/multi_node/agentic_srt.sh @@ -26,8 +26,6 @@ for concurrency in "${CONCURRENCIES[@]}"; do fi done -resolve_trace_source -install_agentic_deps wait_for_agentic_servers_idle() { local timeout_seconds="${AIPERF_DRAIN_TIMEOUT_SECONDS:-1800}" @@ -98,6 +96,9 @@ raise SystemExit(f"Agentic servers did not drain within {timeout_seconds} second PY } +resolve_trace_source +install_agentic_deps + # The AgentX scenario's first-turn cache-bust marker includes AIPerf's unique # per-invocation benchmark ID. Each point therefore gets a disjoint KV keyspace # while its own warmup and profile phases share markers. This makes sequential diff --git a/benchmarks/multi_node/srt-slurm-recipes/vllm/kimi-k3/agentic/agg-b200-tp8pp2-agentic.yaml b/benchmarks/multi_node/srt-slurm-recipes/vllm/kimi-k3/agentic/agg-b200-tp8pp2-agentic.yaml index a0207d65f2..e6ed84715d 100644 --- a/benchmarks/multi_node/srt-slurm-recipes/vllm/kimi-k3/agentic/agg-b200-tp8pp2-agentic.yaml +++ b/benchmarks/multi_node/srt-slurm-recipes/vllm/kimi-k3/agentic/agg-b200-tp8pp2-agentic.yaml @@ -80,7 +80,7 @@ backend: TILELANG_CLEANUP_TEMP_FILES: "1" UCX_MEMTYPE_CACHE: "n" UCX_MEMTYPE_REG_WHOLE: "n" - UCX_NET_DEVICES: "mlx5_0:1,mlx5_1:1,mlx5_2:1,mlx5_3:1,mlx5_4:1,mlx5_5:1,mlx5_10:1,mlx5_11:1" + UCX_NET_DEVICES: "mlx5_0:1,mlx5_1:1,mlx5_2:1,mlx5_3:1,mlx5_4:1,mlx5_5:1,mlx5_6:1,mlx5_7:1" HF_HUB_CACHE: "/hf_hub_cache" HUGGINGFACE_HUB_CACHE: "/hf_hub_cache" vllm_config: diff --git a/benchmarks/multi_node/srt-slurm-recipes/vllm/kimi-k3/agentic/agg-gb200-dep16-throughput-agentic.yaml b/benchmarks/multi_node/srt-slurm-recipes/vllm/kimi-k3/agentic/agg-gb200-dep16-throughput-agentic.yaml index e7de6389a1..3ab5688e08 100644 --- a/benchmarks/multi_node/srt-slurm-recipes/vllm/kimi-k3/agentic/agg-gb200-dep16-throughput-agentic.yaml +++ b/benchmarks/multi_node/srt-slurm-recipes/vllm/kimi-k3/agentic/agg-gb200-dep16-throughput-agentic.yaml @@ -116,6 +116,7 @@ backend: enable-prefix-caching: true scheduler-cls: "vllm.v1.core.sched.async_scheduler.AsyncScheduler" dyn-tool-call-parser: "kimi_k3" + dyn-enable-structural-tag: true reasoning-parser: "kimi_k3" dyn-reasoning-parser: "kimi_k3" no-enable-flashinfer-autotune: true diff --git a/benchmarks/multi_node/srt-slurm-recipes/vllm/kimi-k3/agentic/agg-gb200-dep16-throughput-vllm-simple-offload-agentic.yaml b/benchmarks/multi_node/srt-slurm-recipes/vllm/kimi-k3/agentic/agg-gb200-dep16-throughput-vllm-simple-offload-agentic.yaml index 703262fc3b..d80a3d1e49 100644 --- a/benchmarks/multi_node/srt-slurm-recipes/vllm/kimi-k3/agentic/agg-gb200-dep16-throughput-vllm-simple-offload-agentic.yaml +++ b/benchmarks/multi_node/srt-slurm-recipes/vllm/kimi-k3/agentic/agg-gb200-dep16-throughput-vllm-simple-offload-agentic.yaml @@ -119,6 +119,7 @@ backend: kv-transfer-config: '{"kv_connector":"SimpleCPUOffloadConnector","kv_role":"kv_both","kv_connector_extra_config":{"cpu_bytes_to_use":549755813888,"cpu_bytes_to_use_per_rank":137438953472,"lazy_offload":false}}' scheduler-cls: "vllm.v1.core.sched.async_scheduler.AsyncScheduler" dyn-tool-call-parser: "kimi_k3" + dyn-enable-structural-tag: true reasoning-parser: "kimi_k3" dyn-reasoning-parser: "kimi_k3" no-enable-flashinfer-autotune: true diff --git a/benchmarks/multi_node/srt-slurm-recipes/vllm/kimi-k3/agentic/agg-gb200-tep16-balanced-agentic.yaml b/benchmarks/multi_node/srt-slurm-recipes/vllm/kimi-k3/agentic/agg-gb200-tep16-balanced-agentic.yaml index bf135c0e33..6c73e65705 100644 --- a/benchmarks/multi_node/srt-slurm-recipes/vllm/kimi-k3/agentic/agg-gb200-tep16-balanced-agentic.yaml +++ b/benchmarks/multi_node/srt-slurm-recipes/vllm/kimi-k3/agentic/agg-gb200-tep16-balanced-agentic.yaml @@ -101,17 +101,17 @@ backend: max-num-seqs: 32 max-num-batched-tokens: 8192 speculative-config: '{"method":"dspark","model":"/tmp/Kimi-K3-DSpark","num_speculative_tokens":2,"attention_backend":"FLASHINFER_MLA","draft_sample_method":"probabilistic","rejection_sample_method":"synthetic","synthetic_acceptance_length":2.51}' - compilation-config: '{"cudagraph_capture_sizes":[3,6,9,12,15,18,21,24,27,30,33,36,39,42,45,48,51,54,57,60,63,66,69,72,75,78,81,84,87,90,93,96]}' + compilation-config: '{"cudagraph_capture_sizes":[3,6,9,12,15,18,21,24,27,30,33,36,39,42,45,48,51,54,57,60,63,66,69,72,75,78,81,84,87,90,93,96],"pass_config":{"fuse_allreduce_rms":false}}' block-size: 64 language-model-only: true disable-custom-all-reduce: true enable-prefix-caching: true scheduler-cls: "vllm.v1.core.sched.async_scheduler.AsyncScheduler" dyn-tool-call-parser: "kimi_k3" + dyn-enable-structural-tag: true reasoning-parser: "kimi_k3" dyn-reasoning-parser: "kimi_k3" no-enable-flashinfer-autotune: true - compilation-config: '{"pass_config":{"fuse_allreduce_rms":false}}' sbatch_directives: cpus-per-task: "144" diff --git a/benchmarks/multi_node/srt-slurm-recipes/vllm/kimi-k3/agentic/agg-gb200-tp16-latency-agentic.yaml b/benchmarks/multi_node/srt-slurm-recipes/vllm/kimi-k3/agentic/agg-gb200-tp16-latency-agentic.yaml index baed3f19e7..bb1fc550e5 100644 --- a/benchmarks/multi_node/srt-slurm-recipes/vllm/kimi-k3/agentic/agg-gb200-tp16-latency-agentic.yaml +++ b/benchmarks/multi_node/srt-slurm-recipes/vllm/kimi-k3/agentic/agg-gb200-tp16-latency-agentic.yaml @@ -98,17 +98,17 @@ backend: max-num-seqs: 8 max-num-batched-tokens: 8192 speculative-config: '{"method":"dspark","model":"/tmp/Kimi-K3-DSpark","num_speculative_tokens":2,"attention_backend":"FLASHINFER_MLA","draft_sample_method":"probabilistic","rejection_sample_method":"synthetic","synthetic_acceptance_length":2.51}' - compilation-config: '{"cudagraph_capture_sizes":[3,6,9,12,15,18,21,24]}' + compilation-config: '{"cudagraph_capture_sizes":[3,6,9,12,15,18,21,24],"pass_config":{"fuse_allreduce_rms":false}}' block-size: 64 language-model-only: true disable-custom-all-reduce: true enable-prefix-caching: true scheduler-cls: "vllm.v1.core.sched.async_scheduler.AsyncScheduler" dyn-tool-call-parser: "kimi_k3" + dyn-enable-structural-tag: true reasoning-parser: "kimi_k3" dyn-reasoning-parser: "kimi_k3" no-enable-flashinfer-autotune: true - compilation-config: '{"pass_config":{"fuse_allreduce_rms":false}}' sbatch_directives: cpus-per-task: "144" diff --git a/benchmarks/single_node/agentic/glm5.2_fp4_mi355x_sglang_mtp.sh b/benchmarks/single_node/agentic/glm5.2_fp4_mi355x_sglang_mtp.sh index d7ed7a359d..62ec5db23a 100644 --- a/benchmarks/single_node/agentic/glm5.2_fp4_mi355x_sglang_mtp.sh +++ b/benchmarks/single_node/agentic/glm5.2_fp4_mi355x_sglang_mtp.sh @@ -4,7 +4,6 @@ set -x source "$(dirname "$0")/../../benchmark_lib.sh" - export EVAL_FRAMEWORK="lm-eval" check_env_vars MODEL TP CONC KV_OFFLOADING TOTAL_CPU_DRAM_GB RESULT_DIR DURATION EP_SIZE DP_ATTENTION diff --git a/benchmarks/single_node/agentic/minimaxm3_fp4_b200_mtp.sh b/benchmarks/single_node/agentic/minimaxm3_fp4_b200_mtp.sh index be705c0c21..58566be51f 100755 --- a/benchmarks/single_node/agentic/minimaxm3_fp4_b200_mtp.sh +++ b/benchmarks/single_node/agentic/minimaxm3_fp4_b200_mtp.sh @@ -21,8 +21,6 @@ set -x source "$(dirname "$0")/../../benchmark_lib.sh" -export EVAL_FRAMEWORK="lm-eval" - check_env_vars MODEL TP CONC KV_OFFLOADING TOTAL_CPU_DRAM_GB RESULT_DIR DURATION DRAFT_MODEL="Inferact/MiniMax-M3-EAGLE3-GQA" diff --git a/benchmarks/single_node/agentic/minimaxm3_fp4_b300_mtp.sh b/benchmarks/single_node/agentic/minimaxm3_fp4_b300_mtp.sh index 3593cac4ce..1b65687c18 100755 --- a/benchmarks/single_node/agentic/minimaxm3_fp4_b300_mtp.sh +++ b/benchmarks/single_node/agentic/minimaxm3_fp4_b300_mtp.sh @@ -7,8 +7,6 @@ set -x source "$(dirname "$0")/../../benchmark_lib.sh" -export EVAL_FRAMEWORK="lm-eval" - check_env_vars MODEL TP CONC KV_OFFLOADING TOTAL_CPU_DRAM_GB RESULT_DIR DURATION DRAFT_MODEL="Inferact/MiniMax-M3-EAGLE3-GQA" diff --git a/benchmarks/single_node/agentic/minimaxm3_fp4_mi355x.sh b/benchmarks/single_node/agentic/minimaxm3_fp4_mi355x.sh index 7faaf69a51..a82924b167 100644 --- a/benchmarks/single_node/agentic/minimaxm3_fp4_mi355x.sh +++ b/benchmarks/single_node/agentic/minimaxm3_fp4_mi355x.sh @@ -10,13 +10,6 @@ set -x source "$(dirname "$0")/../../benchmark_lib.sh" -# Force the eval framework to lm-eval for this recipe. run_eval derives its -# default as swebench for agentic scenarios (scenario_default=swebench when -# IS_AGENTIC/SCENARIO_TYPE=agentic-coding), but EVAL_FRAMEWORK takes precedence -# over that default (benchmark_lib.sh: framework=${EVAL_FRAMEWORK:-...}), so -# setting it here makes the effective framework always lm-eval, never swebench. -export EVAL_FRAMEWORK="lm-eval" - check_env_vars MODEL TP CONC KV_OFFLOADING KV_OFFLOAD_BACKEND TOTAL_CPU_DRAM_GB RESULT_DIR DURATION EP_SIZE DP_ATTENTION echo "MODEL=$MODEL TP=$TP CONC=$CONC KV_OFFLOADING=$KV_OFFLOADING TOTAL_CPU_DRAM_GB=$TOTAL_CPU_DRAM_GB RESULT_DIR=$RESULT_DIR DURATION=$DURATION EP_SIZE=$EP_SIZE DP_ATTENTION=$DP_ATTENTION" diff --git a/benchmarks/single_node/agentic/qwen3.5_fp4_b200_sglang_mtp.sh b/benchmarks/single_node/agentic/qwen3.5_fp4_b200_sglang_mtp.sh index f0fc1d09da..fc956e14a0 100755 --- a/benchmarks/single_node/agentic/qwen3.5_fp4_b200_sglang_mtp.sh +++ b/benchmarks/single_node/agentic/qwen3.5_fp4_b200_sglang_mtp.sh @@ -8,9 +8,6 @@ set -x source "$(dirname "$0")/../../benchmark_lib.sh" -# Use the lightweight GSM8K eval instead of the AgentX SWE-bench default. -export EVAL_FRAMEWORK="lm-eval" - check_env_vars \ MODEL TP CONC EP_SIZE KV_OFFLOADING \ TOTAL_CPU_DRAM_GB RESULT_DIR DURATION diff --git a/benchmarks/single_node/agentic/qwen3.5_fp4_b300_sglang_mtp.sh b/benchmarks/single_node/agentic/qwen3.5_fp4_b300_sglang_mtp.sh index a6a0ccf3bd..b43eb06454 100755 --- a/benchmarks/single_node/agentic/qwen3.5_fp4_b300_sglang_mtp.sh +++ b/benchmarks/single_node/agentic/qwen3.5_fp4_b300_sglang_mtp.sh @@ -8,9 +8,6 @@ set -x source "$(dirname "$0")/../../benchmark_lib.sh" -# Use the lightweight GSM8K eval instead of the AgentX SWE-bench default. -export EVAL_FRAMEWORK="lm-eval" - check_env_vars \ MODEL TP CONC EP_SIZE KV_OFFLOADING \ TOTAL_CPU_DRAM_GB RESULT_DIR DURATION diff --git a/benchmarks/single_node/agentic/qwen3.5_fp8_b200_sglang_mtp.sh b/benchmarks/single_node/agentic/qwen3.5_fp8_b200_sglang_mtp.sh index 472b9e4b12..536602459b 100755 --- a/benchmarks/single_node/agentic/qwen3.5_fp8_b200_sglang_mtp.sh +++ b/benchmarks/single_node/agentic/qwen3.5_fp8_b200_sglang_mtp.sh @@ -8,9 +8,6 @@ set -x source "$(dirname "$0")/../../benchmark_lib.sh" -# Use the lightweight GSM8K eval instead of the AgentX SWE-bench default. -export EVAL_FRAMEWORK="lm-eval" - check_env_vars \ MODEL TP CONC EP_SIZE KV_OFFLOADING \ TOTAL_CPU_DRAM_GB RESULT_DIR DURATION diff --git a/benchmarks/single_node/agentic/qwen3.5_fp8_b300_sglang_mtp.sh b/benchmarks/single_node/agentic/qwen3.5_fp8_b300_sglang_mtp.sh index 2d0a390250..96583ebe82 100755 --- a/benchmarks/single_node/agentic/qwen3.5_fp8_b300_sglang_mtp.sh +++ b/benchmarks/single_node/agentic/qwen3.5_fp8_b300_sglang_mtp.sh @@ -8,9 +8,6 @@ set -x source "$(dirname "$0")/../../benchmark_lib.sh" -# Use the lightweight GSM8K eval instead of the AgentX SWE-bench default. -export EVAL_FRAMEWORK="lm-eval" - check_env_vars \ MODEL TP CONC EP_SIZE KV_OFFLOADING \ TOTAL_CPU_DRAM_GB RESULT_DIR DURATION diff --git a/docs/eval-agentx-procedures.md b/docs/eval-agentx-procedures.md index a3afb3076a..263927d908 100644 --- a/docs/eval-agentx-procedures.md +++ b/docs/eval-agentx-procedures.md @@ -18,11 +18,11 @@ There are two distinct layers: the matrix generator decides **which jobs exist** | Normal sweep | no eval option | Throughput jobs plus the selected 8k/1k eval subset | | Throughput only | `--no-evals` | No eval jobs | | Selected eval subset only | `--evals-only` | Jobs have `RUN_EVAL=true`, `EVAL_ONLY=true` | -| Every eligible eval only | `--all-evals` | Equivalent to `--evals-only --all-evals` and includes all fixed-sequence 8k/1k rows, plus single-node agentic SWE-bench rows | +| Every eligible eval only | `--all-evals` | Equivalent to `--evals-only --all-evals` and includes all fixed-sequence 8k/1k rows plus single-node and multi-node agentic GSM8K rows | | Throughput then eval in one recipe | `RUN_EVAL=true`, `EVAL_ONLY=false` | Server starts, throughput runs, then `run_eval` runs | | Eval against a freshly started server | `RUN_EVAL=true`, `EVAL_ONLY=true` | Launcher expands eval context, skips throughput, and runs the eval | -Default selection is scenario-aware. Single-node fixed-sequence evals use the median and highest eligible concurrency for each 8k/1k model/runner/framework/precision/parallelism group. Multi-node evals use the highest eligible concurrency per topology. Concurrency below 16 is not selected. Agentic evals are opt-in, and multi-node agentic eval is unsupported. See [`mark_eval_entries()`](../utils/matrix_logic/generate_sweep_configs.py#L238-L339) and [`mark_all_eval_entries()`](../utils/matrix_logic/generate_sweep_configs.py#L342-L398). +Default selection is scenario-aware. Single-node fixed-sequence evals use the median and highest eligible concurrency for each 8k/1k model/runner/framework/precision/parallelism group. Multi-node evals use the highest eligible concurrency per topology. Concurrency below 16 is not selected. Agentic evals are opt-in; single-node and multi-node agentic rows select their highest eligible concurrency. See [`mark_eval_entries()`](../utils/matrix_logic/generate_sweep_configs.py#L276-L396) and [`mark_all_eval_entries()`](../utils/matrix_logic/generate_sweep_configs.py#L399-L482). On a PR, combine one primary sweep label (normally `full-sweep-fail-fast`) with eval modifiers. `all-evals` expands coverage without suppressing throughput. `evals-only` suppresses throughput. Together they run all eligible evals only. Runs with `evals-only` are not reusable, while normal full sweeps and `all-evals` full sweeps are reusable. Adding or removing a modifier restarts the active sweep ([label policy](../.github/workflows/README.md#pr-eval-modifiers)). @@ -48,14 +48,14 @@ uv run --no-project --with pydantic --with pyyaml --python 3.12 \ --config-files configs/nvidia-master.yaml | jq . ``` -A correct AgentX eval row contains `"scenario-type": "agentic-coding"`, `"run-eval": true`, and `"eval-only": true`. The workflow splits generated rows into throughput, fixed-sequence eval, and agentic eval jobs in [`.github/workflows/e2e-tests.yml`](../.github/workflows/e2e-tests.yml#L257-L271). +A correct AgentX eval row contains `"scenario-type": "agentic-coding"`, `"run-eval": true`, and `"eval-only": true`. The workflow splits generated rows into throughput, fixed-sequence eval, and agentic eval jobs in [`.github/workflows/e2e-tests.yml`](../.github/workflows/e2e-tests.yml#L278-L293). ## 2. Add a graded eval 1. Add `utils/evals/.yaml` using the lm-evaluation-harness task format. Pin the dataset/split, deterministic generation settings, prompt contract, filters, and primary metric. Use [`gsm8k.yaml`](../utils/evals/gsm8k.yaml) or [`gpqa_diamond.yaml`](../utils/evals/gpqa_diamond.yaml) as an in-tree pattern. 2. Give `task:` a stable name. That exact name is the key used by score thresholds and appears in collected rows. 3. Add the minimum accepted score to [`utils/evals/thresholds.yaml`](../utils/evals/thresholds.yaml). Put a general floor under `default`. Add `models..` only when a justified model-specific floor is required. -4. If the task's primary result is not compatible with the collector's strict/extract/accuracy rules, extend [`extract_lm_metrics()`](../utils/collect_eval_results.py#L114-L181). Do not publish a row whose `score` is null. +4. If the task's primary result is not compatible with the collector's strict/extract/accuracy rules, extend [`extract_lm_metrics()`](../utils/collect_eval_results.py#L115-L197). Do not publish a row whose `score` is null. 5. Run a small explicit slice, inspect samples, then run the full split. `EVAL_LIMIT` is a smoke-test control, not a publishable score setting. Against an already healthy OpenAI-compatible server: @@ -85,7 +85,7 @@ append_lm_eval_summary python3 utils/evals/validate_scores.py --model-prefix "$MODEL_PREFIX" ``` -`run_lm_eval` passes concurrency through `num_concurrent` in `--model_args`. It is deliberately an environment variable, not a `run_eval` CLI option. The exact invocation is in [`run_lm_eval()`](../benchmarks/benchmark_lib.sh#L890-L970). +`run_lm_eval` passes concurrency through `num_concurrent` in `--model_args`. It is deliberately an environment variable, not a `run_eval` CLI option. The exact invocation is in [`run_lm_eval()`](../benchmarks/benchmark_lib.sh#L1080-L1162). ## 3. `EVAL_ONLY` is a launcher contract @@ -97,9 +97,9 @@ Set `EVAL_ONLY=true` **before server launch**. It is not merely a switch inside 4. Throughput returns immediately or is skipped. 5. `run_eval` and artifact staging run. -Relevant implementation: [context setup](../benchmarks/benchmark_lib.sh#L853-L888), [eval dispatch and failure policy](../benchmarks/benchmark_lib.sh#L1537-L1654), and [workflow inputs](../.github/workflows/benchmark-tmpl.yml#L162-L185). +Relevant implementation: [context setup](../benchmarks/benchmark_lib.sh#L1049-L1078), [eval dispatch and failure policy](../benchmarks/benchmark_lib.sh#L1789-L1923), and [workflow inputs](../.github/workflows/benchmark-tmpl.yml#L79-L97). -Do not toggle `EVAL_ONLY` after a throughput-sized server is already running and assume the context changed. Restart through the recipe. In eval-only mode an eval failure is returned after available artifacts are staged. In a workflow, upload happens with `always()` before score validation so failed evidence survives ([single-node upload and gate](../.github/workflows/benchmark-tmpl.yml#L387-L404), [multi-node upload and gate](../.github/workflows/benchmark-multinode-tmpl.yml#L450-L468)). +Do not toggle `EVAL_ONLY` after a throughput-sized server is already running and assume the context changed. Restart through the recipe. In eval-only mode an eval failure is returned after available artifacts are staged. In a workflow, upload happens with `always()` before score validation so failed evidence survives ([single-node upload and gate](../.github/workflows/benchmark-tmpl.yml#L399-L417), [multi-node upload and gate](../.github/workflows/benchmark-multinode-tmpl.yml#L466-L488)). ## 4. Batched eval concurrency @@ -121,9 +121,9 @@ The batch runner creates a fresh temporary output directory per point, stages fi - `completed_eval_concs`: eval and staging both succeeded. - `failed_eval_concs`: either eval or staging failed. -A failed point is deferred so artifacts from every attempted point can upload. The post-upload validator then fails the job. Batched mode accepts positive integers and supports only `lm-eval`. See [`run_eval` batching](../benchmarks/benchmark_lib.sh#L1537-L1631), [artifact suffixing](../benchmarks/benchmark_lib.sh#L972-L1030), and [manifest validation](../utils/evals/validate_scores.py#L72-L171). +A failed point is deferred so artifacts from every attempted point can upload. The post-upload validator then fails the job. Batched mode accepts positive integers and supports only `lm-eval`. See [`run_eval` batching](../benchmarks/benchmark_lib.sh#L1839-L1900), [artifact suffixing](../benchmarks/benchmark_lib.sh#L1163-L1222), and [manifest validation](../utils/evals/validate_scores.py#L72-L171). -For multi-node `all-evals`, the workflow constructs `EVAL_CONC` by joining the topology's concurrency list ([dispatch](../.github/workflows/e2e-tests.yml#L375-L378)). Never compare a point if its `_conc` result or completed-manifest entry is missing. +For multi-node `all-evals`, the workflow constructs `EVAL_CONC` by joining the topology's concurrency list ([dispatch](../.github/workflows/e2e-tests.yml#L397-L400)). Never compare a point if its `_conc` result or completed-manifest entry is missing. ## 5. Validate scores, not file existence @@ -173,7 +173,7 @@ Retain `meta_env.json`, `results*.json`, and `sample*.jsonl`. Agentic SWE-bench ## 7. Run AgentX: fast feedback versus canonical evidence -AgentX is AIPerf `inferencex-agentx-mvp` trace replay, not a fixed-token synthetic benchmark. The checked-in default uses ten additional warmup requests per trajectory lane and the recipe's configured profile duration. `agentx-fast` forces one warmup request per lane and a 1,200-second profile. It affects single- and multi-node AgentX throughput only. Fixed-sequence throughput and evals remain canonical. Fast runs are not eligible for artifact reuse ([workflow policy](../.github/workflows/README.md#agentx-fast-mode), [fast replay settings](../benchmarks/benchmark_lib.sh#L1824-L1848)). +AgentX is AIPerf `inferencex-agentx-mvp` trace replay, not a fixed-token synthetic benchmark. The checked-in default uses ten additional warmup requests per trajectory lane and the recipe's configured profile duration. `agentx-fast` forces one warmup request per lane and a 1,200-second profile. It affects single- and multi-node AgentX throughput only. Fixed-sequence throughput and evals remain canonical. Fast runs are not eligible for artifact reuse ([workflow policy](../.github/workflows/README.md#agentx-fast-mode), [fast replay settings](../benchmarks/benchmark_lib.sh#L2104-L2128)). Targeted canonical run (configured duration and warmup, with fast and duration overrides omitted): @@ -199,17 +199,18 @@ Targeted AgentX SWE-bench smoke eval (first ten instances, real agentic generati gh workflow run e2e-tests.yml --repo SemiAnalysisAI/InferenceX --ref "$REF" \ -f generate-cli-command='test-config --config-keys qwen3.5-fp8-b200-sglang-agentic --conc 1 --evals-only --config-files configs/nvidia-master.yaml' \ -f test-name='swebench-smoke-qwen35-c1' \ + -f eval-framework=swebench \ -f eval-limit='10' \ -f swebench-gen-mode='agentic' ``` For a publishable SWE-bench score, omit `eval-limit`. Do not use `single-shot`, which is only a debugging escape hatch. SWE-bench generation/scoring controls and its `0.50` full-split threshold are documented next to the implementation in [`utils/evals/EVALS.md`](../utils/evals/EVALS.md#swe-bench-lite---framework-swebench). -Treat fast results as bring-up evidence, never as a replacement for the canonical candidate. A duration below 900 seconds or `AIPERF_UNSAFE_OVERRIDE=true` adds AIPerf's `--unsafe-override` and flags the submission invalid. Use it only for smoke diagnosis ([source](../benchmarks/benchmark_lib.sh#L1982-L1989)). After a fast run is healthy, run the exact candidate canonically before claiming benchmark success. +Treat fast results as bring-up evidence, never as a replacement for the canonical candidate. A duration below 900 seconds or `AIPERF_UNSAFE_OVERRIDE=true` adds AIPerf's `--unsafe-override` and flags the submission invalid. Use it only for smoke diagnosis ([source](../benchmarks/benchmark_lib.sh#L2266-L2268)). After a fast run is healthy, run the exact candidate canonically before claiming benchmark success. ## 8. Preserve trace and run provenance -AgentX defaults to recorded assistant-response replay. Live server outputs are measured but discarded when constructing later turns. Set `AIPERF_DATASET_WEKA_LIVE_ASSISTANT_RESPONSES=1` only for an explicitly different live-assistant experiment. The selected trace corpus is model-family dependent unless `WEKA_LOADER_OVERRIDE` pins it. The resolver logs both loader and Hugging Face dataset ([trace resolution](../benchmarks/benchmark_lib.sh#L1743-L1822), [replay semantics](../benchmarks/benchmark_lib.sh#L1824-L1850)). +AgentX defaults to recorded assistant-response replay. Live server outputs are measured but discarded when constructing later turns. Set `AIPERF_DATASET_WEKA_LIVE_ASSISTANT_RESPONSES=1` only for an explicitly different live-assistant experiment. The selected trace corpus is model-family dependent unless `WEKA_LOADER_OVERRIDE` pins it. The resolver logs both loader and Hugging Face dataset ([trace resolution](../benchmarks/benchmark_lib.sh#L2023-L2102), [replay semantics](../benchmarks/benchmark_lib.sh#L2104-L2270)). Capture orchestration provenance immediately: @@ -241,7 +242,7 @@ For each concurrency retain: - server/frontend logs and every metrics endpoint represented. - run URL/ID, attempt, head SHA, recipe/config identity, image, topology, fast flag, and any override. -The runner writes the command before replay and validates raw results after aggregation ([execution path](../benchmarks/benchmark_lib.sh#L2040-L2079)). Aggregation preserves dataset provenance and hardware/model/topology fields ([aggregate construction](../utils/agentic/aggregation/process_agentic_result.py#L194-L272)). Raw workflow uploads intentionally omit very large `inputs.json` and `profile_export_raw.jsonl`. If those are required for an investigation, preserve them from the live allocation before cleanup ([single-node artifact contract](../.github/workflows/benchmark-tmpl.yml#L337-L346), [multi-node contract](../.github/workflows/benchmark-multinode-tmpl.yml#L439-L448)). +The runner writes the command before replay and validates raw results after aggregation ([execution path](../benchmarks/benchmark_lib.sh#L2320-L2360)). Aggregation preserves dataset provenance and hardware/model/topology fields ([aggregate construction](../utils/agentic/aggregation/process_agentic_result.py#L194-L272)). Raw workflow uploads intentionally omit very large `inputs.json` and `profile_export_raw.jsonl`. If those are required for an investigation, preserve them from the live allocation before cleanup ([single-node artifact contract](../.github/workflows/benchmark-tmpl.yml#L349-L358), [multi-node contract](../.github/workflows/benchmark-multinode-tmpl.yml#L455-L464)). ## 9. Debug long AgentX runs from live evidence @@ -290,7 +291,7 @@ curl -fsS '' | \ rg -i 'request|queue|cache|token|prefill|decode|error|fail' ``` -Track trends over repeated samples: running/waiting requests, KV usage, prefix hits, input/output token rates, completed/cancelled/errored requests, frontend routing balance, and disaggregated KV transfer. AIPerf records endpoint identity for every server series ([metrics wiring](../benchmarks/benchmark_lib.sh#L1963-L1980)). +Track trends over repeated samples: running/waiting requests, KV usage, prefix hits, input/output token rates, completed/cancelled/errored requests, frontend routing balance, and disaggregated KV transfer. AIPerf records endpoint identity for every server series ([metrics wiring](../benchmarks/benchmark_lib.sh#L2236-L2260)). Use phase markers, not total Slurm age: diff --git a/docs/eval-agentx-procedures_zh.md b/docs/eval-agentx-procedures_zh.md index 669ed290c3..66c205782e 100644 --- a/docs/eval-agentx-procedures_zh.md +++ b/docs/eval-agentx-procedures_zh.md @@ -18,11 +18,11 @@ | 常规 sweep | 不加 eval 选项 | 吞吐量作业,加上选定的 8k/1k eval 子集 | | 仅吞吐量 | `--no-evals` | 不生成 eval 作业 | | 仅选定的 eval 子集 | `--evals-only` | 作业带有 `RUN_EVAL=true`、`EVAL_ONLY=true` | -| 仅运行所有符合条件的 eval | `--all-evals` | 等价于 `--evals-only --all-evals`;包含全部定长序列 8k/1k 行,以及单节点 agentic SWE-bench 行 | +| 仅运行所有符合条件的 eval | `--all-evals` | 等价于 `--evals-only --all-evals`;包含全部定长序列 8k/1k 行,以及单节点和多节点 agentic GSM8K 行 | | 在一个 recipe 中先跑吞吐量再跑 eval | `RUN_EVAL=true`、`EVAL_ONLY=false` | 启动服务,运行吞吐量,然后执行 `run_eval` | | 对新启动的服务仅运行 eval | `RUN_EVAL=true`、`EVAL_ONLY=true` | launcher 扩大 eval context,跳过吞吐量并运行 eval | -默认选择会区分场景。单节点定长序列 eval 对每个 8k/1k 的模型/runner/framework/precision/并行配置分组选取符合条件的中位和最高并发;多节点 eval 对每种拓扑选取符合条件的最高并发。低于 16 的并发不会被选中。Agentic eval 需要显式启用;不支持多节点 agentic eval。参见 [`mark_eval_entries()`](../utils/matrix_logic/generate_sweep_configs.py#L238-L339) 与 [`mark_all_eval_entries()`](../utils/matrix_logic/generate_sweep_configs.py#L342-L398)。 +默认选择会区分场景。单节点定长序列 eval 对每个 8k/1k 的模型/runner/framework/precision/并行配置分组选取符合条件的中位和最高并发;多节点 eval 对每种拓扑选取符合条件的最高并发。低于 16 的并发不会被选中。Agentic eval 需要显式启用;单节点和多节点 agentic 行均选择符合条件的最高并发。参见 [`mark_eval_entries()`](../utils/matrix_logic/generate_sweep_configs.py#L276-L396) 与 [`mark_all_eval_entries()`](../utils/matrix_logic/generate_sweep_configs.py#L399-L482)。 在 PR 上,应将一个主要 sweep label(通常为 `full-sweep-fail-fast`)与 eval modifier 组合使用。`all-evals` 在不抑制吞吐量的情况下扩大覆盖范围;`evals-only` 会抑制吞吐量;两者一起使用时只运行所有符合条件的 eval。带有 `evals-only` 的运行不可复用,而常规 full sweep 和 `all-evals` full sweep 可以复用。添加或移除 modifier 会重启当前 sweep([label 策略](../.github/workflows/README.md#pr-eval-modifiers))。 @@ -48,14 +48,14 @@ uv run --no-project --with pydantic --with pyyaml --python 3.12 \ --config-files configs/nvidia-master.yaml | jq . ``` -正确的 AgentX eval 行包含 `"scenario-type": "agentic-coding"`、`"run-eval": true` 和 `"eval-only": true`。工作流会在 [`.github/workflows/e2e-tests.yml`](../.github/workflows/e2e-tests.yml#L257-L271) 中将生成的行拆分到吞吐量、定长序列 eval 和 agentic eval 作业。 +正确的 AgentX eval 行包含 `"scenario-type": "agentic-coding"`、`"run-eval": true` 和 `"eval-only": true`。工作流会在 [`.github/workflows/e2e-tests.yml`](../.github/workflows/e2e-tests.yml#L278-L293) 中将生成的行拆分到吞吐量、定长序列 eval 和 agentic eval 作业。 ## 2. 添加评分 eval 1. 按照 lm-evaluation-harness task 格式添加 `utils/evals/.yaml`。固定 dataset/split、确定性生成设置、prompt 约定、filter 和主指标。可参考仓库内的 [`gsm8k.yaml`](../utils/evals/gsm8k.yaml) 或 [`gpqa_diamond.yaml`](../utils/evals/gpqa_diamond.yaml)。 2. 为 `task:` 指定稳定名称。分数阈值以该精确名称为键,收集后的行中也会出现该名称。 3. 在 [`utils/evals/thresholds.yaml`](../utils/evals/thresholds.yaml) 中添加最低可接受分数。通用下限放在 `default`;只有在确有依据需要模型专用下限时,才添加 `models..`。 -4. 如果 task 的主结果与 collector 的 strict/extract/accuracy 规则不兼容,请扩展 [`extract_lm_metrics()`](../utils/collect_eval_results.py#L114-L181)。不要发布 `score` 为 null 的行。 +4. 如果 task 的主结果与 collector 的 strict/extract/accuracy 规则不兼容,请扩展 [`extract_lm_metrics()`](../utils/collect_eval_results.py#L115-L197)。不要发布 `score` 为 null 的行。 5. 先运行一个显式的小切片并检查样本,再运行完整 split。`EVAL_LIMIT` 是 smoke test 控制项,不是可发布分数的运行设置。 对已经健康的 OpenAI-compatible 服务执行: @@ -85,7 +85,7 @@ append_lm_eval_summary python3 utils/evals/validate_scores.py --model-prefix "$MODEL_PREFIX" ``` -`run_lm_eval` 通过 `--model_args` 中的 `num_concurrent` 传递并发;它刻意采用环境变量,而不是 `run_eval` CLI 选项。准确调用见 [`run_lm_eval()`](../benchmarks/benchmark_lib.sh#L890-L970)。 +`run_lm_eval` 通过 `--model_args` 中的 `num_concurrent` 传递并发;它刻意采用环境变量,而不是 `run_eval` CLI 选项。准确调用见 [`run_lm_eval()`](../benchmarks/benchmark_lib.sh#L1080-L1162)。 ## 3. `EVAL_ONLY` 是 launcher 约定 @@ -97,9 +97,9 @@ python3 utils/evals/validate_scores.py --model-prefix "$MODEL_PREFIX" 4. 吞吐量路径立即返回或被跳过。 5. 运行 `run_eval` 和 artifact staging。 -相关实现:[context 设置](../benchmarks/benchmark_lib.sh#L853-L888)、[eval 分派与失败策略](../benchmarks/benchmark_lib.sh#L1537-L1654) 和[工作流输入](../.github/workflows/benchmark-tmpl.yml#L162-L185)。 +相关实现:[context 设置](../benchmarks/benchmark_lib.sh#L1049-L1078)、[eval 分派与失败策略](../benchmarks/benchmark_lib.sh#L1789-L1923) 和[工作流输入](../.github/workflows/benchmark-tmpl.yml#L79-L97)。 -不要在吞吐量规格的服务已经运行后才切换 `EVAL_ONLY`,并假定 context 会随之变化。应通过 recipe 重启。Eval-only 模式会在暂存已有 artifact 后返回 eval 失败;在工作流中,上传步骤使用 `always()`,并位于分数校验前,因此失败证据仍会保留([单节点上传与 gate](../.github/workflows/benchmark-tmpl.yml#L387-L404)、[多节点上传与 gate](../.github/workflows/benchmark-multinode-tmpl.yml#L450-L468))。 +不要在吞吐量规格的服务已经运行后才切换 `EVAL_ONLY`,并假定 context 会随之变化。应通过 recipe 重启。Eval-only 模式会在暂存已有 artifact 后返回 eval 失败;在工作流中,上传步骤使用 `always()`,并位于分数校验前,因此失败证据仍会保留([单节点上传与 gate](../.github/workflows/benchmark-tmpl.yml#L399-L417)、[多节点上传与 gate](../.github/workflows/benchmark-multinode-tmpl.yml#L466-L488))。 ## 4. 批量 eval 并发 @@ -121,9 +121,9 @@ python3 utils/evals/validate_scores.py --expected-concs '16 32 64' - `completed_eval_concs`:eval 与 staging 均成功的点; - `failed_eval_concs`:eval 或 staging 失败的点。 -失败点会延迟报错,使所有已尝试点的 artifact 都能上传;随后 post-upload validator 会使作业失败。批量模式只接受正整数,且仅支持 `lm-eval`。参见 [`run_eval` batching](../benchmarks/benchmark_lib.sh#L1537-L1631)、[artifact 后缀处理](../benchmarks/benchmark_lib.sh#L972-L1030) 和[manifest 校验](../utils/evals/validate_scores.py#L72-L171)。 +失败点会延迟报错,使所有已尝试点的 artifact 都能上传;随后 post-upload validator 会使作业失败。批量模式只接受正整数,且仅支持 `lm-eval`。参见 [`run_eval` batching](../benchmarks/benchmark_lib.sh#L1839-L1900)、[artifact 后缀处理](../benchmarks/benchmark_lib.sh#L1163-L1222) 和[manifest 校验](../utils/evals/validate_scores.py#L72-L171)。 -对于多节点 `all-evals`,工作流通过连接拓扑的并发列表构造 `EVAL_CONC`([分派](../.github/workflows/e2e-tests.yml#L375-L378))。如果缺少某点的 `_conc` 结果或 completed manifest 条目,绝不能比较该点。 +对于多节点 `all-evals`,工作流通过连接拓扑的并发列表构造 `EVAL_CONC`([分派](../.github/workflows/e2e-tests.yml#L397-L400))。如果缺少某点的 `_conc` 结果或 completed manifest 条目,绝不能比较该点。 ## 5. 校验分数,而不只是检查文件存在 @@ -173,7 +173,7 @@ gh run download "$RUN_ID" --repo SemiAnalysisAI/InferenceX \ ## 7. 运行 AgentX:快速反馈与 canonical 证据 -AgentX 是 AIPerf `inferencex-agentx-mvp` trace replay,不是固定 token 的合成 benchmark。仓库默认设置对每条 trajectory lane 额外执行十个 warmup 请求,并使用 recipe 配置的 profile 时长。`agentx-fast` 强制每条 lane 只运行一个 warmup 请求,并将 profile 设为 1,200 秒。它只影响单节点和多节点 AgentX 吞吐量;定长序列吞吐量与 eval 保持 canonical。Fast 运行不符合 artifact reuse 条件([工作流策略](../.github/workflows/README.md#agentx-fast-mode)、[Fast replay 设置](../benchmarks/benchmark_lib.sh#L1824-L1848))。 +AgentX 是 AIPerf `inferencex-agentx-mvp` trace replay,不是固定 token 的合成 benchmark。仓库默认设置对每条 trajectory lane 额外执行十个 warmup 请求,并使用 recipe 配置的 profile 时长。`agentx-fast` 强制每条 lane 只运行一个 warmup 请求,并将 profile 设为 1,200 秒。它只影响单节点和多节点 AgentX 吞吐量;定长序列吞吐量与 eval 保持 canonical。Fast 运行不符合 artifact reuse 条件([工作流策略](../.github/workflows/README.md#agentx-fast-mode)、[fast replay 设置](../benchmarks/benchmark_lib.sh#L2104-L2128))。 目标 canonical 运行(使用配置的 duration 和 warmup;不要加 fast 或 duration override): @@ -199,17 +199,18 @@ gh workflow run e2e-tests.yml --repo SemiAnalysisAI/InferenceX --ref "$REF" \ gh workflow run e2e-tests.yml --repo SemiAnalysisAI/InferenceX --ref "$REF" \ -f generate-cli-command='test-config --config-keys qwen3.5-fp8-b200-sglang-agentic --conc 1 --evals-only --config-files configs/nvidia-master.yaml' \ -f test-name='swebench-smoke-qwen35-c1' \ + -f eval-framework=swebench \ -f eval-limit='10' \ -f swebench-gen-mode='agentic' ``` 要得到可发布的 SWE-bench 分数,省略 `eval-limit`;不要使用 `single-shot`,它只是诊断逃生选项。SWE-bench generation/scoring 控制项以及完整 split 的 `0.50` 阈值在实现旁的 [`utils/evals/EVALS.md`](../utils/evals/EVALS.md#swe-bench-lite---framework-swebench) 中说明。 -Fast 结果只能作为 bring-up 证据,绝不能替代 canonical candidate。小于 900 秒的 duration 或 `AIPERF_UNSAFE_OVERRIDE=true` 会添加 AIPerf 的 `--unsafe-override` 并将 submission 标记为无效;只能用于 smoke 诊断([源码](../benchmarks/benchmark_lib.sh#L1982-L1989))。Fast 运行健康后,必须对完全相同的 candidate 进行 canonical 运行,才能宣称 benchmark 成功。 +Fast 结果只能作为 bring-up 证据,绝不能替代 canonical candidate。小于 900 秒的 duration 或 `AIPERF_UNSAFE_OVERRIDE=true` 会添加 AIPerf 的 `--unsafe-override` 并将 submission 标记为无效;只能用于 smoke 诊断([源码](../benchmarks/benchmark_lib.sh#L2266-L2268))。Fast 运行健康后,必须对完全相同的 candidate 进行 canonical 运行,才能宣称 benchmark 成功。 ## 8. 保留 trace 与运行 provenance -AgentX 默认 replay 已记录的 assistant response。实时服务输出会被测量,但构造后续 turn 时会丢弃。只有在明确要进行不同的 live-assistant 实验时,才设置 `AIPERF_DATASET_WEKA_LIVE_ASSISTANT_RESPONSES=1`。除非用 `WEKA_LOADER_OVERRIDE` 固定,否则所选 trace corpus 依赖模型 family;resolver 会同时记录 loader 与 Hugging Face dataset([trace 解析](../benchmarks/benchmark_lib.sh#L1743-L1822)、[replay 语义](../benchmarks/benchmark_lib.sh#L1824-L1850))。 +AgentX 默认 replay 已记录的 assistant response。实时服务输出会被测量,但构造后续 turn 时会丢弃。只有在明确要进行不同的 live-assistant 实验时,才设置 `AIPERF_DATASET_WEKA_LIVE_ASSISTANT_RESPONSES=1`。除非用 `WEKA_LOADER_OVERRIDE` 固定,否则所选 trace corpus 依赖模型 family;resolver 会同时记录 loader 与 Hugging Face dataset([trace 解析](../benchmarks/benchmark_lib.sh#L2023-L2102)、[replay 语义](../benchmarks/benchmark_lib.sh#L2104-L2270))。 立即记录 orchestration provenance: @@ -241,7 +242,7 @@ gh run download "$RUN_ID" --repo SemiAnalysisAI/InferenceX \ - server/frontend 日志以及所代表的每个 metrics endpoint; - run URL/ID、attempt、head SHA、recipe/config 标识、image、topology、fast 标志和所有 override。 -Runner 会在 replay 前写入命令,并在聚合后校验原始结果([执行路径](../benchmarks/benchmark_lib.sh#L2040-L2079))。聚合会保留 dataset provenance 以及硬件/模型/拓扑字段([aggregate 构造](../utils/agentic/aggregation/process_agentic_result.py#L194-L272))。工作流的 raw upload 会有意排除体积很大的 `inputs.json` 和 `profile_export_raw.jsonl`;如果调查需要这些文件,应在清理前从实时 allocation 保存([单节点 artifact 约定](../.github/workflows/benchmark-tmpl.yml#L337-L346)、[多节点约定](../.github/workflows/benchmark-multinode-tmpl.yml#L439-L448))。 +Runner 会在 replay 前写入命令,并在聚合后校验原始结果([执行路径](../benchmarks/benchmark_lib.sh#L2320-L2360))。聚合会保留 dataset provenance 以及硬件/模型/拓扑字段([aggregate 构造](../utils/agentic/aggregation/process_agentic_result.py#L194-L272))。工作流的 raw upload 会有意排除体积很大的 `inputs.json` 和 `profile_export_raw.jsonl`;如果调查需要这些文件,应在清理前从实时 allocation 保存([单节点 artifact 约定](../.github/workflows/benchmark-tmpl.yml#L349-L358)、[多节点约定](../.github/workflows/benchmark-multinode-tmpl.yml#L455-L464))。 ## 9. 用实时证据调试长时间 AgentX 运行 @@ -290,7 +291,7 @@ curl -fsS '' | \ rg -i 'request|queue|cache|token|prefill|decode|error|fail' ``` -通过重复 sample 跟踪趋势:running/waiting request、KV usage、prefix hit、input/output token rate、completed/cancelled/errored request、frontend routing balance,以及 disaggregated KV transfer。AIPerf 会为每条 server series 记录 endpoint identity([metrics 接线](../benchmarks/benchmark_lib.sh#L1963-L1980))。 +通过重复 sample 跟踪趋势:running/waiting request、KV usage、prefix hit、input/output token rate、completed/cancelled/errored request、frontend routing balance,以及 disaggregated KV transfer。AIPerf 会为每条 server series 记录 endpoint identity([metrics 接线](../benchmarks/benchmark_lib.sh#L2236-L2260))。 应使用 phase marker,而不是 Slurm 总运行时间: diff --git a/runners/inject_synthetic_acceptance.py b/runners/inject_synthetic_acceptance.py index 80d2740e79..1382454d13 100644 --- a/runners/inject_synthetic_acceptance.py +++ b/runners/inject_synthetic_acceptance.py @@ -1,18 +1,13 @@ #!/usr/bin/env python3 -"""Inject synthetic acceptance parameters into an srt-slurm recipe (generic driver). +"""Configure speculative acceptance in an srt-slurm recipe. -This is the framework-agnostic half of the synthetic-acceptance mechanism. It -decides *whether* to inject (the ``SYNTHETIC_ACCEPTANCE`` flag) and *what* mean -acceptance length to inject, then delegates the actual recipe rewrite to a -framework-specific backend (see ``runners/synthetic_injectors/``). - -The script is a no-op (exit 0, file untouched) when: - - SYNTHETIC_ACCEPTANCE is unset/false, -so existing callers that do not opt in get exactly the previous behavior. When -enabled it requires a backend registered for the given framework; the vLLM -backend is added in a follow-up framework-support change. +Eval-only runs remove synthetic acceptance so generated text is checked against +the target model. Throughput runs inject a configured synthetic acceptance +length only when ``SYNTHETIC_ACCEPTANCE=true``. Framework-specific rewriting +lives under ``runners/synthetic_injectors/``. Environment variables: + EVAL_ONLY "true" to restore real target verification SYNTHETIC_ACCEPTANCE "true" to enable (default: "false") SYNTHETIC_ACCEPTANCE_LENGTH target mean acceptance length; if unset, it is auto-resolved from the reference AL YAML using @@ -72,7 +67,9 @@ def _lookup_al(model_block, num_spec_tokens): if isinstance(model_block, dict): # Thinking matrix form: pick the requested mode, then index by level. if any(str(k).startswith("thinking") for k in model_block): - mode = os.environ.get("THINKING_MODE", "thinking_on").strip() or "thinking_on" + mode = ( + os.environ.get("THINKING_MODE", "thinking_on").strip() or "thinking_on" + ) mode_block = model_block.get(mode) if mode_block is None: sys.exit( @@ -115,7 +112,9 @@ def _resolve_al(config_text, injector, ref_yaml): al = _lookup_al(model_block, num_spec_tokens) if al is None: - sys.exit(f"ERROR: num_spec_tokens={num_spec_tokens} not found for {key} in {ref_yaml}") + sys.exit( + f"ERROR: num_spec_tokens={num_spec_tokens} not found for {key} in {ref_yaml}" + ) _log( f"Auto-resolved AL={al} from {ref_yaml} " @@ -125,14 +124,30 @@ def _resolve_al(config_text, injector, ref_yaml): def inject(config_file, framework): + injector = get_injector(framework) + if _enabled("EVAL_ONLY"): - print("[Synthetic AL] EVAL_ONLY=true: keeping real MTP recipe") + if injector is None or not hasattr(injector, "rewrite_real"): + print( + f"[Synthetic AL] EVAL_ONLY=true: no real-acceptance rewriter " + f"for FRAMEWORK='{framework}'" + ) + return 0 + + with open(config_file) as f: + content = f.read() + new_content, count = injector.rewrite_real(content, _log) + if count: + with open(config_file, "w") as f: + f.write(new_content) + _log(f"Restored real acceptance in {count} speculative-config entries") + else: + _log("EVAL_ONLY=true: recipe already uses real acceptance") return 0 if not _enabled("SYNTHETIC_ACCEPTANCE"): return 0 - injector = get_injector(framework) if injector is None: sys.exit( "ERROR: SYNTHETIC_ACCEPTANCE=true but no synthetic-acceptance " @@ -145,7 +160,12 @@ def inject(config_file, framework): al = _resolve_al( content, injector, - os.path.join(os.path.dirname(__file__), "..", "benchmarks", "speedbench-reference-al.yaml"), + os.path.join( + os.path.dirname(__file__), + "..", + "benchmarks", + "speedbench-reference-al.yaml", + ), ) _log(f"Injecting synthetic acceptance (length={al}) into {config_file}") diff --git a/runners/launch_b200-dgxc.sh b/runners/launch_b200-dgxc.sh index 8c2b357b00..7758ad90ee 100644 --- a/runners/launch_b200-dgxc.sh +++ b/runners/launch_b200-dgxc.sh @@ -220,6 +220,9 @@ if [[ "$IS_MULTINODE" == "true" ]]; then cd "$SRT_REPO_DIR" || exit 1 git checkout sa-submission-q2-2026 fi + if [[ "${EVAL_FRAMEWORK:-lm-eval}" == "kimi-vendor" ]]; then + python3 "$GITHUB_WORKSPACE/runners/patch_srt_eval_dispatch.py" "$(pwd)" || exit 1 + fi echo "Installing srtctl..." export UV_INSTALL_DIR="$GITHUB_WORKSPACE/.local/bin" @@ -368,6 +371,8 @@ EOF # so large-model loads (e.g. DSR1-FP8 ~680GB off shared FS) finish in time. # Uses ${CONFIG_FILE%%:*} because CONFIG_FILE may carry an :override[N] suffix. sed -i 's/^ max_attempts: [0-9]*/ max_attempts: 720/' "${CONFIG_FILE%%:*}" + python3 "$GITHUB_WORKSPACE/runners/inject_synthetic_acceptance.py" \ + "${CONFIG_FILE%%:*}" "$FRAMEWORK" || exit 1 SRTCTL_PREFLIGHT_ARGS=() # Kimi K2.6 weights are staged on the Slurm compute nodes, not the login node. diff --git a/runners/launch_b300-nv.sh b/runners/launch_b300-nv.sh index cad9ba30aa..5fced02ffb 100644 --- a/runners/launch_b300-nv.sh +++ b/runners/launch_b300-nv.sh @@ -481,8 +481,21 @@ else export GPU_COUNT="${GPU_COUNT:-${TP:?TP must be set}}" - SALLOC_TIME_LIMIT="${SALLOC_TIME_LIMIT:-480}" - salloc --partition=$SLURM_PARTITION --account=$SLURM_ACCOUNT -N 1 --gres=gpu:$GPU_COUNT --exclusive --mem=0 --time="$SALLOC_TIME_LIMIT" --no-shell --job-name="$RUNNER_NAME" + SALLOC_ARGS=( + --partition="$SLURM_PARTITION" + --account="$SLURM_ACCOUNT" + -N 1 + --gres="gpu:$GPU_COUNT" + --exclusive + --mem=0 + --time="${SALLOC_TIME_LIMIT:-480}" + --no-shell + --job-name="$RUNNER_NAME" + ) + if [[ -n "${SALLOC_EXCLUDE:-}" ]]; then + SALLOC_ARGS+=(--exclude="$SALLOC_EXCLUDE") + fi + salloc "${SALLOC_ARGS[@]}" JOB_ID=$(squeue --name="$RUNNER_NAME" -u "$USER" -h -o %A | head -n1) srun --jobid=$JOB_ID \ diff --git a/runners/launch_gb200-nv.sh b/runners/launch_gb200-nv.sh index 660ae078d5..c4c0e53847 100755 --- a/runners/launch_gb200-nv.sh +++ b/runners/launch_gb200-nv.sh @@ -503,6 +503,9 @@ else git clone --branch cam/sa-submission-q2-2026 --single-branch https://github.com/cquil11/srt-slurm-nv.git "$SRT_REPO_DIR" cd "$SRT_REPO_DIR" fi +if [[ "${EVAL_FRAMEWORK:-lm-eval}" == "kimi-vendor" ]]; then + python3 "$GITHUB_WORKSPACE/runners/patch_srt_eval_dispatch.py" "$(pwd)" || exit 1 +fi echo "Installing srtctl..." curl -LsSf https://astral.sh/uv/install.sh | sh @@ -650,10 +653,10 @@ if command -v squeue >/dev/null 2>&1; then fi sed -i "s/^name:.*/name: \"${SRT_SLURM_JOB_NAME}\"/" "$CONFIG_PATH" -# Optionally inject synthetic acceptance into the recipe's speculative-config -# when SYNTHETIC_ACCEPTANCE=true (no-op otherwise). Must run after the name -# override and before srtctl apply so the rendered job picks it up. -python3 "$GITHUB_WORKSPACE/runners/inject_synthetic_acceptance.py" "$CONFIG_PATH" "$FRAMEWORK" +# Restore real acceptance for eval-only jobs, or inject synthetic acceptance +# when a throughput run explicitly enables it. +python3 "$GITHUB_WORKSPACE/runners/inject_synthetic_acceptance.py" \ + "$CONFIG_PATH" "$FRAMEWORK" || exit 1 # Don't leak the login-node venv to the compute-node orchestrator. sbatch's # default --export=ALL propagates VIRTUAL_ENV (set by `source diff --git a/runners/launch_h200-dgxc-slurm.sh b/runners/launch_h200-dgxc-slurm.sh index f904d8bc27..61c290146e 100755 --- a/runners/launch_h200-dgxc-slurm.sh +++ b/runners/launch_h200-dgxc-slurm.sh @@ -106,6 +106,9 @@ if [[ "$IS_MULTINODE" == "true" ]]; then cd "$SRT_REPO_DIR" git checkout sa-submission-q2-2026 fi + if [[ "${EVAL_FRAMEWORK:-lm-eval}" == "kimi-vendor" ]]; then + python3 "$GITHUB_WORKSPACE/runners/patch_srt_eval_dispatch.py" "$(pwd)" + fi echo "Installing srtctl..." curl -LsSf https://astral.sh/uv/install.sh | sh @@ -233,6 +236,8 @@ EOF sed -i "s/^name:.*/name: \"${RUNNER_NAME}\"/" "$CONFIG_PATH" sed -i '/^health_check:/,/^[^ ]/{ /^health_check:/d; /^ /d; }' "$CONFIG_PATH" printf '\nhealth_check:\n max_attempts: 720\n interval_seconds: 10\n' >> "$CONFIG_PATH" + python3 "$GITHUB_WORKSPACE/runners/inject_synthetic_acceptance.py" \ + "$CONFIG_PATH" "$FRAMEWORK" WORKLOAD_TAG="${ISL}x${OSL}" if [[ "$IS_AGENTIC" == "1" ]]; then WORKLOAD_TAG="agentic" diff --git a/runners/patch_srt_eval_dispatch.py b/runners/patch_srt_eval_dispatch.py new file mode 100755 index 0000000000..daa52d9ab0 --- /dev/null +++ b/runners/patch_srt_eval_dispatch.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Enable InferenceX-selected eval dispatch in an srt-slurm checkout.""" + +from __future__ import annotations + +import sys +from pathlib import Path + + +DO_SWEEP_ENV_BLOCK = """ "EVAL_ONLY", + "IS_MULTINODE",""" +DO_SWEEP_ENV_REPLACEMENT = """ "EVAL_ONLY", + "EVAL_FRAMEWORK", + "EVAL_SUITE", + "IS_MULTINODE",""" +LM_EVAL_COMMAND = 'run_eval --framework lm-eval --port "$PORT" || eval_rc=$?' +GENERIC_EVAL_COMMAND = 'run_eval --port "$PORT" || eval_rc=$?' +EVAL_ARTIFACT_COPY = """cp -v results*.json /logs/eval_results/ 2>/dev/null || true +cp -v sample*.jsonl /logs/eval_results/ 2>/dev/null || true""" +KIMI_ARTIFACT_COPY = """cp -v results*.json /logs/eval_results/ 2>/dev/null || true +cp -v *_vendor_report.json /logs/eval_results/ 2>/dev/null || true +cp -v sample*.jsonl /logs/eval_results/ 2>/dev/null || true""" + + +def prepare_replacements( + path: Path, + replacements: tuple[tuple[str, str], ...], +) -> tuple[str, str, bool]: + """Validate source replacements without mutating the checkout.""" + original = path.read_text() + content = original + changed = False + for old, new in replacements: + old_count = content.count(old) + new_count = content.count(new) + if old_count == 1 and new_count == 0: + content = content.replace(old, new, 1) + changed = True + elif old_count != 0 or new_count != 1: + raise RuntimeError( + f"invalid patch state in {path}: old anchor count={old_count}, " + f"replacement count={new_count}" + ) + return original, content, changed + + +def patch_checkout(root: Path) -> list[Path]: + """Patch both post-eval sources after validating the complete checkout.""" + patches = ( + ( + root / "src/srtctl/cli/do_sweep.py", + ((DO_SWEEP_ENV_BLOCK, DO_SWEEP_ENV_REPLACEMENT),), + ), + ( + root / "src/srtctl/benchmarks/scripts/lm-eval/bench.sh", + ( + (LM_EVAL_COMMAND, GENERIC_EVAL_COMMAND), + (EVAL_ARTIFACT_COPY, KIMI_ARTIFACT_COPY), + ), + ), + ) + staged = [ + (path, *prepare_replacements(path, replacements)) + for path, replacements in patches + ] + changed = [] + written = [] + try: + for path, original, replacement, needs_write in staged: + if needs_write: + path.write_text(replacement) + written.append((path, original)) + changed.append(path) + except OSError: + for path, original in reversed(written): + path.write_text(original) + raise + return changed + + +def main(argv: list[str]) -> int: + """Patch the checkout named on the command line.""" + if len(argv) != 2: + print(f"Usage: {argv[0]} SRT_SLURM_CHECKOUT", file=sys.stderr) + return 2 + root = Path(argv[1]).resolve() + try: + changed = patch_checkout(root) + except (OSError, RuntimeError) as error: + print( + f"ERROR: failed to patch srt-slurm eval dispatch: {error}", file=sys.stderr + ) + return 1 + if changed: + for path in changed: + print(f"Patched srt-slurm eval dispatch: {path}") + else: + print("srt-slurm eval dispatch is already patched") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/runners/synthetic_injectors/vllm.py b/runners/synthetic_injectors/vllm.py index e71a5df2b4..d36740195c 100644 --- a/runners/synthetic_injectors/vllm.py +++ b/runners/synthetic_injectors/vllm.py @@ -1,13 +1,8 @@ -"""vLLM synthetic-acceptance backend (FRAMEWORK=dynamo-vllm). +"""vLLM speculative-acceptance recipe rewriting. -Rewrites every ``speculative-config: ''`` entry in an srt-slurm recipe to -use synthetic rejection sampling: it adds ``rejection_sample_method=synthetic`` -and ``synthetic_acceptance_length=`` to the JSON so the engine emits a -controlled mean acceptance length instead of running the real draft model. - -Registered under the "dynamo-vllm" framework key at import time, so importing -the ``synthetic_injectors`` package is enough for the generic driver to resolve -this backend. +Throughput opt-ins can inject synthetic acceptance. Eval-only runs restore real +block verification so model outputs remain valid for accuracy checks. The +backend is registered for both direct vLLM and Dynamo-vLLM recipes. """ import json @@ -57,7 +52,9 @@ def _replace(match): new_content, count = _SPEC_CONFIG_RE.subn(_replace, content) if count: - after = [ln.strip() for ln in new_content.splitlines() if _SPEC_CONFIG_RE.search(ln)] + after = [ + ln.strip() for ln in new_content.splitlines() if _SPEC_CONFIG_RE.search(ln) + ] if after: log("After:") for ln in after: @@ -66,4 +63,28 @@ def _replace(match): return new_content, count +def rewrite_real(content, log): + """Restore real block verification in every synthetic config entry.""" + modified = 0 + + def _replace(match): + nonlocal modified + spec = json.loads(match.group(1)) + if ( + spec.get("rejection_sample_method") != "synthetic" + and "synthetic_acceptance_length" not in spec + ): + return match.group(0) + spec["rejection_sample_method"] = "block" + spec.pop("synthetic_acceptance_length", None) + modified += 1 + return "speculative-config: '" + json.dumps(spec, separators=(",", ":")) + "'" + + new_content = _SPEC_CONFIG_RE.sub(_replace, content) + if modified: + log("Restored real block verification for eval-only mode") + return new_content, modified + + register("dynamo-vllm", sys.modules[__name__]) +register("vllm", sys.modules[__name__]) diff --git a/runners/test_slurm_utils.py b/runners/test_slurm_utils.py index c027cc9b2f..a89a3354bb 100644 --- a/runners/test_slurm_utils.py +++ b/runners/test_slurm_utils.py @@ -1,9 +1,14 @@ +import json +import os import subprocess from pathlib import Path +import yaml REPO_ROOT = Path(__file__).resolve().parents[1] SLURM_UTILS = REPO_ROOT / "runners" / "slurm_utils.sh" +PATCH_SRT_EVAL = REPO_ROOT / "runners" / "patch_srt_eval_dispatch.py" +INJECT_ACCEPTANCE = REPO_ROOT / "runners" / "inject_synthetic_acceptance.py" def run_bash(command: str, *args: Path | str) -> subprocess.CompletedProcess[str]: @@ -55,3 +60,213 @@ def test_copy_agentic_results_fails_when_aggregate_is_missing( assert result.returncode != 0 assert "no run_conc*.json results found" in result.stderr + + +def test_patch_srt_eval_dispatch_forwards_selection_and_is_idempotent( + tmp_path: Path, +) -> None: + do_sweep = tmp_path / "src/srtctl/cli/do_sweep.py" + eval_script = tmp_path / "src/srtctl/benchmarks/scripts/lm-eval/bench.sh" + do_sweep.parent.mkdir(parents=True) + eval_script.parent.mkdir(parents=True) + do_sweep.write_text( + " for var in [\n" + ' "RUN_EVAL",\n' + ' "EVAL_ONLY",\n' + ' "IS_MULTINODE",\n' + " ]:\n" + ) + eval_script.write_text( + 'run_eval --framework lm-eval --port "$PORT" || eval_rc=$?\n' + "cp -v results*.json /logs/eval_results/ 2>/dev/null || true\n" + "cp -v sample*.jsonl /logs/eval_results/ 2>/dev/null || true\n" + ) + + first = subprocess.run( + ["python3", str(PATCH_SRT_EVAL), str(tmp_path)], + check=False, + capture_output=True, + text=True, + ) + second = subprocess.run( + ["python3", str(PATCH_SRT_EVAL), str(tmp_path)], + check=False, + capture_output=True, + text=True, + ) + + assert first.returncode == 0, first.stderr + assert second.returncode == 0, second.stderr + assert do_sweep.read_text().count('"EVAL_FRAMEWORK"') == 1 + assert do_sweep.read_text().count('"EVAL_SUITE"') == 1 + assert 'run_eval --port "$PORT"' in eval_script.read_text() + assert "--framework lm-eval" not in eval_script.read_text() + assert "*_vendor_report.json" in eval_script.read_text() + assert "already patched" in second.stdout + + +def test_patch_srt_eval_dispatch_preflights_before_writing(tmp_path: Path) -> None: + do_sweep = tmp_path / "src/srtctl/cli/do_sweep.py" + eval_script = tmp_path / "src/srtctl/benchmarks/scripts/lm-eval/bench.sh" + do_sweep.parent.mkdir(parents=True) + eval_script.parent.mkdir(parents=True) + original_do_sweep = ' "EVAL_ONLY",\n "IS_MULTINODE",\n' + original_eval_script = "unsupported eval hook\n" + do_sweep.write_text(original_do_sweep) + eval_script.write_text(original_eval_script) + + result = subprocess.run( + ["python3", str(PATCH_SRT_EVAL), str(tmp_path)], + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 1 + assert do_sweep.read_text() == original_do_sweep + assert eval_script.read_text() == original_eval_script + + +def test_patch_srt_eval_dispatch_rejects_mixed_patch_state(tmp_path: Path) -> None: + do_sweep = tmp_path / "src/srtctl/cli/do_sweep.py" + eval_script = tmp_path / "src/srtctl/benchmarks/scripts/lm-eval/bench.sh" + do_sweep.parent.mkdir(parents=True) + eval_script.parent.mkdir(parents=True) + original_do_sweep = ( + ' "EVAL_ONLY",\n' + ' "IS_MULTINODE",\n' + ' "EVAL_ONLY",\n' + ' "EVAL_FRAMEWORK",\n' + ' "EVAL_SUITE",\n' + ' "IS_MULTINODE",\n' + ) + original_eval_script = ( + 'run_eval --framework lm-eval --port "$PORT" || eval_rc=$?\n' + "cp -v results*.json /logs/eval_results/ 2>/dev/null || true\n" + "cp -v sample*.jsonl /logs/eval_results/ 2>/dev/null || true\n" + ) + do_sweep.write_text(original_do_sweep) + eval_script.write_text(original_eval_script) + + result = subprocess.run( + ["python3", str(PATCH_SRT_EVAL), str(tmp_path)], + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 1 + assert "invalid patch state" in result.stderr + assert do_sweep.read_text() == original_do_sweep + assert eval_script.read_text() == original_eval_script + + +def test_eval_only_restores_real_vllm_acceptance(tmp_path: Path) -> None: + recipe = tmp_path / "recipe.yaml" + recipe.write_text( + "speculative-config: " + """'{\"method\":\"dspark\",\"num_speculative_tokens\":2,""" + """\"rejection_sample_method\":\"synthetic\",""" + """\"synthetic_acceptance_length\":2.51}'\n""" + ) + env = { + **os.environ, + "EVAL_ONLY": "true", + "SYNTHETIC_ACCEPTANCE": "true", + } + + result = subprocess.run( + ["python3", str(INJECT_ACCEPTANCE), str(recipe), "vllm"], + env=env, + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + rewritten = recipe.read_text() + assert '"rejection_sample_method":"block"' in rewritten + assert "synthetic_acceptance_length" not in rewritten + + +def test_eval_only_acceptance_rewrite_allows_non_speculative_recipe( + tmp_path: Path, +) -> None: + recipe = tmp_path / "recipe.yaml" + original = "backend:\n type: vllm\n" + recipe.write_text(original) + + result = subprocess.run( + ["python3", str(INJECT_ACCEPTANCE), str(recipe), "dynamo-vllm"], + env={**os.environ, "EVAL_ONLY": "true"}, + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + assert recipe.read_text() == original + + +def test_nvidia_srt_launchers_prepare_kimi_eval_dispatch() -> None: + launchers = ( + REPO_ROOT / "runners/launch_h200-dgxc-slurm.sh", + REPO_ROOT / "runners/launch_b200-dgxc.sh", + REPO_ROOT / "runners/launch_gb200-nv.sh", + ) + + for launcher in launchers: + content = launcher.read_text() + assert "patch_srt_eval_dispatch.py" in content + assert 'EVAL_FRAMEWORK:-lm-eval}" == "kimi-vendor"' in content + assert "inject_synthetic_acceptance.py" in content + + +def test_gb200_kimi_compilation_config_preserves_all_settings() -> None: + recipes = { + "agg-gb200-tep16-balanced-agentic.yaml": 96, + "agg-gb200-tp16-latency-agentic.yaml": 24, + } + recipe_dir = ( + REPO_ROOT / "benchmarks/multi_node/srt-slurm-recipes/vllm/kimi-k3/agentic" + ) + + for filename, largest_capture in recipes.items(): + recipe = yaml.safe_load((recipe_dir / filename).read_text()) + raw_config = recipe["backend"]["vllm_config"]["aggregated"][ + "compilation-config" + ] + compilation_config = json.loads(raw_config) + + assert compilation_config["cudagraph_capture_sizes"][-1] == largest_capture + assert compilation_config["pass_config"]["fuse_allreduce_rms"] is False + + +def test_gb200_dynamo_kimi_recipes_enable_structural_tool_constraints() -> None: + recipe_dir = ( + REPO_ROOT / "benchmarks/multi_node/srt-slurm-recipes/vllm/kimi-k3/agentic" + ) + recipes = [ + yaml.safe_load(path.read_text()) + for path in recipe_dir.glob("agg-gb200-*-agentic.yaml") + ] + + assert recipes + for recipe in recipes: + frontend = recipe["frontend"] + assert frontend["type"] == "dynamo" + config = recipe["backend"]["vllm_config"]["aggregated"] + assert config["dyn-tool-call-parser"] == "kimi_k3" + assert config["dyn-enable-structural-tag"] is True + + +def test_b200_kimi_recipe_uses_available_roce_devices() -> None: + recipe_path = ( + REPO_ROOT + / "benchmarks/multi_node/srt-slurm-recipes/vllm/kimi-k3/agentic" + / "agg-b200-tp8pp2-agentic.yaml" + ) + recipe = yaml.safe_load(recipe_path.read_text()) + devices = recipe["backend"]["aggregated_environment"]["UCX_NET_DEVICES"] + + assert devices == ",".join(f"mlx5_{index}:1" for index in range(8)) diff --git a/utils/collect_eval_results.py b/utils/collect_eval_results.py index 667e60bc6f..545ef3a855 100644 --- a/utils/collect_eval_results.py +++ b/utils/collect_eval_results.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import sys import json +import math import re from pathlib import Path from typing import Any, Dict, List, Optional, Tuple @@ -32,6 +33,7 @@ SPEC_DECODING = "Spec Decode" CONC_SUFFIX_RE = re.compile(r"_conc(\d+)(?:_\d+)?\.json$") +EVAL_RESULT_FORMAT = "inferencex-eval-v1" def load_json(path: Path) -> Optional[Dict[str, Any]]: @@ -71,10 +73,10 @@ def result_concurrency(path: Path) -> Optional[int]: def detect_lm_eval_jsons(d: Path, batched: bool = False) -> List[Path]: - """Return lm-eval result JSONs from one artifact directory. + """Return the latest collector-compatible eval result JSONs. - Legacy artifacts contribute their latest result file. Batched artifacts - contribute the latest result file for each `_concN` suffix. + Result filenames contain sortable timestamps. Mtime remains a fallback for + legacy names, with the filename as a deterministic tie-breaker. """ immediate_jsons = set(d.glob('results*.json')) immediate_jsons.update( @@ -82,17 +84,28 @@ def detect_lm_eval_jsons(d: Path, batched: bool = False) -> List[Path]: ) lm_paths = [] + def recency_key(path: Path) -> Tuple[str, int, str]: + match = re.search( + r"\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}(?:\.\d+)?", + path.name, + ) + return ( + match.group(0) if match else "", + path.stat().st_mtime_ns, + path.name, + ) + for p in immediate_jsons: data = load_json(p) if not isinstance(data, dict): continue - if 'lm_eval_version' in data: + if data.get('result_format') == EVAL_RESULT_FORMAT or 'lm_eval_version' in data: lm_paths.append(p) if not lm_paths: return [] if not batched: - return [max(lm_paths, key=lambda path: path.stat().st_mtime)] + return [max(lm_paths, key=recency_key)] latest_by_conc: Dict[int, Path] = {} for path in lm_paths: @@ -100,15 +113,28 @@ def detect_lm_eval_jsons(d: Path, batched: bool = False) -> List[Path]: if conc is None: continue current = latest_by_conc.get(conc) - if current is None or path.stat().st_mtime > current.stat().st_mtime: + if current is None or recency_key(path) > recency_key(current): latest_by_conc[conc] = path return [latest_by_conc[conc] for conc in sorted(latest_by_conc)] -def detect_eval_jsons(d: Path) -> Tuple[Optional[Path], Optional[Path]]: - """Return the latest legacy lm-eval JSON and deprecated second slot.""" - lm_paths = detect_lm_eval_jsons(d) - return (lm_paths[0] if lm_paths else None), None +def has_invalid_effective_count(data: Dict[str, Any], task: str) -> bool: + """Return whether a task has an explicitly invalid effective count.""" + if 'n-samples' not in data: + return False + sample_counts = data['n-samples'] + if not isinstance(sample_counts, dict) or task not in sample_counts: + return True + task_samples = sample_counts[task] + if not isinstance(task_samples, dict) or 'effective' not in task_samples: + return True + effective = task_samples['effective'] + return ( + isinstance(effective, bool) + or not isinstance(effective, (int, float)) + or not math.isfinite(effective) + or effective <= 0 + ) def extract_lm_metrics(json_path: Path) -> List[Dict[str, Any]]: @@ -123,6 +149,8 @@ def extract_lm_metrics(json_path: Path) -> List[Dict[str, Any]]: - Values from results[task][metric,filter] """ data = load_json(json_path) or {} + if 'integration_error' in data: + return [] results = data.get('results', {}) configs = data.get('configs', {}) @@ -132,6 +160,8 @@ def extract_lm_metrics(json_path: Path) -> List[Dict[str, Any]]: extracted = [] for task in results.keys(): + if has_invalid_effective_count(data, task): + continue task_results = results[task] task_config = configs.get(task, {}) @@ -284,6 +314,9 @@ def build_row(meta: Dict[str, Any], m: Dict[str, Any]) -> Dict[str, Any]: 'source': m.get('source'), } + if 'eval_suite' in meta: + row['eval_suite'] = meta['eval_suite'] + # Add universal score field (primary metric for unified comparison) if m.get('strict') is not None: row['score'] = m.get('strict') @@ -404,7 +437,7 @@ def main(): f"{pct(r['score'])}{se(r['score_se'])}", f"{pct(r['em_strict'])}{se(r['em_strict_se'])}", f"{pct(r['em_flexible'])}{se(r['em_flexible_se'])}", - r['n_eff'] or '', + r['n_eff'] if r['n_eff'] is not None else '', r['model'], ] for r in single_node_rows @@ -442,7 +475,7 @@ def main(): f"{pct(r['score'])}{se(r['score_se'])}", f"{pct(r['em_strict'])}{se(r['em_strict_se'])}", f"{pct(r['em_flexible'])}{se(r['em_flexible_se'])}", - r['n_eff'] or '', + r['n_eff'] if r['n_eff'] is not None else '', r['model'], ] for r in multinode_rows diff --git a/utils/evals/EVALS.md b/utils/evals/EVALS.md index 0a7bf213df..9c1375e7c0 100644 --- a/utils/evals/EVALS.md +++ b/utils/evals/EVALS.md @@ -11,11 +11,10 @@ from throughput. Selection lives in `mark_eval_entries()` in runner, framework, precision, TP, and decoding configuration. - **Multi-node:** 8k1k only, with one job per parallelism topology at its highest eligible concurrency. Rows differing only by concurrency share a topology. -- **Agentic (SWE-bench), single-node:** highest-conc entry per (model, - runner, framework, precision) group. -- **Agentic (SWE-bench), multi-node:** same policy as multi-node fixed-seq-len - above (highest eligible conc per parallelism topology), since SWE-bench - doesn't support batched concurrencies the way lm-eval does. +- **Agentic (GSM8K), single-node:** highest-conc entry per (model, runner, + framework, precision) group. +- **Agentic (GSM8K), multi-node:** highest eligible concurrency per + parallelism topology. Generator eval modes: @@ -44,10 +43,75 @@ malformed metadata, duplicates, and raw/aggregate mismatches are not. See [workflow reuse](../../.github/workflows/README.md#reusing-an-approved-pr-full-sweep). ## How? -`run_eval` in `benchmarks/benchmark_lib.sh` runs EleutherAI/lm-evaluation-harness against the server's OpenAI-compatible endpoint. Concurrency is set via `EVAL_CONCURRENT_REQUESTS` env var (not a CLI flag). Results are collected by `utils/collect_eval_results.py` and published as a summary table. +`run_eval` in `benchmarks/benchmark_lib.sh` dispatches to the selected eval +runner. Existing jobs continue to use lm-eval with GSM8K by default. The default eval framework is [lm-evaluation-harness](https://github.com/EleutherAI/lm-evaluation-harness) (`lm-eval`). Agentic eval-only matrix jobs inherit this default and therefore run the same GSM8K task as 8k1k. Explicit agentic runs can still select SWE-bench. +The Phase 1 Kimi smoke is opt-in. It supports single-node jobs and Kimi K3 +aggregate H200, B200, and GB200 srt-slurm jobs. Select +`eval-framework: kimi-vendor` and `eval-suite: kimi_tool_call_schema` on +`e2e-tests.yml`, or invoke it from the repository root after a server is ready: + +```bash +source benchmarks/benchmark_lib.sh +export EVAL_FRAMEWORK=kimi-vendor +export EVAL_SUITE=kimi_tool_call_schema +export EVAL_RESULT_DIR="$(mktemp -d /tmp/eval_out-XXXXXX)" +run_eval --port "$PORT" +append_lm_eval_summary +python3 utils/evals/validate_scores.py +``` + +The framework selects a provider-specific subprocess adapter, while the suite +selects a case set understood by that adapter. Each adapter owns its endpoint +format, dependencies, native report, metrics, and pass policy. Future MiniMax +or BFCL support should add explicit `run_eval` cases rather than a shared +request or report abstraction. +Agentic eval jobs forward the matrix `spec-decoding` value, so MTP entries +launch their existing `*_mtp.sh` server instead of silently falling back to STP. + +### Stock Kimi tool-call schema smoke + +The smoke runs the unmodified +[MoonshotAI/Kimi-Vendor-Verifier](https://github.com/MoonshotAI/Kimi-Vendor-Verifier) +at commit `b9ed3a6665bdff2c943246f7d2903cd003d6ddd6`. Each run downloads the +fresh pinned GitHub source archive and safely extracts only the upstream pytest +configuration, tool-call schema tests, and bundled Walle cases. InferenceX does +not install the verifier package or reimplement its request, streaming, or +validation logic. + +Python 3.12 or newer is required. The runner installs the minimal pinned runtime +(`httpx[http2]`, `openai`, `jsonschema`, `pytest`, and +`pytest-rerunfailures`) into a temporary isolated package directory, then runs +upstream +`tests/tool_call_json_schema/test_tool_call_json_schema.py` with: + +- the local OpenAI-compatible endpoint, `EMPTY` API key, and served model name; +- `--think-mode none --selection object --max-cases 1 --max-tokens 2048`; +- the upstream-recommended `--reruns 3 --reruns-delay 2`; +- the bundled Walle case directory and `--tool-json-report`. + +The selection is `TestAdditionalProperties:1`, parametrized upstream in +non-streaming and streaming modes. Pytest makes one initial attempt and up to +three reruns of each failing mode, with a two-second delay before each rerun. +These retries reduce transient transport and model-sampling flakes; they do not +make the smoke deterministic. The unchanged native report remains one final +outcome per mode because the upstream report deduplicates rerun records by case +and mode. It is uploaded as `kimi_vendor_report.json`, and +`utils/evals/kimi_vendor_eval.py` projects those two outcomes into the existing +eval result shape. Both must pass, so the `kimi_tool_call_schema` threshold is +`1.0`. Setup, timeout, and collection failures emit a zero-score result with +error metadata. The adapter's 900-second global timeout bounds the entire +upstream pytest process, including all attempts and rerun delays. + +This smoke validates one object-schema tool call. It does not cover tool choice, +parallel calls, multi-turn execution, or general agent quality. Multi-value +batched concurrency is unsupported. Multi-node aggregate jobs run the same +two-case smoke against their OpenAI-compatible frontend. Eval-only launchers +restore real block verification before submitting recipes that otherwise use +synthetic acceptance for throughput. + ### Benchmark script flow All benchmark scripts in `benchmarks/` follow one of two flows: @@ -77,14 +141,17 @@ Key eval functions in `benchmarks/benchmark_lib.sh`: |----------|-------------| | `run_eval` | Unified entrypoint - dispatches to framework-specific runner | | `run_lm_eval` | Runs lm-eval harness against the OpenAI-compatible endpoint | +| `run_kimi_vendor_eval` | Selects and runs a pinned Kimi Vendor Verifier suite | | `append_lm_eval_summary` | Writes `meta_env.json` and moves eval artifacts to workspace | | `_install_lm_eval_deps` | Installs lm-eval dependencies | +| `_prepare_kimi_vendor_runtime` | Installs the pinned verifier dependencies in an isolated temp path | +| `_prepare_kimi_vendor_verifier` | Downloads and safely extracts a fresh subset of the pinned source archive | | `_patch_lm_eval` | Patches lm-eval for reasoning tokens and TRT compatibility | | `compute_eval_context_length` | Computes eval context length (requested benchmark context, capped at model native max) | | `get_native_max_context_length` | Extracts model's native max context length from HF config | ### Single-node -In eval-only mode (`EVAL_ONLY=true`), the benchmark script computes `EVAL_MAX_MODEL_LEN` via `compute_eval_context_length`, starts the server with that context length, skips throughput, and runs lm-eval directly. Each framework wires that context differently (`--context-length` for SGLang, `--max_seq_len` for TRT-LLM). +For default lm-eval jobs in eval-only mode (`EVAL_ONLY=true`), the benchmark script computes `EVAL_MAX_MODEL_LEN` via `compute_eval_context_length`, starts the server with that context length, skips throughput, and runs lm-eval. Each framework wires that context differently (`--context-length` for SGLang, `--max_seq_len` for TRT-LLM). ### Multi-node Multi-node evals support two hardware paths: @@ -92,13 +159,13 @@ Multi-node evals support two hardware paths: **MI355X (AMD)** — `benchmarks/multi_node/amd_utils/server_sglang.sh` - Skips throughput when `EVAL_ONLY=true` - Fixed-seq-len: runs lm-eval via `run_eval --framework lm-eval` against the router on port 30000 -- Agentic-coding (disaggregated, `IS_AGENTIC=1`): runs SWE-bench via `run_eval --port 30000` (no - `--framework` override, same auto-selection as single-node agentic eval-only). Since there's no - single "TP" for a disaggregated topology, and the workflow spells a couple of metadata fields - differently (`PREFILL_DP_ATTN`/`DECODE_DP_ATTN`) than `append_lm_eval_summary` expects - (`PREFILL_DP_ATTENTION`/`DECODE_DP_ATTENTION`), the agentic branch bridges those before calling - `run_eval`; `append_lm_eval_summary` itself runs automatically inside `run_eval()` (same - `EVAL_ONLY=true && IS_AGENTIC` auto-staging as single-node), not as a separate call. +- Agentic-coding (disaggregated, `IS_AGENTIC=1`): follows the same GSM8K/lm-eval path via + `run_eval --framework lm-eval`. Since there's no single "TP" for a disaggregated topology, + and the workflow spells a couple of metadata fields differently + (`PREFILL_DP_ATTN`/`DECODE_DP_ATTN`) than `append_lm_eval_summary` expects + (`PREFILL_DP_ATTENTION`/`DECODE_DP_ATTENTION`), the agentic branch bridges those before + calling `run_eval`; `append_lm_eval_summary` itself runs automatically inside `run_eval()` + (same `EVAL_ONLY=true && IS_AGENTIC` auto-staging as single-node), not as a separate call. - Concurrency uses workflow-provided `EVAL_CONC` when set, otherwise falls back to max of `BENCH_MAX_CONCURRENCY` (x-separated values) - Eval artifacts copied to `/run_logs/slurm_job-*/eval_results/` - `runners/launch_mi355x-amds.sh` skips benchmark result collection when `EVAL_ONLY=true` and uses `find` to locate eval results @@ -106,10 +173,10 @@ Multi-node evals support two hardware paths: **NVIDIA Slurm multi-node (GB200, GB300, B200, B300, H100, H200)** runs through [srt-slurm](https://github.com/NVIDIA/srt-slurm) on the `sa-submission-q2-2026` branch. - `do_sweep.py` skips the benchmark stage when `EVAL_ONLY=true`, runs `_run_post_eval()` directly - In eval-only mode, uses the full `wait_for_model()` health check (same as benchmark stage) since the benchmark health check was skipped -- `lm-eval` runner (`benchmarks/lm_eval.py`) is invoked by `do_sweep.py` as a post/eval-only step and sources InferenceX's `benchmark_lib.sh` from the mounted workspace (`/infmax-workspace`) +- The registered srt-slurm `lm-eval` post-runner sources InferenceX's `benchmark_lib.sh` from the mounted workspace (`/infmax-workspace`). Kimi-selected launches patch that hook to use generic `run_eval` dispatch while preserving lm-eval as the default. - Eval artifacts written to `/logs/eval_results/` inside the container, collected by launch scripts - NVIDIA Slurm launch scripts always collect server logs for debugging but skip benchmark result collection when `EVAL_ONLY=true` -- Env vars threaded: `RUN_EVAL`, `EVAL_ONLY`, `IS_MULTINODE`, `FRAMEWORK`, `PRECISION`, `MODEL_PREFIX`, `RUNNER_TYPE`, `RESULT_FILENAME`, `SPEC_DECODING`, `ISL`, `OSL`, `PREFILL_TP/EP/NUM_WORKERS/DP_ATTN`, `DECODE_TP/EP/NUM_WORKERS/DP_ATTN`, `MODEL_NAME`, `EVAL_CONC` +- Env vars threaded: `RUN_EVAL`, `EVAL_ONLY`, `EVAL_FRAMEWORK`, `EVAL_SUITE`, `IS_MULTINODE`, `FRAMEWORK`, `PRECISION`, `MODEL_PREFIX`, `RUNNER_TYPE`, `RESULT_FILENAME`, `SPEC_DECODING`, `ISL`, `OSL`, `PREFILL_TP/EP/NUM_WORKERS/DP_ATTN`, `DECODE_TP/EP/NUM_WORKERS/DP_ATTN`, `MODEL_NAME`, `EVAL_CONC` For multi-node `all-evals`, `EVAL_CONC` is a space-separated list. When it contains multiple values, `run_eval` runs those concurrency points sequentially against the same live engine, stages each result with a `_concN` filename suffix, and records expected/completed/failed points in `meta_env.json`. @@ -159,6 +226,7 @@ cat ./evals/agg_eval_all.json | jq '[.[] | select(.hw == "B200")]' | `em_flexible` | Flexible extraction (looser number matching) | | `n_eff` | Number of samples evaluated | | `task` | Eval task name (e.g., `gsm8k`) | +| `eval_suite` | Explicit suite identity used for collection and artifact reuse | ### Environment variables @@ -166,7 +234,8 @@ cat ./evals/agg_eval_all.json | jq '[.[] | select(.hw == "B200")]' |----------|---------|-------------| | `RUN_EVAL` | `false` | Enable eval after throughput benchmark | | `EVAL_ONLY` | `false` | Skip throughput, only run evals (set by workflow) | -| `EVAL_FRAMEWORK` | `lm-eval` | Eval framework to use | +| `EVAL_FRAMEWORK` | `lm-eval` | Eval runner (`lm-eval`, `swebench`, or `kimi-vendor`) | +| `EVAL_SUITE` | basename of `EVAL_TASKS_DIR`, else `gsm8k` | Provider suite selector and artifact identity. External override is currently supported only by `kimi-vendor`; other runners derive it from their task | | `EVAL_TASKS_DIR` | `utils/evals/gsm8k.yaml` | Path to lm-eval task YAML | | `EVAL_RESULT_DIR` | `/tmp/eval_out-*` | Output directory for eval results | | `EVAL_MAX_MODEL_LEN` | `16384` | Max context for eval (set by `compute_eval_context_length`) | @@ -182,6 +251,15 @@ cat ./evals/agg_eval_all.json | jq '[.[] | select(.hw == "B200")]' 2. Set `EVAL_TASKS_DIR=utils/evals/.yaml` when running benchmarks. 3. Update `utils/collect_eval_results.py` if new metrics need extraction. +### Adding a provider verifier + +1. Add a provider-specific adapter under `utils/evals/`. +2. Add an explicit framework case in `run_eval`; keep suite-specific policy in + that adapter's shell runner. +3. Install dependencies in a provider-specific isolated runtime. +4. Emit `result_format: inferencex-eval-v1`, preserve the native report as + `*_vendor_report.json`, set `EVAL_SUITE`, and add a threshold. + ### Runtime patches (`utils/evals/patches/`) The benchmark helpers invoke these standalone scripts against pinned dependencies. diff --git a/utils/evals/kimi_vendor_eval.py b/utils/evals/kimi_vendor_eval.py new file mode 100755 index 0000000000..8851f2be66 --- /dev/null +++ b/utils/evals/kimi_vendor_eval.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +"""Run the stock Kimi Vendor Verifier and project its native report.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from collections.abc import Mapping, Sequence +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +TASK_NAME = "kimi_tool_call_schema" +NATIVE_REPORT_FILENAME = "kimi_vendor_report.json" +COMPATIBILITY_GLOB = "results_kimi_vendor_*.json" +EXPECTED_MODES = {"non-stream", "stream"} +DEFAULT_TIMEOUT_SECONDS = 900 +RESULT_FORMAT = "inferencex-eval-v1" +ADAPTER_NAME = "kimi-vendor-verifier" + + +def prepare_compatibility_path(output_dir: Path) -> Path: + """Remove stale projections and return a timestamped collector artifact path.""" + for stale_path in output_dir.glob(COMPATIBILITY_GLOB): + stale_path.unlink() + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%S.%f") + return output_dir / f"results_kimi_vendor_{timestamp}.json" + + +def build_pytest_command( + *, base_url: str, api_key: str, model: str, report_path: Path +) -> list[str]: + """Build the fixed Phase 1 invocation of the upstream verifier.""" + return [ + sys.executable, + "-m", + "pytest", + "tests/tool_call_json_schema/test_tool_call_json_schema.py", + "--reruns", + "3", + "--reruns-delay", + "2", + "--base-url", + base_url, + "--api-key", + api_key, + "--smoke-model", + model, + "--think-mode", + "none", + "--selection", + "object", + "--max-cases", + "1", + "--case-dir", + "testdata/walle_validator_cases/validator_cases", + "--max-tokens", + "2048", + "--tool-json-report", + str(report_path), + ] + + +def _mapping(value: Any, name: str) -> Mapping[str, Any]: + if not isinstance(value, Mapping): + raise ValueError(f"{name} must be an object") + return value + + +def _project_report(model: str, report: Any) -> tuple[dict[str, Any], bool]: + root = _mapping(report, "report") + summary = _mapping(root.get("summary"), "report.summary") + results = root.get("results") + if not isinstance(results, list): + raise ValueError("report.results must be an array") + + total = summary.get("total") + by_status = _mapping(summary.get("by_status"), "report.summary.by_status") + if not isinstance(total, int) or isinstance(total, bool): + raise ValueError("report summary contains invalid counts") + for status, count in by_status.items(): + if ( + status not in {"passed", "failed"} + or not isinstance(count, int) + or isinstance(count, bool) + or count < 0 + ): + raise ValueError("report summary contains invalid counts") + if sum(by_status.values()) != total: + raise ValueError("report summary does not match total") + passed = by_status.get("passed", 0) + + modes: list[str] = [] + result_passes = 0 + for index, result in enumerate(results): + record = _mapping(result, f"report.results[{index}]") + mode = record.get("mode") + status = record.get("status") + if ( + not isinstance(mode, str) + or not isinstance(status, str) + or status not in {"passed", "failed"} + ): + raise ValueError(f"report.results[{index}] has invalid mode or status") + modes.append(mode) + result_passes += status == "passed" + + if total != len(results) or passed != result_passes: + raise ValueError("report summary does not match result records") + + if ( + total != 2 + or len(results) != 2 + or set(modes) != EXPECTED_MODES + or len(modes) != len(set(modes)) + ): + raise ValueError("report does not contain the expected stream modes") + score = passed / 2.0 + return _compatibility_result(model, score, n_samples=2), passed == 2 + + +def _compatibility_result( + model: str, + score: float, + *, + n_samples: int, + integration_error: BaseException | None = None, +) -> dict[str, Any]: + result: dict[str, Any] = { + "result_format": RESULT_FORMAT, + "eval_adapter": ADAPTER_NAME, + "model_name": model, + "results": { + TASK_NAME: { + "exact_match,strict-match": score, + "exact_match_stderr,strict-match": 0.0, + } + }, + "configs": { + TASK_NAME: { + "metric_list": [{"metric": "exact_match"}], + "filter_list": [{"name": "strict-match"}], + } + }, + "n-samples": { + TASK_NAME: { + "original": len(EXPECTED_MODES), + "effective": n_samples, + } + }, + } + if integration_error is not None: + result["integration_error"] = { + "type": type(integration_error).__name__, + "message": str(integration_error), + } + return result + + +def _write_compatibility(path: Path, result: Mapping[str, Any]) -> None: + path.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + + +def run_evaluation( + *, + verifier_dir: Path, + base_url: str, + api_key: str, + model: str, + output_dir: Path, + timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS, +) -> bool: + """Run upstream pytest and always attempt to publish a compatibility result.""" + output_dir.mkdir(parents=True, exist_ok=True) + native_report = output_dir / NATIVE_REPORT_FILENAME + compatibility_path = prepare_compatibility_path(output_dir) + subprocess_rc: int | None = None + integration_error: BaseException | None = None + compatibility = _compatibility_result(model, 0.0, n_samples=0) + complete_pass = False + try: + native_report.unlink(missing_ok=True) + completed = subprocess.run( + build_pytest_command( + base_url=base_url, + api_key=api_key, + model=model, + report_path=native_report.resolve(), + ), + cwd=verifier_dir, + check=False, + timeout=timeout_seconds, + ) + subprocess_rc = completed.returncode + report = json.loads(native_report.read_text(encoding="utf-8")) + compatibility, complete_pass = _project_report(model, report) + if subprocess_rc != 0 and complete_pass: + integration_error = RuntimeError( + f"upstream verifier exited with code {subprocess_rc}" + ) + compatibility = _compatibility_result( + model, 0.0, n_samples=2, integration_error=integration_error + ) + complete_pass = False + except (OSError, ValueError, subprocess.TimeoutExpired) as exc: + integration_error = exc + compatibility = _compatibility_result( + model, 0.0, n_samples=0, integration_error=exc + ) + finally: + try: + _write_compatibility(compatibility_path, compatibility) + except OSError as exc: + if integration_error is not None: + exc.add_note(f"Earlier integration error: {integration_error}") + raise + + return subprocess_rc == 0 and complete_pass and integration_error is None + + +def _positive_int(value: str) -> int: + parsed = int(value) + if parsed < 1: + raise argparse.ArgumentTypeError("must be a positive integer") + return parsed + + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run the pinned stock Kimi Vendor Verifier tool-schema smoke test." + ) + parser.add_argument("--verifier-dir", type=Path) + parser.add_argument("--base-url") + parser.add_argument("--api-key", default="EMPTY") + parser.add_argument("--model", required=True) + parser.add_argument("--output-dir", required=True, type=Path) + parser.add_argument( + "--timeout-seconds", type=_positive_int, default=DEFAULT_TIMEOUT_SECONDS + ) + parser.add_argument("--integration-error") + args = parser.parse_args(argv) + if args.integration_error is None: + missing = [ + option + for option, value in ( + ("--verifier-dir", args.verifier_dir), + ("--base-url", args.base_url), + ) + if value is None + ] + if missing: + parser.error( + f"{', '.join(missing)} required unless --integration-error is provided" + ) + return args + + +def main(argv: Sequence[str] | None = None) -> int: + args = parse_args(argv) + if args.integration_error is not None: + args.output_dir.mkdir(parents=True, exist_ok=True) + (args.output_dir / NATIVE_REPORT_FILENAME).unlink(missing_ok=True) + _write_compatibility( + prepare_compatibility_path(args.output_dir), + _compatibility_result( + args.model, + 0.0, + n_samples=0, + integration_error=RuntimeError(args.integration_error), + ), + ) + return 0 + passed = run_evaluation( + verifier_dir=args.verifier_dir, + base_url=args.base_url, + api_key=args.api_key, + model=args.model, + output_dir=args.output_dir, + timeout_seconds=args.timeout_seconds, + ) + return 0 if passed else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/utils/evals/test_batched_eval.py b/utils/evals/test_batched_eval.py index a5d6df0085..56c219e558 100644 --- a/utils/evals/test_batched_eval.py +++ b/utils/evals/test_batched_eval.py @@ -227,6 +227,106 @@ def test_validate_scores_checks_threshold_for_every_concurrency( assert "FAIL: [conc=4] gsm8k exact_match,strict-match" in captured.err +def test_validate_scores_reports_integration_failure_without_thresholding( + tmp_path: Path, + monkeypatch, + capsys, +) -> None: + result_path = tmp_path / "results_test.json" + result_path.write_text(json.dumps({ + "integration_error": { + "type": "RuntimeError", + "message": "vendor verifier checkout failed", + }, + "results": { + "gsm8k": { + "exact_match,strict-match": 0.0, + }, + }, + "n-samples": {"gsm8k": {"effective": 0}}, + })) + monkeypatch.setattr(sys, "argv", [ + "validate_scores.py", + "--meta-env", + str(tmp_path / "meta_env.json"), + "--results-glob", + str(result_path), + ]) + + assert validate_scores_main() == 1 + captured = capsys.readouterr() + assert "integration failure: RuntimeError: vendor verifier checkout failed" in captured.err + assert "gsm8k exact_match,strict-match" not in captured.err + + +def test_validate_scores_rejects_invalid_effective_count_without_thresholding( + tmp_path: Path, + monkeypatch, + capsys, +) -> None: + result_path = tmp_path / "results_test.json" + result_path.write_text(json.dumps({ + "results": { + "gsm8k": { + "exact_match,strict-match": 0.0, + }, + "other": { + "exact_match,strict-match": 0.0, + }, + "nonfinite": { + "exact_match,strict-match": 1.0, + }, + }, + "n-samples": { + "gsm8k": {"effective": "unknown"}, + "other": {"effective": 0}, + "nonfinite": {"effective": float("inf")}, + }, + })) + monkeypatch.setattr(sys, "argv", [ + "validate_scores.py", + "--meta-env", + str(tmp_path / "meta_env.json"), + "--results-glob", + str(result_path), + ]) + + assert validate_scores_main() == 1 + captured = capsys.readouterr() + assert "gsm8k invalid effective sample count: 'unknown'" in captured.err + assert "gsm8k exact_match,strict-match" not in captured.err + assert "other invalid effective sample count: 0" in captured.err + assert "other exact_match,strict-match" not in captured.err + + assert "nonfinite invalid effective sample count: inf" in captured.err + assert "nonfinite exact_match,strict-match" not in captured.err + +def test_validate_scores_accepts_legacy_result_without_effective_count( + tmp_path: Path, + monkeypatch, + capsys, +) -> None: + result_path = tmp_path / "results_test.json" + result_path.write_text(json.dumps({ + "results": { + "gsm8k": { + "exact_match,strict-match": 1.0, + }, + }, + })) + monkeypatch.setattr(sys, "argv", [ + "validate_scores.py", + "--meta-env", + str(tmp_path / "meta_env.json"), + "--results-glob", + str(result_path), + ]) + + assert validate_scores_main() == 0 + captured = capsys.readouterr() + assert "PASS: gsm8k exact_match,strict-match" in captured.out + + def test_amd_multinode_container_forwards_eval_concurrency_list() -> None: job_slurm = ( Path(__file__).resolve().parents[2] diff --git a/utils/evals/test_kimi_vendor_eval.py b/utils/evals/test_kimi_vendor_eval.py new file mode 100644 index 0000000000..a71d3d962f --- /dev/null +++ b/utils/evals/test_kimi_vendor_eval.py @@ -0,0 +1,242 @@ +import json +import re +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import kimi_vendor_eval as kve + + +def _report(stream_status: str = "passed") -> dict[str, Any]: + statuses = ["passed", stream_status] + by_status = {status: statuses.count(status) for status in set(statuses)} + return { + "summary": {"total": 2, "by_status": by_status}, + "results": [ + {"mode": "non-stream", "status": "passed"}, + {"mode": "stream", "status": stream_status}, + ], + } + + +def _report_with_inconsistent_counts() -> dict[str, Any]: + report = _report("failed") + report["summary"]["by_status"]["failed"] = 2 + return report + + +def _result(output_dir: Path) -> dict[str, Any]: + paths = list(output_dir.glob(kve.COMPATIBILITY_GLOB)) + assert len(paths) == 1 + assert re.fullmatch( + r"results_kimi_vendor_\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.\d{6}\.json", + paths[0].name, + ) + return json.loads(paths[0].read_text()) + + +def _score(output_dir: Path) -> float: + return _result(output_dir)["results"][kve.TASK_NAME]["exact_match,strict-match"] + + +def _n_eff(output_dir: Path) -> int: + return _result(output_dir)["n-samples"][kve.TASK_NAME]["effective"] + + +def test_builds_fixed_upstream_pytest_command(tmp_path: Path) -> None: + report = tmp_path / kve.NATIVE_REPORT_FILENAME + + assert kve.build_pytest_command( + base_url="http://127.0.0.1:8000/v1", + api_key="EMPTY", + model="test-model", + report_path=report, + ) == [ + sys.executable, + "-m", + "pytest", + "tests/tool_call_json_schema/test_tool_call_json_schema.py", + "--reruns", + "3", + "--reruns-delay", + "2", + "--base-url", + "http://127.0.0.1:8000/v1", + "--api-key", + "EMPTY", + "--smoke-model", + "test-model", + "--think-mode", + "none", + "--selection", + "object", + "--max-cases", + "1", + "--case-dir", + "testdata/walle_validator_cases/validator_cases", + "--max-tokens", + "2048", + "--tool-json-report", + str(report), + ] + + +@pytest.mark.parametrize( + ("stream_status", "return_code", "expected_pass", "expected_score"), + ( + ("passed", 0, True, 1.0), + ("passed", 1, False, 0.0), + ("failed", 1, False, 0.5), + ), +) +def test_projects_upstream_outcomes( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + stream_status: str, + return_code: int, + expected_pass: bool, + expected_score: float, +) -> None: + output_dir = tmp_path / "output" + native_bytes = json.dumps(_report(stream_status)).encode() + invocation: dict[str, Any] = {} + + def fake_run( + command: list[str], *, cwd: Path, check: bool, timeout: int + ) -> SimpleNamespace: + invocation.update(command=command, cwd=cwd, check=check, timeout=timeout) + Path(command[command.index("--tool-json-report") + 1]).write_bytes(native_bytes) + return SimpleNamespace(returncode=return_code) + + monkeypatch.setattr(kve.subprocess, "run", fake_run) + + assert ( + kve.run_evaluation( + verifier_dir=tmp_path, + base_url="http://localhost/v1", + api_key="EMPTY", + model="model-a", + output_dir=output_dir, + ) + is expected_pass + ) + assert invocation["cwd"] == tmp_path + assert invocation["check"] is False + assert invocation["timeout"] == kve.DEFAULT_TIMEOUT_SECONDS + assert _score(output_dir) == expected_score + assert _n_eff(output_dir) == 2 + projected = _result(output_dir) + assert projected["result_format"] == kve.RESULT_FORMAT + assert projected["eval_adapter"] == kve.ADAPTER_NAME + assert "lm_eval_version" not in projected + assert (output_dir / kve.NATIVE_REPORT_FILENAME).read_bytes() == native_bytes + + +@pytest.mark.parametrize( + ("failure", "error_type"), + ( + (None, "FileNotFoundError"), + ("{bad-json", "JSONDecodeError"), + (OSError("boom"), "OSError"), + (json.dumps(_report("skipped")), "ValueError"), + (json.dumps(_report_with_inconsistent_counts()), "ValueError"), + ( + subprocess.TimeoutExpired("pytest", kve.DEFAULT_TIMEOUT_SECONDS), + "TimeoutExpired", + ), + ), +) +def test_collection_failures_write_zero_score( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + failure: str | BaseException | None, + error_type: str, +) -> None: + def fake_run( + command: list[str], *, cwd: Path, check: bool, timeout: int + ) -> SimpleNamespace: + if isinstance(failure, BaseException): + raise failure + if failure is not None: + Path(command[command.index("--tool-json-report") + 1]).write_text(failure) + return SimpleNamespace(returncode=1) + + monkeypatch.setattr(kve.subprocess, "run", fake_run) + output_dir = tmp_path / "output" + + assert not kve.run_evaluation( + verifier_dir=tmp_path, + base_url="http://localhost/v1", + api_key="EMPTY", + model="model-a", + output_dir=output_dir, + ) + projected = _result(output_dir) + assert _score(output_dir) == 0.0 + assert projected["integration_error"]["type"] == error_type + assert _n_eff(output_dir) == 0 + + +def test_failure_cannot_reuse_stale_outputs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + output_dir = tmp_path / "output" + output_dir.mkdir() + native_report = output_dir / kve.NATIVE_REPORT_FILENAME + native_report.write_text(json.dumps(_report())) + (output_dir / "results_kimi_vendor_2000-01-01T00-00-00.000000.json").write_text( + "{}" + ) + foreign_result = output_dir / "results_other_eval.json" + foreign_result.write_text("{}") + + def fail_collection(*args: Any, **kwargs: Any) -> SimpleNamespace: + assert not native_report.exists() + return SimpleNamespace(returncode=2) + + monkeypatch.setattr(kve.subprocess, "run", fail_collection) + + assert not kve.run_evaluation( + verifier_dir=tmp_path, + base_url="http://localhost/v1", + api_key="EMPTY", + model="model-a", + output_dir=output_dir, + ) + assert _score(output_dir) == 0.0 + assert not native_report.exists() + assert foreign_result.exists() + + +def test_cli_setup_failure_writes_zero_score_artifact(tmp_path: Path) -> None: + output_dir = tmp_path / "output" + output_dir.mkdir() + (output_dir / kve.NATIVE_REPORT_FILENAME).write_text(json.dumps(_report())) + (output_dir / "results_kimi_vendor_2000-01-01T00-00-00.000000.json").write_text( + "{}" + ) + + assert ( + kve.main( + [ + "--model", + "model-a", + "--output-dir", + str(output_dir), + "--integration-error", + "checkout failed", + ] + ) + == 0 + ) + projected = _result(output_dir) + assert not (output_dir / kve.NATIVE_REPORT_FILENAME).exists() + assert _score(output_dir) == 0.0 + assert projected["integration_error"]["message"] == "checkout failed" + assert _n_eff(output_dir) == 0 diff --git a/utils/evals/test_run_eval_dispatch.py b/utils/evals/test_run_eval_dispatch.py index 45be3a6e4d..cbcf25b7e3 100644 --- a/utils/evals/test_run_eval_dispatch.py +++ b/utils/evals/test_run_eval_dispatch.py @@ -1,25 +1,44 @@ from __future__ import annotations +import io +import json import os +import re import stat import subprocess +import tarfile +import threading +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path -BENCHMARK_LIB = Path(__file__).resolve().parents[2] / "benchmarks" / "benchmark_lib.sh" +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[2] +BENCHMARK_LIB = REPO_ROOT / "benchmarks" / "benchmark_lib.sh" +MULTINODE_WORKFLOW = REPO_ROOT / ".github/workflows/benchmark-multinode-tmpl.yml" +E2E_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "e2e-tests.yml" _SCRIPT = r''' source "$BENCHMARK_LIB" run_lm_eval() { echo "DISPATCH=lm-eval"; } run_swebench_eval() { echo "DISPATCH=swebench"; } +run_kimi_vendor_eval() { echo "DISPATCH=kimi-vendor"; } append_lm_eval_summary() { echo "STAGED=summary"; } export EVAL_MAX_MODEL_LEN=16384 -unset EVAL_CONCURRENT_REQUESTS +export EVAL_CONCURRENT_REQUESTS="" run_eval ${CLI_FW:+--framework "$CLI_FW"} --port 8888 ''' -def _dispatch(*, is_agentic: str = "0", eval_only: str = "false", cli_fw=None, env_fw=None) -> str: +def _dispatch( + *, + is_agentic: str = "0", + eval_only: str = "false", + cli_fw=None, + env_fw=None, +) -> str: env = { **os.environ, "BENCHMARK_LIB": str(BENCHMARK_LIB), @@ -71,10 +90,73 @@ def test_env_can_force_swebench_on_fixed_seqlen(): assert "DISPATCH=swebench" in _dispatch(is_agentic="0", env_fw="swebench") + + +def test_env_can_force_kimi_vendor_on_agentic_eval() -> None: + assert "DISPATCH=kimi-vendor" in _dispatch( + is_agentic="1", + eval_only="true", + env_fw="kimi-vendor", + ) + + +def test_kimi_vendor_skips_unused_model_context_loading() -> None: + script = r''' +source "$BENCHMARK_LIB" +unset EVAL_MAX_MODEL_LEN +compute_eval_context_length() { echo "UNEXPECTED_CONTEXT_LOAD"; return 99; } +run_kimi_vendor_eval() { echo "DISPATCH=kimi-vendor"; } +export EVAL_FRAMEWORK=kimi-vendor +export EVAL_CONCURRENT_REQUESTS="" +export EVAL_ONLY=false +export IS_AGENTIC=0 +run_eval --port 8888 +''' + result = subprocess.run( + ["bash", "-c", script], + env={**os.environ, "BENCHMARK_LIB": str(BENCHMARK_LIB)}, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert "DISPATCH=kimi-vendor" in result.stdout + assert "UNEXPECTED_CONTEXT_LOAD" not in result.stdout + + +def test_kimi_failure_preserves_rc_without_eval_only() -> None: + script = r''' +set -u +source "$BENCHMARK_LIB" +run_kimi_vendor_eval() { return 7; } +export EVAL_FRAMEWORK=kimi-vendor +export EVAL_CONCURRENT_REQUESTS="" +export EVAL_MAX_MODEL_LEN=16384 +export IS_AGENTIC=0 +unset EVAL_ONLY +run_eval --port 8888 +''' + result = subprocess.run( + ["bash", "-c", script], + env={**os.environ, "BENCHMARK_LIB": str(BENCHMARK_LIB)}, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 7 + assert "unbound variable" not in result.stderr + + + + def test_recipe_lm_eval_arg_still_lm_eval_on_fixed_seqlen(): assert "DISPATCH=lm-eval" in _dispatch(is_agentic="0", cli_fw="lm-eval") + + def _run_invalid_call(call: str) -> subprocess.CompletedProcess: env = { **os.environ, @@ -95,6 +177,482 @@ def test_run_eval_rejects_missing_framework_value(): assert "--framework requires a value" in result.stderr +def test_run_eval_rejects_unsafe_suite_name() -> None: + result = _run_invalid_call( + "EVAL_SUITE='kimi\"suite' run_eval --framework kimi-vendor" + ) + + assert result.returncode == 2 + assert "EVAL_SUITE may contain only" in result.stderr + + +def test_run_eval_rejects_suite_override_for_lm_eval() -> None: + result = _run_invalid_call( + "EVAL_SUITE=gpqa_diamond run_eval --framework lm-eval" + ) + + assert result.returncode == 2 + assert "only supported with EVAL_FRAMEWORK=kimi-vendor" in result.stderr + + +def test_run_eval_scopes_runner_selected_suite_to_one_call() -> None: + script = r''' +source "$BENCHMARK_LIB" +run_kimi_vendor_eval() { + export EVAL_SUITE=kimi_tool_call_schema + echo "DISPATCH=kimi-vendor SUITE=$EVAL_SUITE" +} +run_lm_eval() { + echo "DISPATCH=lm-eval SUITE=${EVAL_SUITE:-unset} COMPLETED=${EVAL_COMPLETED_SUITE:-unset}" +} +append_lm_eval_summary() { + echo "METADATA=${EVAL_COMPLETED_SUITE:-gsm8k}" +} +export EVAL_MAX_MODEL_LEN=16384 +export EVAL_CONCURRENT_REQUESTS="" +export EVAL_ONLY=false +export IS_AGENTIC=0 +unset EVAL_SUITE +export EVAL_FRAMEWORK=kimi-vendor +run_eval --port 8888 +printf 'KIMI_COMPLETED=%s\n' "${EVAL_COMPLETED_SUITE:-unset}" +append_lm_eval_summary +export EVAL_FRAMEWORK=lm-eval +run_eval --port 8888 +printf 'LM_COMPLETED=%s\n' "${EVAL_COMPLETED_SUITE:-unset}" +append_lm_eval_summary +printf 'FINAL_SUITE=%s\n' "${EVAL_SUITE-unset}" +''' + result = subprocess.run( + ["bash", "-c", script], + env={**os.environ, "BENCHMARK_LIB": str(BENCHMARK_LIB)}, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert "DISPATCH=kimi-vendor SUITE=kimi_tool_call_schema" in result.stdout + assert "KIMI_COMPLETED=kimi_tool_call_schema" in result.stdout + assert "METADATA=kimi_tool_call_schema" in result.stdout + assert "DISPATCH=lm-eval SUITE=unset COMPLETED=unset" in result.stdout + assert "LM_COMPLETED=unset" in result.stdout + assert "METADATA=gsm8k" in result.stdout + assert "FINAL_SUITE=unset" in result.stdout + + +def test_kimi_default_suite_reaches_eval_only_metadata() -> None: + script = r''' +source "$BENCHMARK_LIB" +run_kimi_vendor_eval() { echo "DISPATCH=$EVAL_SUITE"; } +append_lm_eval_summary() { echo "METADATA=$EVAL_COMPLETED_SUITE"; } +export EVAL_FRAMEWORK=kimi-vendor +export EVAL_ONLY=true +export IS_AGENTIC=1 +export EVAL_CONCURRENT_REQUESTS="" +unset EVAL_SUITE +run_eval --port 8888 +''' + result = subprocess.run( + ["bash", "-c", script], + env={**os.environ, "BENCHMARK_LIB": str(BENCHMARK_LIB)}, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert "DISPATCH=kimi_tool_call_schema" in result.stdout + assert "METADATA=kimi_tool_call_schema" in result.stdout + + +def test_kimi_vendor_rejects_batched_concurrency() -> None: + result = _run_invalid_call( + "EVAL_MAX_MODEL_LEN=16384 " + "EVAL_CONCURRENT_REQUESTS='1 4' " + "run_eval --framework kimi-vendor" + ) + assert result.returncode == 1 + assert "batched eval concurrency is only supported for lm-eval" in result.stderr + + +def test_kimi_vendor_rejects_unsupported_suite() -> None: + result = _run_invalid_call( + "EVAL_SUITE=gsm8k run_kimi_vendor_eval" + ) + assert result.returncode == 2 + assert "unsupported Kimi Vendor Verifier suite 'gsm8k'" in result.stderr + + + +def test_kimi_vendor_setup_failure_writes_compatibility_result( + tmp_path: Path, +) -> None: + results_dir = tmp_path / "results" + script = r''' +source "$BENCHMARK_LIB" +_require_kimi_vendor_python() { :; } +_prepare_kimi_vendor_runtime() { return 12; } +run_kimi_vendor_eval --results-dir "$RESULTS_DIR" +printf 'SETUP_RC=%s\n' "$?" +''' + env = { + **os.environ, + "BENCHMARK_LIB": str(BENCHMARK_LIB), + "RESULTS_DIR": str(results_dir), + "MODEL": "test-model", + "IS_MULTINODE": "false", + "KV_OFFLOADING": "none", + } + for key in ( + "EVAL_SUITE", + "EVAL_RESULT_DIR", + "MODEL_NAME", + ): + env.pop(key, None) + + result = subprocess.run( + ["bash", "-c", script], + env=env, + text=True, + capture_output=True, + check=True, + ) + message = "Kimi Vendor Verifier dependency installation failed with exit code 12" + score_files = list(results_dir.glob("results*.json")) + + assert "SETUP_RC=12" in result.stdout + assert message in result.stderr + assert "failed to write Kimi verifier failure artifact" not in result.stderr + assert len(score_files) == 1 + score_result = json.loads(score_files[0].read_text()) + assert ( + score_result["results"]["kimi_tool_call_schema"][ + "exact_match,strict-match" + ] + == 0.0 + ) + assert score_result["integration_error"]["message"] == message + assert not (results_dir / "kimi_vendor_report.json").exists() + + +_KIMI_VERIFIER_REQUIRED_FILES = { + "pyproject.toml", + "tests/conftest.py", + "tests/__init__.py", + "tests/tool_call_json_schema/conftest.py", + "tests/tool_call_json_schema/__init__.py", + "tests/tool_call_json_schema/test_tool_call_json_schema.py", + "tests/tool_call_json_schema/validator.py", + *{ + f"testdata/walle_validator_cases/validator_cases/{case}/valid.jsonl" + for case in ( + "TestAdditionalProperties", + "TestAnyOf", + "TestBasicTypes", + "TestDefs", + "TestDescription", + "TestEnforcerCases", + "TestID", + "TestKeywordsValidation", + "TestNestedDefsDepth", + "TestNumberFormat", + "TestRangeConstraints", + "TestRefInProperties", + "TestReferences", + "TestRequired", + "TestSingleTypeInArray", + "TestTypeLocation", + ) + }, +} + + +def _kimi_verifier_archive( + *, + missing: str | None = None, + unsafe_member: tarfile.TarInfo | None = None, +) -> bytes: + output = io.BytesIO() + with tarfile.open(fileobj=output, mode="w:gz") as archive: + for relative_path in sorted(_KIMI_VERIFIER_REQUIRED_FILES - {missing}): + payload = relative_path.encode() + member = tarfile.TarInfo(f"verifier-pinned/{relative_path}") + member.size = len(payload) + archive.addfile(member, io.BytesIO(payload)) + extra = b"must not be extracted" + member = tarfile.TarInfo("verifier-pinned/README.md") + member.size = len(extra) + archive.addfile(member, io.BytesIO(extra)) + if unsafe_member is not None: + archive.addfile( + unsafe_member, + io.BytesIO(b"unsafe") if unsafe_member.isfile() else None, + ) + return output.getvalue() + + +@contextmanager +def _serve_archive(payload: bytes, *, transient_failures: int = 0): + request_paths = [] + request_count = 0 + + class ArchiveHandler(BaseHTTPRequestHandler): + def do_GET(self): + nonlocal request_count + request_paths.append(self.path) + request_count += 1 + if request_count <= transient_failures: + self.send_response(503) + self.end_headers() + return + self.send_response(200) + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, *args): + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), ArchiveHandler) + thread = threading.Thread(target=server.serve_forever) + thread.start() + try: + yield ( + f"http://127.0.0.1:{server.server_port}/owner/verifier.git", + request_paths, + ) + finally: + server.shutdown() + thread.join() + server.server_close() + + +def _prepare_local_kimi_verifier( + tmp_path: Path, + payload: bytes, + verifier_ref: str = "1" * 40, + transient_failures: int = 0, +) -> tuple[subprocess.CompletedProcess[str], Path, list[str]]: + checkout = tmp_path / "checkout" + script = r''' +source "$BENCHMARK_LIB" +git() { echo "git must not be invoked" >&2; return 127; } +mktemp() { mkdir "$CHECKOUT"; printf '%s\n' "$CHECKOUT"; } +_prepare_kimi_vendor_verifier "$REPO_URL" "$VERIFIER_REF" +''' + with _serve_archive( + payload, + transient_failures=transient_failures, + ) as (repo_url, request_paths): + result = subprocess.run( + ["bash", "-c", script], + env={ + **os.environ, + "BENCHMARK_LIB": str(BENCHMARK_LIB), + "CHECKOUT": str(checkout), + "REPO_URL": repo_url, + "VERIFIER_REF": verifier_ref, + }, + text=True, + capture_output=True, + ) + return result, checkout, request_paths + + +def test_kimi_vendor_verifier_fetches_expected_subset_without_git(tmp_path: Path) -> None: + result, checkout, request_paths = _prepare_local_kimi_verifier( + tmp_path, + _kimi_verifier_archive(), + ) + verifier_ref = "1" * 40 + + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == str(checkout) + assert request_paths == [ + f"/owner/verifier/archive/{verifier_ref}.tar.gz", + ] + assert { + path.relative_to(checkout).as_posix() + for path in checkout.rglob("*") + if path.is_file() + } == _KIMI_VERIFIER_REQUIRED_FILES + assert "git must not be invoked" not in result.stderr + + +def test_kimi_vendor_verifier_retries_transient_archive_failure( + tmp_path: Path, +) -> None: + result, checkout, request_paths = _prepare_local_kimi_verifier( + tmp_path, + _kimi_verifier_archive(), + transient_failures=1, + ) + verifier_ref = "1" * 40 + + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == str(checkout) + assert request_paths == [ + f"/owner/verifier/archive/{verifier_ref}.tar.gz", + f"/owner/verifier/archive/{verifier_ref}.tar.gz", + ] + assert "archive download attempt 1/3 failed" in result.stderr + + +def test_kimi_vendor_verifier_removes_partial_checkout_when_member_missing( + tmp_path: Path, +) -> None: + missing = "tests/tool_call_json_schema/validator.py" + result, checkout, _ = _prepare_local_kimi_verifier( + tmp_path, + _kimi_verifier_archive(missing=missing), + ) + + assert result.returncode == 1 + assert missing in result.stderr + assert not checkout.exists() + + +def test_kimi_vendor_verifier_rejects_unsafe_archive_members(tmp_path: Path) -> None: + unsafe = tarfile.TarInfo("verifier-pinned/../../escaped") + unsafe.size = len(b"unsafe") + result, checkout, _ = _prepare_local_kimi_verifier( + tmp_path, + _kimi_verifier_archive(unsafe_member=unsafe), + ) + + assert result.returncode == 1 + assert "unsafe archive member path" in result.stderr + assert not checkout.exists() + assert not (tmp_path / "escaped").exists() + + +def test_kimi_vendor_dependency_install_is_isolated(tmp_path: Path) -> None: + runtime_dir = tmp_path / "runtime" + script = r''' +source "$BENCHMARK_LIB" +python3() { printf 'PYTHON_ARG=<%s>\n' "$@"; } +_install_kimi_vendor_eval_deps "$RUNTIME_DIR" +''' + result = subprocess.run( + ["bash", "-c", script], + env={ + **os.environ, + "BENCHMARK_LIB": str(BENCHMARK_LIB), + "RUNTIME_DIR": str(runtime_dir), + }, + text=True, + capture_output=True, + check=True, + ) + + assert "PYTHON_ARG=<--target>" in result.stdout + assert f"PYTHON_ARG=<{runtime_dir}>" in result.stdout + assert "PYTHON_ARG=" in result.stdout + assert "--break-system-packages" not in result.stdout + + +def test_kimi_vendor_surfaces_failure_artifact_error(tmp_path: Path) -> None: + script = r''' +source "$BENCHMARK_LIB" +_require_kimi_vendor_python() { return 12; } +_write_kimi_vendor_integration_error() { return 23; } +run_kimi_vendor_eval --results-dir "$RESULTS_DIR" +printf 'EVAL_RC=%s\n' "$?" +''' + result = subprocess.run( + ["bash", "-c", script], + env={ + **os.environ, + "BENCHMARK_LIB": str(BENCHMARK_LIB), + "RESULTS_DIR": str(tmp_path / "results"), + "MODEL": "test-model", + "IS_MULTINODE": "false", + }, + text=True, + capture_output=True, + check=True, + ) + + assert "EVAL_RC=12" in result.stdout + assert "failed to write Kimi verifier failure artifact" in result.stderr + + + + +def test_kimi_vendor_multinode_runner_uses_fixed_upstream_contract( + tmp_path: Path, +) -> None: + results_dir = tmp_path / "results" + verifier_dir = tmp_path / "verifier" + runtime_dir = tmp_path / "runtime" + runtime_dir.mkdir() + verifier_dir.mkdir() + script = r''' +source "$BENCHMARK_LIB" +_require_kimi_vendor_python() { :; } +_prepare_kimi_vendor_runtime() { printf '%s\n' "$RUNTIME_DIR"; } +_prepare_kimi_vendor_verifier() { + printf 'CHECKOUT=%s@%s\n' "$1" "$2" >&2 + printf '%s\n' "$VERIFIER_DIR" +} +python3() { + printf 'PYTHONPATH=<%s>\n' "$PYTHONPATH" + printf 'PYTHON_ARG=<%s>\n' "$@" +} +run_kimi_vendor_eval --port 9999 --results-dir "$RESULTS_DIR" +printf 'EVAL_SUITE=%s\n' "$EVAL_SUITE" +printf 'EVAL_RESULT_DIR=%s\n' "$EVAL_RESULT_DIR" +''' + env = { + **os.environ, + "BENCHMARK_LIB": str(BENCHMARK_LIB), + "RESULTS_DIR": str(results_dir), + "VERIFIER_DIR": str(verifier_dir), + "MODEL": "test-model", + "RUNTIME_DIR": str(runtime_dir), + "OPENAI_API_KEY": "must-not-be-forwarded", + "KV_OFFLOADING": "none", + "IS_MULTINODE": "true", + } + for key in ( + "EVAL_SUITE", + "EVAL_RESULT_DIR", + "MODEL_NAME", + ): + env.pop(key, None) + + result = subprocess.run( + ["bash", "-c", script], + env=env, + text=True, + capture_output=True, + check=True, + ) + output = result.stdout + result.stderr + adapter = BENCHMARK_LIB.parents[1] / "utils/evals/kimi_vendor_eval.py" + + assert f"PYTHONPATH=<{tmp_path / 'runtime'}" in output + assert ( + "CHECKOUT=https://github.com/MoonshotAI/Kimi-Vendor-Verifier.git" + "@b9ed3a6665bdff2c943246f7d2903cd003d6ddd6" + ) in output + for value in ( + adapter, + verifier_dir, + "http://127.0.0.1:9999/v1", + "EMPTY", + "test-model", + results_dir, + ): + assert f"PYTHON_ARG=<{value}>" in output + assert "must-not-be-forwarded" not in output + assert "EVAL_SUITE=kimi_tool_call_schema" in output + assert f"EVAL_RESULT_DIR={results_dir}" in output + assert not (tmp_path / "runtime").exists() + assert not verifier_dir.exists() + + def test_run_lm_eval_rejects_missing_option_value(): result = _run_invalid_call("run_lm_eval --port") assert result.returncode == 2 @@ -177,6 +735,140 @@ def test_lm_eval_defaults_to_gsm8k(): assert "utils/evals/gsm8k.yaml" in out +def _summary_metadata(tmp_path: Path, **overrides: str) -> dict: + work_dir = tmp_path / "work" + results_dir = tmp_path / "results" + work_dir.mkdir(parents=True) + results_dir.mkdir() + script = r''' +source "$BENCHMARK_LIB" +cd "$WORK_DIR" +append_lm_eval_summary >/dev/null +''' + env = { + **os.environ, + "BENCHMARK_LIB": str(BENCHMARK_LIB), + "WORK_DIR": str(work_dir), + "EVAL_RESULT_DIR": str(results_dir), + "MODEL": "test-model", + "CONC": "7", + "KV_OFFLOADING": "none", + } + for key in ("EVAL_COMPLETED_SUITE", "EVAL_SUITE", "EVAL_TASKS_DIR"): + env.pop(key, None) + env.update(overrides) + subprocess.run(["bash", "-c", script], env=env, check=True) + return json.loads((work_dir / "meta_env.json").read_text()) + + +def test_summary_metadata_preserves_lm_eval_gsm8k_defaults(tmp_path: Path) -> None: + meta = _summary_metadata(tmp_path) + + assert meta["eval_suite"] == "gsm8k" + assert meta["conc"] == 7 + + +def test_run_lm_eval_exports_cli_task_path(tmp_path: Path) -> None: + script = r''' +source "$BENCHMARK_LIB" +python3() { :; } +export EVAL_MAX_MODEL_LEN=16384 +export INFERENCEX_LM_EVAL_RUNTIME_READY=true +run_lm_eval --task custom.yaml --results-dir "$RESULTS_DIR" +printf 'EVAL_TASKS_DIR=%s\n' "$EVAL_TASKS_DIR" +''' + env = { + **os.environ, + "BENCHMARK_LIB": str(BENCHMARK_LIB), + "RESULTS_DIR": str(tmp_path / "results"), + "MODEL_NAME": "test-model", + "OPENAI_API_KEY": "EMPTY", + "KV_OFFLOADING": "none", + } + env.pop("EVAL_TASKS_DIR", None) + result = subprocess.run( + ["bash", "-c", script], + env=env, + text=True, + capture_output=True, + check=True, + ) + + assert "EVAL_TASKS_DIR=custom.yaml" in result.stdout + + +def test_summary_metadata_prefers_explicit_suite_then_task_basename( + tmp_path: Path, +) -> None: + from_task = _summary_metadata( + tmp_path / "task", + EVAL_TASKS_DIR="/tmp/custom_reasoning.yaml", + ) + explicit = _summary_metadata( + tmp_path / "explicit", + EVAL_SUITE="kimi_tool_call_schema", + EVAL_TASKS_DIR="/tmp/ignored.yaml", + ) + + assert from_task["eval_suite"] == "custom_reasoning" + assert explicit["eval_suite"] == "kimi_tool_call_schema" + + +def test_summary_metadata_prefers_completed_eval_identity(tmp_path: Path) -> None: + meta = _summary_metadata( + tmp_path, + EVAL_COMPLETED_SUITE="kimi_tool_call_schema", + EVAL_SUITE="stale_input_selector", + EVAL_TASKS_DIR="/tmp/ignored.yaml", + ) + + assert meta["eval_suite"] == "kimi_tool_call_schema" + + +def test_env_is_true_is_case_insensitive_and_unset_safe() -> None: + script = r''' +set -u +source "$BENCHMARK_LIB" +for value in TrUe yEs oN 1 false 0; do + if _env_is_true "$value"; then + echo true + else + echo false + fi +done +for empty_call in with-argument without-argument; do + if [ "$empty_call" = "with-argument" ]; then + _env_is_true "" + else + _env_is_true + fi + if [ "$?" -eq 0 ]; then + echo true + else + echo false + fi +done +''' + result = subprocess.run( + ["bash", "-c", script], + env={**os.environ, "BENCHMARK_LIB": str(BENCHMARK_LIB)}, + text=True, + capture_output=True, + check=True, + ) + + assert result.stdout.splitlines() == [ + "true", + "true", + "true", + "true", + "false", + "false", + "false", + "false", + ] + + _MODAL_CREDS_SCRIPT = r''' source "$BENCHMARK_LIB" @@ -547,19 +1239,212 @@ def test_agentic_eval_limit_full_runs_whole_split(tmp_path): assert "GEN_RC=0" in res.stdout, res.stdout + res.stderr +def test_multinode_eval_artifact_names_are_bounded_and_distinct() -> None: + workflow = yaml.safe_load(MULTINODE_WORKFLOW.read_text()) + upload = next( + step + for step in workflow["jobs"]["benchmark"]["steps"] + if step.get("name") == "Upload eval results (if any)" + ) + expression = upload["with"]["name"] + assert expression.startswith("eval_") + assert "RESULT_FILENAME" not in expression + + targets = [ + { + "EXP_NAME": "kimik3_p2x16ep32dpa_d0x16ep32dpa_conc12", + "PRECISION": "fp4", + "FRAMEWORK": "vllm", + "PREFILL_NUM_WORKERS": "2", + "PREFILL_TP": "16", + "PREFILL_PP_SIZE": "1", + "SPEC_DECODING": "mtp", + "PREFILL_DCP_SIZE": "1", + "PREFILL_PCP_SIZE": "1", + "PREFILL_EP": "32", + "PREFILL_DP_ATTN": "true", + "DECODE_NUM_WORKERS": "0", + "DECODE_TP": "16", + "DECODE_PP_SIZE": "1", + "DECODE_DCP_SIZE": "1", + "DECODE_PCP_SIZE": "1", + "DECODE_EP": "32", + "DECODE_DP_ATTN": "true", + "KV_OFFLOADING": "none", + "KV_OFFLOAD_BACKEND": "", + "conc-list": ["1", "12", "16"], + "runner.name": "h200-dgxc-slurm_00", + }, + { + "EXP_NAME": "kimik3_p4x8ep32dpa_d0x8ep32dpa_conc12", + "PRECISION": "fp4", + "FRAMEWORK": "vllm", + "PREFILL_NUM_WORKERS": "4", + "PREFILL_TP": "8", + "PREFILL_PP_SIZE": "1", + "PREFILL_DCP_SIZE": "1", + "PREFILL_PCP_SIZE": "1", + "PREFILL_EP": "32", + "PREFILL_DP_ATTN": "true", + "DECODE_NUM_WORKERS": "0", + "SPEC_DECODING": "mtp", + "DECODE_TP": "8", + "DECODE_PP_SIZE": "1", + "DECODE_DCP_SIZE": "1", + "DECODE_PCP_SIZE": "1", + "DECODE_EP": "32", + "DECODE_DP_ATTN": "true", + "KV_OFFLOADING": "none", + "KV_OFFLOAD_BACKEND": "", + "conc-list": ["1", "12", "16"], + "runner.name": "h200-dgxc-slurm_01", + }, + { + "EXP_NAME": "kimik3_p4x8ep32dpa_d0x8ep32dpa_conc12_kvdram-vllm-simple", + "PRECISION": "fp4", + "FRAMEWORK": "vllm", + "PREFILL_NUM_WORKERS": "4", + "PREFILL_TP": "8", + "PREFILL_PP_SIZE": "1", + "PREFILL_DCP_SIZE": "1", + "PREFILL_PCP_SIZE": "1", + "PREFILL_EP": "32", + "PREFILL_DP_ATTN": "true", + "DECODE_NUM_WORKERS": "0", + "DECODE_TP": "8", + "DECODE_PP_SIZE": "1", + "DECODE_DCP_SIZE": "1", + "DECODE_PCP_SIZE": "1", + "DECODE_EP": "32", + "SPEC_DECODING": "mtp", + "DECODE_DP_ATTN": "true", + "KV_OFFLOADING": "dram", + "KV_OFFLOAD_BACKEND": "vllm-simple", + "conc-list": ["1", "12", "16"], + "runner.name": "h200-dgxc-slurm_02", + }, + { + "EXP_NAME": "kimik3_p1x8_d0x8_conc16", + "PRECISION": "fp4", + "FRAMEWORK": "dynamo-vllm", + "PREFILL_NUM_WORKERS": "1", + "PREFILL_TP": "8", + "PREFILL_PP_SIZE": "2", + "PREFILL_DCP_SIZE": "1", + "PREFILL_PCP_SIZE": "1", + "PREFILL_EP": "1", + "PREFILL_DP_ATTN": "false", + "DECODE_NUM_WORKERS": "0", + "DECODE_TP": "8", + "DECODE_PP_SIZE": "2", + "DECODE_DCP_SIZE": "1", + "DECODE_PCP_SIZE": "1", + "DECODE_EP": "1", + "DECODE_DP_ATTN": "false", + "SPEC_DECODING": "mtp", + "KV_OFFLOADING": "none", + "KV_OFFLOAD_BACKEND": "", + "conc-list": ["1", "16"], + "runner.name": "b200-dgxc_00", + }, + { + "EXP_NAME": "kimik3_p1x16ep16_d0x16ep16_conc16", + "PRECISION": "fp4", + "FRAMEWORK": "dynamo-vllm", + "PREFILL_NUM_WORKERS": "1", + "PREFILL_TP": "16", + "PREFILL_PP_SIZE": "1", + "PREFILL_DCP_SIZE": "1", + "PREFILL_PCP_SIZE": "1", + "PREFILL_EP": "16", + "PREFILL_DP_ATTN": "false", + "DECODE_NUM_WORKERS": "0", + "DECODE_TP": "16", + "DECODE_PP_SIZE": "1", + "DECODE_DCP_SIZE": "1", + "DECODE_PCP_SIZE": "1", + "DECODE_EP": "16", + "DECODE_DP_ATTN": "false", + "KV_OFFLOADING": "none", + "SPEC_DECODING": "mtp", + "KV_OFFLOAD_BACKEND": "", + "conc-list": ["16"], + "runner.name": "gb200-nv_00", + }, + { + "EXP_NAME": "kimik3_p1x16_d0x16_conc1", + "PRECISION": "fp4", + "FRAMEWORK": "dynamo-vllm", + "PREFILL_NUM_WORKERS": "1", + "PREFILL_TP": "16", + "PREFILL_PP_SIZE": "1", + "PREFILL_DCP_SIZE": "1", + "PREFILL_PCP_SIZE": "1", + "PREFILL_EP": "1", + "PREFILL_DP_ATTN": "false", + "DECODE_NUM_WORKERS": "0", + "DECODE_TP": "16", + "DECODE_PP_SIZE": "1", + "DECODE_DCP_SIZE": "1", + "DECODE_PCP_SIZE": "1", + "DECODE_EP": "1", + "DECODE_DP_ATTN": "false", + "KV_OFFLOADING": "none", + "KV_OFFLOAD_BACKEND": "", + "SPEC_DECODING": "mtp", + "conc-list": ["1"], + "runner.name": "gb200-nv_01", + }, + ] + non_mtp_twin = {**targets[3], "SPEC_DECODING": "none"} + + def render(values: dict[str, object]) -> str: + name = expression + name = re.sub( + r"\$\{\{ join\(fromJson\(inputs\.conc-list\), 'x'\) \}\}", + "x".join(values["conc-list"]), + name, + ) + for key, value in values.items(): + if key != "conc-list": + name = name.replace(f"${{{{ env.{key} }}}}", str(value)) + name = name.replace("${{ runner.name }}", str(values["runner.name"])) + assert "${{" not in name + return name + + names = [render(target) for target in targets] + assert len(names) == len(set(names)) == 6 + assert render(targets[3]) != render(non_mtp_twin) + assert all(name.startswith("eval_") and len(name.encode()) <= 256 for name in names) + + _GENMODE_SCRIPT = r''' source "$BENCHMARK_LIB" 2>/dev/null _install_swebench_agent_deps() { :; } _ensure_modal_credentials() { :; } -_run_swebench_agentic_generation() { echo "GEN=agentic"; return 42; } -run_lm_eval() { echo "GEN=single-shot"; return 42; } +_run_swebench_agentic_generation() { + echo "GEN=agentic" + echo "SUITE=$EVAL_SUITE" + return 42 +} +run_lm_eval() { + echo "GEN=single-shot" + echo "SUITE=$EVAL_SUITE" + return 42 +} run_swebench_eval --port 8888 echo "RC=$?" ''' -def _gen_mode(tmp_path, *, is_agentic, gen_mode=None) -> str: +def _gen_mode( + tmp_path: Path, + *, + is_agentic, + gen_mode=None, + eval_suite=None, +) -> str: env = {**os.environ, "BENCHMARK_LIB": str(BENCHMARK_LIB), "KV_OFFLOADING": "none", @@ -567,8 +1452,11 @@ def _gen_mode(tmp_path, *, is_agentic, gen_mode=None) -> str: "EVAL_RESULT_DIR": str(tmp_path / "out")} env.pop("SWEBENCH_GEN_MODE", None) env.pop("SCENARIO_TYPE", None) + env.pop("EVAL_SUITE", None) if gen_mode is not None: env["SWEBENCH_GEN_MODE"] = gen_mode + if eval_suite is not None: + env["EVAL_SUITE"] = eval_suite res = subprocess.run(["bash", "-c", _GENMODE_SCRIPT], env=env, text=True, capture_output=True, cwd=BENCHMARK_LIB.parents[1]) @@ -577,7 +1465,9 @@ def _gen_mode(tmp_path, *, is_agentic, gen_mode=None) -> str: def test_gen_mode_defaults_to_agentic(tmp_path): - assert "GEN=agentic" in _gen_mode(tmp_path, is_agentic="1") + output = _gen_mode(tmp_path, is_agentic="1") + assert "GEN=agentic" in output + assert "SUITE=swebench_lite" in output def test_gen_mode_agentic_even_without_agentic_scenario(tmp_path): @@ -585,7 +1475,20 @@ def test_gen_mode_agentic_even_without_agentic_scenario(tmp_path): def test_explicit_single_shot_escape_hatch(tmp_path): - assert "GEN=single-shot" in _gen_mode(tmp_path, is_agentic="1", gen_mode="single-shot") + output = _gen_mode(tmp_path, is_agentic="1", gen_mode="single-shot") + assert "GEN=single-shot" in output + assert "SUITE=swebench_lite" in output + + +def test_swebench_generation_modes_preserve_explicit_suite(tmp_path): + for gen_mode in ("agentic", "single-shot"): + output = _gen_mode( + tmp_path / gen_mode, + is_agentic="1", + gen_mode=gen_mode, + eval_suite="explicit_swebench", + ) + assert "SUITE=explicit_swebench" in output def test_agent_sandbox_cpu_knob(tmp_path): @@ -640,3 +1543,44 @@ def test_eval_limit_full_and_zero_accepted(tmp_path): assert "GEN_RC=0" in res.stdout, f"EVAL_LIMIT={sentinel!r}: {res.stdout}{res.stderr}" argv = (shim / "argv.log").read_text() assert "--slice" not in argv + + +def test_agentic_eval_workflow_forwards_runner_contract() -> None: + workflow = yaml.safe_load(E2E_WORKFLOW.read_text()) + forwarded = workflow["jobs"]["test-sweep-agentic-evals"]["with"] + + assert forwarded["spec-decoding"] == "${{ matrix.config.spec-decoding }}" + assert forwarded["eval-framework"] == "${{ inputs.eval-framework }}" + assert forwarded["eval-suite"] == "${{ inputs.eval-suite }}" + + +def test_multinode_agentic_eval_workflow_forwards_runner_contract() -> None: + workflow = yaml.safe_load(E2E_WORKFLOW.read_text()) + forwarded = workflow["jobs"]["test-sweep-multi-node-agentic-evals"]["with"] + reusable_workflow = yaml.safe_load(MULTINODE_WORKFLOW.read_text()) + + assert forwarded["eval-framework"] == "${{ inputs.eval-framework }}" + assert forwarded["eval-suite"] == "${{ inputs.eval-suite }}" + assert reusable_workflow["env"]["EVAL_FRAMEWORK"] == "${{ inputs.eval-framework }}" + assert reusable_workflow["env"]["EVAL_SUITE"] == "${{ inputs.eval-suite }}" + assert "*_vendor_report.json" in MULTINODE_WORKFLOW.read_text() + + + +def test_trusted_changelog_matrix_keeps_multinode_agentic_evals() -> None: + workflow = yaml.safe_load(E2E_WORKFLOW.read_text()) + get_jobs = next( + step + for step in workflow["jobs"]["get-jobs"]["steps"] + if step.get("id") == "get-jobs" + ) + flatten_command = next( + line + for line in get_jobs["run"].splitlines() + if "rows.extend" in line + ) + + assert '"multinode_agentic_evals"' in flatten_command + get_jobs_command = get_jobs["run"] + assert "EVALS=$(" in get_jobs_command + assert "score_matrix eval" in get_jobs_command \ No newline at end of file diff --git a/utils/evals/thresholds.yaml b/utils/evals/thresholds.yaml index 6ff9731c45..bb3a4f58b6 100644 --- a/utils/evals/thresholds.yaml +++ b/utils/evals/thresholds.yaml @@ -1,6 +1,7 @@ # Model thresholds override task defaults. default: gsm8k: 0.90 + kimi_tool_call_schema: 1.0 gpqa_diamond_cot_n_shot: 0.30 swebench_lite: 0.50 models: diff --git a/utils/evals/validate_scores.py b/utils/evals/validate_scores.py index ba7fc13962..bf4b391728 100644 --- a/utils/evals/validate_scores.py +++ b/utils/evals/validate_scores.py @@ -5,6 +5,7 @@ import argparse import glob import json +import math import os import re import sys @@ -68,6 +69,34 @@ def resolve_threshold(config: dict, prefix: str | None, task: str, fallback: flo return default[task], "default" return fallback, "min-score" +def invalid_effective_count(data: dict, task: str) -> tuple[bool, object]: + """Return whether an explicitly present effective count is invalid.""" + if "n-samples" not in data: + return False, None + sample_counts = data["n-samples"] + if not isinstance(sample_counts, dict) or task not in sample_counts: + return True, sample_counts + task_samples = sample_counts[task] + if not isinstance(task_samples, dict) or "effective" not in task_samples: + return True, task_samples + effective = task_samples["effective"] + invalid = ( + isinstance(effective, bool) + or not isinstance(effective, (int, float)) + or not math.isfinite(effective) + or effective <= 0 + ) + return invalid, effective + + +def integration_error_message(error: object) -> str: + """Render the structured integration error fields for a direct failure.""" + if isinstance(error, dict): + error_type = error.get("type", "unknown") + message = error.get("message", "") + return f"{error_type}: {message}" + return f"unknown: {error}" + def validate_batch_manifest( meta_env_path: str, @@ -277,7 +306,24 @@ def main() -> int: conc_label = f"[conc={match.group(1)}] " if match else "" with open(f) as fh: data = json.load(fh) + if "integration_error" in data: + print( + f"FAIL: {conc_label}integration failure: " + f"{integration_error_message(data['integration_error'])}", + file=sys.stderr, + ) + failed = True + continue for task, metrics in data.get("results", {}).items(): + invalid_effective, effective = invalid_effective_count(data, task) + if invalid_effective: + print( + f"FAIL: {conc_label}{task} invalid effective sample count: " + f"{effective!r}", + file=sys.stderr, + ) + failed = True + continue min_score, source = resolve_threshold(config, prefix, task, args.min_score) for name, val in metrics.items(): if not name.startswith(args.metric_prefix) or "stderr" in name: diff --git a/utils/matrix_logic/generate_sweep_configs.py b/utils/matrix_logic/generate_sweep_configs.py index 5b2dee4108..815eefb01a 100644 --- a/utils/matrix_logic/generate_sweep_configs.py +++ b/utils/matrix_logic/generate_sweep_configs.py @@ -289,10 +289,9 @@ def mark_eval_entries(matrix_values: list[dict], include_agentic: bool = False) - Single-node: run GSM8K through the same lm-eval path as fixed-sequence 8k1k evals, marking the highest-conc entry per (model, runner, framework, precision) group. - - Multi-node: same policy as the fixed-seq-len multi-node case above - (highest eligible conc per distinct parallelism config, via - eval-conc), using SWE-bench since it doesn't support batched - concurrencies. + - Multi-node: run GSM8K through the same lm-eval path, selecting the + highest eligible concurrency per distinct parallelism config via + eval-conc. """ from collections import defaultdict @@ -360,8 +359,7 @@ def _eligible_eval_concs(entry): ag_sn_groups = defaultdict(list) # Multi-node agentic: same "highest eligible conc per distinct # parallelism config" policy as the fixed-seq-len mn_groups above. - # SWE-bench doesn't support batched concurrencies (unlike lm-eval), - # so exactly one conc is picked per group, never the full list. + # The selected eval subset uses exactly one conc per group. ag_mn_groups = defaultdict(list) for i, entry in enumerate(matrix_values): if entry.get(Fields.SCENARIO_TYPE.value) != 'agentic-coding': @@ -402,12 +400,10 @@ def mark_all_eval_entries(matrix_values: list[dict]) -> list[dict]: Evals only run at 8k1k (matching mark_eval_entries), so entries at other sequence lengths (e.g. 1k1k) are passed through untouched rather than expanded into eval rows. - Single-node agentic entries use GSM8K through the same lm-eval path as - fixed-sequence 8k1k evals. Multi-node agentic entries use SWE-bench, - which doesn't support batched concurrencies (unlike lm-eval): multi-node - agentic rows with the same topology are merged (to recombine any chunking - split), but only the highest resulting conc is marked for eval via - eval-conc, not the full list. + Single- and multi-node agentic entries use GSM8K through lm-eval. + Multi-node agentic rows with the same topology are merged (to recombine + any chunking split), but only the highest resulting conc is marked for + eval via eval-conc, matching the default agentic selection policy. Multi-node fixed-seq-len rows with the same engine topology are merged into one eval row whose full concurrency list is run sequentially against the same engine. diff --git a/utils/test_collect_eval_results.py b/utils/test_collect_eval_results.py index 019bbdf123..13f1cefe2e 100644 --- a/utils/test_collect_eval_results.py +++ b/utils/test_collect_eval_results.py @@ -3,7 +3,16 @@ import json from pathlib import Path -from collect_eval_results import build_row, collect_eval_rows +from collect_eval_results import ( + EVAL_RESULT_FORMAT, + build_row, + collect_eval_rows, +) +from evals.kimi_vendor_eval import RESULT_FORMAT as KIMI_VENDOR_RESULT_FORMAT + + +def test_kimi_vendor_result_format_matches_collector_contract() -> None: + assert KIMI_VENDOR_RESULT_FORMAT == EVAL_RESULT_FORMAT def test_build_row_preserves_sequence_lengths() -> None: @@ -21,25 +30,39 @@ def test_build_row_preserves_sequence_lengths() -> None: assert row["isl"] == 1024 assert row["osl"] == 1024 + assert "eval_suite" not in row + + +def test_build_row_preserves_explicit_eval_suite() -> None: + row = build_row( + {"eval_suite": "kimi_tool_call_schema"}, + {"task": "kimi_tool_call_schema"}, + ) + assert row["eval_suite"] == "kimi_tool_call_schema" -def _write_lm_eval_result(path: Path, score: float) -> None: + +def _write_lm_eval_result( + path: Path, + score: float, + task: str = "gsm8k", +) -> None: path.write_text(json.dumps({ "lm_eval_version": "0.4.0", "model_name": "test-model", "results": { - "gsm8k": { + task: { "exact_match,strict-match": score, "exact_match_stderr,strict-match": 0.01, }, }, "configs": { - "gsm8k": { + task: { "metric_list": [{"metric": "exact_match"}], "filter_list": [{"name": "strict-match"}], }, }, - "n-samples": {"gsm8k": {"effective": 10}}, + "n-samples": {task: {"effective": 10}}, })) @@ -67,6 +90,7 @@ def test_collect_eval_rows_expands_batched_concurrencies( "completed_eval_concs": [4, 16], "failed_eval_concs": [], "conc": 4, + "eval_suite": "gsm8k", })) _write_lm_eval_result( artifact_dir / "results_test_conc4.json", @@ -81,6 +105,7 @@ def test_collect_eval_rows_expands_batched_concurrencies( assert [row["conc"] for row in rows] == [4, 16] assert [row["score"] for row in rows] == [0.90, 0.91] + assert {row["eval_suite"] for row in rows} == {"gsm8k"} def test_collect_eval_rows_ignores_failed_batch_points( @@ -107,3 +132,103 @@ def test_collect_eval_rows_ignores_failed_batch_points( rows = collect_eval_rows(tmp_path) assert [row["conc"] for row in rows] == [4] + + + +def test_collect_eval_rows_accepts_neutral_result_format(tmp_path: Path) -> None: + artifact_dir = tmp_path / "eval_provider" + artifact_dir.mkdir() + (artifact_dir / "meta_env.json").write_text( + json.dumps({"eval_suite": "provider_smoke"}) + ) + result_path = artifact_dir / "results_provider.json" + _write_lm_eval_result(result_path, 1.0) + result = json.loads(result_path.read_text()) + result.pop("lm_eval_version") + result["result_format"] = EVAL_RESULT_FORMAT + result_path.write_text(json.dumps(result)) + + rows = collect_eval_rows(tmp_path) + + assert len(rows) == 1 + assert rows[0]["score"] == 1.0 + assert rows[0]["eval_suite"] == "provider_smoke" + + +def test_collect_eval_rows_excludes_integration_and_sample_failures( + tmp_path: Path, +) -> None: + for name, invalid in ( + ("integration", "integration"), + ("zero", 0), + ("nonnumeric", "unknown"), + ("nonfinite", float("nan")), + ("malformed", []), + ): + artifact_dir = tmp_path / f"eval_{name}" + artifact_dir.mkdir() + (artifact_dir / "meta_env.json").write_text(json.dumps({ + "eval_suite": "gsm8k", + })) + result_path = artifact_dir / f"results_{name}.json" + _write_lm_eval_result(result_path, 0.0) + result = json.loads(result_path.read_text()) + if invalid == "integration": + result["integration_error"] = { + "type": "RuntimeError", + "message": "vendor verifier checkout failed", + } + else: + result["n-samples"]["gsm8k"]["effective"] = invalid + result_path.write_text(json.dumps(result)) + + assert collect_eval_rows(tmp_path) == [] + + +def test_collect_eval_rows_accepts_legacy_missing_effective_count( + tmp_path: Path, +) -> None: + artifact_dir = tmp_path / "eval_legacy" + artifact_dir.mkdir() + (artifact_dir / "meta_env.json").write_text(json.dumps({ + "eval_suite": "gsm8k", + })) + result_path = artifact_dir / "results_legacy.json" + _write_lm_eval_result(result_path, 0.9) + result = json.loads(result_path.read_text()) + result.pop("n-samples") + result_path.write_text(json.dumps(result)) + + rows = collect_eval_rows(tmp_path) + + assert len(rows) == 1 + assert rows[0]["score"] == 0.9 + assert rows[0]["n_eff"] is None + + +def test_collect_eval_rows_does_not_resurrect_stale_valid_result( + tmp_path: Path, +) -> None: + artifact_dir = tmp_path / "eval_retry" + artifact_dir.mkdir() + (artifact_dir / "meta_env.json").write_text(json.dumps({ + "eval_suite": "kimi_tool_call_schema", + })) + stale_path = ( + artifact_dir / "results_kimi_vendor_2026-08-12T01-00-00.000000.json" + ) + _write_lm_eval_result(stale_path, 1.0, task="kimi_tool_call_schema") + current_path = ( + artifact_dir / "results_kimi_vendor_2026-08-12T02-00-00.000000.json" + ) + _write_lm_eval_result(current_path, 0.0, task="kimi_tool_call_schema") + result = json.loads(current_path.read_text()) + result["integration_error"] = { + "type": "RuntimeError", + "message": "vendor verifier checkout failed", + } + current_path.write_text(json.dumps(result)) + current_path.touch() + stale_path.touch() + + assert collect_eval_rows(tmp_path) == [] \ No newline at end of file diff --git a/utils/test_validate_reusable_sweep_artifacts.py b/utils/test_validate_reusable_sweep_artifacts.py index 69eb633cdc..cfa0a1df1a 100644 --- a/utils/test_validate_reusable_sweep_artifacts.py +++ b/utils/test_validate_reusable_sweep_artifacts.py @@ -32,8 +32,9 @@ def single_eval_result( runner: str = "h100-dgxc-slurm", isl: int = 8192, osl: int = 1024, + eval_suite: str | None = None, ) -> dict: - return { + row = { "is_multinode": False, "hw": runner.upper(), "model_prefix": "gptoss", @@ -51,6 +52,9 @@ def single_eval_result( "conc": conc, "task": "gsm8k", } + if eval_suite is not None: + row["eval_suite"] = eval_suite + return row def single_eval_meta( @@ -58,8 +62,9 @@ def single_eval_meta( runner: str = "h100-dgxc-slurm", isl: int = 8192, osl: int = 1024, + eval_suite: str | None = None, ) -> dict: - row = single_eval_result(conc, runner, isl, osl) + row = single_eval_result(conc, runner, isl, osl, eval_suite) row["infmax_model_prefix"] = row.pop("model_prefix") return row @@ -72,11 +77,20 @@ def write_raw_eval_artifact( physical_runner: str = "h100-dgxc-slurm_00", isl: int = 8192, osl: int = 1024, + eval_suite: str | None = None, ) -> None: artifact_dir = root / f"eval_result_conc{conc}_{physical_runner}" artifact_dir.mkdir() (artifact_dir / "meta_env.json").write_text( - json.dumps(single_eval_meta(conc, logical_runner, isl, osl)) + json.dumps( + single_eval_meta( + conc, + logical_runner, + isl, + osl, + eval_suite, + ) + ) ) @@ -330,7 +344,7 @@ def test_eval_validation_requires_raw_result_dirs_not_eval_debug_dirs( assert any("unexpected" in error for error in errors) -def test_eval_validation_accepts_matching_raw_and_aggregate( +def test_eval_validation_accepts_matching_legacy_artifacts_without_suite( tmp_path: Path, ) -> None: write_eval_aggregate( @@ -347,6 +361,31 @@ def test_eval_validation_accepts_matching_raw_and_aggregate( assert validate_eval_artifacts(tmp_path) == [] +def test_eval_validation_separates_explicit_suite_identities( + tmp_path: Path, +) -> None: + gsm8k = single_eval_result(32, eval_suite="gsm8k") + tool_use = single_eval_result( + 32, + eval_suite="kimi_tool_call_schema", + ) + write_eval_aggregate(tmp_path, [gsm8k, tool_use]) + write_raw_eval_artifact( + tmp_path, + 32, + eval_suite="gsm8k", + ) + write_raw_eval_artifact( + tmp_path, + 32, + physical_runner="h100-dgxc-slurm_01", + eval_suite="kimi_tool_call_schema", + ) + + assert eval_key(gsm8k) != eval_key(tool_use) + assert validate_eval_artifacts(tmp_path) == [] + + def test_eval_validation_distinguishes_sequence_lengths(tmp_path: Path) -> None: write_eval_aggregate( tmp_path, diff --git a/utils/validate_reusable_sweep_artifacts.py b/utils/validate_reusable_sweep_artifacts.py index 0cfd1d2662..8942d88fbf 100644 --- a/utils/validate_reusable_sweep_artifacts.py +++ b/utils/validate_reusable_sweep_artifacts.py @@ -313,6 +313,10 @@ def normalized_runner(value: Any) -> str: return str(value or "").lower() +LEGACY_EVAL_SUITE = "" + + + def eval_key(row: dict[str, Any]) -> tuple[Any, ...]: """Build an eval identity from one aggregate row.""" if as_bool(row.get("is_multinode", False)): @@ -322,6 +326,7 @@ def eval_key(row: dict[str, Any]) -> tuple[Any, ...]: row.get("model_prefix", row.get("infmax_model_prefix")), row.get("framework"), row.get("precision"), + row.get("eval_suite", LEGACY_EVAL_SUITE), row.get("spec_decoding", "none"), as_int(row.get("isl", 8192), 8192), as_int(row.get("osl", 1024), 1024), @@ -347,6 +352,7 @@ def eval_key(row: dict[str, Any]) -> tuple[Any, ...]: row.get("model_prefix", row.get("infmax_model_prefix")), row.get("framework"), row.get("precision"), + row.get("eval_suite", LEGACY_EVAL_SUITE), row.get("spec_decoding", "none"), as_int(row.get("isl", 8192), 8192), as_int(row.get("osl", 1024), 1024),