From 139c4ecd14bd3ebfd064ad77c4782c5188ed6803 Mon Sep 17 00:00:00 2001 From: wanzhenchn Date: Mon, 8 Jun 2026 15:59:34 +0000 Subject: [PATCH 1/9] ci(mesh): add Atomesh accuracy and benchmark workflows - Validate standalone-mode accuracy via Atomesh entrypoints. - Mocker benchmark to PD routing scenarios with topology and consumer concurrency matrix. --- .github/scripts/atomesh_mocker_benchmark.sh | 287 +++++++ .../atomesh_mocker_benchmark_summary.py | 135 +++ .../atomesh-accuracy-validation.yaml | 791 ++++++++++++++++++ .../workflows/atomesh-mocker-benchmark.yaml | 253 ++++++ .../mocker/fixtures/grpc_pd_generate.json | 4 +- .../fixtures/grpc_regular_generate.json | 4 +- .../fixtures/grpc_regular_generate_vllm.json | 4 +- atom/mesh/mocker/fixtures/http_pd_chat.json | 4 +- .../mocker/fixtures/http_regular_chat.json | 4 +- .../fixtures/http_regular_chat_streaming.json | 2 +- .../fixtures/http_regular_completion.json | 4 +- .../fixtures/http_regular_generate.json | 2 +- 12 files changed, 1480 insertions(+), 14 deletions(-) create mode 100644 .github/scripts/atomesh_mocker_benchmark.sh create mode 100644 .github/scripts/atomesh_mocker_benchmark_summary.py create mode 100644 .github/workflows/atomesh-accuracy-validation.yaml create mode 100644 .github/workflows/atomesh-mocker-benchmark.yaml diff --git a/.github/scripts/atomesh_mocker_benchmark.sh b/.github/scripts/atomesh_mocker_benchmark.sh new file mode 100644 index 0000000000..3a0bd4683b --- /dev/null +++ b/.github/scripts/atomesh_mocker_benchmark.sh @@ -0,0 +1,287 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCENARIO="${SCENARIO:-pd-chat}" +BENCHMARK_NAME="${BENCHMARK_NAME:-${SCENARIO}}" +DURATION="${DURATION:-20s}" +KILL_AFTER="${KILL_AFTER:-300s}" +PRODUCER_THREADS="${PRODUCER_THREADS:-1}" +CONSUMER_THREADS="${CONSUMER_THREADS:-8}" +PREFILL_WORKERS="${PREFILL_WORKERS:-1}" +DECODE_WORKERS="${DECODE_WORKERS:-1}" +POLICY="${POLICY:-round_robin}" +RESULT_DIR="${RESULT_DIR:-atomesh-mocker-results}" + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +MESH_DIR="${REPO_ROOT}/atom/mesh" +MOCKER_DIR="${MESH_DIR}/mocker" +MOCKER_TARGET_DIR="${MOCKER_DIR}/target/mocker" +MESH_TARGET_DIR="${MOCKER_DIR}/target/mesh" +ATOMESH_BIN="${MESH_TARGET_DIR}/release/atomesh" +MOCKER_BIN="${MOCKER_TARGET_DIR}/release/atomesh-mocker" +LOG_DIR="${RESULT_DIR}/logs/${BENCHMARK_NAME}" +FIXTURE="${MOCKER_DIR}/fixtures/http_pd_chat.json" +ROUTER_MODE="pd" +WORKERS=$((PREFILL_WORKERS + DECODE_WORKERS)) + +mkdir -p "${RESULT_DIR}" "${LOG_DIR}" + +if [[ "${SCENARIO}" != "pd-chat" ]]; then + echo "Unsupported SCENARIO=${SCENARIO}; this benchmark script only runs pd-chat" >&2 + exit 2 +fi + +if (( PREFILL_WORKERS < 1 || DECODE_WORKERS < 1 )); then + echo "PREFILL_WORKERS and DECODE_WORKERS must both be >= 1" >&2 + exit 2 +fi + +pick_ports() { + python3 - <<'PY' +import socket + +def free_port(): + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + return port + +print(free_port(), free_port(), free_port()) +PY +} + +wait_http() { + local url="$1" + local name="$2" + for _ in $(seq 1 100); do + if curl -fsS "${url}" >/dev/null 2>&1; then + return 0 + fi + sleep 0.2 + done + echo "${name} did not become ready at ${url}" >&2 + return 1 +} + +cleanup() { + local status=$? + if [[ -n "${ROUTER_PID:-}" ]]; then + kill -INT "${ROUTER_PID}" 2>/dev/null || true + fi + if [[ -n "${WORKER_PID:-}" ]]; then + kill -INT "${WORKER_PID}" 2>/dev/null || true + fi + wait "${ROUTER_PID:-}" 2>/dev/null || true + wait "${WORKER_PID:-}" 2>/dev/null || true + exit "${status}" +} +trap cleanup EXIT + +read -r ROUTER_PORT WORKER_BASE_PORT PROMETHEUS_PORT < <(pick_ports) + +if [[ ! -x "${MOCKER_BIN}" || ! -x "${ATOMESH_BIN}" ]]; then + echo "Missing release binaries. Build them before running this benchmark script." >&2 + echo " MOCKER_BIN=${MOCKER_BIN}" >&2 + echo " ATOMESH_BIN=${ATOMESH_BIN}" >&2 + exit 2 +fi + +echo "=== Starting virtual workers for ${BENCHMARK_NAME} (${PREFILL_WORKERS}P${DECODE_WORKERS}D) ===" +"${MOCKER_BIN}" virtual-workers \ + --ip 127.0.0.1 \ + --base-port "${WORKER_BASE_PORT}" \ + --workers "${WORKERS}" \ + "${FIXTURE}" \ + > "${LOG_DIR}/virtual-workers.log" 2>&1 & +WORKER_PID=$! +for index in $(seq 0 $((WORKERS - 1))); do + wait_http "http://127.0.0.1:$((WORKER_BASE_PORT + index))/health" "virtual worker ${index}" +done + +echo "=== Starting Atomesh router (${ROUTER_MODE}) ===" +COMMON_ROUTER_ARGS=( + launch + --host 127.0.0.1 + --port "${ROUTER_PORT}" + --policy "${POLICY}" + --worker-startup-timeout-secs 10 + --worker-startup-check-interval 1 + --request-timeout-secs 30 + --disable-retries + --disable-circuit-breaker + --health-check-interval-secs 300 + --prometheus-port "${PROMETHEUS_PORT}" + --log-level warn +) + +pd_worker_args=(--pd-disaggregation) +for index in $(seq 0 $((PREFILL_WORKERS - 1))); do + pd_worker_args+=(--prefill "http://127.0.0.1:$((WORKER_BASE_PORT + index))") +done +for index in $(seq 0 $((DECODE_WORKERS - 1))); do + pd_worker_args+=(--decode "http://127.0.0.1:$((WORKER_BASE_PORT + PREFILL_WORKERS + index))") +done + +"${ATOMESH_BIN}" "${COMMON_ROUTER_ARGS[@]}" \ + "${pd_worker_args[@]}" \ + --prefill-policy "${POLICY}" \ + --decode-policy "${POLICY}" \ + > "${LOG_DIR}/atomesh.log" 2>&1 & +ROUTER_PID=$! +wait_http "http://127.0.0.1:${ROUTER_PORT}/health" "Atomesh router" + +echo "=== Running request benchmark ${BENCHMARK_NAME} for ${DURATION} ===" +BENCH_LOG="${LOG_DIR}/benchmark-request.log" +set +e +timeout --signal=INT --kill-after="${KILL_AFTER}" "${DURATION}" \ + "${MOCKER_BIN}" benchmark-request \ + --base-url "http://127.0.0.1:${ROUTER_PORT}" \ + --producer-threads "${PRODUCER_THREADS}" \ + --consumer-threads "${CONSUMER_THREADS}" \ + "${FIXTURE}" \ + > "${BENCH_LOG}" 2>&1 +bench_status=$? +set -e + +if [[ "${bench_status}" -ne 0 && "${bench_status}" -ne 124 && "${bench_status}" -ne 130 ]]; then + echo "benchmark-request failed with status ${bench_status}" >&2 + exit "${bench_status}" +fi + +echo "=== Parsing benchmark metrics ===" +RESULT_JSON="${RESULT_DIR}/${BENCHMARK_NAME}.json" +ACTION_JSON="${RESULT_DIR}/${BENCHMARK_NAME}-benchmark-action.json" +SUMMARY_MD="${RESULT_DIR}/${BENCHMARK_NAME}.md" + +python3 - <<'PY' \ + "${BENCH_LOG}" "${RESULT_JSON}" "${ACTION_JSON}" "${SUMMARY_MD}" \ + "${SCENARIO}" "${FIXTURE}" "${ROUTER_MODE}" "${DURATION}" \ + "${PRODUCER_THREADS}" "${CONSUMER_THREADS}" "${WORKERS}" "${POLICY}" \ + "${BENCHMARK_NAME}" "${PREFILL_WORKERS}" "${DECODE_WORKERS}" +from datetime import UTC, datetime +import json +import re +import sys +from pathlib import Path + +( + bench_log, + result_json, + action_json, + summary_md, + scenario, + fixture, + router_mode, + duration, + producer_threads, + consumer_threads, + workers, + policy, + benchmark_name, + prefill_workers, + decode_workers, +) = sys.argv[1:] + +text = Path(bench_log).read_text(encoding="utf-8", errors="replace") +metric_lines = [ + line for line in text.splitlines() + if re.match(r"^all\s+\d+\s+\d+\s+\d+\s+", line) +] +if not metric_lines: + print(text) + raise SystemExit("No aggregate metrics line found in benchmark log") + +fields = metric_lines[-1].split() +total = int(fields[1]) +success = int(fields[2]) +failed = int(fields[3]) +avg_ms = float(fields[4]) +p99_ms = float(fields[5]) +p999_ms = float(fields[6]) +one_second_qps = float(fields[8]) +one_minute_qps = float(fields[10]) +five_minute_qps = float(fields[12]) + +seconds_match = re.match(r"^(\d+)([smh]?)$", duration) +duration_seconds = None +if seconds_match: + value = int(seconds_match.group(1)) + unit = seconds_match.group(2) or "s" + duration_seconds = value * {"s": 1, "m": 60, "h": 3600}[unit] + +request_throughput = ( + success / duration_seconds + if duration_seconds and duration_seconds > 0 + else one_minute_qps +) + +payload = { + "date": datetime.now(UTC).strftime("%Y%m%d-%H%M%S"), + "benchmark_backend": "Atomesh-Mocker", + "dashboard_backend": "Atomesh-Mocker", + "benchmark_model_name": benchmark_name, + "benchmark_name": benchmark_name, + "scenario": scenario, + "fixture": str(Path(fixture).name), + "router_mode": router_mode, + "connection_mode": "http", + "policy": policy, + "producer_threads": int(producer_threads), + "consumer_threads": int(consumer_threads), + "workers": int(workers), + "prefill_workers": int(prefill_workers), + "decode_workers": int(decode_workers), + "duration_seconds": duration_seconds, + "completed": success, + "failed": failed, + "request_throughput": request_throughput, + "output_throughput": request_throughput, + "total_token_throughput": request_throughput, + "avg_latency_ms": avg_ms, + "mean_ttft_ms": avg_ms, + "mean_tpot_ms": avg_ms, + "p99_latency_ms": p99_ms, + "p999_latency_ms": p999_ms, + "one_second_qps": one_second_qps, + "one_minute_qps": one_minute_qps, + "five_minute_qps": five_minute_qps, + "total": total, +} +Path(result_json).write_text(json.dumps(payload, indent=2), encoding="utf-8") + +entries = [ + { + "name": f"Atomesh-Mocker::{benchmark_name} request throughput", + "unit": "req/s", + "value": round(request_throughput, 2), + "extra": ( + f"cell={benchmark_name} router={router_mode} policy={policy} " + f"workers={workers} prefill={prefill_workers} decode={decode_workers} " + f"producers={producer_threads} consumers={consumer_threads}" + ), + } +] +Path(action_json).write_text(json.dumps(entries, indent=2), encoding="utf-8") + +summary = f"""### Atomesh Mocker Benchmark: {benchmark_name} + +| Metric | Value | +| --- | ---: | +| scenario | {scenario} | +| router mode | {router_mode} | +| workers | {workers} | +| prefill/decode workers | {prefill_workers}/{decode_workers} | +| producer/consumer threads | {producer_threads}/{consumer_threads} | +| completed | {success} | +| failed | {failed} | +| request throughput | {request_throughput:.2f} req/s | +| avg latency | {avg_ms:.3f} ms | +| p99 latency | {p99_ms:.3f} ms | +| p999 latency | {p999_ms:.3f} ms | +""" +Path(summary_md).write_text(summary, encoding="utf-8") +print(summary) +PY + +echo "Result JSON: ${RESULT_JSON}" diff --git a/.github/scripts/atomesh_mocker_benchmark_summary.py b/.github/scripts/atomesh_mocker_benchmark_summary.py new file mode 100644 index 0000000000..03fb2d3e97 --- /dev/null +++ b/.github/scripts/atomesh_mocker_benchmark_summary.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Run Atomesh mocker benchmark cells and generate an aggregate summary.""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from pathlib import Path + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run Atomesh mocker benchmark cells and summarize results." + ) + parser.add_argument( + "--cells-json", + default=os.environ.get("CELLS_JSON", "[]"), + help="JSON array of benchmark cells. Defaults to CELLS_JSON.", + ) + parser.add_argument( + "--result-dir", + default=os.environ.get("RESULT_DIR", "atomesh-mocker-results"), + help="Directory where per-cell results and summary are written.", + ) + parser.add_argument( + "--benchmark-script", + default=".github/scripts/atomesh_mocker_benchmark.sh", + help="Single-cell benchmark script to invoke.", + ) + return parser.parse_args() + + +def run_cells(cells: list[dict], result_dir: Path, benchmark_script: str) -> int: + result_dir.mkdir(parents=True, exist_ok=True) + + for index, cell in enumerate(cells, start=1): + print( + f"=== Running benchmark cell {index}/{len(cells)}: {cell['display']} ===", + flush=True, + ) + env = os.environ.copy() + env.update( + { + "BENCHMARK_NAME": cell["id"], + "SCENARIO": cell["scenario"], + "DURATION": cell["duration"], + "PREFILL_WORKERS": str(cell["prefill_workers"]), + "DECODE_WORKERS": str(cell["decode_workers"]), + "PRODUCER_THREADS": str(cell["producer_threads"]), + "CONSUMER_THREADS": str(cell["consumer_threads"]), + "RESULT_DIR": str(result_dir), + } + ) + try: + subprocess.run([benchmark_script], check=True, env=env) + except subprocess.CalledProcessError as error: + print( + f"Benchmark cell {cell['id']} failed with status {error.returncode}", + file=sys.stderr, + ) + return error.returncode + + return 0 + + +def collect_rows(result_dir: Path) -> list[tuple]: + rows = [] + for path in sorted(result_dir.glob("pd-chat-*.json")): + if path.name.endswith("-benchmark-action.json"): + continue + payload = json.loads(path.read_text(encoding="utf-8")) + rows.append( + ( + payload["prefill_workers"], + payload["decode_workers"], + payload["consumer_threads"], + payload["duration_seconds"], + payload["completed"], + payload["failed"], + payload["request_throughput"], + payload["avg_latency_ms"], + payload["p99_latency_ms"], + payload["p999_latency_ms"], + ) + ) + rows.sort(key=lambda row: (row[0], row[1], row[2])) + return rows + + +def write_summary(result_dir: Path) -> str: + rows = collect_rows(result_dir) + lines = [ + "### Atomesh Mocker Benchmark Summary", + "", + "| Topology | Concurrency | Duration (s) | Completed | Failed | Throughput (req/s) | Avg Latency (ms) | P99 (ms) | P999 (ms) |", + "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |", + ] + for ( + prefill, + decode, + consumers, + duration, + completed, + failed, + throughput, + avg, + p99, + p999, + ) in rows: + lines.append( + f"| {prefill}P{decode}D | {consumers} | {duration} | {completed} | {failed} | " + f"{throughput:.2f} | {avg:.3f} | {p99:.3f} | {p999:.3f} |" + ) + + summary = "\n".join(lines) + "\n" + (result_dir / "benchmark-summary.md").write_text(summary, encoding="utf-8") + return summary + + +def main() -> int: + args = parse_args() + cells = json.loads(args.cells_json) + result_dir = Path(args.result_dir) + + exit_code = run_cells(cells, result_dir, args.benchmark_script) + summary = write_summary(result_dir) + print(summary) + return exit_code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/atomesh-accuracy-validation.yaml b/.github/workflows/atomesh-accuracy-validation.yaml new file mode 100644 index 0000000000..2073a94648 --- /dev/null +++ b/.github/workflows/atomesh-accuracy-validation.yaml @@ -0,0 +1,791 @@ +name: Atomesh Accuracy Validation + +on: + push: + branches: [main] + pull_request: + branches: [main] # Triggers on PRs targeting `main` + types: [opened, synchronize, reopened, ready_for_review] + paths-ignore: + - '**/*.md' + - 'docs/**' + - 'LICENSE' + - '.gitignore' + schedule: + # Nightly at 00:00 Beijing time (16:00 UTC) + - cron: '0 16 * * *' + workflow_dispatch: + inputs: + aiter_branch: + description: 'ROCm/aiter branch to build inside the CI image' + required: false + default: 'main' + type: string + atom_base_image: + description: 'Docker image used as the ATOM test base image' + required: false + default: 'rocm/atom-dev:latest' + type: string + +concurrency: + # Keep scheduled main runs from blocking push-triggered validation. + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +env: + ATOM_BASE_IMAGE: ${{ github.event_name == 'workflow_dispatch' && inputs.atom_base_image || 'rocm/atom-dev:latest' }} + ATOM_PYTHON_TAG: "cp312" + GITHUB_REPO_URL: ${{ github.event.pull_request.head.repo.clone_url || 'https://github.com/ROCm/ATOM.git' }} + GITHUB_COMMIT_SHA: ${{ github.event.pull_request.head.sha || github.event.head_commit.id || github.sha }} + # workflow_dispatch: inputs.aiter_branch; otherwise main (matches previous default-branch shallow clone) + AITER_GIT_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.aiter_branch || 'main' }} + +jobs: + check-signal: + if: ${{ !github.event.pull_request || github.event.pull_request.draft == false }} + name: Check Pre Checkin Signal + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + steps: + - name: Checkout ATOM repo + if: ${{ github.event_name != 'workflow_dispatch' }} + uses: actions/checkout@v6 + + - name: Wait for Pre Checkin workflow + if: ${{ github.event_name != 'workflow_dispatch' }} + run: bash ./.github/scripts/check_signal.sh + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_SHA: ${{ github.sha }} + + download_aiter_wheel: + if: ${{ needs.check-signal.result == 'success' && (!github.event.pull_request || github.event.pull_request.draft == false) }} + needs: [check-signal] + name: Download aiter wheel + runs-on: ubuntu-latest + steps: + - name: Prefer latest main aiter wheel manifest and fallback to artifact + run: | + set -euo pipefail + echo "=== Trying latest main aiter wheel manifest from S3 first ===" + + S3_MAIN_MANIFEST_URL="https://rocm.frameworks-nightlies.amd.com/whl-staging/gfx942-gfx950/main/latest.json" + API_URL="https://api.github.com" + AUTH_HEADER="Authorization: token ${{ secrets.GITHUB_TOKEN }}" + AITER_TEST_WORKFLOW_ID=179476100 + + ARTIFACT_ID="" + ARTIFACT_NAME="" + ARTIFACT_RUN_ID="" + ARTIFACT_RUN_SHA="" + ARTIFACT_RUN_CREATED_AT="" + + resolve_download_url() { + python3 -c 'import sys + from urllib.parse import quote, unquote, urlsplit, urlunsplit + parts = urlsplit(sys.argv[1]) + encoded_path = "/".join(quote(unquote(segment), safe="") for segment in parts.path.split("/")) + print(urlunsplit((parts.scheme, parts.netloc, encoded_path, parts.query, parts.fragment)))' "$1" + } + + find_latest_artifact() { + local runs_json artifact_json run_id python_artifact_suffix + + if [ -n "$ARTIFACT_ID" ] && [ "$ARTIFACT_ID" != "null" ]; then + return 0 + fi + + python_artifact_suffix="py${ATOM_PYTHON_TAG#cp}" + python_artifact_suffix="${python_artifact_suffix:0:3}.${python_artifact_suffix:3}" + + echo "=== Finding latest aiter-whl-* artifact for ${python_artifact_suffix} from ROCm/aiter ===" + runs_json=$(curl -fsSL -H "$AUTH_HEADER" \ + "$API_URL/repos/ROCm/aiter/actions/workflows/$AITER_TEST_WORKFLOW_ID/runs?per_page=100&branch=main&event=push") + + for run_id in $(echo "$runs_json" | jq -r '.workflow_runs[].id'); do + artifact_json=$(curl -fsSL -H "$AUTH_HEADER" \ + "$API_URL/repos/ROCm/aiter/actions/runs/$run_id/artifacts" \ + | jq --arg artifact_suffix "-${python_artifact_suffix}" '[.artifacts[] | select(.name | startswith("aiter-whl-") and endswith($artifact_suffix)) | select(.expired == false)] | sort_by(.created_at) | last') + + if [ "$artifact_json" != "null" ] && [ -n "$artifact_json" ]; then + ARTIFACT_ID=$(echo "$artifact_json" | jq -r '.id') + ARTIFACT_NAME=$(echo "$artifact_json" | jq -r '.name') + ARTIFACT_RUN_ID="$run_id" + ARTIFACT_RUN_SHA=$(echo "$runs_json" | jq -r --arg run_id "$run_id" '.workflow_runs[] | select((.id | tostring) == $run_id) | .head_sha') + ARTIFACT_RUN_CREATED_AT=$(echo "$runs_json" | jq -r --arg run_id "$run_id" '.workflow_runs[] | select((.id | tostring) == $run_id) | .created_at') + echo "Found artifact in run $ARTIFACT_RUN_ID: $ARTIFACT_NAME (ID: $ARTIFACT_ID, SHA: $ARTIFACT_RUN_SHA)" + return 0 + fi + done + + return 1 + } + + download_from_s3_manifest() { + local manifest_file manifest_fetch_url manifest_branch manifest_timestamp manifest_commit wheel_name wheel_url resolved_wheel_url + + mkdir -p aiter-whl + rm -f aiter-whl/amd_aiter*.whl + + manifest_file=$(mktemp) + trap 'rm -f "$manifest_file"' RETURN + manifest_fetch_url="${S3_MAIN_MANIFEST_URL}?ts=$(date +%s)" + curl -fsSL -H "Cache-Control: no-cache" "$manifest_fetch_url" -o "$manifest_file" || return 1 + + manifest_branch=$(jq -r '.branch // empty' "$manifest_file") + manifest_timestamp=$(jq -r '.timestamp // empty' "$manifest_file") + manifest_commit=$(jq -r '.commit // empty' "$manifest_file") + + wheel_name=$(jq -r ".wheels.${ATOM_PYTHON_TAG}.wheel_name // empty" "$manifest_file") + wheel_url=$(jq -r ".wheels.${ATOM_PYTHON_TAG}.wheel_url // empty" "$manifest_file") + if [ -n "$wheel_name" ] && [ -n "$wheel_url" ]; then + echo "Selected ${ATOM_PYTHON_TAG} wheel from versioned manifest" + else + wheel_name=$(jq -r '.wheel_name // empty' "$manifest_file") + wheel_url=$(jq -r '.wheel_url // empty' "$manifest_file") + echo "Versioned manifest not available, using top-level wheel fields" + fi + + if [ "$manifest_branch" != "main" ] || [ -z "$manifest_timestamp" ] || [ -z "$manifest_commit" ] || [ -z "$wheel_name" ] || [ -z "$wheel_url" ]; then + echo "Invalid latest main wheel manifest" + return 1 + fi + + if [[ "$wheel_name" == *cp* ]] && [[ "$wheel_name" != *${ATOM_PYTHON_TAG}* ]]; then + echo "WARNING: wheel $wheel_name does not match target Python ${ATOM_PYTHON_TAG}" + return 1 + fi + + if find_latest_artifact; then + if [ -n "$ARTIFACT_RUN_SHA" ] && [ "$manifest_commit" != "$ARTIFACT_RUN_SHA" ]; then + if [ -n "$ARTIFACT_RUN_CREATED_AT" ] && [[ "$manifest_timestamp" < "$ARTIFACT_RUN_CREATED_AT" ]]; then + echo "Manifest commit $manifest_commit is older than latest artifact run $ARTIFACT_RUN_ID ($ARTIFACT_RUN_SHA); treating manifest as stale" + return 1 + fi + echo "Manifest commit $manifest_commit differs from latest artifact run $ARTIFACT_RUN_ID ($ARTIFACT_RUN_SHA), but manifest timestamp is not older" + fi + else + echo "No GitHub fallback artifact found while checking manifest freshness" + fi + + resolved_wheel_url=$(resolve_download_url "$wheel_url") + + echo "Selected latest main wheel manifest: $S3_MAIN_MANIFEST_URL" + echo "Manifest timestamp: $manifest_timestamp" + echo "Manifest commit: $manifest_commit" + echo "Manifest wheel: $wheel_name" + echo "Downloading manifest-selected wheel: $resolved_wheel_url" + curl -fsSL "$resolved_wheel_url" -o "aiter-whl/$wheel_name" || return 1 + echo "Downloaded wheel from manifest: aiter-whl/$wheel_name" + + rm -f "$manifest_file" + trap - RETURN + } + + download_from_artifact() { + local fallback_wheel fallback_wheel_name + + echo "=== Falling back to latest ${ATOM_PYTHON_TAG} aiter-whl-* artifact from ROCm/aiter ===" + find_latest_artifact || { + echo "ERROR: No ${ATOM_PYTHON_TAG} aiter-whl-* artifact found in recent Aiter Test runs" + return 1 + } + + mkdir -p aiter-whl + rm -f aiter-whl/amd_aiter*.whl + curl -fsSL -H "$AUTH_HEADER" \ + "$API_URL/repos/ROCm/aiter/actions/artifacts/$ARTIFACT_ID/zip" \ + -o aiter-whl.zip + unzip -o aiter-whl.zip -d aiter-whl + rm -f aiter-whl.zip + + fallback_wheel=$(ls -t aiter-whl/amd_aiter*.whl 2>/dev/null | head -1) + fallback_wheel_name=$(basename "${fallback_wheel:-}") + if [ -z "$fallback_wheel" ] || [[ "$fallback_wheel_name" != *${ATOM_PYTHON_TAG}* ]]; then + echo "ERROR: artifact fallback did not produce a ${ATOM_PYTHON_TAG} wheel" + ls -la aiter-whl/ || true + return 1 + fi + echo "Downloaded artifact-selected wheel: $fallback_wheel" + } + + if download_from_s3_manifest; then + echo "Using wheel from S3 main manifest" + else + echo "Main wheel manifest download failed, falling back to GitHub artifact" + download_from_artifact + fi + + AITER_WHL=$(ls -t aiter-whl/amd_aiter*.whl 2>/dev/null | head -1) + if [ -z "$AITER_WHL" ]; then + echo "ERROR: No amd_aiter wheel available after S3/artifact attempts" + ls -la aiter-whl/ || true + exit 1 + fi + if [[ "$(basename "$AITER_WHL")" != *${ATOM_PYTHON_TAG}* ]]; then + echo "ERROR: selected wheel $AITER_WHL does not match target Python ${ATOM_PYTHON_TAG}" + exit 1 + fi + + echo "Selected wheel: $AITER_WHL" + + - name: Upload aiter wheel + uses: actions/upload-artifact@v4 + with: + name: aiter-whl + path: aiter-whl/amd_aiter*.whl + retention-days: 7 + + load-test-models: + name: Load test model configs + runs-on: ubuntu-latest + outputs: + models_json: ${{ steps.load.outputs.models_json }} + steps: + - uses: actions/checkout@v6 + - id: load + env: + EVENT_NAME: ${{ github.event_name }} + run: | + python3 << 'PY' + import json, os + event = os.environ["EVENT_NAME"] + # Atomesh standalone validates a small representative subset only. + # Keep this whitelist local; full ATOM accuracy owns test_level. + level_map = {"schedule": "nightly", "workflow_dispatch": "nightly", "push": "main"} + current = level_map.get(event, "pr") + allowed = {"pr": {"pr"}, "main": {"pr", "main"}, "nightly": {"pr", "main", "nightly"}}[current] + models = json.load(open(".github/benchmark/models_accuracy.json", encoding="utf-8")) + atomesh_levels = { + "Meta-Llama-3-8B-Instruct": "pr", + "DeepSeek-R1-0528": "main", + "DeepSeek-V4-Pro MTP": "nightly", + "gpt-oss-120b": "nightly", + } + filtered = [m for m in models if atomesh_levels.get(m["model_name"], "skip") in allowed] + with open(os.environ["GITHUB_OUTPUT"], "a") as f: + f.write(f"models_json={json.dumps(filtered)}\n") + print(f"Event={event} level={current}: {len(filtered)}/{len(models)} models") + print(f"{'Model':<45} {'Atomesh':<10} {'ATOM':<10} {'Runner'}") + print("-" * 80) + for m in models: + enabled = "✓" if m in filtered else "·" + print( + f" {enabled} {m['model_name']:<43} " + f"{atomesh_levels.get(m['model_name'],'skip'):<10} " + f"{m.get('test_level','?'):<10} {m['runner']}" + ) + PY + + atomesh-test: + needs: [download_aiter_wheel, load-test-models] + name: Accuracy + strategy: + fail-fast: false + matrix: + include: ${{ fromJson(needs.load-test-models.outputs.models_json) }} + if: ${{ !github.event.pull_request || github.event.pull_request.draft == false }} + runs-on: ${{ matrix.runner }} + + env: + CONTAINER_NAME: atomesh_test_${{ strategy.job-index }} + USE_ATOMESH_ENTRYPOINTS: 1 + ATOM_SERVER_PORT: 8000 + + steps: + - name: Kill all Docker containers and clean up workspace + if: matrix.runner == 'atom-mi355-8gpu.predownload' || matrix.runner == 'linux-atom-do-mi350x-8' + run: | + echo "=== Cleaning up containers on $(hostname) ===" + containers=$(docker ps -q) + if [ -n "$containers" ]; then + docker kill $containers || true + fi + docker run --rm -v "${GITHUB_WORKSPACE:-$PWD}":/workspace -w /workspace --privileged rocm/pytorch:latest bash -lc "ls -la /workspace/ && find /workspace -mindepth 1 -delete" || true + + - name: Show Docker containers + if: matrix.runner == 'atom-mi355-8gpu.predownload' || matrix.runner == 'linux-atom-do-mi350x-8' + run: docker ps -a + + - name: Show ROCm memory usage + if: matrix.runner == 'atom-mi355-8gpu.predownload' || matrix.runner == 'linux-atom-do-mi350x-8' + run: rocm-smi --showmemuse + + - name: Show ROCm GPU processes + if: matrix.runner == 'atom-mi355-8gpu.predownload' || matrix.runner == 'linux-atom-do-mi350x-8' + run: rocm-smi --showpidgpus + + - name: Checkout ATOM repo + uses: actions/checkout@v6 + + - name: Docker Login + if: ${{ !github.event.pull_request.head.repo.fork }} + run: | + echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin + + - name: Resolve immutable native dashboard image + if: ${{ github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'schedule') }} + env: + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + run: | + set -euo pipefail + if RESOLUTION_JSON="$( + python3 .github/scripts/resolve_atom_image.py \ + --repository rocm/atom-dev \ + --reference-tag latest \ + --image-family native + )"; then + RESOLVED_ATOM_IMAGE="$( + RESOLUTION_JSON="${RESOLUTION_JSON}" python3 - <<'PY' + import json + import os + + resolution = json.loads(os.environ["RESOLUTION_JSON"]) + print(resolution["resolved_image"]) + PY + )" + echo "Resolved native dashboard image: ${RESOLVED_ATOM_IMAGE}" + else + echo "::error::Failed to resolve ${ATOM_BASE_IMAGE} to an immutable reference for dashboard-uploading native runs." + exit 1 + fi + echo "RESOLVED_ATOM_BASE_IMAGE=${RESOLVED_ATOM_IMAGE}" >> "$GITHUB_ENV" + echo "ATOM_DASHBOARD_DOCKER_IMAGE=${RESOLVED_ATOM_IMAGE}" >> "$GITHUB_ENV" + + - name: Pull immutable native dashboard image + if: ${{ github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'schedule') }} + run: | + echo "Pulling immutable native dashboard image: ${RESOLVED_ATOM_BASE_IMAGE}" + docker pull "${RESOLVED_ATOM_BASE_IMAGE}" + + - name: Generate Dockerfile for forked repo + if: ${{ github.event.pull_request.head.repo.fork }} + run: | + cat < Dockerfile.mod + FROM ${{ env.ATOM_BASE_IMAGE }} + RUN pip install -U lm-eval[api] + RUN pip show lm-eval || true + RUN pip install hf_transfer + RUN pip show hf_transfer || true + RUN echo "=== Aiter version BEFORE uninstall ===" && pip show amd-aiter || true + RUN pip uninstall -y amd-aiter + RUN pip install --upgrade "pybind11>=3.0.1" + RUN pip show pybind11 + RUN rm -rf /app/aiter-test + RUN git clone --depth 1 -b ${{ env.AITER_GIT_REF }} https://github.com/ROCm/aiter.git /app/aiter-test && \\ + cd /app/aiter-test && \\ + git submodule sync && git submodule update --init --recursive && \\ + MAX_JOBS=64 PREBUILD_KERNELS=0 GPU_ARCHS=gfx950 python3 setup.py develop + RUN echo "=== Aiter version AFTER installation ===" && pip show amd-aiter || true + + RUN echo "=== ATOM version BEFORE uninstall ===" && pip show atom || true + RUN pip uninstall -y atom + RUN rm -rf /app/ATOM + ARG RUST_VERSION="1.94.0" + RUN if ! command -v cargo >/dev/null 2>&1; then \\ + echo "=== Installing Rust toolchain for atomesh build ===" && \\ + apt-get update && \\ + apt --fix-broken install -y && \\ + apt-get install -y --no-install-recommends curl build-essential pkg-config libssl-dev protobuf-compiler libprotobuf-dev && \\ + rm -rf /var/lib/apt/lists/* && \\ + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \\ + | sh -s -- -y --default-toolchain "\${RUST_VERSION}" --profile minimal && \\ + . "\$HOME/.cargo/env" && \\ + rustc --version && cargo --version; \\ + fi + ENV PATH="/root/.cargo/bin:\$PATH" + RUN git clone ${{ env.GITHUB_REPO_URL }} /app/ATOM && \\ + cd /app/ATOM && \\ + git checkout ${{ env.GITHUB_COMMIT_SHA }} && \\ + ATOM_MESH_BUILD=1 python -m pip install -e . + + RUN echo "=== ATOM version AFTER installation ===" && pip show atom || true + EOF + + - name: Download aiter wheel + uses: actions/download-artifact@v4 + with: + name: aiter-whl + path: /tmp/aiter-whl + + - name: Set HF token for predownload runner + if: matrix.runner == 'atom-mi355-8gpu.predownload' || matrix.runner == 'linux-atom-do-mi350x-8' + run: echo "HF_TOKEN=${HF_TOKEN:-${{ secrets.AMD_HF_TOKEN }}}" >> "$GITHUB_ENV" + + - name: Start CI container + run: | + echo "Clean up containers..." + (docker ps -aq -f name="^${CONTAINER_NAME}$" | xargs -r docker stop) || true + (docker ps -aq -f name="^${CONTAINER_NAME}$" | xargs -r docker rm) || true + + if [ -f "/etc/podinfo/gha-render-devices" ]; then + DEVICE_FLAG=$(cat /etc/podinfo/gha-render-devices) + else + DEVICE_FLAG="--device /dev/dri" + fi + + if [ -d "/models" ]; then + MODEL_MOUNT="-v /models:/models" + else + echo "Warning: /models directory not found on runner; skipping /models mount and disabling model pre-download optimization." + MODEL_MOUNT="" + fi + + # Write env_vars via env block (avoids expression injection) + printenv MODEL_ENV_VARS | grep -v '^$' > /tmp/env_file.txt || true + + IMAGE_TAG="${RESOLVED_ATOM_BASE_IMAGE:-$ATOM_BASE_IMAGE}" + echo "Starting container with image: $IMAGE_TAG" + echo "Model-specific environment variables:" + cat /tmp/env_file.txt + + PULL_FLAG="" + if [ -n "${RESOLVED_ATOM_BASE_IMAGE:-}" ]; then + PULL_FLAG="" + elif [ "${{ matrix.runner }}" = "atom-mi355-8gpu.predownload" ] || [ "${{ matrix.runner }}" = "linux-atom-do-mi350x-8" ]; then + PULL_FLAG="--pull always" + fi + + docker run -dt $PULL_FLAG --device=/dev/kfd $DEVICE_FLAG \ + -v "${GITHUB_WORKSPACE:-$PWD}":/workspace \ + $MODEL_MOUNT \ + -w /workspace \ + --ipc=host --group-add video \ + --shm-size=16G \ + --privileged \ + --cap-add=SYS_PTRACE \ + -e HF_TOKEN="${HF_TOKEN:-}" \ + -e ATOM_DOCKER_IMAGE="${ATOM_DASHBOARD_DOCKER_IMAGE:-}" \ + -e USE_ATOMESH_ENTRYPOINTS="${USE_ATOMESH_ENTRYPOINTS}" \ + -e ATOM_SERVER_PORT="${ATOM_SERVER_PORT}" \ + --env-file /tmp/env_file.txt \ + --security-opt seccomp=unconfined \ + --ulimit memlock=-1 \ + --ulimit stack=67108864 \ + -e ATOM_DISABLE_MMAP=true \ + -v "${{ github.workspace }}:/workspace" \ + -w /workspace \ + --name "$CONTAINER_NAME" \ + $IMAGE_TAG + + env: + GITHUB_WORKSPACE: ${{ github.workspace }} + MODEL_ENV_VARS: ${{ matrix.env_vars }} + + - name: Check shm size + run: | + docker exec "$CONTAINER_NAME" df -h /dev/shm + + - name: Collect GPU info (inside container) + id: gpu-info + run: bash .github/scripts/collect_gpu_info.sh "$CONTAINER_NAME" docker "${{ matrix.runner }}" + + - name: Install aiter from wheel + run: | + AITER_WHL=$(ls -t /tmp/aiter-whl/amd_aiter*.whl 2>/dev/null | head -1) + if [ -z "$AITER_WHL" ]; then + echo "ERROR: No amd_aiter wheel found" + ls -la /tmp/aiter-whl/ + exit 1 + fi + + echo "=== Copying wheel into container ===" + WHL_NAME=$(basename "$AITER_WHL") + docker cp "$AITER_WHL" "$CONTAINER_NAME:/tmp/$WHL_NAME" + + docker exec "$CONTAINER_NAME" bash -lc " + set -euo pipefail + echo '=== Uninstalling existing amd-aiter ===' + pip uninstall -y amd-aiter || true + + echo '=== Installing amd-aiter from wheel ===' + pip install /tmp/$WHL_NAME + + echo '=== Installed amd-aiter version ===' + pip show amd-aiter + " + + - name: Install ATOM and dependencies + run: | + docker exec "$CONTAINER_NAME" bash -lc " + set -euo pipefail + pip install --timeout 60 --retries 10 -U 'lm-eval[api]' + pip install --timeout 60 --retries 10 hf_transfer + pip install --timeout 60 --retries 10 --upgrade 'pybind11>=3.0.1' + if ! command -v cargo >/dev/null 2>&1; then + echo '=== Installing Rust toolchain for atomesh build ===' + RUST_VERSION='1.94.0' + apt-get update + apt --fix-broken install -y + apt-get install -y --no-install-recommends curl build-essential pkg-config libssl-dev protobuf-compiler libprotobuf-dev + rm -rf /var/lib/apt/lists/* + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --default-toolchain \"\$RUST_VERSION\" --profile minimal + . \"\$HOME/.cargo/env\" + fi + export PATH=\"\$HOME/.cargo/bin:\$PATH\" + rustc --version + cargo --version + + echo '=== Installing ATOM ===' + cd /workspace + git config --global --add safe.directory /workspace + ATOM_MESH_BUILD=1 python -m pip install -e . + + echo '=== Installed package versions ===' + pip show amd-aiter | grep -E '^(Name|Version):' + pip show atom | grep -E '^(Name|Version):' + pip show triton | grep -E '^(Name|Version):' + pip show torch | grep -E '^(Name|Version):' + " + + - name: Download models + timeout-minutes: 150 + run: | + set -euo pipefail + if [ -d "/models" ]; then + model_dir="/models/${{ matrix.model_path }}" + echo "/models directory found, checking cache and lock-protected download for ${model_dir}" + if ! docker exec \ + -e HF_TOKEN="${HF_TOKEN:-}" \ + -e MODEL_ID="${{ matrix.model_path }}" \ + -e TARGET_DIR="${model_dir}" \ + -e MODEL_DOWNLOAD_TIMEOUT="${MODEL_DOWNLOAD_TIMEOUT}" \ + -e MODEL_LOCK_WAIT_SECONDS="${MODEL_LOCK_WAIT_SECONDS}" \ + -e MODEL_LOCK_POLL_INTERVAL="${MODEL_LOCK_POLL_INTERVAL}" \ + -e MODEL_PROGRESS_INTERVAL="${MODEL_PROGRESS_INTERVAL}" \ + "$CONTAINER_NAME" bash -lc 'bash /workspace/.github/scripts/download_model_with_lock.sh "$MODEL_ID" "$TARGET_DIR"'; then + echo "Model download failed for '${{ matrix.model_path }}'. Aborting." + exit 1 + fi + else + echo "/models directory not found, skipping model download" + fi + env: + MODEL_DOWNLOAD_TIMEOUT: "30m" + MODEL_LOCK_WAIT_SECONDS: "1800" + MODEL_LOCK_POLL_INTERVAL: "30" + MODEL_PROGRESS_INTERVAL: "60" + + - name: Run ATOM simple inference + # Skip simple inference; accuracy test already validates correctness + if: false + timeout-minutes: 30 + run: | + # Run the inference and capture output + set -euo pipefail + + echo "" + echo "========== Running test ==========" + + if [ -d "/models" ]; then + model_path="/models/${{ matrix.model_path }}" + else + model_path="${{ matrix.model_path }}" + fi + echo "Model path: $model_path" + ls -la $model_path || true + # Print debug logs + echo "========= Runner debug logs ===============" + ps aux + rocm-smi --showmemuse + rocm-smi --showpids + docker ps -a + echo "========= End runner debug logs ===============" + docker exec "$CONTAINER_NAME" bash -lc " + set -euo pipefail + python3 -m atom.examples.simple_inference \ + --model \"$model_path\" \ + ${{ matrix.extraArgs }} \ + --temperature 0 \ + | grep -E '^Prompt: |^Completion:' + " > atom_test_output.txt + + echo "" + echo "========== Showing test output below ==========" + cat atom_test_output.txt + + - name: Compare output with golden outputs + if: false + timeout-minutes: 30 + # TODO: skip for all test until it's fixed + run: | + echo "========== Comparing output with golden outputs ==========" + if ! diff -u -B -w --strip-trailing-cr \ + atom_test_output.txt \ + ".github/workflows/golden_outputs/${{ matrix.model_name }}_golden_output.txt"; then + echo "Failed: Output does not match golden outputs." + exit 1 + else + echo "Success: Output matches golden outputs." + fi + + - name: Run ATOM accuracy test + timeout-minutes: 30 + env: + MODEL_EXTRA_ARGS: ${{ matrix.extraArgs }} + CLIENT_COMMAND: ${{ matrix.client_command || '' }} + run: | + set -euo pipefail + echo "" + echo "========== Launching ATOM server ==========" + if [ -d "/models" ]; then + model_path="/models/${{ matrix.model_path }}" + else + model_path="${{ matrix.model_path }}" + fi + # Pipe via stdin so container bash parses shell quoting in extraArgs + # (e.g. single-quoted JSON in --default-chat-template-kwargs) naturally. + echo ".github/scripts/atom_test.sh launch $model_path $MODEL_EXTRA_ARGS" | \ + docker exec -i "$CONTAINER_NAME" bash -l + echo "" + echo "========== Running accuracy test ==========" + docker exec \ + -e CLIENT_COMMAND="${CLIENT_COMMAND}" \ + -e GPU_NAME="${{ steps.gpu-info.outputs.gpu_name }}" \ + -e GPU_VRAM_GB="${{ steps.gpu-info.outputs.gpu_vram_gb }}" \ + -e ROCM_VERSION="${{ steps.gpu-info.outputs.rocm_version }}" \ + "$CONTAINER_NAME" bash -lc " + .github/scripts/atom_test.sh accuracy $model_path + " 2>&1 | tee atom_accuracy_output.txt + + - name: Dump server log + if: always() + run: | + docker exec "$CONTAINER_NAME" cat /tmp/atom_server.log 2>/dev/null || true + + - name: Dump client log + if: always() + run: | + docker exec "$CONTAINER_NAME" cat /tmp/atom_client.log 2>/dev/null || true + + - name: Check accuracy test results + if: success() + env: + MODEL_NAME: ${{ matrix.model_name }} + run: | + result_file=$(ls -1t accuracy_test_results/*.json 2>/dev/null | head -n 1) + if [ -z "$result_file" ] || [ ! -f "$result_file" ]; then + echo "ERROR: No results JSON file found in accuracy_test_results/" + exit 2 + else + echo "RESULT_FILE: $result_file" + fi + flexible_extract_value=$(jq '.results.gsm8k["exact_match,flexible-extract"]' "$result_file") + echo "Flexible extract value: $flexible_extract_value" + + # Read threshold from models_accuracy.json (via env var to avoid shell injection) + threshold=$(python3 -c " + import json, os + models = json.load(open('.github/benchmark/models_accuracy.json', encoding='utf-8')) + name = os.environ['MODEL_NAME'] + t = next((m.get('accuracy_threshold', 0) for m in models if m['model_name'] == name), 0) + print(t) + ") + echo "Accuracy test threshold: $threshold" + + result=$(awk -v val="$flexible_extract_value" -v threshold="$threshold" 'BEGIN {print (val < threshold) ? 1 : 0}') + if [ "$result" -eq 1 ]; then + echo "Accuracy test failed: $flexible_extract_value < $threshold" + exit 1 + else + echo "Accuracy test passed: $flexible_extract_value >= $threshold" + fi + + - name: Collect Test Summary + if: success() + env: + MODEL_NAME: ${{ matrix.model_name }} + run: | + # Read threshold and score for summary + threshold=$(python3 -c " + import json, os + models = json.load(open('.github/benchmark/models_accuracy.json', encoding='utf-8')) + name = os.environ['MODEL_NAME'] + print(next((m.get('accuracy_threshold', 0) for m in models if m['model_name'] == name), 0)) + ") + result_file=$(ls -1t accuracy_test_results/*.json 2>/dev/null | head -n 1) + score=$(jq '.results.gsm8k["exact_match,flexible-extract"]' "$result_file" 2>/dev/null || echo "N/A") + + echo "Accuracy Test Summary for ${{ matrix.model_name }} (threshold: ${threshold}, score: ${score}):" >> $GITHUB_STEP_SUMMARY + awk '/\|Tasks\|Version\|/,/^$/ { if (NF > 0) print }' atom_accuracy_output.txt >> $GITHUB_STEP_SUMMARY + + - name: Upload output + if: always() + uses: actions/upload-artifact@v7 + with: + name: ${{ matrix.model_name }}_atom_test_output.txt + path: atom_test_output.txt + + - name: Upload accuracy results + if: always() + uses: actions/upload-artifact@v7 + with: + name: accuracy-${{ matrix.model_name }} + path: accuracy_test_results/*.json + if-no-files-found: ignore + + - name: Clean Up + if: always() + run: | + # TODO: run a separate container for cleanup of the workspace due to permission issue to remove some pyc files under __pycache__ whose owners are root. + # We should use non-root user to run the test to avoid this issue. + set -x + echo "========== Cleaning up workspace ==========" + if [[ ${{ matrix.runner }} == atom-mi355-8gpu.predownload ]]; then + docker run --rm -v "${GITHUB_WORKSPACE:-$PWD}":/workspace -w /workspace --privileged rocm/pytorch:latest bash -lc "ls -la /workspace/ && find /workspace -mindepth 1 -delete" || true + fi + docker stop "$CONTAINER_NAME" || true + docker rm "$CONTAINER_NAME" || true + # Remove the pre-built image to free disk space on the runner + docker rmi "rocm/atom-dev:pre-build-${{ env.GITHUB_COMMIT_SHA }}" || true + + # ---------- Push accuracy data to benchmark dashboard ---------- + accuracy-dashboard: + name: Update accuracy dashboard + needs: [atomesh-test] + if: always() && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'schedule') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Download accuracy artifacts + uses: actions/download-artifact@v8 + with: + path: /tmp/accuracy-results + pattern: accuracy-* + + - name: List downloaded artifacts + run: | + echo "=== Downloaded accuracy artifacts ===" + find /tmp/accuracy-results -type f -name '*.json' | head -20 || echo "No JSON files found" + + - name: Transform accuracy results for dashboard + run: | + python3 .github/scripts/accuracy_to_dashboard.py \ + /tmp/accuracy-results \ + --output accuracy-benchmark-input.json \ + --models .github/benchmark/models_accuracy.json \ + --run-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + echo "=== Generated entries ===" + cat accuracy-benchmark-input.json + + - name: Store accuracy result to dashboard + if: hashFiles('accuracy-benchmark-input.json') != '' + uses: benchmark-action/github-action-benchmark@v1 + with: + tool: customBiggerIsBetter + output-file-path: accuracy-benchmark-input.json + gh-pages-branch: gh-pages + benchmark-data-dir-path: benchmark-dashboard + auto-push: true + max-items-in-chart: 300 + github-token: ${{ secrets.GITHUB_TOKEN }} + diff --git a/.github/workflows/atomesh-mocker-benchmark.yaml b/.github/workflows/atomesh-mocker-benchmark.yaml new file mode 100644 index 0000000000..d5ea981b9c --- /dev/null +++ b/.github/workflows/atomesh-mocker-benchmark.yaml @@ -0,0 +1,253 @@ +name: Atomesh Mocker Benchmark + +on: + push: + branches: [main] + paths: + - 'atom/mesh/**' + - '.github/scripts/atomesh_mocker_benchmark.sh' + - '.github/workflows/atomesh-mocker-benchmark.yaml' + pull_request: + branches: [main] + types: [opened, synchronize, reopened, ready_for_review] + paths: + - 'atom/mesh/**' + - '.github/scripts/atomesh_mocker_benchmark.sh' + - '.github/workflows/atomesh-mocker-benchmark.yaml' + schedule: + # Nightly at 02:00 Beijing time (18:00 UTC) + - cron: '0 18 * * *' + workflow_dispatch: + inputs: + suite: + description: 'Benchmark suite to run' + required: false + default: 'full' + type: choice + options: + - smoke + - full + publish_dashboard: + description: 'Publish workflow_dispatch results to the benchmark dashboard' + required: false + default: false + type: boolean + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +permissions: + actions: read + contents: write + +jobs: + run_atomesh_test_harness: + if: ${{ !github.event.pull_request || github.event.pull_request.draft == false }} + name: run_atomesh_test_harness + runs-on: ubuntu-latest + steps: + - name: Checkout ATOM repo + uses: actions/checkout@v6 + + - name: Set up build environment + run: | + sudo apt-get update + sudo apt-get install -y protobuf-compiler + + - name: Cache cargo build output + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + atom/mesh/mocker/target + key: atomesh-mocker-cargo-${{ runner.os }}-${{ hashFiles('atom/mesh/Cargo.lock', 'atom/mesh/Cargo.toml', 'atom/mesh/mocker/Cargo.toml') }} + restore-keys: | + atomesh-mocker-cargo-${{ runner.os }}- + + - name: Run Atomesh test harness + run: | + set -euo pipefail + cargo test \ + --manifest-path atom/mesh/mocker/Cargo.toml \ + --target-dir atom/mesh/mocker/target/mocker \ + --release \ + test_atomesh_harness + + run_atomesh_mocker_benchmark: + if: ${{ !github.event.pull_request || github.event.pull_request.draft == false }} + name: run_atomesh_mocker_benchmark + needs: [run_atomesh_test_harness] + runs-on: ubuntu-latest + timeout-minutes: 75 + steps: + - name: Checkout ATOM repo + uses: actions/checkout@v6 + + - name: Build benchmark matrix + run: | + python3 <<'PY' + import json + import os + + duration = "3m" + consumer_threads = [1, 2, 4, 8, 16] + topologies = [(1, 1), (2, 1), (2, 2), (3, 1)] + + cells = [] + + def add_pd(duration, prefill, decode, consumers): + cells.append({ + "id": f"pd-chat-{prefill}p{decode}d-conc{consumers}", + "display": f"pd-chat {prefill}P{decode}D CONC{consumers}", + "scenario": "pd-chat", + "duration": duration, + "prefill_workers": prefill, + "decode_workers": decode, + "producer_threads": 1, + "consumer_threads": consumers, + }) + + for prefill, decode in topologies: + for consumers in consumer_threads: + add_pd(duration, prefill, decode, consumers) + + cells_json = json.dumps(cells) + with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as env: + env.write(f"CELLS_JSON={cells_json}\n") + + print(f"Generated {len(cells)} benchmark cells") + for cell in cells: + print( + f" {cell['id']}: scenario={cell['scenario']} duration={cell['duration']} " + f"P/D={cell['prefill_workers']}/{cell['decode_workers']} " + f"producer/consumer={cell['producer_threads']}/{cell['consumer_threads']}" + ) + PY + + - name: Cache Rust build artifacts + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + atom/mesh/mocker/target + key: atomesh-mocker-cargo-${{ runner.os }}-${{ hashFiles('atom/mesh/Cargo.lock', 'atom/mesh/Cargo.toml', 'atom/mesh/mocker/Cargo.toml') }} + restore-keys: | + atomesh-mocker-cargo-${{ runner.os }}- + + - name: Build Atomesh + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y protobuf-compiler + cargo build \ + --manifest-path atom/mesh/mocker/Cargo.toml \ + --target-dir atom/mesh/mocker/target/mocker \ + --release + cargo build \ + --manifest-path atom/mesh/Cargo.toml \ + --target-dir atom/mesh/mocker/target/mesh \ + --release + + - name: Run mocker benchmark + env: + RESULT_DIR: atomesh-mocker-results + run: | + set -euo pipefail + chmod +x .github/scripts/atomesh_mocker_benchmark.sh + python3 .github/scripts/atomesh_mocker_benchmark_summary.py + + - name: Dump mocker benchmark-request log + if: always() + run: | + set -euo pipefail + shopt -s nullglob + logs=(atomesh-mocker-results/logs/*/benchmark-request.log) + if [ "${#logs[@]}" -eq 0 ]; then + echo "No Atomesh mocker benchmark-request logs found." + exit 0 + fi + + for log in "${logs[@]}"; do + cell="$(basename "$(dirname "$log")")" + echo "::group::benchmark-request ${cell}" + cat "$log" + echo "::endgroup::" + done + + - name: Summarize mocker benchmark result + if: always() + run: | + set -euo pipefail + if [ -f "atomesh-mocker-results/benchmark-summary.md" ]; then + cat "atomesh-mocker-results/benchmark-summary.md" + cat "atomesh-mocker-results/benchmark-summary.md" >> "$GITHUB_STEP_SUMMARY" + else + echo "No Atomesh mocker benchmark summary was generated." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload benchmark result + if: always() + uses: actions/upload-artifact@v7 + with: + name: atomesh-mocker-benchmark-results + path: | + atomesh-mocker-results/*.json + atomesh-mocker-results/*.md + atomesh-mocker-results/logs/ + if-no-files-found: ignore + + dashboard: + name: Update Mocker Benchmark Dashboard + needs: [run_atomesh_mocker_benchmark] + if: >- + !cancelled() + && needs.run_atomesh_mocker_benchmark.result == 'success' + && ( + github.event_name == 'schedule' + || github.event_name == 'push' + || (github.event_name == 'workflow_dispatch' && inputs.publish_dashboard) + ) + runs-on: ubuntu-latest + steps: + - name: Checkout ATOM repo + uses: actions/checkout@v6 + + - name: Download benchmark artifacts + uses: actions/download-artifact@v8 + with: + pattern: atomesh-mocker-benchmark-* + merge-multiple: true + path: atomesh-mocker-results + + - name: Build benchmark-action input + run: | + set -euo pipefail + python3 - <<'PY' + import json + from pathlib import Path + + entries = [] + for path in sorted(Path("atomesh-mocker-results").glob("*-benchmark-action.json")): + entries.extend(json.loads(path.read_text(encoding="utf-8"))) + + Path("atomesh-mocker-dashboard-input.json").write_text( + json.dumps(entries, indent=2), + encoding="utf-8", + ) + print(f"Generated {len(entries)} dashboard entries") + PY + + - name: Store benchmark result to dashboard + if: hashFiles('atomesh-mocker-dashboard-input.json') != '' + uses: benchmark-action/github-action-benchmark@v1 + with: + tool: customBiggerIsBetter + output-file-path: atomesh-mocker-dashboard-input.json + gh-pages-branch: gh-pages + benchmark-data-dir-path: atomesh-mocker-dashboard + auto-push: false + max-items-in-chart: 300 + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/atom/mesh/mocker/fixtures/grpc_pd_generate.json b/atom/mesh/mocker/fixtures/grpc_pd_generate.json index 83b2c94b8b..4278a47dcf 100644 --- a/atom/mesh/mocker/fixtures/grpc_pd_generate.json +++ b/atom/mesh/mocker/fixtures/grpc_pd_generate.json @@ -1,6 +1,6 @@ { "name": "grpc_pd_generate", - "model": "test-model", + "model": "hf-internal-testing/llama-tokenizer", "endpoint": "/generate", "route": { "worker_kind": "prefill_decode", @@ -8,7 +8,7 @@ "backend": "sglang" }, "request": { - "model": "test-model", + "model": "hf-internal-testing/llama-tokenizer", "text": "Hello world", "stream": false }, diff --git a/atom/mesh/mocker/fixtures/grpc_regular_generate.json b/atom/mesh/mocker/fixtures/grpc_regular_generate.json index 191f8ea4d9..6b485eed2a 100644 --- a/atom/mesh/mocker/fixtures/grpc_regular_generate.json +++ b/atom/mesh/mocker/fixtures/grpc_regular_generate.json @@ -1,6 +1,6 @@ { "name": "grpc_regular_generate", - "model": "test-model", + "model": "hf-internal-testing/llama-tokenizer", "endpoint": "/generate", "route": { "worker_kind": "regular", @@ -8,7 +8,7 @@ "backend": "sglang" }, "request": { - "model": "test-model", + "model": "hf-internal-testing/llama-tokenizer", "text": "Hello world", "stream": false }, diff --git a/atom/mesh/mocker/fixtures/grpc_regular_generate_vllm.json b/atom/mesh/mocker/fixtures/grpc_regular_generate_vllm.json index c94e4e683b..816b84438c 100644 --- a/atom/mesh/mocker/fixtures/grpc_regular_generate_vllm.json +++ b/atom/mesh/mocker/fixtures/grpc_regular_generate_vllm.json @@ -1,6 +1,6 @@ { "name": "grpc_regular_generate_vllm", - "model": "test-model", + "model": "hf-internal-testing/llama-tokenizer", "endpoint": "/generate", "route": { "worker_kind": "regular", @@ -8,7 +8,7 @@ "backend": "vllm" }, "request": { - "model": "test-model", + "model": "hf-internal-testing/llama-tokenizer", "text": "Hello world", "stream": false }, diff --git a/atom/mesh/mocker/fixtures/http_pd_chat.json b/atom/mesh/mocker/fixtures/http_pd_chat.json index cccfb97cbe..e548f179cf 100644 --- a/atom/mesh/mocker/fixtures/http_pd_chat.json +++ b/atom/mesh/mocker/fixtures/http_pd_chat.json @@ -1,6 +1,6 @@ { "name": "http_pd_chat", - "model": "test-model", + "model": "hf-internal-testing/llama-tokenizer", "endpoint": "/v1/chat/completions", "route": { "worker_kind": "prefill_decode", @@ -20,7 +20,7 @@ "body": { "id": "chatcmpl-pd-test", "object": "chat.completion", - "model": "test-model", + "model": "hf-internal-testing/llama-tokenizer", "choices": [ { "index": 0, diff --git a/atom/mesh/mocker/fixtures/http_regular_chat.json b/atom/mesh/mocker/fixtures/http_regular_chat.json index 45bd74e7db..30d96dea56 100644 --- a/atom/mesh/mocker/fixtures/http_regular_chat.json +++ b/atom/mesh/mocker/fixtures/http_regular_chat.json @@ -1,6 +1,6 @@ { "name": "http_regular_chat", - "model": "test-model", + "model": "hf-internal-testing/llama-tokenizer", "endpoint": "/v1/chat/completions", "route": { "worker_kind": "regular", @@ -20,7 +20,7 @@ "body": { "id": "chatcmpl-test", "object": "chat.completion", - "model": "test-model", + "model": "hf-internal-testing/llama-tokenizer", "choices": [ { "index": 0, diff --git a/atom/mesh/mocker/fixtures/http_regular_chat_streaming.json b/atom/mesh/mocker/fixtures/http_regular_chat_streaming.json index 23800f2b46..f998d2c4c8 100644 --- a/atom/mesh/mocker/fixtures/http_regular_chat_streaming.json +++ b/atom/mesh/mocker/fixtures/http_regular_chat_streaming.json @@ -1,6 +1,6 @@ { "name": "http_regular_chat_streaming", - "model": "test-model", + "model": "hf-internal-testing/llama-tokenizer", "endpoint": "/v1/chat/completions", "route": { "worker_kind": "regular", diff --git a/atom/mesh/mocker/fixtures/http_regular_completion.json b/atom/mesh/mocker/fixtures/http_regular_completion.json index af462fe9ad..b47adcc1b7 100644 --- a/atom/mesh/mocker/fixtures/http_regular_completion.json +++ b/atom/mesh/mocker/fixtures/http_regular_completion.json @@ -1,6 +1,6 @@ { "name": "http_regular_completion", - "model": "test-model", + "model": "hf-internal-testing/llama-tokenizer", "endpoint": "/v1/completions", "route": { "worker_kind": "regular", @@ -16,7 +16,7 @@ "body": { "id": "cmpl-test", "object": "text_completion", - "model": "test-model", + "model": "hf-internal-testing/llama-tokenizer", "choices": [ { "index": 0, diff --git a/atom/mesh/mocker/fixtures/http_regular_generate.json b/atom/mesh/mocker/fixtures/http_regular_generate.json index 4238567ce0..108ebab95c 100644 --- a/atom/mesh/mocker/fixtures/http_regular_generate.json +++ b/atom/mesh/mocker/fixtures/http_regular_generate.json @@ -1,6 +1,6 @@ { "name": "http_regular_generate", - "model": "test-model", + "model": "hf-internal-testing/llama-tokenizer", "endpoint": "/generate", "route": { "worker_kind": "regular", From ba2f101e856579cfa837ca8466efb01cf1bd86f8 Mon Sep 17 00:00:00 2001 From: zhen wan Date: Thu, 11 Jun 2026 10:17:39 +0000 Subject: [PATCH 2/9] [ci][mesh] add Atomesh mocker benchmark dashboard - Add a custom dashboard for Atomesh mocker benchmark results. - Show throughput, latency, detailed performance data, commit links, and CI run links. - Align the benchmark matrix with 1P1D, 2P1D, and 3P1D topologies across consumer concurrency levels. --- .github/dashboard/atomesh_mocker_index.html | 245 ++++++++++++++++++ .github/scripts/atomesh_mocker_benchmark.sh | 50 +++- .../workflows/atomesh-mocker-benchmark.yaml | 20 +- 3 files changed, 303 insertions(+), 12 deletions(-) create mode 100644 .github/dashboard/atomesh_mocker_index.html mode change 100644 => 100755 .github/scripts/atomesh_mocker_benchmark.sh diff --git a/.github/dashboard/atomesh_mocker_index.html b/.github/dashboard/atomesh_mocker_index.html new file mode 100644 index 0000000000..e1e25d8086 --- /dev/null +++ b/.github/dashboard/atomesh_mocker_index.html @@ -0,0 +1,245 @@ + + + + + + ATOMesh Mocker Dashboard + + + +

