Skip to content

feat(power): report each round's power-throttle share and sustained clock - #2038

Merged
chaojhou merged 4 commits into
mainfrom
feat/gpu-power-throttle-measurement
Oct 20, 2026
Merged

chaojhou merged 4 commits into
mainfrom
feat/gpu-power-throttle-measurement

Conversation

@rpoornac

@rpoornac rpoornac commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator
  • Description: what and why

    A round run under a low GPU power cap gives no sign that the cap, not the clock limit, set its throughput. gpu_power.json records only watts and VRAM, so an operator comparing rounds at different caps cannot tell a power-bound round from a clock-bound one. This PR makes that visible per round, without changing the cards:

    • The measured-phase sampler adds --clock to its existing read-only amd-smi metric query. Each card in gpu_power.json gains sclk_mean_mhz, its mean graphics clock while busy (an XCD in deep sleep, below its own min_clk, is idle time and left out), and sclk_max_mhz, its clock limit, read from the XCD slots amd-smi fills (null when none reported one, or when the limit changed during the phase).
    • It reads the amd-smi metric --violation accumulators as each measured window opens and closes, summed over windows so a GEAK replay's boots between replicas do not count. Each card gains power_throttle_fraction: PPT-limited ticks over all ticks in the measured phase.
    • gpu_power.json gains power_throttled_gpus: the serving cards throttled for at least 10% of the measured phase. For those the round logs a warning naming each card's share and mean clock.
    • A measured window whose counters could not be read leaves power_throttle_fraction unread (null) instead of counting only the windows that were read: every card's when a --violation read failed, one card's when it was missing from either end of a window or both, or its counters went back or stopped advancing, and every card's when the sampler was still blocked in amd-smi as the round closed. A card is published only when its counters were read across every measured window. A failed read is counted in the new throttle_query_errors / throttle_last_error, so query_errors / last_error keep meaning the power reads failed.

    Measured on MI355X (amd-smi 26.2.2) under a synthetic compute load: capped at 700 W, the cards were power throttled for about 99.9% of the phase at 920 to 1020 MHz; at the default 1400 W cap the same load still reached the cap and was throttled for 14 to 52%. The extra --clock field costs nothing measurable per sample; the --violation read takes about a second and runs only at window boundaries, after the window's first power sample.

  • Linked issue(s): none. Follow-up to the measured-phase power sampler from feat(power): --max-power-w total and --max-per-gpu-power-w budgets #1770.

  • Tests: added/updated? commands run?

    • Added to src/hyperloom/inference_optimizer/tests/test_gpu_power_sampling.py: clock parsing (XCD averaging, the limit, deep-sleep exclusion, and amd-smi's eight printed gfx_<n> slots of which a partitioned card fills fewer), throttle-counter parsing, a throttled and an unthrottled round end to end through GpuPowerRecorder (fields, power_throttled_gpus, the warning text), counters summed over measured windows only, the sampler closing a window at the phase change, a round with idle gaps, a failed --violation read leaving the power reading and query_errors alone, a failed read in either window (or at both ends of one) leaving every card's share unread, a card missing from one or both ends of a window or with counters going back left unread, a sampler still blocked in amd-smi at close leaving every share unread, a limit that changed during the round left unread, and an unread clock named as such in the warning.
    • Updated the existing tests' injected amd-smi stand-ins (_Replay, test_native_power_uses_client_boundaries_and_waits_for_responses, test_a_geak_replay_is_sampled_over_its_measured_phase): the recorder's query seam now takes the amd-smi metric arguments, and a --violation read goes through the same seam.
    • Ran: pytest src/hyperloom/inference_optimizer/tests/test_gpu_power_sampling.py src/hyperloom/inference_optimizer/tests/test_power_budget.py; ruff check and format; pylint errors-only; lint-imports --no-cache; scripts/code_metrics.py --base-ref origin/main; scripts/pr_hygiene.py size; scripts/check_cli_references.py.
  • Size/complexity triggers crossed: none. 204 production Python lines changed (budget 1000); no function over the complexity or parameter limits.

  • If this simplifies or refactors: no. The per-card power fields move into _power_fields beside the new _clock_fields with the same keys and rounding.

  • Observable effect: gpu_power.json gains per-card sclk_mean_mhz, sclk_max_mhz and power_throttle_fraction, and top-level power_throttled_gpus, throttle_query_errors and throttle_last_error; a round with a card power throttled for at least 10% of its measured phase logs a warning naming the cards, their throttled share and mean clock; the sampler runs one more read-only amd-smi metric --violation per measured window boundary. Before, a power-bound round looked the same as a clock-bound one in every artifact. Nothing changes the cards, and no gate or decision reads the new fields.

  • Breaking changes: no. The fields are additive; schema_version stays 1 and existing readers of gpu_power.json read only the fields they read before.

  • PR addresses single concern: yes

  • Root cause is upstream: no

…lock

Make visible, per round, how the cards ran under their power cap:

- The measured-phase sampler adds --clock to its amd-smi query (no measurable
  cost) and records each card's mean sclk while busy (deep-sleep readings
  below the XCD's min_clk are idle time and left out) and its clock limit.
- It reads the amd-smi --violation accumulators as each measured window opens
  and closes (summed over windows, so a GEAK replay's boots between replicas do
  not count) and reports power_throttle_fraction: PPT-limited ticks over all
  ticks in the measured phase.
- gpu_power.json lists serving cards throttled for at least 10% of the phase in
  power_throttled_gpus, and the round logs a warning naming each card's
  throttled share and mean clock. Nothing changes the cards.
- A measured window whose counters could not be read leaves the share unread
  instead of counting only the windows that were: every card's when a
  --violation read failed, one card's when it was missing from either end or
  its counters went back or stopped advancing. A failed read is counted in throttle_query_errors /
  throttle_last_error, so query_errors / last_error keep meaning the power
  reads failed.
- sclk_max_mhz is read from the XCD slots amd-smi fills, and is unread when
  they report different limits in any sample.

The new fields are additive; existing readers of gpu_power.json are unaffected.

Co-authored-by: Cursor <cursoragent@cursor.com>
@rpoornac
rpoornac requested a review from a team as a code owner October 19, 2026 23:29
@rpoornac

Copy link
Copy Markdown
Collaborator Author

/review

@github-actions

Copy link
Copy Markdown

Automated review started for head 196f385. Progress: https://github.com/AMD-AGI/Hyperloom/actions/runs/39341905732

@github-actions

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Code metrics gate: PASSED

Judged: the 3 files this change touches. Files it does not touch never fail it; anything found there is listed under Outside this change for information.

Dimension Limit New Worse Baseline out of date Baseline entries vs base
Cyclomatic complexity fails > 20 0 0 0 64 +0
Cognitive complexity fails > 30 0 0 0 259 +0
Function length (lines) fails > 80 0 0 0 401 +0
Nested block depth fails > 5 0 0 0 24 +0
Parameter count fails > 5 0 0 0 95 +0
Module length (lines) warns > 800, fails > 1200 0 0 0 50 +0
Duplicated lines per file fails > 0 0 0 0 97 +0
Dead code (vulture) fails > 0 0 0 0 2 +0
Check Findings Effect
Comments 0
English only 0
Production code imports test code 0
Repeated literals 0
A1 Private name of another package 0
A8 Flag argument 0
A9 Tuple return 0
A10 Raw vocabulary comparison 0
A11 Hidden global write 0
A12 Pure forwarder 0
A12 Single-implementation abstraction 0
100 findings in files this change does not touch

Outside this change (informational): 100

Unit Dimension Baseline Now
src/hyperloom/orchestrator/state/shared_state.py resolve_graded_comparison Cognitive complexity absent 34
src/hyperloom_kb/collect/mapping.py _experience Cognitive complexity absent 31
src/hyperloom_kb/schema.py FieldDeclaration.__post_init__ Cognitive complexity absent 32
src/kernelforge/cli/forge_loop_command.py _prepare_measurement_driver Cognitive complexity absent 36
src/kernelforge/cli/forge_loop_command.py _read_kb_warm_start Cognitive complexity absent 40
src/kernelforge/loop/runner.py IterationLoop._collect_session_candidate Cognitive complexity absent 44
src/kernelforge/loop/runner.py IterationLoop._rule_on_attempt Cognitive complexity absent 38
src/hyperloom/inference_optimizer/cli/__init__.py _reexport_session_environment Function length (lines) absent 111
src/hyperloom/inference_optimizer/cli/__init__.py _resolve_agent_lineup Function length (lines) absent 89
src/hyperloom/inference_optimizer/cli/__init__.py _resolve_run_topology Function length (lines) absent 92
src/hyperloom/inference_optimizer/cli/__init__.py _resume_session Function length (lines) absent 170
src/hyperloom/inference_optimizer/cli/__init__.py _seed_fresh_session Function length (lines) absent 170
src/hyperloom/inference_optimizer/multi_node/cli.py _restart_multinode Function length (lines) absent 107
src/hyperloom/inference_optimizer/multi_node/scripts/launch_multinode.py _build_parser Function length (lines) absent 84
src/hyperloom/inference_optimizer/multi_node/scripts/launch_multinode.py _spawn_rank_actors Function length (lines) absent 84
src/hyperloom_kb/sync.py GlobalSync.pull Function length (lines) absent 84
src/kernelforge/cli/forge_loop_command.py _RunReporter.build_result Function length (lines) absent 91
src/kernelforge/cli/forge_loop_command.py _prepare_measurement_driver Function length (lines) absent 136
src/kernelforge/cli/forge_loop_command.py _read_kb_warm_start Function length (lines) absent 149
src/kernelforge/loop/runner.py IterationLoop._close_iteration Function length (lines) absent 117
src/kernelforge/loop/runner.py IterationLoop._collect_session_candidate Function length (lines) absent 184
src/kernelforge/loop/runner.py IterationLoop._implementer_history Function length (lines) absent 112
src/kernelforge/loop/runner.py IterationLoop._rule_on_attempt Function length (lines) absent 102
src/kernelforge/loop/runner.py IterationLoop._source_attempt Function length (lines) absent 173
src/hyperloom_kb/http_service.py Module length (lines) absent 1525

...and 75 more; the job log lists every row.

Verdict computed by the gate scripts in /home/runner/work/_temp/code-metrics-gate, not by this tree's copy.

Thresholds and sources: [tool.hyperloom.code_metrics] in pyproject.toml. Tools: complexipy 8.0.1, jscpd 5.4.1, ruff 0.16.2, vulture 2.16.

@github-actions

Copy link
Copy Markdown

PR #2038 -- feat(power): report each round's power-throttle share and sustained clock

What it does: A round run under a low GPU power cap was indistinguishable from a clock-bound one in every artifact. The measured-phase GPU power sampler (src/hyperloom/orchestrator/actions/executors/_gpu_power.py) now also reads each card's graphics clock with the same amd-smi query and the PPT violation accumulators as each measured window opens and closes, so gpu_power.json gains per-card sclk_mean_mhz, sclk_max_mhz and power_throttle_fraction plus a top-level power_throttled_gpus, and a round with a serving card power throttled for at least 10% of its measured phase logs a warning naming the cards, their throttled share and mean clock. Windows whose counters could not be read leave the share null rather than summing only the windows that were; nothing changes the cards and no gate or decision reads the new fields.

Blocking issues: none

SKIPPED: V4 -- "# PARTIAL: #1276 changes 1311 files, only its first 100 were compared" -- overlap with those files unchecked
SKIPPED: V4 -- "# PARTIAL: #2004 changes 104 files, only its first 100 were compared" -- overlap unchecked
SKIPPED: V4 -- "# PARTIAL: #1907 changes 188 files, only its first 100 were compared" -- overlap unchecked
SKIPPED: V4 -- "# PARTIAL: #1869 changes 462 files, only its first 100 were compared" -- overlap unchecked
SKIPPED: V4 -- "# PARTIAL: #1154 changes 1232 files, only its first 100 were compared" -- overlap unchecked
SKIPPED: V4 -- "# PARTIAL: AMD-AGI/Hyperloom has 101 open PRs, only the newest 100 were compared" -- the one unlisted PR was never compared

Checked: _gpu_power.py at head and base (parse_clock_sample/parse_throttle_sample, _read_throttle/_close_window/_complete_throttle_totals, summary/close, _power_throttled, _POWER_THROTTLE_REPORT_FRACTION); test_gpu_power_sampling.py (all 43 tests, _Replay/_with_counters/_run_measured helpers); docs/conceptual/optimization-loop.md against the code; tree-wide greps for the new fields, _query_amd_smi, GpuPowerRecorder, parse_clock_sample, parse_throttle_sample and gpu_power across src and docs; production wiring in _subprocess_kill.py:_RecorderFanout and _geak_sweep.py; read_measured_gpu_power_by_gpu consumers in test_power_budget.py | Ran: pytest src/hyperloom/inference_optimizer/tests/test_gpu_power_sampling.py (43 passed) and src/hyperloom/inference_optimizer/tests/test_power_budget.py (71 passed) at head; ruff C901 (max 20) and PLR0913 (max 5) on head and base copies of _gpu_power.py (both clean); direct GpuPowerRecorder reproduction of a throttled card with unread clock | Base: b7004d1 | Head: 196f385

LGTM

Co-authored-by: Cursor <cursoragent@cursor.com>
@rpoornac

Copy link
Copy Markdown
Collaborator Author

/review

@github-actions

Copy link
Copy Markdown

Automated review started for head 2825429. Progress: https://github.com/AMD-AGI/Hyperloom/actions/runs/39345196012

@github-actions

Copy link
Copy Markdown

Automated review: no verdict applied -- the review job ended with result 'failure'. Comment /review to retry. Run: https://github.com/AMD-AGI/Hyperloom/actions/runs/39345196012

@rpoornac

Copy link
Copy Markdown
Collaborator Author

/review

@github-actions

Copy link
Copy Markdown

Automated review started for head 2825429. Progress: https://github.com/AMD-AGI/Hyperloom/actions/runs/39346757649

@github-actions

Copy link
Copy Markdown

Automated review: no verdict applied -- the review job ended with result 'failure'. Comment /review to retry. Run: https://github.com/AMD-AGI/Hyperloom/actions/runs/39346757649

@rpoornac

Copy link
Copy Markdown
Collaborator Author

/review

@github-actions

Copy link
Copy Markdown

Automated review started for head 2825429. Progress: https://github.com/AMD-AGI/Hyperloom/actions/runs/39351438384

@github-actions

Copy link
Copy Markdown

Automated review: no verdict applied -- the review job ended with result 'failure'. Comment /review to retry. Run: https://github.com/AMD-AGI/Hyperloom/actions/runs/39351438384

@chaojhou chaojhou left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

PR #2038 -- feat(power): report each round's power-throttle share and sustained clock

What it does: the measured-phase GPU power sampler adds --clock to its existing amd-smi metric query and reads the amd-smi metric --violation accumulators as each measured window opens and closes, so gpu_power.json gains per-card sclk_mean_mhz, sclk_max_mhz and power_throttle_fraction and a top-level power_throttled_gpus, and a round with a card power throttled for at least 10% of its measured phase logs a warning. The arithmetic matches amd-smi's own PVIOL% (checked against the amdsmi rocm-7.2.0 source: ppt_accumulated and accumulation_counter come from one gpu_metrics snapshot, so the delta ratio cannot exceed 1, and a reset or stalled counter is caught), the deep-sleep cut uses the same min_clk rule amd-smi uses, the ~1 s --violation read runs on the sampler thread and never on the event loop or inside note_phase, and the measured-phase and client boundaries are untouched.

Blocking issues: 1

  1. [D] A card whose counters are missing at both ends of a window keeps a share computed from the other windows only [verified]
    Problem: _close_window marks a card unread only for opened.keys() ^ closed.keys(), a card present at one end and missing at the other. A card missing at both ends of a window is not in the symmetric difference, gets no delta for that window and is not marked unread, and _complete_throttle_totals (:314-321) publishes its share from the remaining windows. The read itself succeeded, so _throttle_read_failed does not fire either. The code's invariant comment (:222-223) and optimization-loop.md both promise "unread" here. src/hyperloom/orchestrator/actions/executors/_gpu_power.py:282
    Impact: amd-smi's CLI prints a card's whole throttle block as "N/A" with exit 0 when amdsmi_get_violation_status raises for that card, and parse_throttle_sample then drops the card. Reproduced through GpuPowerRecorder: window 1 has 900 of 1,000 ticks PPT-limited on both cards; in window 2 card 4 reads N/A at both ends while card 5 shows 100 of 98,000. Card 4 is published as power_throttle_fraction 0.9 and listed in power_throttled_gpus with throttle_query_errors 0. Native rounds already split the phase into a short warmup window and the benchmark window, so a double miss on the benchmark window publishes the idle window's share as the whole phase.
    Action: tie validity to the windows read instead of adding another check: count closed windows, accumulate per card [ticks, ppt, windows_read] only when the card had good counters at both ends, and publish a card only when its windows_read equals the window count. A failed read, a card missing at one or both ends, and a counter going back then all mean the same thing, which also removes _throttle_unread and _throttle_read_failed. Add the both-ends test.

Non-blocking:

  • If close()'s join times out while the thread is still in a window-open --violation read (amd-smi slow twice in a row), the last window is dropped without the card being marked unread; the window count above does not help there because the window never closes. close() also now runs the closing read on the watchdog thread (about 1 s per round end, up to the 10 s query timeout), while the class docstring says the watchdog must not stall on amd-smi.
  • amd-smi copies one min_clk / max_clk per clock type into every filled gfx_<n> slot, so XCDs cannot disagree within one sample; the case modelled in parse_clock_sample and its test cannot occur, and a null sclk_max_mhz really means the limit changed between samples.
  • The warning prints "at a mean clock of None MHz" when a throttled card had no awake-XCD sample.
  • Clock parsing runs inside the power sample's try; a parse failure would drop the power reading the power budget gates on. Parsing clocks after the power reading is recorded would keep them apart.

Checked: _gpu_power.py at head and base (parse_clock_sample, parse_throttle_sample, _read_throttle, _close_window, _complete_throttle_totals, summary, close, the sampler loop and thread); amdsmi rocm-7.2.0 CLI and library source for --clock and --violation fields, N/A handling and PVIOL%; ServerLogPhaseDriver and note_client_phases boundaries; readers of gpu_power.json; optimization-loop.md; the description | Ran: pytest test_gpu_power_sampling.py test_power_budget.py with a stub fcntl (all 43 sampling tests pass; the only failures are power-budget CLI tests needing signal.SIGHUP on Windows); a GpuPowerRecorder probe with a card N/A at both ends of window 2 (share 0.9 published); a timing probe with a fake 1 s --violation and 0.3 s power read (note_phase 0 s, close() 1 s) | Base: b7004d1 | Head: 2825429

… window

A card whose --violation counters were missing at both ends of a window was
neither given that window's delta nor marked unread, so its share came from
the other windows only (for example a short warmup window standing in for the
whole phase).

- Count the measured windows, and per card the windows it was read across with
  counters moving forward; publish a card only when the two match. A failed
  read, a card missing at one or both ends, and counters going back now all
  mean the same thing, replacing the unread set and the read-failed flag.
- close() counts one more, unread window when the sampler is still blocked in
  amd-smi after the join timeout, and the class docstring says close() waits.
- Clock parsing runs after the power reading is recorded.
- The warning names an unread clock as unread, not "None MHz".
- sclk_max_mhz is described as unread when the limit changed during the
  round: amd-smi copies one limit into every filled XCD slot.

Co-authored-by: Cursor <cursoragent@cursor.com>
@rpoornac

Copy link
Copy Markdown
Collaborator Author

Thanks @chaojhou. Addressed in 6c833b7:

Blocking: a card missing at both ends of a window. Done as you suggested. The recorder counts measured windows and, per card, the windows it was read across with counters moving forward. A card is published only when the two counts match. A failed read, a card missing at one or both ends, and counters going back now all leave the share unread, and _throttle_unread / _throttle_read_failed are gone. test_a_card_unread_at_both_ends_of_a_window_is_left_unread reproduces your case: window 1 at 90% on both cards, card 4 N/A at both ends of window 2. Card 4 is now unread and not listed; card 5 keeps its share.

Non-blocking:

  • Join timeout during a window read: when the sampler is still alive after close()'s join, close() counts one more window that no card was read across, so every share is unread rather than one window short. The class docstring now says close() waits, for the sampler and the closing counter read (about a second).
  • XCD limits: parse_clock_sample's docstring, the docs and the test now describe a limit that changed during the round. The impossible within-sample case is gone.
  • "None MHz": the warning names an unread clock as unread.
  • Clock parsing: it runs after the power reading is recorded, outside the power read's try, so it can't drop the reading the budget gates on.

The description and optimization-loop.md state the new unread rules. Checks: the targeted suites, the full src suite against main (no new failures), ruff, pylint, mypy (no new errors), bandit, lint-imports, check_broad_suppress.py, code_metrics.py, and an independent review of the change, with each new test checked to fail when its fix is reverted.

@chaojhou chaojhou left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

PR #2038 -- feat(power): report each round's power-throttle share and sustained clock

What it does: the measured-phase GPU power sampler adds --clock to its existing amd-smi metric query and reads the amd-smi metric --violation accumulators as each measured window opens and closes, so gpu_power.json gains per-card sclk_mean_mhz, sclk_max_mhz and power_throttle_fraction plus power_throttled_gpus, and a round with a card power throttled for at least 10% of its measured phase logs a warning. The previous blocker is fixed in 6c833b7: the recorder counts closed windows and keeps [ticks, ppt, windows_read] per card, and publishes a share only when the card was read across every window, so _throttle_unread / _throttle_read_failed are gone. My earlier probe (card 4 N/A at both ends of window 2) now leaves card 4 unread and unlisted while card 5 keeps its own share, pinned by test_a_card_unread_at_both_ends_of_a_window_is_left_unread. The non-blocking items are addressed too: a join timeout counts one more window so no card is published a window short, the clock-limit model describes a limit that changed during the round, the warning prints unread instead of None MHz, and clock parsing runs after the power reading, outside its try.

Blocking issues: none

Non-blocking:

  • When the sampler outlives close()'s join, it can make one more --violation read after the round is summarised (_window_open was cleared and the stop flag is only checked at the top of the loop). It changes no published value and needs two amd-smi calls near the 10 s timeout in a row.

Checked: git diff 2825429d7 4f20a60d0; _gpu_power.py at head (_loop, _read_throttle, _close_window, _sample_once, _complete_throttle_totals, summary, close, parse_clock_sample); the description and optimization-loop.md unread rules against the code; the author's reply against the code | Ran: GpuPowerRecorder probes at head: the both-ends N/A case, single and zero windows, a failed read at window open and close, a card missing at one end, counters and PPT going back, stalled counters, a card present only in window 2, close() running the closing read after a finished thread and after the sampler closed the window (no double count), the warning with a sleeping and an awake clock; a real sampler thread blocked in the opening read, in a power sample and in the closing read with the query timeout patched to 0.2 s (every share unread, as intended); pytest test_gpu_power_sampling.py (46 passed); CI at head (33 pass, 7 skipped) | Base: b7004d1 | Head: 4f20a60

LGTM

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants