Skip to content

[AMD][Power] fix: wait for AMD telemetry to cover the benchmark window end before stopping the monitor / 修复 AMD 遥测在基准窗口结束前被截断导致功耗校验失败的问题 - #2767

Open
edwingao28 wants to merge 5 commits into
mainfrom
fix/amd-agentx-window-bracketing
Open

[AMD][Power] fix: wait for AMD telemetry to cover the benchmark window end before stopping the monitor / 修复 AMD 遥测在基准窗口结束前被截断导致功耗校验失败的问题#2767
edwingao28 wants to merge 5 commits into
mainfrom
fix/amd-agentx-window-bracketing

Conversation

@edwingao28

@edwingao28 edwingao28 commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Fixes the benchmark_window_not_bracketed power-validation failures on AMD single-node AgentX runs (e.g. run 32433563482, kimik3-fp4-mi355x-atom-agentic-mtp, 3 of 4 points failed).

Problem

On run 32433563482 conc1 the amd-smi stream's last CSV tick is 1787277605 (integer epoch second) while the aiperf window end is 1787277609.157 — telemetry stops ~4 s before the formal window end. Three interacting mechanisms, none bounded by the old fixed sleep interval+2 before killing the pipeline:

  1. Consumer-side data loss at killGPU_MONITOR_PID is the awk consumer, not amd-smi; rows sitting in the OS pipe (awk's per-row fflush() can block on NFS-backed $result_dir) are discarded at kill.
  2. Producer staleness — one amd-smi metric -p -c -t -u iteration over 8 GPUs takes non-trivial time, so the newest emitted tick can trail wall clock by seconds.
  3. Degenerate teardown rows — SMI can emit N/A or 0 W power cells near termination; an N/A row in the ±3 s ingest band flipped validity via invalid_power_sample, and a 0 W row past the window end could silently fake end bracketing while corrupting the boundary interpolation.

Solution

benchmarks/benchmark_lib.sh — deterministic poll-until-covered stop.
stop_gpu_monitor's AMD branch now polls $GPU_METRICS_CSV until every observed GPU has a usable tick (numeric epoch timestamp, numeric power > 0) stamped ≥ floor(stop-entry epoch)+1, then kills. The window end is always ≤ the stop-entry wall clock and amd-smi stamps integer seconds, so that tick strictly covers any fractional window end — no aiperf knowledge needed in the shell. Details:

  • Bounded by AMD_MONITOR_STOP_TIMEOUT_S (default 30 s, env-overridable, 0 skips the wait; a non-integer value warns and falls back to 30 instead of aborting the stop). On timeout or early monitor death: warn and proceed — aggregation then flags the point exactly as today (fail-safe, never fail-silent).
  • Non-epoch timestamps (older amd-smi emitting ISO strings) fall back to the legacy interval+2 fixed tail once; millisecond epochs (>1e12) are normalized to seconds, mirroring _parse_timestamp in utils/aggregate_power.py.
  • The awk coverage helper is POSIX-only (no interval regex, no gawk extensions; works under mawk/busybox awk) and neutralizes amd-smi's quoted list cells (embedded commas), CR line endings, and blank lines between tick groups — all verified against the real run-32433563482 CSV.
  • AgentX INT/TERM/EXIT traps stop in abort mode (AMD_MONITOR_STOP_TIMEOUT_S=0): a cancelled run's power validity is moot and teardown stays fast. The explicit post-replay stop performs the full wait. Idempotency via agentx_monitor_stopped preserved.
  • NVIDIA branch is behaviorally unchanged. This also fixes every AMD single-node consumer of stop_gpu_monitor (all benchmarks/single_node/fixed_seq_len/* recipes), not just AgentX.

utils/aggregate_power.py — explicit boundary-degenerate handling.
integrate_power now skips-and-counts rows inside the ±max_sample_gap_s ingest band but outside the formal window whose power is unparseable, non-finite, or ≤ 0, into a new per-GPU sidecar field boundary_degenerate_rows ("unknown" buckets rows without a GPU identity). They are never used for bracketing and never poison validity. In-window semantics are byte-identical: in-window N/A still ⇒ invalid_power_sample; in-window 0 W still integrates.

Contract compatibility

Relevant to the queued row-level power provenance change (feat/power-row-provenance in InferenceX) and to InferenceX-app PR #939:

  • No new top-level reason codes; benchmark_window_not_bracketed keeps meaning "a real usable sample does not bracket the boundary".
  • Aggregate-row schema untouched (power_valid still numeric; no power_invalid_reasons on aggregates).
  • Sidecar schema_version stays 1; boundary_degenerate_rows is additive and readers tolerate its absence.

Validity flips on re-aggregated legacy artifacts (intentional)

Only when explicitly re-running aggregation on old raw artifacts (e.g. the failed-ingest recovery path):

  • streams previously failed only by an outside-window N/A row now validate (strict improvement);
  • streams whose only end bracket was a degenerate 0 W row now fail benchmark_window_not_bracketed (they were silently corrupt before).
    Already-ingested aggregate rows are untouched.

Cost

Normal case ~1–2 s at stop (first whole second after stop entry). Pathological hang: up to 30 s (was 3 s), bounded, logged, env-tunable.

Tests

  • bash -n benchmarks/benchmark_lib.sh clean.
  • utils/test_aggregate_power.py + utils/agentic/aggregation/test_power_lifecycle.py: 74 passed (57 + 17).
    • 5 new aggregation tests — N/A-outside-window skipped-and-counted; 0 W tail no longer fakes bracketing (flips a silently-corrupt legacy case to explicit-invalid); in-window 0 W semantics frozen; sidecar key present-and-empty on clean streams; regression fixture encoding the run-32433563482 conc1 shape (real 21-column MI355X watch header, integer ticks ending 4 s before fractional end ...609.157497, real power values, quoted list cells, CR endings, blank inter-tick lines, plus synthetic N/A / 0 W teardown shapes) asserting benchmark_window_not_bracketed attribution.
    • 6 new shell-contract tests running the real stop_gpu_monitor against a scripted producer (coverage wait for all GPUs; degenerate rows never satisfy coverage → timeout warning; per-GPU min semantics; ISO-timestamp legacy fallback; non-integer timeout survival; millisecond-epoch normalization), plus the signal test now pins abort mode (AMD_MONITOR_STOP_TIMEOUT_S=0 at trap-driven stop) and the new trap strings.
  • utils/test_process_result.py: the AMD stop test now pins the new contract (covered tick ⇒ no legacy sleep, energy snapshot still written); truncated-row repair test runs with the wait skipped.
  • Broader power suites (test_aggregate_power*, test_process_result, utils/agentic/aggregation/): 224 passed.
  • Full utils/ suite: 804 passed; the only 2 failures (utils/evals/test_run_eval_dispatch.py) reproduce identically on clean origin/main in this local environment (macOS temp-path issue), unrelated to power.
  • Shell-contract tests re-run 3× locally, no flakes (0.2 s producer cadence vs ≥1 s coverage threshold ⇒ ≥5× margin).

Review notes

  • A non-integer AMD_MONITOR_STOP_TIMEOUT_S (e.g. "30s") aborted the whole stop_gpu_monitor call via a bash arithmetic error under set -e — leaking the monitor process and skipping tail repair and the energy sidecar. Fixed: the timeout is sanitized once before any arithmetic (warn + fall back to 30); the timeout warning now always prints a real number. Pinned by test_amd_stop_survives_non_integer_timeout.
  • A hypothetical amd-smi build stamping millisecond epochs would trivially satisfy coverage (~1.8e12 ≥ any second-scale target) and skip the tail wait entirely. No such build is known, and the failure mode was fail-safe, but the hardening is cheap. Fixed: the awk helper normalizes >1e12 timestamps to seconds, mirroring _parse_timestamp. Pinned by test_amd_stop_normalizes_millisecond_epoch_timestamps.

Evidence note

The retrieved run-32433563482 conc1 artifact shows the trailing rows all carry valid socket_power (254–264 W) with N/A activity/voltage cells — the observed failure is purely mechanisms (1)+(2) (the stream stops 4 s early). The degenerate-row handling hardens against mechanism (3) using the synthetic N/A / 0 W teardown shapes exercised in the tests.

Hardware smoke (post-merge hardware validation; not a merge gate for this PR, but the acceptance gate for the fix)

Re-sweep kimik3-fp4-mi355x-atom-agentic-mtp (configs/amd-master.yaml:656) from this PR branch on the MI355X ATOM cluster. For each point (conc 1, 4, 8, 10) in the raw-results artifact:

  • agg_*.json has power_valid == 1 and finite avg_power_w / total_gpu_energy_j;
  • results/**/power_validation.json has reasons == [], and for every GPU the newest usable gpu_metrics.csv tick ≥ benchmark_window.end_time_unix;
  • boundary_degenerate_rows (if non-empty) shows teardown rows were counted, not integrated;
  • accumulator_check.within_tolerance == true where the sidecar snapshots exist;
  • the job log shows no never covered the stop request warning, and per-point wall time increased by ≲ a few seconds versus the failing run.

If any point still fails with benchmark_window_not_bracketed, attach its CSV tail + power_validation.json to this PR before iterating (per-GPU sampling_gap_exceeded would instead indicate the separate sampling-cadence issue tracked as follow-up work to this PR).


Note

Medium Risk
Changes AMD benchmark stop timing and power-validation semantics (including intentional validity flips when re-aggregating legacy artifacts); bounded fail-safe behavior but affects published power metrics on AMD paths.

Overview
Fixes AMD AgentX and single-node runs failing benchmark_window_not_bracketed when amd-smi integer-second ticks stop before the fractional benchmark window end.

stop_gpu_monitor (AMD) replaces the fixed interval+2 sleep with a bounded poll of gpu_metrics.csv until every GPU has a usable sample (numeric epoch, power > 0) at or after the next whole second past stop entry, configurable via AMD_MONITOR_STOP_TIMEOUT_S (default 30s; 0 skips). ISO timestamps keep the legacy tail; ms epochs are normalized like aggregate_power. AgentX INT/TERM/EXIT traps use abort mode (AMD_MONITOR_STOP_TIMEOUT_S=0) so cancelled runs tear down quickly; the normal post-replay stop keeps the full wait.

integrate_power now skips—and counts in the validation sidecar as boundary_degenerate_rows—ingest-band rows outside the formal window with missing, non-finite, or ≤0 W power, so teardown N/A/0 W rows no longer poison validity or fake end bracketing (in-window behavior unchanged).

Tests cover the real shell stop path, signal abort mode, timeout sanitization, and aggregation regressions including the MI355X conc1 shape.

Reviewed by Cursor Bugbot for commit 0c4f204. Bugbot is set up for automated code reviews on this repo. Configure here.

…e killing the monitor / 修复:AMD 遥测覆盖停止请求后再终止功耗监控进程

The AMD stop path used an open-loop fixed sleep (interval+2) before killing
the awk pipeline consumer, so rows still in the OS pipe or not yet emitted by
a slow amd-smi iteration were lost and the last on-file tick could trail the
aiperf window end by several seconds (run 32433563482 conc1: last tick
1787277605 vs end ...609.157), failing power validation with
benchmark_window_not_bracketed.

Replace the fixed tail with a bounded poll of the output CSV: wait until every
observed GPU has a usable tick (numeric epoch timestamp, power > 0) stamped at
the first whole second past stop entry — amd-smi stamps integer seconds, and
the window end never exceeds the stop-entry wall clock, so that tick strictly
covers any fractional window end. Bounded by AMD_MONITOR_STOP_TIMEOUT_S
(default 30 s, 0 skips); non-epoch timestamps keep the legacy fixed tail; on
timeout or early monitor death it warns and proceeds so aggregation attributes
the failure (fail-safe, never fail-silent). AgentX INT/TERM/EXIT traps stop in
abort mode (timeout 0) to keep signal teardown fast; the explicit post-replay
stop performs the full wait. The NVIDIA branch is unchanged.

AMD 停止路径原先在杀掉 awk 管道消费者前只做固定时长的 sleep,导致管道缓冲中的
采样行丢失、文件中最后一个时间戳可能落后于基准窗口结束数秒,功耗校验因
benchmark_window_not_bracketed 失败。本补丁改为有界轮询输出 CSV:等到每个 GPU
都有一条时间戳到达停止时刻下一整秒的可用采样(数值时间戳、功率 > 0)再终止监控,
由 AMD_MONITOR_STOP_TIMEOUT_S 限定(默认 30 秒,0 表示跳过);非 epoch 时间戳
回退到旧的固定等待;超时或监控提前退出时告警并继续,由聚合端归因。AgentX 的
INT/TERM/EXIT trap 以 abort 模式停止(跳过等待),正常收尾仍执行完整覆盖等待。
NVIDIA 分支不变。
…oisoning with them / 修复:统计窗口外的异常功耗行,不再用其伪造窗口覆盖或污染校验

SMI teardown rows can carry N/A or 0 W power cells. Inside the +/-3 s ingest
band but outside the formal window, an N/A row previously flipped validity via
invalid_power_sample, and a 0 W row could silently satisfy end bracketing while
corrupting the boundary interpolation. integrate_power now skips such rows
(power missing, non-finite, or <= 0 outside [start, end]) and counts them per
GPU in a new additive sidecar field boundary_degenerate_rows. In-window
semantics are unchanged: in-window N/A still yields invalid_power_sample and
in-window 0 W still integrates. No new reason codes; sidecar schema_version
stays 1; aggregate rows are untouched.

SMI 收尾阶段可能输出功率为 N/A 或 0 W 的行:在 ±3 秒摄取带内但位于正式窗口外时,
N/A 行会误置 invalid_power_sample,0 W 行则可能伪造窗口末端覆盖并污染边界插值。
integrate_power 现在跳过此类行(窗口外且功率缺失、非有限或 <= 0),并按 GPU 计入
新增的 sidecar 字段 boundary_degenerate_rows。窗口内语义不变:窗口内 N/A 仍记
invalid_power_sample,窗口内 0 W 仍参与积分。不新增 reason 代码,sidecar
schema_version 保持 1,聚合结果行结构不变。

Includes a regression fixture for run 32433563482 conc1 (MI355X integer-second
ticks ending 4 s before the fractional aiperf window end) asserting the
producer failure stays attributed as benchmark_window_not_bracketed.
…epochs / 加固 AMD 停止覆盖轮询:容错非法超时值并归一化毫秒时间戳

- Sanitize AMD_MONITOR_STOP_TIMEOUT_S: a non-integer value (e.g. "30s")
  previously aborted stop_gpu_monitor via a bash arithmetic error under
  set -e, leaking the monitor process and skipping tail repair and the
  energy sidecar; it now warns and falls back to the default 30.
- Normalize millisecond epoch timestamps (>1e12) in the awk coverage
  helper, mirroring _parse_timestamp in utils/aggregate_power.py, so a
  ms-stamping amd-smi build cannot trivially satisfy the stop target.
- Shell-contract tests pin both behaviors.
@github-actions

Copy link
Copy Markdown
Contributor

Thanks for the contribution! Please reach out to respective companies' CODEOWNER to fill in the latest PR_REVIEW_CHECKLIST.md before pinging core maintainer on Slack for review. In order for the signoff PR check bot to trigger, you must follow the PR_REVIEW_CHECKLIST.md template correctly, including the phrase As a PR reviewer and CODEOWNER, I have reviewed this and have.

For PR verification, add the full-sweep-fail-fast label (strongly recommended) to this PR — the benchmark sweep only runs on labeled PRs. Use full-sweep-enabled only if you need matrix jobs to keep running past a failure.

PR authors are responsible for ensuring that after merging, all GitHub Action jobs fully pass. A lot of the time, failures are just flakes and simply re-running the failed jobs will fix it. See GitHub's docs on re-running failed jobs


感谢你的贡献!请联系相应公司的 CODEOWNER 填写最新的 PR_REVIEW_CHECKLIST.md,然后再在 Slack 上联系核心维护者进行审阅。为了触发 signoff PR 检查机器人,你必须正确遵循 PR_REVIEW_CHECKLIST.md 模板,包括保留英文语句 As a PR reviewer and CODEOWNER, I have reviewed this and have

如需进行 PR 验证,请为此 PR 添加 full-sweep-fail-fast 标签(强烈推荐)— 基准测试 sweep 仅在带有标签的 PR 上运行。仅当需要矩阵任务在失败后继续运行时才使用 full-sweep-enabled

PR 作者有责任确保合并后所有 GitHub Action 任务完全通过。 很多时候失败只是偶发抖动(flake),重新运行失败的任务即可解决。参见 GitHub 关于重新运行失败任务的文档

@claude claude Bot left a comment

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.

Beyond the inline findings, I also checked how _wait_for_amd_stop_coverage handles an empty result from _amd_monitor_min_covered_tick on the first poll (benchmarks/benchmark_lib.sh:313) — it falls back to the same fixed interval+2 sleep that existed before this PR, so that path is not a regression even though it forgoes retrying before treating the stream as unusable.

Extended reasoning...

Verified by reading _wait_for_amd_stop_coverage: when _amd_monitor_min_covered_tick returns empty on any poll iteration, the function immediately runs sleep $(( ${GPU_MONITOR_INTERVAL:-1} + 2 )) and returns — identical to the pre-PR unconditional sleep. Since this is byte-for-byte the previous behavior (not a new code path with new risk), it doesn't warrant a separate inline finding, but it is worth noting as something checked and ruled out given the two confirmed findings already surfaced in the same area of the diff.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟡 utils/aggregate_power.py — When every row in the CSV ends up in raw_samples empty (all in-band rows are boundary-degenerate outside the window, e.g. amd-smi emitting N/A/0W before it warms up and no valid sample ever falls inside the window), integrate_power hits the if not raw_samples: branch and returns _empty_integration(...), which does not accept/forward the boundary_degenerate dict collected in the row loop. The new boundary_degenerate_rows sidecar field is silently reset to {} even though degenerate rows were actually observed and counted.

    Extended reasoning...

    A stream where amd-smi only ever emits N/A/0W rows within the ingest band around a short benchmark window (e.g. monitor cold-start racing a very short window, or full-window telemetry outage) triggers no_usable_power_samples; before returning, boundary_degenerate already has non-zero per-GPU counts, but _empty_integration builds PowerIntegration without passing them, so the validation sidecar reports boundary_degenerate_rows: {} instead of the true counts, hiding exactly the diagnostic signal (many degenerate teardown/startup rows, zero usable samples) this PR added the field to surface for run investigation. Fix: thread boundary_degenerate through to _empty_integration (or move the if not raw_samples check to build a PowerIntegration that carries it) at this call site.

    Verification: nit. Factually correct diagnostic-loss defect. In integrate_power (utils/aggregate_power.py), boundary_degenerate is populated in the new row-loop branch (lines 417-426: boundary_degenerate[key] = boundary_degenerate.get(key,0)+1; continue). If every in-band row is degenerate-outside-window (all skipped) and no valid in-window sample lands, raw_samples stays empty and the early return at lines…

Comment on lines 2364 to 2375
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
trap '_stop_agentx_power_monitor; exit 143' TERM
# cleanup idempotent. Abort mode only ever fires when the explicit
# post-replay stop did not run (abnormal exit), where the coverage
# wait would only slow teardown down.
trap '_stop_agentx_power_monitor abort' EXIT
trap '_stop_agentx_power_monitor abort; exit 130' INT
trap '_stop_agentx_power_monitor abort; exit 143' TERM
fi

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

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 explicit post-replay stop (line 2385, non-abort mode) sets agentx_monitor_stopped=1 before calling stop_gpu_monitor, then runs the new up-to-30s _wait_for_amd_stop_coverage poll. If INT/TERM arrives during that poll, the EXIT/INT/TERM trap fires '_stop_agentx_power_monitor abort; exit 1xx', but the idempotency guard (agentx_monitor_stopped already '1') makes that call a no-op, so the trap immediately exits the subshell without ever reaching stop_gpu_monitor's kill "$GPU_MONITOR_PID" line. This pre-existing race (previously bounded by only a ~3s fixed sleep) is now exercisable for up to AMD_MONITOR_STOP_TIMEOUT_S seconds (default 30s) because of this diff, making the leak far more likely to actually trigger.

Extended reasoning...

A scheduler/user cancels the benchmark job (SIGTERM/SIGINT) in the tens-of-seconds window after the replay command finishes but before the AMD coverage wait completes. The trap's abort call is swallowed by the idempotency flag, exit happens immediately, and the amd-smi/awk GPU_MONITOR_PID process is orphaned instead of killed, continuing to append to GPU_METRICS_CSV indefinitely and potentially corrupting telemetry for any later measurement window/point that reuses the same CSV path. A correct fix must let a signal received during the explicit (non-abort) wait itself short-circuit stop_gpu_monitor's poll and still guarantee the kill, e.g. by tracking 'wait in progress' state independent of agentx_monitor_stopped, or by killing GPU_MONITOR_PID unconditionally in the trap regardless of the idempotency flag.

Verification: Severity: pre-existing (nit at most). The mechanism is real and reachable. In run_agentic_replay_and_write_outputs, line 2384-2385 calls _stop_agentx_power_monitor (non-abort). The function (diff) sets agentx_monitor_stopped=1 BEFORE running stop_gpu_monitor, whose AMD branch now runs _wait_for_amd_stop_coverage — a poll bounded by AMD_MONITOR_STOP_TIMEOUT_S (default 30s) with `sleep…

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 7277aec. Configure here.

# A cancelled run's power validity is moot; skip the AMD
# coverage wait so signal teardown stays fast.
AMD_MONITOR_STOP_TIMEOUT_S=0
fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Signal during coverage wait leaks monitor

Medium Severity

_stop_agentx_power_monitor sets agentx_monitor_stopped before stop_gpu_monitor returns, while INT/TERM traps stay installed through the new coverage wait. A signal during that wait runs abort-mode cleanup, which is a no-op because the flag is already set, so the amd-smi/awk pipeline is never killed.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7277aec. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

1 participant