ATOMesh Mocker Dashboard

Loading...
+ +
+
+
+

Detailed Performance Data

+
+ +
+

Atomesh Standalone Accuracy (GSM8K)

Read from ../benchmark-dashboard/data.js and filtered to models used by atomesh-accuracy-validation.

+
+
+ + + + + + + + + diff --git a/.github/scripts/atomesh_mocker_benchmark.sh b/.github/scripts/atomesh_mocker_benchmark.sh old mode 100644 new mode 100755 index 3a0bd4683b..6900f491cf --- a/.github/scripts/atomesh_mocker_benchmark.sh +++ b/.github/scripts/atomesh_mocker_benchmark.sh @@ -161,6 +161,7 @@ python3 - <<'PY' \ "${BENCHMARK_NAME}" "${PREFILL_WORKERS}" "${DECODE_WORKERS}" from datetime import UTC, datetime import json +import os import re import sys from pathlib import Path @@ -250,18 +251,45 @@ payload = { } Path(result_json).write_text(json.dumps(payload, indent=2), encoding="utf-8") -entries = [ - { - "name": f"Atomesh-Mocker::{benchmark_name} request throughput", - "unit": "req/s", - "value": round(request_throughput, 2), - "extra": ( - f"cell={benchmark_name} router={router_mode} policy={policy} " - f"workers={workers} prefill={prefill_workers} decode={decode_workers} " - f"producers={producer_threads} consumers={consumer_threads}" - ), - } +run_url = "" +server_url = os.environ.get("GITHUB_SERVER_URL", "https://github.com") +repository = os.environ.get("GITHUB_REPOSITORY") +run_id = os.environ.get("GITHUB_RUN_ID") +if repository and run_id: + run_url = f"{server_url}/{repository}/actions/runs/{run_id}" + +extra_parts = [ + f"cell={benchmark_name}", + f"router={router_mode}", + f"policy={policy}", + f"workers={workers}", + f"prefill={prefill_workers}", + f"decode={decode_workers}", + f"producers={producer_threads}", + f"consumers={consumer_threads}", + f"duration_seconds={duration_seconds}", + f"request_number={success}", ] +if run_url: + extra_parts.append(f"Run: {run_url}") +extra = " ".join(extra_parts) + +entries = [] +for metric_name, unit, value in [ + ("request throughput", "req/s", request_throughput), + ("avg latency", "ms", avg_ms), + ("p99 latency", "ms", p99_ms), + ("p999 latency", "ms", p999_ms), + ("failed requests", "count", failed), +]: + entries.append( + { + "name": f"Atomesh-Mocker::{benchmark_name} {metric_name}", + "unit": unit, + "value": round(float(value), 2), + "extra": extra, + } + ) Path(action_json).write_text(json.dumps(entries, indent=2), encoding="utf-8") summary = f"""### Atomesh Mocker Benchmark: {benchmark_name} diff --git a/.github/workflows/atomesh-mocker-benchmark.yaml b/.github/workflows/atomesh-mocker-benchmark.yaml index d5ea981b9c..91bbee1196 100644 --- a/.github/workflows/atomesh-mocker-benchmark.yaml +++ b/.github/workflows/atomesh-mocker-benchmark.yaml @@ -93,7 +93,7 @@ jobs: duration = "3m" consumer_threads = [1, 2, 4, 8, 16] - topologies = [(1, 1), (2, 1), (2, 2), (3, 1)] + topologies = [(1, 1), (2, 1), (3, 1)] cells = [] @@ -251,3 +251,21 @@ jobs: auto-push: false max-items-in-chart: 300 github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Deploy mocker benchmark dashboard to gh-pages + if: hashFiles('atomesh-mocker-dashboard-input.json') != '' + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + DASHBOARD_TEMPLATE=$(mktemp) + cp .github/dashboard/atomesh_mocker_index.html "$DASHBOARD_TEMPLATE" + CURRENT_SHA=$(git rev-parse HEAD) + git fetch origin gh-pages + git checkout gh-pages + mkdir -p atomesh-mocker-dashboard + cp "$DASHBOARD_TEMPLATE" atomesh-mocker-dashboard/index.html + git add atomesh-mocker-dashboard/ + git diff --cached --quiet || git commit -m "Update Atomesh mocker benchmark dashboard" + git push origin gh-pages + git checkout "$CURRENT_SHA" From 3f281ab525153e6b5e6dbaa23f128fb15848df08 Mon Sep 17 00:00:00 2001 From: zhen wan Date: Thu, 11 Jun 2026 10:31:14 +0000 Subject: [PATCH 3/9] [ci] Skip unrelated ATOM, vLLM, and SGLang CI for mesh-only PRs. --- .github/workflows/atom-sglang-test.yaml | 4 ++++ .github/workflows/atom-test.yaml | 4 ++++ .github/workflows/atom-vllm-test.yaml | 4 ++++ .../atomesh-accuracy-validation.yaml | 15 +++++++++----- .../workflows/atomesh-mocker-benchmark.yaml | 20 ++++++++++++++----- 5 files changed, 37 insertions(+), 10 deletions(-) diff --git a/.github/workflows/atom-sglang-test.yaml b/.github/workflows/atom-sglang-test.yaml index 14a4640b40..f13025e252 100644 --- a/.github/workflows/atom-sglang-test.yaml +++ b/.github/workflows/atom-sglang-test.yaml @@ -8,6 +8,10 @@ on: - '**/*.md' - 'docs/**' - 'atom/plugin/vllm/**' + - 'atom/mesh/**' + - '.github/workflows/atomesh-*.yaml' + - '.github/scripts/atomesh_*.sh' + - '.github/dashboard/atomesh_*.html' - '.github/workflows/atom-vllm-*.yaml' - '.github/benchmark/oot_models_accuracy.json' - 'LICENSE' diff --git a/.github/workflows/atom-test.yaml b/.github/workflows/atom-test.yaml index 708ab30a91..b7fc03dcf1 100644 --- a/.github/workflows/atom-test.yaml +++ b/.github/workflows/atom-test.yaml @@ -10,6 +10,10 @@ on: - '**/*.md' - 'docs/**' - 'atom/plugin/**' + - 'atom/mesh/**' + - '.github/workflows/atomesh-*.yaml' + - '.github/scripts/atomesh_*.sh' + - '.github/dashboard/atomesh_*.html' - '.github/workflows/atom-vllm-*.yaml' - '.github/workflows/atom-sglang-*.yaml' - '.github/benchmark/oot_models_accuracy.json' diff --git a/.github/workflows/atom-vllm-test.yaml b/.github/workflows/atom-vllm-test.yaml index 5d33a27215..1542e4d49d 100644 --- a/.github/workflows/atom-vllm-test.yaml +++ b/.github/workflows/atom-vllm-test.yaml @@ -8,6 +8,10 @@ on: - '**/*.md' - 'docs/**' - 'atom/plugin/sglang/**' + - 'atom/mesh/**' + - '.github/workflows/atomesh-*.yaml' + - '.github/scripts/atomesh_*.sh' + - '.github/dashboard/atomesh_*.html' - '.github/workflows/atom-sglang-*.yaml' - '.github/benchmark/sglang_models_accuracy.json' - 'LICENSE' diff --git a/.github/workflows/atomesh-accuracy-validation.yaml b/.github/workflows/atomesh-accuracy-validation.yaml index 2073a94648..d71fee01b7 100644 --- a/.github/workflows/atomesh-accuracy-validation.yaml +++ b/.github/workflows/atomesh-accuracy-validation.yaml @@ -3,14 +3,19 @@ name: Atomesh Accuracy Validation on: push: branches: [main] + paths: + - 'atom/mesh/**' + - '.github/workflows/atomesh-accuracy-validation.yaml' + - '.github/scripts/accuracy_to_dashboard.py' + - '.github/benchmark/models_accuracy.json' pull_request: branches: [main] # Triggers on PRs targeting `main` types: [opened, synchronize, reopened, ready_for_review] - paths-ignore: - - '**/*.md' - - 'docs/**' - - 'LICENSE' - - '.gitignore' + paths: + - 'atom/mesh/**' + - '.github/workflows/atomesh-accuracy-validation.yaml' + - '.github/scripts/accuracy_to_dashboard.py' + - '.github/benchmark/models_accuracy.json' schedule: # Nightly at 00:00 Beijing time (16:00 UTC) - cron: '0 16 * * *' diff --git a/.github/workflows/atomesh-mocker-benchmark.yaml b/.github/workflows/atomesh-mocker-benchmark.yaml index 91bbee1196..268ebf98f4 100644 --- a/.github/workflows/atomesh-mocker-benchmark.yaml +++ b/.github/workflows/atomesh-mocker-benchmark.yaml @@ -20,7 +20,7 @@ on: workflow_dispatch: inputs: suite: - description: 'Benchmark suite to run' + description: 'Benchmark suite: smoke runs 1P1D/c=1 sanity check; full runs 1P1D, 2P1D, 3P1D across c=1,2,4,8,16' required: false default: 'full' type: choice @@ -86,14 +86,24 @@ jobs: uses: actions/checkout@v6 - name: Build benchmark matrix + env: + SUITE: ${{ github.event_name == 'workflow_dispatch' && inputs.suite || 'full' }} run: | python3 <<'PY' import json import os - duration = "3m" - consumer_threads = [1, 2, 4, 8, 16] - topologies = [(1, 1), (2, 1), (3, 1)] + suite = os.environ.get("SUITE", "full") + if suite == "smoke": + duration = "30s" + consumer_threads = [1] + topologies = [(1, 1)] + elif suite == "full": + duration = "3m" + consumer_threads = [1, 2, 4, 8, 16] + topologies = [(1, 1), (2, 1), (3, 1)] + else: + raise SystemExit(f"Unsupported suite={suite}") cells = [] @@ -117,7 +127,7 @@ jobs: with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as env: env.write(f"CELLS_JSON={cells_json}\n") - print(f"Generated {len(cells)} benchmark cells") + print(f"Generated {len(cells)} benchmark cells for suite={suite}") for cell in cells: print( f" {cell['id']}: scenario={cell['scenario']} duration={cell['duration']} " From cb741cd9b9277e2d34ec279b12d44b0af578c1c7 Mon Sep 17 00:00:00 2001 From: zhen wan Date: Thu, 11 Jun 2026 14:24:31 +0000 Subject: [PATCH 4/9] [ci][mesh] Enable mocker dashboard publishing workflow to run on zwan/feat-mesh-ci pushes. --- .github/workflows/atomesh-mocker-benchmark.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/atomesh-mocker-benchmark.yaml b/.github/workflows/atomesh-mocker-benchmark.yaml index 268ebf98f4..9bdedee232 100644 --- a/.github/workflows/atomesh-mocker-benchmark.yaml +++ b/.github/workflows/atomesh-mocker-benchmark.yaml @@ -2,11 +2,14 @@ name: Atomesh Mocker Benchmark on: push: - branches: [main] + branches: + - main + - zwan/feat-mesh-ci paths: - 'atom/mesh/**' - '.github/scripts/atomesh_mocker_benchmark.sh' - '.github/workflows/atomesh-mocker-benchmark.yaml' + - '.github/dashboard/atomesh_mocker_index.html' pull_request: branches: [main] types: [opened, synchronize, reopened, ready_for_review] @@ -14,6 +17,7 @@ on: - 'atom/mesh/**' - '.github/scripts/atomesh_mocker_benchmark.sh' - '.github/workflows/atomesh-mocker-benchmark.yaml' + - '.github/dashboard/atomesh_mocker_index.html' schedule: # Nightly at 02:00 Beijing time (18:00 UTC) - cron: '0 18 * * *' From 1a0ec50ca0fba0f4f27c6d37492c8d88f34fff5c Mon Sep 17 00:00:00 2001 From: zhen wan Date: Fri, 12 Jun 2026 05:48:50 +0000 Subject: [PATCH 5/9] Polish Atomesh mocker dashboard legends --- .github/dashboard/atomesh_mocker_index.html | 38 +++++++++++++++------ 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/.github/dashboard/atomesh_mocker_index.html b/.github/dashboard/atomesh_mocker_index.html index e1e25d8086..553f03e796 100644 --- a/.github/dashboard/atomesh_mocker_index.html +++ b/.github/dashboard/atomesh_mocker_index.html @@ -5,7 +5,7 @@ ATOMesh Mocker Dashboard @@ -38,7 +39,7 @@
-

Atomesh Standalone Accuracy (GSM8K)

Read from ../benchmark-dashboard/data.js and filtered to models used by atomesh-accuracy-validation.

+

Atomesh Standalone Accuracy (GSM8K)

@@ -54,6 +55,7 @@ const labels={'request throughput':'Request Throughput','avg latency':'Avg Latency','p99 latency':'P99 Latency','p999 latency':'P999 Latency','failed requests':'Failed Requests'}; const units={'request throughput':'req/s','avg latency':'ms','p99 latency':'ms','p999 latency':'ms','failed requests':'count'}; const latencyMetrics=['avg latency','p99 latency','p999 latency']; + const pointStyles=['circle','triangle','rectRot','star','crossRot','rectRounded','cross']; const enabledTopologies=new Set(['1P1D','2P1D','3P1D']); const atomeshModels=new Set(['Meta-Llama-3-8B-Instruct','DeepSeek-R1-0528','DeepSeek-V4-Pro MTP','gpt-oss-120b']); const valueLabelPlugin={ @@ -157,17 +159,31 @@ document.getElementById('latest').innerHTML=`${body||''}
BackendScenarioConfigurationRequest NumberConcReq/sAvg Latency (ms)P99 (ms)P999 (ms)FailedCommitRun
No data
`; } function fmt(value,digits=2){return Number.isFinite(value)?value.toFixed(digits):'--'} + function legendSvg(style,color){ + const c=esc(color); + const shapes={circle:``,triangle:``,rectRot:``,star:``,crossRot:``,rectRounded:``,cross:``}; + return ``; + } + function renderHtmlLegend(chart,legendId,vertical=false){ + const el=document.getElementById(legendId); + if(!el)return; + el.className=`chart-legend${vertical?' vertical':''}`; + el.innerHTML=chart.data.datasets.map((ds,i)=>``).join(''); + el.querySelectorAll('button').forEach(btn=>btn.addEventListener('click',()=>{const i=Number(btn.dataset.dataset);chart.setDatasetVisibility(i,!chart.isDatasetVisible(i));chart.update();renderHtmlLegend(chart,legendId,vertical)})); + } function renderPerf(){ performanceCharts.forEach(chart=>chart.destroy()); performanceCharts=[]; const grid=document.getElementById('performance-grid'); - grid.innerHTML=['request throughput','latency'].map((metric,i)=>`
Atomesh Mocker · ${metric==='latency'?'Latency':labels[metric]}
`).join(''); + grid.innerHTML=['request throughput','latency'].map((metric,i)=>`
Atomesh Mocker · ${metric==='latency'?'Latency':labels[metric]}
${metric==='latency'?'
':''}
`).join(''); const throughputRows=latest('request throughput'); performanceCharts.push(new Chart(document.getElementById('performance-chart-0'),{type:'bar',data:{labels:throughputRows.map(barLabel),datasets:[{label:`${labels['request throughput']} (${units['request throughput']})`,data:throughputRows.map(p=>p.value),backgroundColor:'rgba(96,165,250,.55)',borderColor:'#2b6cb0',borderWidth:2,borderRadius:6}]},options:{responsive:true,maintainAspectRatio:false,layout:{padding:{top:24}},plugins:{legend:{display:false},tooltip:{callbacks:{label:ctx=>`${labels['request throughput']}: ${ctx.parsed.y} ${units['request throughput']}`}}},scales:{x:{title:{display:true,text:'Configuration / Concurrency'},ticks:{minRotation:45,maxRotation:45,color:'#64748b'}},y:{beginAtZero:true,title:{display:true,text:units['request throughput']},ticks:{color:'#64748b'},grid:{color:'rgba(148,163,184,.28)'}}}}})); const baseRows=latest('avg latency'); const cs=colors(latencyMetrics.length); - const latencyDatasets=latencyMetrics.map((metric,i)=>{const byCell=new Map(latest(metric).map(p=>[p.cell,p.value]));return{label:labels[metric],data:baseRows.map(p=>byCell.get(p.cell)??null),borderColor:cs[i],backgroundColor:cs[i],tension:.25,pointRadius:4,pointHoverRadius:6}}); - performanceCharts.push(new Chart(document.getElementById('performance-chart-1'),{type:'line',data:{labels:baseRows.map(barLabel),datasets:latencyDatasets},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'bottom'},tooltip:{callbacks:{label:ctx=>`${ctx.dataset.label}: ${ctx.parsed.y} ms`}}},scales:{x:{title:{display:true,text:'Configuration / Concurrency'},ticks:{minRotation:45,maxRotation:45,color:'#64748b'}},y:{beginAtZero:true,title:{display:true,text:'ms'},ticks:{color:'#64748b'},grid:{color:'rgba(148,163,184,.28)'}}}}})); + const latencyDatasets=latencyMetrics.map((metric,i)=>{const byCell=new Map(latest(metric).map(p=>[p.cell,p.value]));return{label:labels[metric],data:baseRows.map(p=>byCell.get(p.cell)??null),borderColor:cs[i],backgroundColor:cs[i],tension:.25,pointStyle:pointStyles[i%pointStyles.length],pointRadius:5,pointHoverRadius:7}}); + const latencyChart=new Chart(document.getElementById('performance-chart-1'),{type:'line',data:{labels:baseRows.map(barLabel),datasets:latencyDatasets},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false},tooltip:{callbacks:{label:ctx=>`${ctx.dataset.label}: ${ctx.parsed.y} ms`}}},scales:{x:{title:{display:true,text:'Configuration / Concurrency'},ticks:{minRotation:45,maxRotation:45,color:'#64748b'}},y:{beginAtZero:true,title:{display:true,text:'ms'},ticks:{color:'#64748b'},grid:{color:'rgba(148,163,184,.28)'}}}}}); + performanceCharts.push(latencyChart); + renderHtmlLegend(latencyChart,'performance-legend-1'); renderDetailTable(); } function trendConfigurations(){ @@ -185,7 +201,7 @@ document.getElementById('trend-selector').innerHTML=configs.map(cfg=>``).join(''); const trendMetrics=['request throughput','avg latency','p99 latency','p999 latency']; const grid=document.getElementById('trend-grid'); - grid.innerHTML=trendMetrics.map((metric,i)=>`
Atomesh Mocker · ${trendConfig} · ${labels[metric]}
Hover to inspect values, use filters to narrow down
`).join(''); + grid.innerHTML=trendMetrics.map((metric,i)=>`
Atomesh Mocker · ${trendConfig} · ${labels[metric]}
Hover to inspect values, use filters to narrow down
`).join(''); trendMetrics.forEach((metric,i)=>{ const rows=allPoints.filter(p=>p.metric===metric&&p.topology===trendConfig); const orderedRuns=[...new Map(rows.sort((a,b)=>a.date-b.date).map(p=>[`${dateYMD(p.date)}|${(p.commit?.id||'').slice(0,7)}`,p])).values()]; @@ -195,9 +211,11 @@ const cs=colors(byConc.size); const datasets=[...byConc.entries()].sort((a,b)=>a[0]-b[0]).map(([conc,ps],idx)=>{ const byLabel=new Map(ps.map(p=>[trendLabel(p),p.value])); - return {label:`c${conc}`,data:axisLabels.map(label=>byLabel.get(label)??null),borderColor:cs[idx],backgroundColor:cs[idx],tension:.2,pointRadius:4,pointHoverRadius:6}; + return {label:`c${conc}`,data:axisLabels.map(label=>byLabel.get(label)??null),borderColor:cs[idx],backgroundColor:cs[idx],tension:.2,pointStyle:pointStyles[idx%pointStyles.length],pointRadius:5,pointHoverRadius:7}; }); - trendCharts.push(new Chart(document.getElementById(`trend-chart-${i}`),{type:'line',data:{labels:axisLabels,datasets},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'bottom'}},scales:{x:{title:{display:true,text:'Commit'},ticks:{minRotation:45,maxRotation:45,color:'#64748b'}},y:{beginAtZero:true,title:{display:true,text:units[metric]},grid:{color:'rgba(148,163,184,.28)'}}}}})); + const chart=new Chart(document.getElementById(`trend-chart-${i}`),{type:'line',data:{labels:axisLabels,datasets},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false},tooltip:{callbacks:{label:ctx=>`${ctx.dataset.label}: ${ctx.parsed.y} ${units[metric]}`}}},scales:{x:{title:{display:true,text:'Commit'},ticks:{minRotation:45,maxRotation:45,color:'#64748b'}},y:{beginAtZero:true,title:{display:true,text:units[metric]},grid:{color:'rgba(148,163,184,.28)'}}}}}); + trendCharts.push(chart); + renderHtmlLegend(chart,`trend-legend-${i}`,true); }); } function accuracyRows(){ From 5f4348e68fa4f10f588c84f868063ba423bce063 Mon Sep 17 00:00:00 2001 From: zhen wan Date: Fri, 12 Jun 2026 07:11:54 +0000 Subject: [PATCH 6/9] [ci][mesh] fix atomesh standalone accuracy data source --- .github/dashboard/atomesh_mocker_index.html | 14 +++++++------- .github/workflows/atomesh-accuracy-validation.yaml | 3 ++- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/dashboard/atomesh_mocker_index.html b/.github/dashboard/atomesh_mocker_index.html index 553f03e796..e09cfe4789 100644 --- a/.github/dashboard/atomesh_mocker_index.html +++ b/.github/dashboard/atomesh_mocker_index.html @@ -46,12 +46,12 @@ - - + + diff --git a/.github/workflows/atomesh-mocker-benchmark.yaml b/.github/workflows/atomesh-mocker-benchmark.yaml index 16bfa11a16..1573ab191d 100644 --- a/.github/workflows/atomesh-mocker-benchmark.yaml +++ b/.github/workflows/atomesh-mocker-benchmark.yaml @@ -271,12 +271,15 @@ jobs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" DASHBOARD_TEMPLATE=$(mktemp) + LOGO_ASSET=$(mktemp) cp .github/dashboard/atomesh_mocker_index.html "$DASHBOARD_TEMPLATE" + cp docs/assets/atomesh_logo.png "$LOGO_ASSET" CURRENT_SHA=$(git rev-parse HEAD) git fetch origin gh-pages git checkout gh-pages mkdir -p atomesh-mocker-dashboard cp "$DASHBOARD_TEMPLATE" atomesh-mocker-dashboard/index.html + cp "$LOGO_ASSET" atomesh-mocker-dashboard/atomesh_logo.png git add atomesh-mocker-dashboard/ git diff --cached --quiet || git commit -m "Update Atomesh mocker benchmark dashboard" git push origin gh-pages diff --git a/docs/assets/atomesh_logo.png b/docs/assets/atomesh_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..14ae5d89fb296ee54ae8e487152ea07b4bbd6347 GIT binary patch literal 50028 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
+AjT1Q35ygewnD@Ch@0z}#Y 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 P00000NkvXXu0mjf9xYxj literal 0 HcmV?d00001 From b8cd27795d28a7d27ba81a819b5fdcade011bea3 Mon Sep 17 00:00:00 2001 From: zhen wan Date: Mon, 15 Jun 2026 03:26:10 +0000 Subject: [PATCH 9/9] [ci][mesh] Polish Atomesh dashboard and accuracy data flow --- .github/workflows/atomesh-accuracy-validation.yaml | 13 ++++++++----- .github/workflows/atomesh-mocker-benchmark.yaml | 2 ++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/atomesh-accuracy-validation.yaml b/.github/workflows/atomesh-accuracy-validation.yaml index ef0ae25419..27ad7205dd 100644 --- a/.github/workflows/atomesh-accuracy-validation.yaml +++ b/.github/workflows/atomesh-accuracy-validation.yaml @@ -748,12 +748,15 @@ jobs: # Remove the pre-built image to free disk space on the runner docker rmi "rocm/atom-dev:pre-build-${{ env.GITHUB_COMMIT_SHA }}" || true - # ---------- Push accuracy data to benchmark dashboard ---------- - accuracy-dashboard: - name: Update accuracy dashboard + # ---------- Publish Atomesh accuracy data for the mocker benchmark dashboard ---------- + publish-atomesh-accuracy-data: + name: Publish Atomesh accuracy data needs: [atomesh-test] if: always() && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'schedule') runs-on: ubuntu-latest + permissions: + actions: read + contents: write steps: - uses: actions/checkout@v6 @@ -772,7 +775,7 @@ jobs: echo "=== Downloaded accuracy artifacts ===" find /tmp/accuracy-results -type f -name '*.json' | head -20 || echo "No JSON files found" - - name: Transform accuracy results for dashboard + - name: Transform accuracy results for mocker dashboard data run: | python3 .github/scripts/accuracy_to_dashboard.py \ /tmp/accuracy-results \ @@ -783,7 +786,7 @@ jobs: echo "=== Generated entries ===" cat accuracy-benchmark-input.json - - name: Store accuracy result to dashboard + - name: Store Atomesh accuracy data if: hashFiles('accuracy-benchmark-input.json') != '' uses: benchmark-action/github-action-benchmark@v1 with: diff --git a/.github/workflows/atomesh-mocker-benchmark.yaml b/.github/workflows/atomesh-mocker-benchmark.yaml index 1573ab191d..826599a8b3 100644 --- a/.github/workflows/atomesh-mocker-benchmark.yaml +++ b/.github/workflows/atomesh-mocker-benchmark.yaml @@ -8,6 +8,7 @@ on: - '.github/scripts/atomesh_mocker_benchmark.sh' - '.github/workflows/atomesh-mocker-benchmark.yaml' - '.github/dashboard/atomesh_mocker_index.html' + - 'docs/assets/atomesh_logo.png' pull_request: branches: [main] types: [opened, synchronize, reopened, ready_for_review] @@ -16,6 +17,7 @@ on: - '.github/scripts/atomesh_mocker_benchmark.sh' - '.github/workflows/atomesh-mocker-benchmark.yaml' - '.github/dashboard/atomesh_mocker_index.html' + - 'docs/assets/atomesh_logo.png' schedule: # Nightly at 02:00 Beijing time (18:00 UTC) - cron: '0 18 * * *'