Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/AGENT_OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ Single-node fixed-sequence results may include `power_valid`, `avg_power_w`, `av

Multinode disaggregated results add `prefill_gpu_energy_j`, `decode_gpu_energy_j`, `prefill_avg_power_w`, `decode_avg_power_w`, `prefill_joules_per_input_token`, and `decode_joules_per_output_token`. Role energy covers the full formal benchmark window, not kernel-level phases, and the role watts are that energy divided by the same window and by the role's GPU count.

Every power result — valid or invalid, single-node or multinode — carries `power_metric_schema_version`. Version 2 defines each unprefixed `joules_per_*` field as whole-deployment GPU-board energy over the named denominator; role-scoped energy uses the explicit `prefill_*` / `decode_*` keys. Rows without the field predate the whole-deployment switch and their unprefixed joules are not comparable across topologies.

For srt-slurm recipes, `telemetry: {provider: dcgm-power}` enables official energy collection. `runners/launch_gb200-nv.sh` and `runners/launch_gb300-nv.sh` are the source of truth for `POWER_SRT_SLURM_PIN`. CI derives `POWER_PRODUCER_SHA` from the launcher stamp. `utils/test_gb200_power_official_contract.py` and `utils/test_gb300_power_official_contract.py` enforce the recipe/launcher contract. Only `PRECISION=fp8` dcgm-power lanes are validated.

Power audit artifacts are named `power_audit_<result>` and contain `power_validation_<result>.json` for single-node runs or `power_validation_<result>_*.json` for multinode runs. They are uploaded even when validation fails.
Expand Down
8 changes: 7 additions & 1 deletion .github/workflows/test-process-result.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,18 @@
- 'benchmarks/multi_node/srt-slurm-recipes/sglang/qwen3.5/gb200-fp8/8k1k/1p1d-tp4-tp4.yaml'
- 'benchmarks/multi_node/srt-slurm-recipes/sglang/qwen3.5/gb300-fp8/8k1k/1p1d-tp4-tp4.yaml'
- 'runners/launch_gb200-nv.sh'
- 'runners/launch_gb300-nv.sh'
- 'utils/aggregate_power.py'
- 'utils/aggregate_power_multinode.py'
- 'utils/agentic/aggregation/power_adapter.py'
- 'utils/agentic/aggregation/test_power_adapter.py'
- 'utils/agentic/aggregation/test_power_lifecycle.py'
- 'utils/agentic/aggregation/process_agentic_result.py'
- 'utils/agentic/aggregation/test_process_agentic_result.py'
- 'utils/pytest.ini'
- 'utils/bench_serving/benchmark_serving.py'
- 'utils/process_result.py'
- 'utils/test_aggregate_power.py'

Check warning on line 25 in .github/workflows/test-process-result.yml

View check run for this annotation

Claude / Claude Code Review

[quality] CI path filter misses transitive deps of the new agentic power tests

The pull_request path filter for this new CI job lists power_adapter.py, process_agentic_result.py, and their test files, but omits the modules those files import transitively: utils/agentic/aggregation/request_metrics.py (extract_per_record_ints, load_aggregate, load_records), plus aggregation_common.py, server_metrics.py, and server_log_metrics.py. A change to any of these transitive dependencies (e.g. tweaking extract_per_record_ints filtering in request_metrics.py) would silently skip the po
Comment on lines 14 to 25

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The pull_request path filter for this new CI job lists power_adapter.py, process_agentic_result.py, and their test files, but omits the modules those files import transitively: utils/agentic/aggregation/request_metrics.py (extract_per_record_ints, load_aggregate, load_records), plus aggregation_common.py, server_metrics.py, and server_log_metrics.py. A change to any of these transitive dependencies (e.g. tweaking extract_per_record_ints filtering in request_metrics.py) would silently skip the power/agentic test suite this PR just wired in. Consider adding those paths, or switching to a glob like 'utils/agentic/aggregation/**'.

Extended reasoning...

What the bug is: .github/workflows/test-process-result.yml gained six new path-filter entries in this PR to trigger the newly-wired agentic/power test suite on pull_request. The filter lists power_adapter.py, test_power_adapter.py, test_power_lifecycle.py, process_agentic_result.py, test_process_agentic_result.py, and pytest.ini — but it does not list the modules those production files import.

The code path: utils/agentic/aggregation/power_adapter.py (added by this PR) contains from .request_metrics import extract_per_record_ints, load_aggregate, load_records. utils/agentic/aggregation/process_agentic_result.py (an existing file whose tests are newly wired in here) imports from .aggregation_common, .request_metrics, .server_log_metrics, and .server_metrics; request_metrics.py itself pulls in trace_metadata.py transitively. None of request_metrics.py, aggregation_common.py, server_metrics.py, or server_log_metrics.py appear anywhere in the path filter (lines 14-25), and a repo-wide grep across .github/workflows confirms no other workflow covers them either.

Why nothing else catches this: the path filter is an allowlist gate on pull_request triggers — GitHub Actions simply won't start the job unless a changed file matches one of the listed globs. There's no fallback trigger (like a periodic or push-to-main run) configured in this workflow that would catch a miss here on a PR that only touches, say, request_metrics.py.

Impact: a PR that edits request_metrics.py — for example changing the filtering logic in extract_per_record_ints (used directly by the new power_adapter.build_power_window) or adjusting load_records — would not trigger test-process-result.yml at all. The 195+ tests covering the agentic/power suite this PR just wired in would silently not run on that PR, even though the change could break power_adapter.py's window derivation or process_agentic_result.py's aggregation.

Step-by-step proof:

  1. Suppose a follow-up PR changes only utils/agentic/aggregation/request_metrics.py, e.g. tightening the token-count validation in extract_per_record_ints.
  2. GitHub evaluates the pull_request.paths filter in test-process-result.yml against the changed file list: ['utils/agentic/aggregation/request_metrics.py'].
  3. None of the filter's globs (lines 14-25) match that path — power_adapter.py, test_power_adapter.py, etc. are file-specific entries, not directory globs.
  4. The workflow does not run for that PR; CI shows no failure because the job was never scheduled.
  5. If the extract_per_record_ints change silently breaks test_power_adapter.py's test_build_power_window_rejects_ambiguous_inputs case, that regression ships to main undetected until some later, unrelated PR happens to touch one of the explicitly-listed files and the job finally runs against stale-broken code.

Fix: add utils/agentic/aggregation/request_metrics.py, aggregation_common.py, server_metrics.py, and server_log_metrics.py to the path list, or replace the file-by-file enumeration with a directory glob such as utils/agentic/aggregation/** so future additions to that package don't require another manual path-filter update.

- 'utils/test_aggregate_power_multinode.py'
- 'utils/test_gb200_power_official_contract.py'
- 'utils/test_gb300_power_official_contract.py'
Expand Down Expand Up @@ -48,4 +54,4 @@
- name: Run pytest
run: |
cd utils
python -m pytest test_aggregate_power.py test_aggregate_power_multinode.py test_gb200_power_official_contract.py test_gb300_power_official_contract.py test_process_result.py -v
python -m pytest test_aggregate_power.py test_aggregate_power_multinode.py agentic/aggregation/test_power_adapter.py agentic/aggregation/test_power_lifecycle.py agentic/aggregation/test_process_agentic_result.py test_gb200_power_official_contract.py test_gb300_power_official_contract.py test_process_result.py -v
68 changes: 66 additions & 2 deletions benchmarks/benchmark_lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2170,10 +2170,43 @@ validate_required_agentic_server_metrics() {
echo "Validated required AIPerf server metrics prefix '$required_prefix'"
}

run_agentic_replay_and_write_outputs() {
run_agentic_replay_and_write_outputs() (
local result_dir="$1"
local replay_rc
local validation_rc
local power_rc=0
local agentx_power_enabled=0
local agentx_monitor_stopped=1

case "${ENABLE_AGENTX_POWER:-1}" in
1|true|TRUE|yes|YES)
if [ "${IS_MULTINODE:-false}" != "true" ]; then
agentx_power_enabled=1
fi
Comment on lines +2181 to +2185

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Append a performance changelog entry for AgentX monitoring

Enabling 1 Hz GPU monitoring by default changes every single-node AgentX benchmark's execution environment and can affect the reported performance, but this commit leaves perf-changelog.yaml byte-for-byte unchanged. Append a new entry at the physical tail so consumers can distinguish results collected with this additional monitoring load.

AGENTS.md reference: AGENTS.md:L21-L21

Useful? React with 👍 / 👎.

;;
esac

_stop_agentx_power_monitor() {
if [ "$agentx_monitor_stopped" = "0" ]; then
agentx_monitor_stopped=1
stop_gpu_monitor
fi
}

if [ "$agentx_power_enabled" = "1" ]; then
# AIPerf currently exports naive local datetimes while SMI emits the
# same host wall clock. Capture the launch-time offset so the adapter
# can attach it explicitly before normalizing the profiling window.
date +%z > "$result_dir/agentic_power_timezone_offset.txt"
start_gpu_monitor --output "$result_dir/gpu_metrics.csv"
agentx_monitor_stopped=0
# This function runs in a subshell, so these handlers cannot replace
# launcher-owned traps. The stopped flag keeps explicit and signal/EXIT
# cleanup idempotent.
trap '_stop_agentx_power_monitor' EXIT
trap '_stop_agentx_power_monitor; exit 130' INT
Comment on lines +2206 to +2207

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prevent the EXIT trap from repeating SIGTERM cleanup

When the replay subprocess group receives SIGTERM, Bash can run this TERM handler and subsequently the installed EXIT handler without retaining the guard update between trap contexts, so stop_gpu_monitor is invoked twice. This makes the newly enabled CI test flaky: the targeted signal test failed in 8 of 12 local repetitions with two monitor-stop events. Clear the EXIT trap before performing signal cleanup, or otherwise make cleanup reliably single-shot.

Useful? React with 👍 / 👎.

trap '_stop_agentx_power_monitor; exit 143' TERM
fi

echo "$REPLAY_CMD" > "$result_dir/benchmark_command.txt"

Expand All @@ -2184,8 +2217,34 @@ run_agentic_replay_and_write_outputs() {
set +x
set -e

if [ "$agentx_power_enabled" = "1" ]; then
_stop_agentx_power_monitor
trap - EXIT INT TERM
fi

write_agentic_result_json "$result_dir"

if [ "$agentx_power_enabled" = "1" ]; then
local expected_num_gpus
local -a power_args
expected_num_gpus=$((${TP:-1} * ${PP_SIZE:-1} * ${PCP_SIZE:-1}))
power_args=(
--result-dir "$result_dir"
--agg-result "${AGENTIC_OUTPUT_DIR:-$INFMAX_CONTAINER_WORKSPACE}/$RESULT_FILENAME.json"
--expected-num-gpus "$expected_num_gpus"
)
case "${REQUIRE_POWER:-0}" in
1|true|TRUE|yes|YES) power_args+=(--require-power) ;;
esac
set +e
(
cd "$INFMAX_CONTAINER_WORKSPACE"
"$AIPERF_PYTHON" -m utils.agentic.aggregation.power_adapter "${power_args[@]}"
)
power_rc=$?
set -e
fi

"$AIPERF_PYTHON" "$AGENTIC_DIR/scripts/analyze_benchmark_distributions.py" \
"$result_dir/aiperf_artifacts" -o "$result_dir" 2>&1 || true

Expand All @@ -2209,5 +2268,10 @@ run_agentic_replay_and_write_outputs() {
return "$validation_rc"
fi

if [ "$power_rc" -ne 0 ]; then
echo "ERROR: AgentX power validation failed after writing audit artifacts" >&2
return "$power_rc"
fi

validate_required_agentic_server_metrics "$result_dir"
}
)
245 changes: 245 additions & 0 deletions utils/agentic/aggregation/power_adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
"""Adapt AIPerf profiling artifacts to the strict power-window contract."""

from __future__ import annotations

import argparse
import json
import math
import os
import re
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any

from utils.aggregate_power import (
_empty_integration,
_patch_power_result,
_validation_payload,
_write_json_atomic,
)
from utils.aggregate_power import run as run_power

from .process_agentic_result import _resolve_artifact_dir
from .request_metrics import extract_per_record_ints, load_aggregate, load_records

_UTC_OFFSET_RE = re.compile(r"^([+-])(\d{2}):?(\d{2})$")


def _captured_timezone(result_dir: Path) -> tuple[timezone | None, str | None]:
"""Load the launch-time UTC offset captured beside AgentX telemetry."""
offset_path = result_dir / "agentic_power_timezone_offset.txt"
if not offset_path.is_file():
return None, "profile_timezone_offset_missing"
try:
raw_offset = offset_path.read_text(encoding="utf-8").strip()
except OSError:
return None, "profile_timezone_offset_invalid"
match = _UTC_OFFSET_RE.fullmatch(raw_offset)
if match is None:
return None, "profile_timezone_offset_invalid"
hours, minutes = int(match.group(2)), int(match.group(3))
if hours > 23 or minutes > 59:
return None, "profile_timezone_offset_invalid"
direction = 1 if match.group(1) == "+" else -1
return timezone(direction * timedelta(hours=hours, minutes=minutes)), None


def _parse_profile_timestamp(value: Any, *, fallback_tz: timezone | None) -> float | None:
"""Parse a timezone-aware ISO timestamp or Unix epoch seconds."""
if isinstance(value, bool):
return None
if isinstance(value, int | float):
timestamp = float(value)
return timestamp if math.isfinite(timestamp) else None
if not isinstance(value, str) or not value.strip():
return None
try:
parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
if fallback_tz is None:
return None
parsed = parsed.replace(tzinfo=fallback_tz)
return parsed.astimezone(timezone.utc).timestamp()


def build_power_window(result_dir: Path) -> tuple[dict[str, int | float] | None, list[str]]:
"""Build a strict benchmark window from successful profiling requests."""
artifact_dir = _resolve_artifact_dir(result_dir)
aggregate_path = artifact_dir / "profile_export_aiperf.json"
records_path = artifact_dir / "profile_export.jsonl"
if not aggregate_path.is_file() or not records_path.is_file():
return None, ["profile_artifacts_missing"]

try:
aggregate = load_aggregate(aggregate_path)
records = load_records(records_path)
except (OSError, json.JSONDecodeError, TypeError, ValueError):
return None, ["profile_artifacts_invalid"]

raw_start = aggregate.get("start_time")
raw_end = aggregate.get("end_time")
if raw_start is None or raw_end is None:
return None, ["profile_window_missing"]
parsed_datetimes: list[datetime] = []
for value in (raw_start, raw_end):
if isinstance(value, str):
try:
parsed_datetimes.append(
datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
)
except ValueError:
pass
needs_captured_timezone = any(value.tzinfo is None for value in parsed_datetimes)
fallback_tz = None
if needs_captured_timezone:
fallback_tz, timezone_reason = _captured_timezone(result_dir)
if timezone_reason is not None:
return None, [timezone_reason]

start = _parse_profile_timestamp(raw_start, fallback_tz=fallback_tz)
end = _parse_profile_timestamp(raw_end, fallback_tz=fallback_tz)
if start is None or end is None or end <= start:
return None, ["profile_window_invalid"]

completed = len(records)
if completed <= 0:
return None, ["successful_request_count_invalid"]

input_tokens = extract_per_record_ints(records, "input_sequence_length")
output_tokens = extract_per_record_ints(records, "output_sequence_length")
if (
len(input_tokens) != completed
or len(output_tokens) != completed
or any(value < 0 for value in input_tokens + output_tokens)
or sum(input_tokens) <= 0
or sum(output_tokens) <= 0
):
return None, ["incomplete_token_accounting"]

return (
{
"benchmark_start_time_unix": start,
"benchmark_end_time_unix": end,
"duration": end - start,
"completed": completed,
"total_input_tokens": sum(input_tokens),
"total_output_tokens": sum(output_tokens),
},
[],
)


def _record_adapter_failure(
*,
result_dir: Path,
agg_result: Path,
expected_num_gpus: int | None,
reasons: list[str],
) -> None:
"""Write the same invalid aggregate and audit artifacts as aggregate_power."""
csv_path = result_dir / "gpu_metrics.csv"
window_path = result_dir / "agentic_power_window.json"
validation_path = result_dir / "power_validation.json"
integration = _empty_integration(
expected_num_gpus=expected_num_gpus,
reasons=reasons,
)
_patch_power_result(agg_result, power_valid=False, metrics={})
payload = _validation_payload(
csv_path=csv_path,
bench_result=window_path,
benchmark=None,
integration=integration,
power_valid=False,
reasons=reasons,
metrics={},
accumulator_check=None,
)
payload["window_source"] = "aiperf_profile_lifecycle"
_write_json_atomic(validation_path, payload)


def run_agentic_power(
*,
result_dir: Path,
agg_result: Path,
expected_num_gpus: int | None,
require_power: bool = False,
) -> int:
"""Validate AgentX power telemetry, failing only in strict mode."""
window, reasons = build_power_window(result_dir)
if window is None:
try:
_record_adapter_failure(
result_dir=result_dir,
agg_result=agg_result,
expected_num_gpus=expected_num_gpus,
reasons=reasons,
)
except (OSError, json.JSONDecodeError, ValueError) as exc:
print(
f"[agentx_power] Failed to record adapter failure: {exc}",
file=sys.stderr,
)
print(
f"[agentx_power] Power-window adaptation failed: {', '.join(reasons)}",
file=sys.stderr,
)
return 1 if require_power else 0

Check warning on line 191 in utils/agentic/aggregation/power_adapter.py

View check run for this annotation

Claude / Claude Code Review

[quality] Duplicated adapter-failure handling in run_agentic_power

run_agentic_power() has two near-identical ~12-line blocks (lines 175-191 and 198-214) that each call _record_adapter_failure() inside the same try/except, print the same failure/adaptation messages, and return the same `1 if require_power else 0`. Extracting a single helper (e.g. `_fail_adapter(result_dir, agg_result, expected_num_gpus, reasons, require_power) -> int`) called from both branches would remove the duplication with no behavior change.
Comment on lines +165 to +191

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 run_agentic_power() has two near-identical ~12-line blocks (lines 175-191 and 198-214) that each call _record_adapter_failure() inside the same try/except, print the same failure/adaptation messages, and return the same 1 if require_power else 0. Extracting a single helper (e.g. _fail_adapter(result_dir, agg_result, expected_num_gpus, reasons, require_power) -> int) called from both branches would remove the duplication with no behavior change.

Extended reasoning...

What the duplication looks like

In utils/agentic/aggregation/power_adapter.py, run_agentic_power() contains two blocks with essentially identical control flow:

  1. Lines 175-191, triggered when build_power_window() returns window is None.
  2. Lines 198-214, triggered when writing agentic_power_window.json raises OSError.

Both blocks do exactly the same four things in the same order:

  • Call _record_adapter_failure(result_dir=..., agg_result=..., expected_num_gpus=..., reasons=...) inside a try/except (OSError, json.JSONDecodeError, ValueError).
  • On exception, print [agentx_power] Failed to record adapter failure: {exc} to stderr.
  • Unconditionally print [agentx_power] Power-window adaptation failed: {', '.join(reasons)} to stderr.
  • Return 1 if require_power else 0.

The only thing that differs between the two call sites is the source of the reasons list: in the first block it comes from build_power_window()'s return value, and in the second it's hardcoded to ['power_window_unwritable'].

Why this matters

This isn't a correctness bug — both code paths behave identically today, and the tests (test_run_agentic_power_records_window_write_failure_before_returning and test_run_agentic_power_records_adapter_failure_before_returning) cover both independently and pass. It's a maintainability issue: any future change to this failure-handling sequence (e.g. adding a new field to the sidecar payload, changing the log message format, or — as another reviewer noted — fixing the sidecar-loss defect where _record_adapter_failure's own exception handler doesn't write a fallback power_validation.json) has to be made in two places, and it's easy to update one copy and forget the other.

Suggested fix

Extract a small helper:

def _fail_adapter(
    *,
    result_dir: Path,
    agg_result: Path,
    expected_num_gpus: int | None,
    reasons: list[str],
    require_power: bool,
) -> int:
    try:
        _record_adapter_failure(
            result_dir=result_dir,
            agg_result=agg_result,
            expected_num_gpus=expected_num_gpus,
            reasons=reasons,
        )
    except (OSError, json.JSONDecodeError, ValueError) as exc:
        print(f"[agentx_power] Failed to record adapter failure: {exc}", file=sys.stderr)
    print(f"[agentx_power] Power-window adaptation failed: {', '.join(reasons)}", file=sys.stderr)
    return 1 if require_power else 0

and call it from both the window is None branch and the OSError branch in run_agentic_power(), passing the appropriate reasons list at each call site. This is a pure refactor with no behavioral change — existing tests should pass unmodified.

Proof of duplication (step-by-step)

  1. Read lines 165-191: on window is None, the code enters a try calling _record_adapter_failure(...), catches the same three exception types, prints the same two messages, returns the same expression.
  2. Read lines 192-214: after _write_json_atomic(window_path, window) raises OSError, reasons is reassigned to ['power_window_unwritable'], and then the exact same four-step sequence (try/except/print/return) repeats verbatim.
  3. Diff the two blocks textually — every line matches except the value bound to reasons before entering the block.
  4. This satisfies the DRY criterion: identical logic, differing only by one input parameter, is a textbook candidate for extraction into a shared function.


window_path = result_dir / "agentic_power_window.json"
try:
_write_json_atomic(window_path, window)
except OSError:
reasons = ["power_window_unwritable"]
try:
_record_adapter_failure(
result_dir=result_dir,
agg_result=agg_result,
expected_num_gpus=expected_num_gpus,
reasons=reasons,
)
except (OSError, json.JSONDecodeError, ValueError) as exc:
print(
f"[agentx_power] Failed to record adapter failure: {exc}",
file=sys.stderr,
)
print(
f"[agentx_power] Power-window adaptation failed: {', '.join(reasons)}",
file=sys.stderr,
)
return 1 if require_power else 0
return run_power(
result_dir / "gpu_metrics.csv",
window_path,
agg_result,
expected_num_gpus=expected_num_gpus,
validation_result=result_dir / "power_validation.json",
require_power=require_power,
)


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--result-dir", type=Path, required=True)
parser.add_argument("--agg-result", type=Path, required=True)
parser.add_argument("--expected-num-gpus", type=int)
parser.add_argument(
"--require-power",
action="store_true",
default=os.environ.get("REQUIRE_POWER", "").lower() in {"1", "true", "yes"},
)
args = parser.parse_args()
return run_agentic_power(
result_dir=args.result_dir,
agg_result=args.agg_result,
expected_num_gpus=args.expected_num_gpus,
require_power=args.require_power,
)


if __name__ == "__main__":
raise SystemExit(main())
Loading