-
Notifications
You must be signed in to change notification settings - Fork 257
[Power] feat: stamp whole-deployment semantics with a schema version / 为功耗指标加盖整机语义版本号 #2599
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -46,6 +46,12 @@ | |
| "joules_per_total_token", | ||
| } | ||
|
|
||
| # The unprefixed joules_per_* fields silently switched from role-local to | ||
| # whole-deployment energy when multinode aggregation landed, and the values | ||
| # alone cannot distinguish the two. Stamp the semantics so consumers fail | ||
| # closed on unversioned rows instead of guessing. | ||
| POWER_METRIC_SCHEMA_VERSION = 2 | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class PowerIntegration: | ||
|
|
@@ -756,6 +762,7 @@ def _patch_power_result( | |
| data.pop(key, None) | ||
| # Keep the canonical aggregate numeric-only for InferenceX-app's metric | ||
| # auto-capture. Detailed reason codes live in the validation sidecar. | ||
| data["power_metric_schema_version"] = POWER_METRIC_SCHEMA_VERSION | ||
| data["power_valid"] = int(power_valid) | ||
| data.pop("power_invalid_reasons", None) | ||
| if power_valid: | ||
|
Comment on lines
762
to
768
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 In _patch_power_result, non-finite metrics (e.g. an absurd-but-finite power sample like 9e307 W overflowing the trapezoidal integration to inf) are only detected inside the per-key write loop, which raises ValueError before _write_json_atomic runs. Since run()'s except handler doesn't retry the write, the on-disk agg keeps whatever process_result.py originally wrote -- no power_metric_schema_version and no power_valid -- breaking this PR's own contract that every power result carries the schema version. The multinode sibling (aggregate_power_multinode.py) already guards this exact overflow case up front so _patch_agg still stamps power_valid=0 plus the schema version; aggregate_power.py should get the analogous guard. Extended reasoning...What happens: In utils/aggregate_power.py, run() calls _derived_metrics() unguarded and passes the result straight to _patch_power_result(). That function only checks for non-finite values inside its per-key write loop (if value is None or not math.isfinite(value): raise ValueError(...)), and this check happens after it has already mutated the in-memory data dict (stamping power_metric_schema_version and power_valid) but before _write_json_atomic(agg_path, data) is called. Since the raise happens before the write, the on-disk agg file is left completely untouched by this invocation. Trigger path: integrate_power() only rejects a power sample if it's non-finite or negative (not math.isfinite(power) or power < 0). An absurd-but-finite value like 9e307 W passes that check. When _integrate_device() sums such values across the trapezoidal integration, the result overflows to inf. power_valid stays True (no reasons were ever appended), so _derived_metrics() runs and returns a metrics dict containing inf/nan values (it only guards against None, not non-finite floats). _patch_power_result() then raises ValueError on the first such key, mid-loop, before the atomic write. Why the except handler doesn't save it: run() wraps the _patch_power_result() call in except (OSError, json.JSONDecodeError, ValueError), which does catch the raised error -- so there's no crash and the run still exits 0 (or 1 under REQUIRE_POWER). But the handler only resets the local power_valid/metrics variables that feed the sidecar/validation JSON; it never re-invokes _patch_power_result or otherwise re-writes the agg JSON. The agg file on disk ends up exactly what process_result.py wrote before power aggregation ran -- meaning it has neither power_metric_schema_version nor power_valid. Why this matters for this PR specifically: the PR's entire premise (per AGENT_OPERATIONS.md) is that every power result, valid or invalid, carries power_metric_schema_version, and that rows missing the field predate the whole-deployment semantics switch. This bug produces a post-PR row that is missing the field for an unrelated reason (an aggregation-time crash), which a downstream consumer would misinterpret as "this row predates the schema" rather than "this row's power integration overflowed." The sibling module already fixed this exact case: aggregate_power_multinode.py's validate_and_integrate() computes metrics, then explicitly checks non_finite = sorted(key for key, value in metrics.items() if not math.isfinite(value)) before calling _patch_agg, and if any are found it adds reason non_finite_power_metric and returns an invalid audit -- so _patch_agg still runs and stamps power_valid=0 plus the schema version. There's even a dedicated test, test_overflowing_power_metric_is_invalid_not_stale, proving the maintainers consider this scenario test-worthy. aggregate_power.py never received the analogous up-front guard. Proof walkthrough:
Suggested fix: mirror the multinode guard -- compute the non-finite metric keys up front in run() (or inside _derived_metrics / before calling _patch_power_result) and fall back to power_valid=False, metrics={} so _patch_power_result is always called with values that can be written, guaranteeing the schema version and power_valid=0 land on disk even in this overflow case. |
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Three modules independently reset the power fields on the aggregate JSON (pop stale keys, stamp
power_metric_schema_version, setpower_valid, poppower_invalid_reasons) —aggregate_power.py::_patch_power_result(762-767),aggregate_power_multinode.py::_patch_agg(~1167-1171), andprocess_result.py::record_power_internal_error(~77-83). This PR adds the same new schema-version line to all three copies instead of factoring the shared stamp logic into one helper inaggregate_power.py(which the other two already import from). Not a correctness issue, but the next contract change (e.g. bumping to version 3) has to be made correctly in three places with three different key-set variables.Extended reasoning...
What:
aggregate_power.py's_patch_power_result,aggregate_power_multinode.py's_patch_agg, andprocess_result.py'srecord_power_internal_erroreach contain an almost-identical four-step block: pop a set of stale power metric keys, setdata['power_metric_schema_version'] = POWER_METRIC_SCHEMA_VERSION, setpower_valid(asint(power_valid),int(audit.power_valid), and the literal0respectively), and poppower_invalid_reasons. This PR's diff makes the triplication visible in the most direct way possible: it adds the exact same new line —data["power_metric_schema_version"] = POWER_METRIC_SCHEMA_VERSION— to all three functions, each as a separate hunk in three different files.\n\nWhy it happens: the only real variation between the three sites is the set of keys being popped (_POWER_METRIC_KEYSvs_ALL_POWER_METRIC_KEYSvs_POWER_METRIC_KEYS + _MULTINODE_ROLE_METRIC_KEYS) and wherepower_validcomes from.process_result.py's copy exists for a legitimate reason — it's the fallback path used whenaggregate_power/aggregate_power_multinodeitself failed to import, so it deliberately keeps its own literal copy of the multinode role-metric keys rather than importing them. But that constraint only explains the key-set duplication, not the stamp/valid/pop sequence itself, which could still be centralized.\n\nWhy nothing catches this today: there's no test or lint rule enforcing that the three envelope-patching functions stay in sync; they're only kept consistent by whoever remembers to touch all three when the contract changes. This PR is itself proof of the pattern working correctly this time (all three got the new line), but that's diligence, not a structural guarantee.\n\nImpact: none today — the PR is correct as written and all 126 tests pass. The cost is future maintenance risk: the next schema-affecting change (e.g. bumping to version 3, adding a new required top-level field, or changing howpower_invalid_reasonsis handled) requires editing three call sites with three different key-set variables and remembering thatprocess_result.py's copy is a fallback that must independently trackaggregate_power_multinode.ROLE_METRIC_KEYSas a literal. Missing one site silently leaves stale keys or an unstamped schema version on some code path, which is exactly the kind of drift this PR itself was written to close (the unprefixedjoules_per_*semantics silently changed once already without any version marker).\n\nSuggested fix: extract a small helper inaggregate_power.py, e.g._stamp_power_envelope(data, *, metric_keys, power_valid)that performs the pop-loop, schema-version stamp,power_validassignment, andpower_invalid_reasonspop in one place.aggregate_power_multinode.pyandprocess_result.pyalready import constants fromaggregate_power.py, so they could import this helper too —process_result.py's fallback path would still pass its own literal_POWER_METRIC_KEYS + _MULTINODE_ROLE_METRIC_KEYStuple as themetric_keysargument, preserving its independence from the multinode module's import.\n\nProof of the triplication — grep the three sites:aggregate_power.py:762-767hasfor key in _POWER_METRIC_KEYS: data.pop(key, None)then the stamp/valid/pop trio;aggregate_power_multinode.py:_patch_agg(~1167-1171) hasfor key in _ALL_POWER_METRIC_KEYS: data.pop(key, None)then the identical stamp/valid/pop trio;process_result.py:record_power_internal_error(~77-83) has two pop loops (_POWER_METRIC_KEYSthen_MULTINODE_ROLE_METRIC_KEYS) then the same trio again. Diff each function's tail four lines side by side and they are structurally identical modulo thepower_validsource expression.