Repository navigation
feat(power): --max-power-w total and --max-per-gpu-power-w budgets - #1770
Conversation
chaojhou
left a comment
There was a problem hiding this comment.
Thanks @rpoornac. The budget argument is well made: an average-power budget is a different question from a per-card cap or from Perf/Watt. With none of the new flags set, nothing changes except that GEAK replays are now sampled.
Requesting changes for one blocker and two must-fix items, all on the opt-in paths, plus a scope point. Line numbers are at 16abb36b5; paths are under src/hyperloom/.
Blocker
1. --apply-gpu-power-settings can leave card-wide settings changed after the session, and doesn't stop two sessions from stacking on the same cards.
- Restore runs only from
atexit(inference_optimizer/cli/__init__.py:1367).- SIGKILL and OOM kills skip it.
- SIGTERM is only drained inside the tick loop. The drain is closed before the terminal close sequence (
orchestrator/loop/coordinator.py:1072-1089), and nothing handles SIGTERM during bootstrap. A SIGTERM in either window ends the process without runningatexit. - The usual Slurm time limit sends SIGTERM, which starts a graceful CLOSE (stack revalidation, report), then sends SIGKILL after
KillWait. If CLOSE outlastsKillWait, the cards stay at the applied cap. - The record is recovered only by a later launch on the same host, with the same ledger directory and
--apply. Any other launch only warns (cli/__init__.py:1369-1370).
- The ledger isn't host-scoped. It lives under
$HYPERLOOM_RUNTIME_DIR, else$USER_DATA_PATH/runtime, else./runtime(cli/bootstrap.py:290-295).- For several runs on one node, the docs recommend a per-job runtime directory such as
runtime-$SLURM_JOB_ID(docs/reference/troubleshooting.md:314-315). Two such sessions don't see each other's locks. - Scenario:
- Session A runs without a mask, so it sets all eight cards.
- Session B then starts on cards 4–7 with its own ledger, and records A's applied cap as their original.
- A's exit resets cards 4–7 while B is still running on them.
- B's exit then "restores" them to A's cap, where they stay.
- The installer default
$USER_DATA_PATH/runtimefollows the shared mount (docs/reference/operations.md:87-90). A record names only the local card index (common/gpu_power_settings.py:300), so hosts sharing that mount contend for each other's card indices.
- For several runs on one node, the docs recommend a per-job runtime directory such as
- Without a parseable integer mask, it sets every card on the host (
gpu_power_settings.py:57-68,bootstrap.py:342-344).- That covers both no mask at all and a UUID
ROCR_VISIBLE_DEVICES, and it includes cards the session doesn't serve on. - The resident-model refusal is checked once, at launch (
bootstrap.py:376), and a card whose VRAM can't be read counts as free (gpu_power_settings.py:186-190).
- That covers both no mask at all and a UUID
- Suggested: split the apply path out, as agreed on #1730 (see Scope below). If it stays, at minimum:
- refuse without a parseable integer mask, and set only the cards in it;
- keep the ledger at a fixed host-local path that doesn't depend on
HYPERLOOM_RUNTIME_DIRorUSER_DATA_PATH, and stamp each record with the host (for example, its boot id) so that records from another host are ignored; - handle SIGTERM from apply until restore, and restore in a
finallyrather than only fromatexit; - document that after a SIGKILL the cards stay at the applied settings until a launch with the flag recovers them, and update
inference_optimizer/SKILL.md:748("Hyperloom never changes them").
Must-fix
2. --max-power-w is accepted when power can't be measured at all.
- With only the total budget set, the launch check returns early (
bootstrap.py:202-203). - So the flag is accepted:
- on multi-node sessions;
- with
HYPERLOOM_GPU_POWER_SAMPLING=0; - on hosts without
amd-smi.
- In the last two cases no recorder is built (
orchestrator/actions/executors/_gpu_power.py:304-309). - Every round is then
power_unmeasured. The run spends a full baseline and stops withbaseline_over_power_budget(orchestrator/loop/writeback.py:3925), which reads as "over budget" rather than "can't be measured". - Please refuse both budget flags with exit 2 in these cases, at launch and on resume.
3. A serving card without a reading drops out of the budget, so a partial reading fails open.
- A card counts as serving only when its peak VRAM is at least 10% and it has a power reading (
_gpu_power.py:185). Only serving cards are returned (:356-372). power_veto_reasonsums whatever readings are present and skips the limits of cards that are absent. Only an empty map ispower_unmeasured(common/perf_metric.py:361-371).- Example: TP4 on cards 4–7 with
--max-power-w 2000. If card 7 reportssocket_poweras N/A, or stays under 10% VRAM:- the total covers only three cards (about 1500 W), so an over-budget candidate can KEEP;
- a
--max-per-gpu-power-w 7 …limit is never checked.
- Please treat a round as unmeasured when a card in the session's mask was active but has no reading, and record such cards in
gpu_power.json. That matches the PR's own fail-closed rule.
Scope
- On #1730, @ZhengGong-amd asked to drop the mutating apply path and
--max-power-wfrom that PR and to take each up separately, and I agreed. - This PR's description answers the earlier objections: an average-power budget is a different question from a cap, and a power cap is reversible and evicts nothing. But it bundles both pieces again, and no agreement is recorded here.
- Blocker 1 is a concrete case of the "cards are shared" concern behind that request.
I suggest splitting the apply path into its own PR, and landing the budgets first once 2 and 3 are fixed. @ZhengGong-amd, can you confirm the direction?
|
Thanks @chaojhou. Addressed in Scope / blocker 1. Split as you suggested. 2. Unmeasurable budgets are refused. New 3. Partial readings fail closed. The sampler now records Tests cover each case, plus the refusal paths. The description is updated to match. |
60b7d99 to
d0e3b88
Compare
|
Rebased onto main (
Checked after the rebase: ruff check and format (0.16.2) clean; the power and contract tests (132) pass; and the broad suites show no failures beyond those on a clean main worktree in this environment. |
d0e3b88 to
d97ef35
Compare
|
Rebased onto main (
Rebase conflicts: Checked before pushing: ruff 0.16.2 check and format clean; |
|
/review |
|
Automated review started for head d97ef35. Progress: https://github.com/AMD-AGI/Hyperloom/actions/runs/38288907446 |
Code metrics gate: PASSED with waived findings (
|
| Dimension | Limit | New | Worse | Baseline out of date | Baseline entries | vs base |
|---|---|---|---|---|---|---|
| Cyclomatic complexity | fails > 20 | 0 | 0 | 0 | 120 | +0 |
| Cognitive complexity | fails > 30 | 0 | 0 | 0 | 320 | +0 |
| Function length (lines) | fails > 80 | 0 | 1 | 0 | 472 | +0 |
| Nested block depth | fails > 5 | 0 | 0 | 0 | 29 | +0 |
| Module length (lines) | warns > 800, fails > 1200 | 0 | 2 | 0 | 54 | +0 |
| Duplicated lines per file | fails > 0 | 0 | 0 | 0 | 120 | +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 |
Worse than the baseline (waived by the baseline-raise label): 3
| Unit | Dimension | Baseline | Now |
|---|---|---|---|
src/hyperloom/orchestrator/phases/kernel.py:2057 KernelPhase.promote_geak_from_candidate |
Function length (lines) | 165 | 167 |
src/hyperloom/orchestrator/actions/executors/_grid_runner.py:1 |
Module length (lines) | 2258 | 2259 |
src/hyperloom/orchestrator/phases/kernel.py:1 |
Module length (lines) | 4227 | 4229 |
How to fix
- New or worse: split the unit (extract a function, a class, a module) until it is back under the limit, or no worse than its recorded value. The baseline cannot absorb a new violation: CI refuses any baseline entry added or raised relative to the base branch.
- Baseline out of date: something improved. Run
python scripts/code_metrics.py --update-baselineand commitscripts/code_metrics_baseline.txt; the command only lowers or removes entries. - Override: the
baseline-raisePR label waives new, worse and baseline-growth findings and the failing design checks (they stay listed here). Use it only with the reason in the PR description; it waives nothing else. - Tools for a local run:
pip install ruff==0.16.2 complexipy==8.0.1 vulture==2.16andnpm install -g jscpd@5.4.1(or pointCODE_METRICS_JSCPDat another jscpd command, such asnpx --yes jscpd@<version>).
Module length warning: over 800 lines, within the 1200-line failure limit (not failing): 5
| Module | Lines | Limit |
|---|---|---|
src/hyperloom/inference_optimizer/cli/bootstrap.py |
816 | warns > 800 |
src/hyperloom/inference_optimizer/cli/parser.py |
1163 | warns > 800 |
src/hyperloom/orchestrator/actions/executors/benchmark_result.py |
986 | warns > 800 |
src/hyperloom/orchestrator/loop/conversation.py |
942 | warns > 800 |
src/hyperloom/orchestrator/state/_shared_state/render.py |
923 | warns > 800 |
Already over at the base (backlog, not this change's): 11
20 findings in files this change does not touch
Outside this change (informational): 20
| Unit | Dimension | Baseline | Now |
|---|---|---|---|
src/hyperloom/orchestrator/enablement/recipe/projections.py project_launch_evidence |
Cognitive complexity | absent | 36 |
src/kernelforge/cli/forge_loop_command.py |
Module length (lines) | absent | 1725 |
src/hyperloom/common/launch_log_evidence.py _server_args_from_log |
Cognitive complexity | 48 | 49 |
src/hyperloom/orchestrator/loop/dispatcher.py DispatcherCollaborator._spawn_fitting_queued |
Cognitive complexity | 110 | 111 |
src/hyperloom/common/launch_log_evidence.py _server_args_from_log |
Cyclomatic complexity | 22 | 23 |
src/hyperloom/orchestrator/actions/executors/_workload_envs.py materialize_config_with_envs |
Cyclomatic complexity | 110 | 111 |
src/hyperloom/orchestrator/actions/executors/_workload_envs.py materialize_config_with_envs |
Function length (lines) | 922 | 928 |
src/hyperloom/orchestrator/actions/executors/baseline.py BenchmarkRunExecutor._run_single_benchmark |
Function length (lines) | 578 | 580 |
src/hyperloom/orchestrator/loop/dispatcher.py DispatcherCollaborator._spawn_fitting_queued |
Function length (lines) | 242 | 243 |
src/hyperloom/orchestrator/loop/dispatcher.py DispatcherCollaborator.run_task_registered |
Function length (lines) | 109 | 111 |
src/hyperloom/orchestrator/policy/gate.py PolicyGate._validate_specialist_dispatch |
Function length (lines) | 91 | 93 |
src/hyperloom/orchestrator/prompts/prompt_builder.py build_orchestration_prompt |
Function length (lines) | 139 | 142 |
src/hyperloom/orchestrator/prompts/specialist_prompt_builder.py _section_identity |
Function length (lines) | 94 | 95 |
src/hyperloom/orchestrator/specialists/runner.py SpecialistRunner._prepare |
Function length (lines) | 192 | 200 |
src/hyperloom/orchestrator/actions/executors/baseline.py |
Module length (lines) | 4253 | 4291 |
src/hyperloom/orchestrator/loop/dispatcher.py |
Module length (lines) | 1503 | 1551 |
src/hyperloom/orchestrator/loop/intent_router.py |
Module length (lines) | 1548 | 1556 |
src/hyperloom/orchestrator/policy/gate.py |
Module length (lines) | 1626 | 1647 |
src/hyperloom/orchestrator/prompts/prompt_builder.py |
Module length (lines) | 1205 | 1223 |
src/hyperloom/orchestrator/prompts/specialist_prompt_builder.py |
Module length (lines) | 2573 | 2597 |
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.
There was a problem hiding this comment.
PR #1770 -- feat(power): --max-power-w total and --max-per-gpu-power-w budgets
What it does: Adds opt-in GPU power budgets for serving frameworks: --max-power-w caps the summed mean power of the serving cards over each round's measured phase, and repeatable --max-per-gpu-power-w GPU_ID W caps one card by its physical amd-smi index. Both ride the existing KEEP veto channel (constraint_veto_reason in common/perf_metric.py), so every KEEP decision point (explore, integrate_patch including the correctness-fix route, the stacked-integrate window, warm replay, the lift, and GEAK replays, which now get a sampled measured phase via a log-driven phase driver) reverts its own over-budget candidate; unmeasurable budgets are refused with exit 2 at launch and on resume, and an over-budget or unread baseline stops the run with baseline_over_power_budget. With neither flag set, decision behaviour is unchanged; gpu_power.json now also records unread possibly-serving cards (leaving that round's mean null), and GEAK replays are sampled.
Blocking issues: 2
- [V5] import-linter (architecture contracts) fails at head: the launch check imports a private orchestrator module across the layer boundary [verified]
Problem:src/hyperloom/inference_optimizer/cli/bootstrap.py:232doesfrom hyperloom.orchestrator.actions.executors._gpu_power import gpu_power_sampling_unavailable, reaching an_-prefixed module of another package from theinference_optimizerlayer (the A1 private-name reach the code-metrics gate also reports). The.importlinterhl-top-layerscontract carries a closed baseline of exactly six suchcli -> executorsentries ("the list may only shrink"), so this seventh cannot be waived by a baseline line; I ranPYTHONPATH=src lint-imports --no-cacheon the head tree and it fails on precisely this import (both the layers and the acyclic contract), while all contracts are kept at the merge base.
Impact: theimport-linter (architecture contracts)CI job is red at headd97ef35, so the PR cannot merge; the owner of_gpu_poweralso gains a caller it cannot see the next time it refactors the module.
Action: Route the sampler-availability check through a surface the CLI layer may import (re-homegpu_power_sampling_unavailablewhere the CLI can reach it legally, or have the launch refusal read it through a module the contract already allows), so the contract holds without adding a baseline entry. - [D12] The code-metrics gate fails at head: this diff raises three functions further past the complexity ceiling and adds a module-length violation and repeated literals, with no baseline-raise reason stated [verified]
Problem: Measured withruff --select C901(max-complexity 20) against the merge base:_run_optimize97->98 (src/hyperloom/inference_optimizer/cli/__init__.py:1743, the new launch-check branch),ConversationCollaborator.compose_prompt29->30 (src/hyperloom/orchestrator/loop/conversation.py:446, the power block),WritebackCollaborator._promote_baseline29->30 (src/hyperloom/orchestrator/loop/writeback.py:1763, the power stop block) — AGENTS.md's ceiling forbids raising a function past 20 without a stated reason, and the description gives none. The same gate also fails onsrc/hyperloom/inference_optimizer/cli/parser.pyreaching 1269 lines (over the 1200 failure limit) and on three repeated string literals thebaseline-raiselabel cannot waive:'max_power_w'and'max_per_gpu_power_w'each written 3 times incli/__init__.py:1962-1963and2401-2407, and'boot'now written 3 times insrc/hyperloom/orchestrator/actions/executors/_gpu_power.py:65(the phase driver's boot note).
Impact: thecode-metricsCI job is red at headd97ef35, so the PR cannot merge as it stands.
Action: Name each repeated literal once as a module-level constant, splitparser.pyunder the module-length limit, and extract the added branches (the power launch check out of_run_optimize, the baseline stop out of_promote_baseline's current-best block, the prompt section out ofcompose_prompt) so the functions are no worse than their recorded values — and where the gate allows a waiver, apply thebaseline-raiselabel with the reason in the PR description.
deferred: A1 -- the private-name reach at bootstrap.py:232 is the defect reported inside finding 1, kept under the V5 key where the red check names it
Checked: diff.txt in full; perf_metric.py (power_veto_reason/constraint_veto_reason/_ceiling_veto), _gpu_power.py (unread_gpus, ServerLogPhaseDriver, read_measured_gpu_power_by_gpu), bootstrap.py (budget conflict/unmeasurable/per-GPU checks), cli/init.py:1940-2000 and 2250-2420 (resume chain, framework resolution before the launch check), writeback.py:1763-2030 (_promote_baseline stops), shared_state.py from_dict, baseline.py:3801, _geak_sweep.py replay loop, integrate_patch.py:1817, integrate_performance.py, prelude.py _settle_warm_replay, render.py to_power_budget_summary; tree-wide greps for baseline_over_latency_budget consumers, latency_veto readers, and gpu_power_by_gpu_w writers/readers; .importlinter contract; ruff C901 at head and base | Ran: PYTHONPATH=src lint-imports --no-cache (fails on bootstrap.py:232); ruff C901 head vs base (three functions raised); pytest test_power_budget.py + test_latency_budget.py (123 passed), test_gpu_power_sampling.py (22), test_integrate_correctness_fix.py (14), warm-replay/GEAK/explore/integrate power tests (9), contract tests (145) | Base: ac6ae79 | Head: d97ef35
… says so ServerLogPhaseDriver caught every Exception around poll() and logged it at DEBUG, so a scan or phase bug left GEAK replays unmeasured with nothing visible. The scanner already absorbs its own read and decode errors; the one failure left is an OSError listing the replay's logs. Catch that, warn once per replay (then debug, so a vanished directory does not log every interval), and let anything else raise. _run_sampled now closes the recorder even when the driver raises, so gpu_power.json is still written.
965ccae to
4e13f77
Compare
|
Rebased onto main
|
|
/review |
|
Automated review started for head 4e13f77. Progress: https://github.com/AMD-AGI/Hyperloom/actions/runs/38418986039 |
|
Automated review: no verdict applied -- the review job ended with result 'failure'. Comment |
|
/review |
|
Automated review started for head 4e13f77. Progress: https://github.com/AMD-AGI/Hyperloom/actions/runs/38426070608 |
|
Automated review: no verdict applied -- the review job ended with result 'failure'. Comment |
|
/review |
|
Automated review started for head 4e13f77. Progress: https://github.com/AMD-AGI/Hyperloom/actions/runs/38442590460 |
There was a problem hiding this comment.
PR #1770 -- feat(power): --max-power-w total and --max-per-gpu-power-w budgets
What it does: Adds two opt-in GPU power budgets for the serving frameworks: --max-power-w caps the summed mean power of the serving cards over each round's measured phase, and repeatable --max-per-gpu-power-w GPU_ID W caps one card by its physical amd-smi index. Both ride the existing KEEP veto channel (constraint_veto_reason in common/perf_metric.py), so every KEEP decision point (explore, integrate_patch including the correctness-fix route and the stacked window, warm replay, the lift, and GEAK replays, which now get a sampled measured phase via a log-driven phase driver) reverts its own over-budget candidate; unmeasurable budgets are refused with exit 2 at launch and on resume, an unread or over-budget baseline stops the run with baseline_over_power_budget, and a round with an unread possibly-serving card (listed in gpu_power.json as unread_gpus, including a 0 W or negative reading) fails closed as power_unmeasured. With neither flag set, decision behaviour is unchanged; GEAK replays are newly sampled.
Blocking issues: 1
- [X3] A power veto is recorded and logged as "latency_budget" [verified]
Problem: The KEEP veto channel was widened to carrypower_budget_exceeded/gpu_power_budget_exceeded/power_unmeasured, but the two texts that name the refusing constraint after the old latency-only contract were not updated: the explore decision gate is labelled"latency_budget"whenevergraded.veto_reasonis set (src/hyperloom/orchestrator/actions/executors/explore.py:1421), and the lift logs "winner refused by the latency budget" (src/hyperloom/orchestrator/loop/writeback.py:1452) — both fire with the power reasons.breakdown/recorder/framework_event.py:987(_blocking_gate) persists that gate name into the session breakdown'sblocked_byfield, so a variant refused by the power budget is recorded as blocked by the latency budget. At the merge base the label was always correct, becauselatency_veto_reasonwas the only veto producer.
Impact: The explore ledger row and the persistedsession_breakdownblocked_bymisname the constraint that refused a variant; the orchestration prompt renders this ledger and its power-budget section tells the model to look for throughput-per-watt levers, so a power refusal mislabelled as latency steers the model toward latency levers; the lift log line names the wrong budget to the operator reading the session log.
Action: Name the gate for the constraint that fired — derive the gate label fromgraded.veto_reason(or a "power_budget" arm beside "latency_budget") at explore.py:1421, and reword the lift log at writeback.py:1452 to name the session constraints rather than the latency budget; extend the over-budget leg of test_explore_grades_the_rounds_own_gpu_power_against_the_budget to pin the gate label.
Checked: diff.txt in full; perf_metric.py (power_veto_reason/constraint_veto_reason/_ceiling_veto/CONSTRAINT_FIELDS/constraint_fields), _gpu_power.py (parse_power_sample's non-positive rule, summary/unread_gpus, ServerLogPhaseDriver, _read_round_artifact, read_measured_gpu_power_by_gpu, build_gpu_power_recorder), _geak_sweep.py (_run_with_power_sampling/_run_sampled/_replay_server_logs, the replay loop at :311-345 and _geak_replay_server_log still called at :371), bootstrap.py (all four budget checks, resolve_launch_gpu_power, constraint_budgets_from_args, _budget_resume_conflict), cli/init.py resume chain and launch path, writeback.py (lift_to_current_best veto and log, _promote_baseline stop, _replay_keep_from_result), baseline_budget.py, baseline.py:3821 result assembly, shared_state.py (resolve_graded_comparison veto placement, from_dict tolerance), integrate_performance.py, integrate_patch.py:1754, prelude.py _settle_warm_replay, kernel.py promote_geak_from_candidate, render.py, conversation.py, explore.py decision gates, stop_reasons.py and the four contract JSONs, constraint_args.py, report.py explanation merge, rebench.py; tree-wide sweeps (head and base) for gpu_power_by_gpu_w, power_budget_w, power_budget_per_gpu_w, baseline_over_power_budget, constraint_veto/latency_veto, latency_veto, GPU_POWER_ENV, _geak_replay_server_log, constraint_fields and the warm-replay outcome key; SharedState loader read and a pre-PR state.json load run; both workflow contract digests recomputed (b25e226a.../ece338b24...) and matching; ruff C901 (max 20) on all 24 changed production files at head and base; per-file comment/code ratio from the diff; comments.txt (all five review rounds' findings re-verified fixed at head); openprs.txt (#1798/#1845 file overlaps, no premise conflict); an independent reader pass over card.md, diff.txt and base.txt | Ran: pytest test_power_budget.py (65 passed), test_gpu_power_sampling.py (28), test_latency_budget.py (61), warm-replay/GEAK-promotion/explore/integrate/correctness-fix power tests (13), all at head 4e13f77; power_veto_reason/constraint_veto_reason called directly on the fail-closed shapes (None/{}/scalar/0.0/-5.0/NaN/inf/bool/string, per-GPU over, total over, at-limit, listed-not-serving, unlisted-inside-total); SharedState.from_dict on a pre-PR raw dict; workflow_contract_digest for both versions | Base: 1a444f6 | Head: 4e13f77
The KEEP veto channel carries power reasons too, but explore labelled every veto gate "latency_budget" (persisted as the session breakdown's blocked_by, and rendered in the ledger the orchestration prompt reads), and the lift logged "refused by the latency budget". GradedComparison.vetoed_by names the constraint behind veto_reason (power_budget or latency_budget); explore's gate and the lift log use it.
|
Re the review at 4e13f77, [X3] a power veto recorded and logged as "latency_budget": verified and fixed in the latest commit.
The description's operator-visible changes now mention the |
|
/review |
|
Automated review started for head c599b3f. Progress: https://github.com/AMD-AGI/Hyperloom/actions/runs/38452258614 |
Superseded by a clean automated review at c599b3f.
PR #1770 -- feat(power): --max-power-w total and --max-per-gpu-power-w budgetsWhat it does: Adds two opt-in GPU power budgets for the serving frameworks: Blocking issues: none Checked: diff.txt in full; perf_metric.py (power_veto_reason/constraint_veto_reason/_ceiling_veto/CONSTRAINT_FIELDS/constraint_fields/vetoed_by vs the latency twin's behaviour), _gpu_power.py (parse_power_sample's non-positive rule, summary/unread_gpus, ServerLogPhaseDriver vs the watchdog's own marker→phase mapping in _subprocess_kill.py:1095-1106, _read_round_artifact, read_measured_gpu_power_by_gpu, build_gpu_power_recorder), _geak_sweep.py (_run_with_power_sampling/_run_sampled/_replay_server_logs and the replay entry's path to promotion_measurement), bootstrap.py (all four budget checks, resolve_launch_gpu_power, constraint_budgets_from_args, _budget_resume_conflict), cli/init.py resume chain and launch path, writeback.py (lift_to_current_best veto and log, _promote_baseline stop, _replay_keep_from_result carrier), baseline_budget.py, baseline.py result assembly (extract_benchmark_measurement feeds the baseline stop at :3821), shared_state.py (resolve_graded_comparison veto placement, from_dict tolerance for pre-PR archives), integrate_performance.py, integrate_patch.py:1753 (correctness-fix route), prelude.py _settle_warm_replay, kernel.py promote_geak_from_candidate, render.py to_power_budget_summary/to_constraint_sections, stop_reasons.py and the four contract JSONs, constraint_args.py, report.py explanations merge, explore.py decision gate, _grid_base.py, _grid_runner.py, conversation.py, rebench.py; tree-wide sweeps (head) for every writer/reader of gpu_power_by_gpu_w, power_budget_w, power_budget_per_gpu_w, baseline_over_power_budget, veto_reason/latency_veto, GPU_POWER_ENV, _round_gpu_power_w, _positive_ms_arg/_positive_watts_arg; ruff --select C901 (max-complexity 20) on the 24 changed production files at head and at base; per-file comment/code ratio from the diff; ci.txt and comments.txt (all prior rounds' findings re-verified fixed at head); openprs.txt (#1798/#1316/#1845 overlaps, no premise conflict) | Ran: pytest test_power_budget.py + test_gpu_power_sampling.py (99 passed), test_latency_budget.py (61), test_integrate_correctness_fix.py (14), warm-replay/GEAK-promotion/explore/integrate power tests (9), all at head c599b3f; power_veto_reason/constraint_veto_reason/latency_veto_reason called directly on the fail-closed shapes (None/{}/0.0/-5.0/NaN/inf/bool/string, per-GPU over, total over, at-limit, listed-not-serving, unlisted-inside-total); SharedState.from_dict on a pre-PR raw dict; both workflow contract digests recomputed via workflow_contract_digest (b25e226a…/ece338b24…, matching) | Base: 1a444f6 | Head: c599b3f LGTM |
chaojhou
left a comment
There was a problem hiding this comment.
Re-reviewed at c599b3f1a. Everything from my reviews is resolved:
- From
16abb36b5:- The apply path is out of this PR.
- Both budgets are refused with exit 2 when power can't be measured, at launch and on resume.
- A possibly-serving card without a reading, or reading 0 W or less, makes the round
power_unmeasured.
- From
da75556dd: the replay phase driver's two broadexcept Exceptionare gone._poll_logscatches onlyOSErrorand warns once, anything else raises, and_run_sampledstill closes the recorder in a nestedfinally. The PR adds no broadexceptanywhere. - New since then:
GradedComparison.vetoed_bynamespower_budgetfor the three power reasons, so the explore gate and the lift log no longer call a power veto a latency one. The rebase only changed thetest_gpu_power_sampling.pyconflict resolution and one loweredcode_metrics_baseline.txtentry.
The baseline-raise label covers three units that grow by one or two lines to carry the power reading, and the description gives that reason. CI is green, including code-metrics and pr-hygiene. LGTM.
PR #1770 -- feat(power): --max-power-w total and --max-per-gpu-power-w budgetsWhat it does: Adds two opt-in GPU power budgets for serving frameworks: Blocking issues: none Checked: the three commits since my re-review at LGTM |
…session ends By default --gpu-power-cap-w / --gpu-perf-level only assert what the operator set. With --apply-gpu-power-settings the session sets them itself, once at launch, and restores the originals when it ends. Split out of #1770 and reworked for the review there: - Only the cards of an integer ROCR_VISIBLE_DEVICES / HIP_VISIBLE_DEVICES mask are set; without one, or when amd-smi does not report a masked card, the launch refuses. A card holding someone else's model, or whose VRAM use cannot be read, refuses too. - The originals go to per-card records under /var/tmp/hyperloom/ gpu_power_settings (host-local, independent of HYPERLOOM_RUNTIME_DIR and USER_DATA_PATH), held under flock for the session and stamped with the boot id, so a record from before a reboot is ignored. A restore sets whatever a record says, so records are readable by all but writable only by their owner, opened without following symlinks, and refused (with their directory) when another unprivileged account owns them or could write them. - The restore is armed before the first amd-smi set, so a SIGTERM or Ctrl-C during the set itself still restores. - The restore runs from the finally of gpu_power_restore_guard around the session, with atexit as a backstop. From apply until restore, SIGTERM and SIGHUP exit through that finally; the coordinator's SignalDrain takes them over during its loop and hands them back at close. A stop signal during the restore is ignored. - A SIGKILL leaves the record with its lock free; the next launch with the flag restores those cards first, and one without it warns. - Budget refusals, including the per-GPU check against the mask, are checked before anything is applied. The set/restore primitives and the lease live in common/gpu_power_control.py so common/gpu_power_settings.py stays read-only; the three power-settings flags move to cli/gpu_power_args.py.
…session ends By default --gpu-power-cap-w / --gpu-perf-level only assert what the operator set. With --apply-gpu-power-settings the session sets them itself, once at launch, and restores the originals when it ends. Split out of #1770 and reworked for the review there: - Only the cards of an integer ROCR_VISIBLE_DEVICES / HIP_VISIBLE_DEVICES mask are set; without one, or when amd-smi does not report a masked card, the launch refuses. A card holding someone else's model, or whose VRAM use cannot be read, refuses too. - The originals go to per-card records under /var/tmp/hyperloom/ gpu_power_settings (host-local, independent of HYPERLOOM_RUNTIME_DIR and USER_DATA_PATH), held under flock for the session and stamped with the boot id, so a record from before a reboot is ignored. A restore sets whatever a record says, so records are readable by all but writable only by their owner, opened without following symlinks, and refused (with their directory) when another unprivileged account owns them or could write them. - The restore is armed before the first amd-smi set, so a SIGTERM or Ctrl-C during the set itself still restores. - The restore runs from the finally of gpu_power_restore_guard around the session, with atexit as a backstop. From apply until restore, SIGTERM and SIGHUP exit through that finally; the coordinator's SignalDrain takes them over during its loop and hands them back at close. A stop signal during the restore is ignored. - A SIGKILL leaves the record with its lock free; the next launch with the flag restores those cards first, and one without it warns. - Budget refusals, including the per-GPU check against the mask, are checked before anything is applied. The set/restore primitives and the lease live in common/gpu_power_control.py so common/gpu_power_settings.py stays read-only; the three power-settings flags move to cli/gpu_power_args.py.
…session ends By default --gpu-power-cap-w / --gpu-perf-level only assert what the operator set. With --apply-gpu-power-settings the session sets them itself, once at launch, and restores the originals when it ends. Split out of #1770 and reworked for the review there: - Only the cards of an integer ROCR_VISIBLE_DEVICES / HIP_VISIBLE_DEVICES mask are set; without one, or when amd-smi does not report a masked card, the launch refuses. A card holding someone else's model, or whose VRAM use cannot be read, refuses too. - The originals go to per-card records under /var/tmp/hyperloom/ gpu_power_settings (host-local, independent of HYPERLOOM_RUNTIME_DIR and USER_DATA_PATH), held under flock for the session and stamped with the boot id, so a record from before a reboot is ignored. A restore sets whatever a record says, so records are readable by all but writable only by their owner, opened without following symlinks, and refused (with their directory) when another unprivileged account owns them or could write them. - The restore is armed before the first amd-smi set and runs from the finally of gpu_power_restore_guard around the session, with atexit as a backstop. From then until the restore, SIGTERM and SIGHUP exit through that finally; the coordinator's SignalDrain takes them over during its loop and hands them back at close. A stop signal during the restore is ignored. - A SIGKILL leaves the record with its lock free; the next launch with the flag restores those cards first, and one without it warns. - A perf-level apply is refused on cards starting at a level a restore could not bring back: determinism (its soft-max is set with amd-smi set -d) and manual (its hand-tuned clock limits are reset by setting another level). A setting a card already has is left alone (neither set nor recorded), so a card asked to stay at its level is not refused; a cap-only apply is allowed. - Every restore holds the stop signals, including the one after a failed set, and reads the cards back: each card back at its originals has its record cleared, even if a set failed; a card not back is reported and keeps it. - Orphan records are judged setting by setting, under the lease: a setting still at its applied value is put back (a card whose cap came back but whose level did not gets its level restored); a setting the card has moved from is left alone; a setting whose reading is missing keeps the record (recovery refuses, the warning reports it); a record with none still applied is stale: cleared, not restored over the operator, and not warned about. - A record that cannot be written or cleared is reported as one ERROR line; no card is set without its record. - Budget refusals, including the per-GPU check against the mask, are checked before anything is applied. The set/restore primitives and the lease live in common/gpu_power_control.py so common/gpu_power_settings.py stays read-only; the power-settings flags move to cli/gpu_power_args.py, and DeclaredPowerSettings carries the declared values.
…session ends By default --gpu-power-cap-w / --gpu-perf-level only assert what the operator set. With --apply-gpu-power-settings the session sets them itself, once at launch, and restores the originals when it ends. Split out of #1770 and reworked for the review there: - Only the cards of an integer ROCR_VISIBLE_DEVICES / HIP_VISIBLE_DEVICES mask are set; without one, or when amd-smi does not report a masked card, the launch refuses. A card holding someone else's model, or whose VRAM use cannot be read, refuses too. - The originals go to per-card records under /var/tmp/hyperloom/ gpu_power_settings (host-local, independent of HYPERLOOM_RUNTIME_DIR and USER_DATA_PATH), held under flock for the session and stamped with the boot id, so a record from before a reboot is ignored. A restore sets whatever a record says, so records are readable by all but writable only by their owner, opened without following symlinks, and refused (with their directory) when another unprivileged account owns them or could write them. - The restore is armed before the first amd-smi set and runs from the finally of gpu_power_restore_guard around the session, with atexit as a backstop. From then until the restore, SIGTERM and SIGHUP exit through that finally; the coordinator's SignalDrain takes them over during its loop and hands them back at close. A stop signal during the restore is ignored. - A SIGKILL leaves the record with its lock free; the next launch with the flag restores those cards first, and one without it warns. - A perf-level apply is refused on cards starting at a level a restore could not bring back: determinism (its soft-max is set with amd-smi set -d) and manual (its hand-tuned clock limits are reset by setting another level). A setting a card already has is left alone (neither set nor recorded), so a card asked to stay at its level is not refused; a cap-only apply is allowed. - Every restore holds the stop signals, including the one after a failed set, and reads the cards back: each card back at its originals has its record cleared, even if a set failed; a card not back is reported and keeps it. - Orphan records are judged setting by setting, under the lease: a setting still at its applied value is put back (a card whose cap came back but whose level did not gets its level restored); a setting the card has moved from is left alone; a setting whose reading is missing keeps the record (recovery refuses, the warning reports it); a record with none still applied is stale: cleared, not restored over the operator, and not warned about. - A record that cannot be written or cleared is reported as one ERROR line; no card is set without its record. - Budget refusals, including the per-GPU check against the mask, are checked before anything is applied. The set/restore primitives and the lease live in common/gpu_power_control.py so common/gpu_power_settings.py stays read-only; the power-settings flags move to cli/gpu_power_args.py, and DeclaredPowerSettings carries the declared values.
…session ends (#2032) * feat(power): opt-in --apply-gpu-power-settings, restored however the session ends By default --gpu-power-cap-w / --gpu-perf-level only assert what the operator set. With --apply-gpu-power-settings the session sets them itself, once at launch, and restores the originals when it ends. Split out of #1770 and reworked for the review there: - Only the cards of an integer ROCR_VISIBLE_DEVICES / HIP_VISIBLE_DEVICES mask are set; without one, or when amd-smi does not report a masked card, the launch refuses. A card holding someone else's model, or whose VRAM use cannot be read, refuses too. - The originals go to per-card records under /var/tmp/hyperloom/ gpu_power_settings (host-local, independent of HYPERLOOM_RUNTIME_DIR and USER_DATA_PATH), held under flock for the session and stamped with the boot id, so a record from before a reboot is ignored. A restore sets whatever a record says, so records are readable by all but writable only by their owner, opened without following symlinks, and refused (with their directory) when another unprivileged account owns them or could write them. - The restore is armed before the first amd-smi set and runs from the finally of gpu_power_restore_guard around the session, with atexit as a backstop. From then until the restore, SIGTERM and SIGHUP exit through that finally; the coordinator's SignalDrain takes them over during its loop and hands them back at close. A stop signal during the restore is ignored. - A SIGKILL leaves the record with its lock free; the next launch with the flag restores those cards first, and one without it warns. - A perf-level apply is refused on cards starting at a level a restore could not bring back: determinism (its soft-max is set with amd-smi set -d) and manual (its hand-tuned clock limits are reset by setting another level). A setting a card already has is left alone (neither set nor recorded), so a card asked to stay at its level is not refused; a cap-only apply is allowed. - Every restore holds the stop signals, including the one after a failed set, and reads the cards back: each card back at its originals has its record cleared, even if a set failed; a card not back is reported and keeps it. - Orphan records are judged setting by setting, under the lease: a setting still at its applied value is put back (a card whose cap came back but whose level did not gets its level restored); a setting the card has moved from is left alone; a setting whose reading is missing keeps the record (recovery refuses, the warning reports it); a record with none still applied is stale: cleared, not restored over the operator, and not warned about. - A record that cannot be written or cleared is reported as one ERROR line; no card is set without its record. - Budget refusals, including the per-GPU check against the mask, are checked before anything is applied. The set/restore primitives and the lease live in common/gpu_power_control.py so common/gpu_power_settings.py stays read-only; the power-settings flags move to cli/gpu_power_args.py, and DeclaredPowerSettings carries the declared values. * fix(power): resolve CodeQL empty-except and unreachable-test findings Co-authored-by: Cursor <cursoragent@cursor.com> * fix(power): apply only as root, with a ledger only root writes An unprivileged launch with --apply-gpu-power-settings created the record directories and an empty record before amd-smi refused it, owned by that account, and a later root launch then refused to trust them until removed. - apply_for_session refuses without root (amd-smi set needs it) before the lease is taken, so nothing is written. - The record directory and its parent are set to 0755, whatever the umask or however an operator made them, instead of sticky 1777: only root writes records, and every account can still read them for the orphan warning. A sticky directory is no longer trusted, and a ledger an account cannot reach reads as no records instead of crashing the warning of a launch without the flag. - A record that is a hard link or not a plain file is refused, so a restore's record write cannot land on another file. - The restore's done flag is set with the stop signals already held. - Refusal messages no longer advise launching as a non-root owner. - Tests: the default hooks hand the restore to the session's guard, main() runs the session inside it, and the root refusal comes before any record. - mypy: no new errors in the new modules; bandit B108 marked for the fixed ledger path. Docs, help and SKILL.md say root is needed and that only a launch whose mask includes the cards recovers them. Co-authored-by: Cursor <cursoragent@cursor.com> * test(power): restore the unreachable-ledger test's directory owner-only Co-authored-by: Cursor <cursoragent@cursor.com> * Potential fix for pull request finding 'CodeQL / Overly permissive file permissions' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Revert "Potential fix for pull request finding 'CodeQL / Overly permissive file permissions'" This reverts commit b985b45. Owner-only (0600) root records cannot be read by a non-root launch without the flag, which then silently loses its warning about cards a dead session left changed. Records hold only a card's original cap and perf level and stay 0644; only root can write them. The CodeQL alert is left for dismissal as won't fix. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(power): translate a visible-device mask into amd-smi's card numbers ROCm numbers GPUs in KFD node order and amd-smi in PCI order; on MI300X/MI355X boards the two differ (on the MI355X validation host, ROCm card 0 is amd-smi card 3). visible_gpu_indices returned the mask numbers unchanged, so the power-settings apply could set another job's card, and the read-only assertion, the per-GPU budget check and the power sampler looked at the wrong cards. HIP_VISIBLE_DEVICES was also ignored whenever ROCR_VISIBLE_DEVICES was set. visible_gpu_indices now resolves ROCR_VISIBLE_DEVICES (cards in KFD node order), then HIP_VISIBLE_DEVICES indexing into what ROCR left, and matches each card to amd-smi through the KFD node id `amd-smi list` reports. A mask that is not numeric, or names a card that does not exist, is refused: the apply and a declared assertion refuse, a per-GPU budget refuses at launch, the sampler samples every card (serving cards are found by their VRAM), and the orphan warning reports every record. No mask means every card and no amd-smi call. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(power): a resume stops its own left-over servers before it applies A flagged session whose optimizer was SIGKILLed while its server was up was refused on resume: the apply's busy-card check took the session's own surviving server for another tenant's resident model, because the Coordinator reaps it only at boot, after the apply. _restore_platform_state now reaps the session's recorded servers (its own pidfiles, serving cmdline only) before the power settings resume, when the flag is set. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(power): drop the injection seams only tests passed _Hooks, the hooks= / ledger_dir= parameters of apply_declared_gpu_power_settings, the ledger_dir= / read= parameters of orphaned_power_settings_warning, AppliedPowerSettings and its second restore channel, and the run= / timeout= / path= parameters of the gpu_power_control helpers existed only for tests (AGENTS.md: no parameter set only by tests). The apply, the restore, orphan recovery and the warning now read the amd-smi helpers, the lease, visible_gpu_indices, GPU_POWER_LEDGER_DIR and _GUARD as module names at call time, the apply arms the guard directly and returns its record, and _RestoreGuard.run returns the problems it reported. Tests patch those names, and amd-smi at subprocess.run, through one helper and an autouse fixture that gives every test its own guard. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(power): close the review's remaining gaps in the apply path - A card not in SPX compute-partition mode is refused: its cap is per socket and would change the sibling partitions other sessions may use. - A fractional cap is refused before anything is leased; amd-smi set takes whole watts. --gpu-perf-level accepts only the levels amd-smi set takes. - _RestoreGuard.run holds the stop signals before it takes the restore, and orphan recovery restores under the same hold. - A lease waits up to a second for the shared lock a launch without the flag takes to read a record, instead of refusing as if a live session held the card; an unexpected flock error no longer leaks the record's descriptor. - After an apply, a setting that did not take says so instead of advising to set it with amd-smi before launch. - The setting keys and perf levels are defined once in gpu_power_settings, and its {value, unit} reader is named for what it reads. - Docs: the mask translation, the partition and whole-watt refusals, which ledger directories are set to 0755 and which are refused, recovery running before anything is set rather than before everything, and that any local account can make a flagged launch refuse (a held record lock, a pre-created /var/tmp/hyperloom), which fails safe. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(power): run the resume's reap through the Coordinator's own reaper inference_optimizer may not import orchestrator internals (the layer contract's grandfathered imports may only shrink), so the resume no longer imports _server_lifecycle. The Coordinator's best-effort orphan reap becomes the module function reap_orphaned_servers_best_effort(session_dir, phase=...), called at boot and shutdown as before, and by _restore_platform_state on every resume before the power settings are restored: it is the reap the Coordinator runs at boot moments later, so a resume without the flag only reaps sooner. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(power): read only the visible-device masks ROCm honours, and refuse malformed ones HSA_VISIBLE_DEVICES and GPU_DEVICE_ORDINAL are ignored by the ROCm runtime (checked against HIP on MI355X, ROCm 7.2.4), so reading them as masks named cards the session's servers are not limited to, and a root apply could cap them. visible_gpu_indices now reads ROCR_VISIBLE_DEVICES, then HIP_VISIBLE_DEVICES or CUDA_VISIBLE_DEVICES, and refuses a mask that is set but empty or repeats a card, both of which the runtime exposes differently from how they read. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(power): accept a visible-device mask only as the HIP runtime spells it HIP stops reading a mask at a space, an empty or ';'-joined token or a leading zero: HIP_VISIBLE_DEVICES='1, 2' exposes one card and '01' none, while the shared token splitter read both as cards. visible_gpu_indices now accepts only plain comma-separated device numbers and refuses anything else, so the cards it names are the ones the runtime exposes. Co-authored-by: Cursor <cursoragent@cursor.com> * test(power): close every file the lease tests open CodeQL py/file-not-closed: the shared-lock test now holds its lock on a file opened in a with block and releases it from the timer, and the descriptor-leak test compares this process's open descriptors before and after instead of wrapping os.open. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Follow-up to #1730, which landed the measured-phase GPU power sampler and the assert-only
--gpu-power-cap-w/--gpu-perf-level. This PR adds the power budgets split out of #1730 in review. They are opt-in: with neither flag set, KEEP behaviour is unchanged (GEAK replays are now sampled, which only adds agpu_power.json). The opt-in--apply-gpu-power-settingsthat was bundled here has been split into its own branch and will come as a separate PR.--max-power-wand--max-per-gpu-power-w: a power budget is a constrained maximumThe deployment it serves. Data-centre racks and rows are provisioned for average power, with each card left at its full cap as headroom for peaks. The question that deployment asks is "the most throughput whose average draw fits the budget". A per-card cap doesn't answer it: capping every card at the budget throws away the boost headroom the rack was provisioned to keep. Perf-per-watt doesn't answer it either.
How it differs from Perf/Watt. Take the example from the #1730 review, with a 1000 W budget:
They answer different questions, so this PR doesn't claim to be a Perf/Watt objective. That belongs in
resolve_graded_comparisonas an objective, and can follow on the same measurement.Why the numbers transfer. The session runs at the deployed cap, which it asserts with
--gpu-power-cap-w. So candidates are measured under the same DVFS and boost conditions the deployment sees; only the average-power budget is added. Grading below the deployed cap would not transfer, and that is not what this does. A site that caps the cards themselves at 1000 W shouldamd-smi setthe cap and assert it, with no ceiling, exactly as the #1730 review suggested. The docs say so.Cost. There's no new mechanism.
constraint_veto_reasonfolds latency and power into the veto channel reviewed for #1297, and it applies only to would-be KEEPs. Every KEEP decision point honours it, so a lane reverts its own over-budget change:integrate_patch(including the correctness-fix route, which KEEPs a verified fix after a REVERT and so applies the budgets itself),assess_integrate_performance(including thestack_positive_keepwindow), warm replay, and the lift;What is budgeted. Two flags, either or both:
--max-power-w Nis the total for the serving GPUs: the sum of each serving card's measured-phase mean, for example 2800 W for four cards. A rack or row budget is a total, so the flag is one too.--max-per-gpu-power-w GPU_ID W, repeated once per card, limits one card by its physicalamd-smiindex, for a card with less headroom (a hotter slot, a weaker supply). A serving card with no entry is bound only by the total, and a listed card that didn't serve a round isn't judged on it.Both are enforced twice:
amd-smireports when there is no mask), or if the per-GPU limits add up to more than the total. Either budget is refused, at launch and on resume, when no round could be measured: on multi-node (the sampler reads only its own host), on a scriptable framework (xdit,custom, which run no server for the sampler to follow), withHYPERLOOM_GPU_POWER_SAMPLING=0, or withoutamd-smi.gpu_power_by_gpu_wnext togpu_power_avg_w:gpu_power_budget_exceededwhen a card is over its own limit,power_budget_exceededwhen the total is over, andpower_unmeasuredwhen there is no per-card reading. Only the feat(power): measure per-GPU power over the measured phase; assert and record the GPU power settings #1730 sampler reads each card, so the benchmark report's host-wide mean counts as unmeasured (fail closed). A round is also unmeasured when a card that may be serving couldn't be read (a card holding a model with no power reading, where a 0 W or negative socket power counts as none, or a card in scope whose VRAM was never read); such cards are listed asunread_gpusingpu_power.json, so a missing card can't drop out of the total or escape its own limit.A baseline that breaks either budget, or has no reading, stops the run with
baseline_over_power_budget. The stop reason is in both contract versions, with the digests regenerated. A resume with a different value for either flag is refused, and the prompt gets one line naming the total and each card's limit.Validation (MI355X, cards 4–7)
baseline_over_power_budget, measured by the feat(power): measure per-GPU power over the measured phase; assert and record the GPU power settings #1730 sampler, before any candidate ran. In an 8-hour session at a 725 W budget, two candidates measured 708 W and 686 W. Both lost on throughput, so they carried no power veto.--max-per-gpu-power-w 0 700refused with exit 2, naming GPU 0 and the available 4–7; limits of 3 × 700 W against--max-power-w 2000refused with exit 2 ("add up to 2100 W, more than --max-power-w 2000 W"). Both stopped before any model loaded.--max-power-w 4000 --max-per-gpu-power-w 4 300, the MiniMax M3 AgentX baseline (306 tok/s) stopped withbaseline_over_power_budget(gpu_power_budget_exceeded): 2877 W total, inside the 4000 W total, but GPU 4 at 710 W against its 300 W limit (GPU 5 735 W, GPU 6 724 W, GPU 7 708 W, each read by the sampler).Operator-visible changes
--max-power-w N: KEEPs whose serving GPUs draw more than N W in total, or with no per-card reading, are refused, and a baseline over N stops the run. An explore variant refused this way is recorded with the decision gatepower_budget(the session breakdown'sblocked_by), notlatency_budget. The launch is refused where power can't be sampled (multi-node, a scriptable framework, sampling off, noamd-smi).--max-per-gpu-power-w GPU_ID W: the same for that one card; the launch is refused if the limits add up to more than--max-power-w, or name a card the session can't use.Breaking changes: none.
Code layout and the code-metrics gate
Main's code-metrics ratchet landed while this PR was open, so the constraint code now has its own homes instead of growing over-limit units:
perf_metric.constraint_fields()copiese2el_mean_ms,gpu_power_avg_wandgpu_power_by_gpu_win every measurement carrier (explore,integrate_patch, warm replay, rebench, the grid runner, writeback), so no lane can drop one.orchestrator/measurement/baseline_budget.pyholds the baseline stop (latency first, then power) and its report text.cli/constraint_args.pyholds--max-latency-ms,--max-power-wand--max-per-gpu-power-w;cli/bootstrap.pyholds the launch and resume budget checks.gpu_power_sampling_unavailable()lives incommon/gpu_power_settings.py, so the CLI does not import the orchestrator for it.Behaviour is unchanged by the move. Thirteen touched units are smaller than on main, and the baseline is lowered for them.
baseline-raise: three units stay over main.kernel.pyandKernelPhase.promote_geak_from_candidateare two lines over:gpu_power_avg_wandgpu_power_by_gpu_win the tuple of fields a GEAK promotion copies._grid_runner.pyis one line over: the import ofconstraint_fields. Those lines carry the power reading on these paths. Without them, a GEAK promotion or grid-runner KEEP would have no reading and fail closed under any power budget. The only other way to pass the gate would be deleting unrelated lines.Tests
unread_gpus(a serving card with no power reading or a 0 W one, a masked card missing fromamd-smi, unreadable VRAM; an idle card with no reading does not block).amd-smi.integrate_patch, the correctness-fix route (total and per-GPU budget),assess_integrate_performance, warm replay, the GEAK lift.