feat(cpu): add cpu_power sampler for RAPL energy and C-state residency - #1071
feat(cpu): add cpu_power sampler for RAPL energy and C-state residency#1071yangxi wants to merge 12 commits into
Conversation
7a20a10 to
58b5878
Compare
|
Reviewed against the PMU work that landed today (#1091, #1093, #1096). One substantive finding, one correction to something I said earlier, and a rebase note. Finding:
|
brayniac
left a comment
There was a problem hiding this comment.
Reviewed against the PMU work that landed today (#1091, #1093, #1096).
One substantive finding, inline below: the member bound is a dense prefix, and these readers can be sparse — so the gaps would publish zero joules rather than nothing. Same failure #1096 fixed today, and there is now a one-line way to express the right thing.
Two notes that need no change:
This does not need a PMU budget entry, and I was wrong to imply otherwise. RAPL is its own PMU — /sys/bus/event_source/devices/power is type 18 against the core PMU's type 4 — so it never draws on the contended core general-purpose counters that #1093 rations. Being unbudgeted is correct here, exactly as for cpu_l3 (uncore) and cpu_frequency (MSR). Adding it to pmu::DEFAULT_PRIORITY as a non-Core kind would only keep that table the complete picture of who opens hardware events, which is what its doc claims — behaviour-neutral, so take it or leave it.
Rebase before merge. This branch predates #1085, #1093 and #1096. #1085 in particular now bounds any acquisition group whose sampler did not come up to zero members, so cpu_power's groups must be registered under a name that appears in SAMPLERS; the Linux-only every_registered_group_names_a_real_sampler test will catch it if not.
The sampler itself reads well — the Index::Ordinal / Index::Cpu distinction and the cpumask handling are the parts I expected to find wrong and did not.
Adds a `cpu_power` sampler that reports CPU energy, power, and idle-state residency, plus Power and C-State Residency sections in the CPU dashboard. Everything is read through perf. The sampler discovers the `power`, `power_core`, `cstate_core`, and `cstate_pkg` PMUs from /sys/bus/event_source/devices and opens counters on the CPUs each PMU's cpumask permits, so there is no CPU vendor or model detection: which PMUs exist, which events they expose, and which CPUs may read them are all properties the kernel already publishes, and they vary by part as much as by vendor. The cpumask also encodes scope. A package-scope PMU lists one CPU per package; a core-scope PMU lists one CPU per physical core with SMT siblings already excluded. Package-scope metrics are indexed by ordinal position in the mask rather than CPU id, since the ids are sparse (`0,64` on a two-socket host) while the groups are sized to MAX_PACKAGES. Notes on two choices: * RAPL is read via perf rather than /dev/cpu/N/msr. The perf PMUs cover every domain the hardware implements, need CAP_PERFMON rather than CAP_SYS_RAWIO, handle the 32-bit counter wrap, and report pre-scaled energy. The `msr` PMU is not an alternative route: despite taking a 64-bit config, msr_event_init() rejects anything outside a fixed allowlist of eight architectural counters, none of them energy registers. * Dashboard power plots derive watts from the energy counters rather than the power gauges. The gauges average over the sampler's refresh interval, so a scrape landing mid-interval reports a stale value, and they are integer milliwatts so a microwatt-scale domain reads zero. Validated against turbostat on three hosts under a staged sysbench load (idle -> 1 thread -> half cores -> all cores -> idle), comparing mean package power over the same window: sky (Skylake, 12c) turbostat 36.31 W rezolus 36.90 W (1.6%) amd (Zen, 24t/12c) turbostat 80.22 W rezolus 81.15 W (1.2%) hyb (hybrid, 16t) turbostat 27.79 W rezolus 28.05 W (0.9%) Per-refresh cost scales with counter count: 146 us p50 for amd's 13 counters, 1002 us for hyb's 37. For comparison, cpu_frequency measured 316 us and 585 us on the same hosts in the same windows. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Removes the six `cpu_<domain>_power` gauges. Power is the derivative of
energy and is better computed at query time:
irate(cpu_package_energy[5m]) / 1000000
The counters are microjoules, so irate() yields uJ/s (= uW) and the
divisor converts to watts. That is correct over any window, which a
sampled gauge is not: a gauge can only ever describe the sampler's own
refresh interval, so a scrape landing mid-interval reads a stale value
(this is what made AMD package power read ~35% low during development).
At integer-milliwatt resolution a gauge also truncates a microwatt-scale
domain to zero -- an idle iGPU reported 0 mW while its energy counter
visibly advanced.
Dropping them halves the sampler's series count (13 of 26 on a Zen host,
14 of 28 on Skylake) and removes the per-refresh timing bookkeeping:
PowerInner::last_sample, the elapsed computation, and the Instant import
were all dead once the gauge write was gone.
The dashboard needed no change -- its Power plots already derived watts
from the energy counters rather than reading the gauges.
Verified on a Zen host: 13 series discovered and reported (12
cpu_core_energy + 1 cpu_package_energy), clippy clean.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Migrates the sampler onto the acquisition-group machinery added in iopsystems#1070. Without this its metrics land in the windowless default group, which is the one shape principle 18 exists to remove. Two groups, not one. Principle 18 collapses like entities within a metric family but keeps families apart, and this sampler has two distinct read sections: * `cpu_power_energy_sweep` -- the RAPL energy domains, read back-to-back in one uninterrupted loop over like entities (one `read()` per counter) with no phase boundary between domains. * `cpu_power_cstate_sweep` -- idle residency, a different family read from different PMUs (`cstate_core`/`cstate_pkg`, not `power`). Both brackets span their member writes and stamp last via `finish()`. Neither discards: a single counter's failed `read()` is individually, normally fallible rather than a bulk sweep failure -- the same ruling `cpu_l3` and `cpu_dtlb` already make for per-entity perf reads. Member bounds come from the discovered readers rather than the metric groups' MAX_CPUS/MAX_PACKAGES array capacity, per "membership comes from registration, not values". Because energy and c-state readers share one Vec, each group's window spans the whole sweep rather than only its own reads -- a deliberate upper bound on the read span, which over-states rate() uncertainty and never under-states it. Verified on a Zen host with a debug build (live debug_assert!): two scrapes, zero assertion failures, 13 series intact. A misregistered group name would trip the assert in snapshot.rs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two drift guards in `agent::samplers::attribution_tests` fail without this: * `registered_samplers_are_in_expected_universe` requires every sampler on SAMPLERS to appear in EXPECTED_SUBSYSTEMS. * `metric_samplers_match_agent_attribution` requires the analysis-side `subsystem_of` to agree with the agent's own `attribute_sampler` for every registered metric. The second needs explicit entries rather than relying on prefix matching, because none of this sampler's metrics are spelled with its name: the sampler is `cpu_power` but the metrics are `cpu_package_energy`, `core_c6_residency`, `package_c6_residency`, and so on. That is the same situation METRIC_SAMPLERS already documents for `cpu_cycles` -> `cpu_perf`. Adds all 23 metric names plus the subsystem entry, keeping both lists sorted. Note for future sampler work: neither guard is reachable from a macOS build, since `samplers/cpu/linux/` is cfg'd out there. They only fail on Linux. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
`EXPECTED_SUBSYSTEMS` is the universe the extractor reports presence against, so adding a sampler to it changes the golden fixture's `subsystems_absent` list -- the fixture exercises blockio only, so `cpu_power` is absent there. The module doc at golden.rs:9 calls out exactly this coupling. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Addresses review feedback on iopsystems#1071. Two defects, one found by review and one it exposed. **Sparse membership.** `set_member_bound(max(index) + 1)` declares the dense prefix `0..n`, but these readers are sparse. `Index::Cpu` uses the CPU id, and a hybrid part's core-scope cpumask skips SMT siblings -- `0,2,4,6,8,10,12-15` on a 16-thread host here -- so a prefix spans six ids nothing writes. Any single `open_counter` failure does the same. A declared-but-unwritten CounterGroup slot reads as 0, so those would have published 0% idle residency on six cores that were never measured. Switched to `set_member_set` (iopsystems#1096), which declares the exact indices. **Mixed entity spaces.** The member set is per group and `members()` walks one index list for every metric in it, so a group must only hold metrics sharing an entity space. The old two groups did not: package- and core-scope energy shared one, and core and package c-states shared another. The union of the core cpumask with the package cpumask, clamped to each metric's backing array, gave `package_cN_residency` four members on a single-socket host. Split into five groups, one per entity space -- which is exactly what the `Index` variant already encoded: cpu_power_package_energy Ordinal pkg/cores/igpu/dram cpu_power_core_energy Cpu AMD energy-core cpu_power_platform_energy Zero PSys cpu_power_core_cstate Cpu core_cN + the summed total cpu_power_package_cstate Ordinal package_cN This also brings the sampler in line with principle 18's granularity rule (different metric families keep their own groups), which the previous grouping read too loosely by treating "one read loop" as one family. Verified on the 16-thread hybrid host that exhibits the sparsity: package_cN_residency [0,2,4,6] -> [0] cpu_core_energy declared -> absent (no power_core PMU) cpu_platform_energy declared -> absent (no PSys domain) core_cN_residency [0,2,4,6,8,10,12,13,14,15] throughout A domain whose PMU is absent but whose entity space is shared with a live one (`cpu_dram_energy` beside `cpu_package_energy`) still appears in the group schema, and emits `null` -- "no measurement" -- rather than a fabricated zero. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
58b5878 to
deeb50f
Compare
Drops this sampler's hand-rolled `parse_cpu_list` for `pmu::parse_cpu_list`, which already exists in the agent crate and is already tested (`0-2,1-3` overlap, whitespace, backwards ranges, trailing garbage). The two agreed on every well-formed input -- verified by running both against real sysfs values including a hybrid host's `0,2,4,6,8,10,12-15` -- and differ only on malformed ones, which sysfs does not emit: the shared parser rejects them outright rather than returning a partial set. `unwrap_or_default()` keeps this sampler's existing behaviour, where an unreadable cpumask yields no counters for that PMU, the same place an absent PMU lands. Net -27 lines, and the parsing now has test coverage it did not have here. Note for a follow-up, deliberately not in this PR: three more copies remain. `cpu_l3` has a near-identical lenient one, and `systeminfo` has two (`hwinfo/util.rs`, `summary/linux.rs`) that return Result. Those are pre-existing, sit outside this PR's scope, and the systeminfo pair would need the parser exposed across a crate boundary. The strict/lenient split itself is deliberate and worth keeping: `pmu.rs` parses an operator-supplied config string where a typo must fail loudly, while the sysfs readers parse kernel-generated values. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Addresses the optional half of the review on iopsystems#1071. Behaviour-neutral: only `PmuKind::Core` entries are rationed, and every other kind is granted unconditionally without touching the budget. The point is the table's own claim. Its doc says the non-Core rows exist so it is "a complete picture of who opens hardware events and where — and so that adding a budget for them later is a matter of measuring their capacity, not of rediscovering which samplers they are." `cpu_power` opens hardware events and was missing, so the table under-claimed. A new kind rather than reusing `Msr`, which would be wrong on the facts: `power` is a different device from `msr` — type 14 (Zen) and 31 (hybrid) against msr's 13 — and the `msr` PMU's fixed eight-entry allowlist holds no energy register, so RAPL is not reachable through it at all. Nothing matches exhaustively on `PmuKind`, so the variant costs nothing. The count column is documented as events per CPU, which does not apply to a package-scope PMU. It carries the most domains `power` can expose (pkg, cores, gpu, ram, psys); real hosts open one to three of them. Since a non-Core grant is never denied, `wants` is never surfaced for it and the number is documentary only — the comment says so. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
They were the last `plot_promql_full` in the CPU power/C-state sections, so each package C-state spanned the full grid while the core C-state plots beside them were half-width -- the same mistake the power plots had before, in a spot the earlier fix did not sweep. Adds the per-package companion on the same gate the core and power plots use (`metric_unique_label_count(..., "id") > 1`). A package-scope counter has one series per socket, and resolveStyle() only draws a heatmap for a multi-series result, so on a single-socket host the companion would just repeat the aggregate beside itself; a two-socket host gets the heatmap with no further change. Verified against the recorded parquets: every plot in the C-State Residency group now reports width=half on both Intel hosts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
brayniac
left a comment
There was a problem hiding this comment.
Nice sampler — the discovery path, the sparse-index registration, and especially the measured overhead (n=600 across three hosts, with cpu_frequency as a comparison baseline) are exactly what docs/principles.md asks for. Principle 16 is satisfied without argument, which is the part that usually isn't.
Requesting changes on two things that are wrong rather than merely wide: the acquisition-group handling, and three dashboard percentages that are silently scaled wrong on any SMT host.
Acquisition groups / observation windows
The group abstraction is declared here but not honored on either axis — time or membership.
W1 — Five groups, one identical window. refresh() acquires all five guards up front (src/agent/samplers/cpu/linux/power/mod.rs:196), sweeps a single flat Vec<Reader>, then finishes all five (:244). All five windows are byte-identical spans of the whole sweep. Principle 18 names this exact outcome — "several groups all wearing one identical whole-loop window: no new information, pure schema bloat" — as the reason the device-visit archetype collapses to one group. As written this pays five groups' schema cost for one group's information.
It also contradicts its own doc comment: Reader.acq (:78-81) is documented as held "so refresh brackets each entity space separately", but refresh never reads the field. init already partitions readers by group at :146, so group-major bracketing is a few lines.
W2 — The stale-value dispensation is claimed without meeting its condition. The "None discard" comment (:188-190) cites the cpu_l3/cpu_dtlb ruling. That ruling is real, but cpu_l3 and cpu_dtlb each declare exactly one group (CPU_L3_ACQ, CPU_DTLB_ACQ), so their single bracket genuinely is their single read section. Principle 18 scopes the "stale value under a freshly stamped window" trade to the device-visit archetype and only when "(c) the families genuinely cannot be read family-major without re-visiting every device once per family." Here (c) plainly fails — a flat Vec of independent perf fds, no per-device handle, no re-visit cost. So this takes the archetype's cost without having its constraint.
W3 — The first refresh stamps a fresh window over values nothing wrote. Three continue paths skip the write (failed read(), the baseline sample, counter went backwards) while finish() stamps regardless. On the very first refresh every member is in the baseline path, so every counter reads 0 under a freshly stamped window. By this PR's own reasoning at :132-134 — "a declared index nothing writes would publish zero joules -- a wrong value on a metric whose whole purpose is measuring draw" — that's the same defect the sparse-index registration carefully avoids, just transient rather than permanent.
W4 — Membership is a union across each group's metrics, so absent domains come back as phantom series. init sets one member set per group (:143-151), and the snapshot builder applies that same set to every metric routed to the group (src/agent/exposition/http/snapshot.rs:1188-1196, member_set = ag.member_set()). But CPU_PACKAGE_ENERGY, CPU_CORES_ENERGY, CPU_IGPU_ENERGY and CPU_DRAM_ENERGY all share cpu_power_package_energy, and CORE_C1..C10 all share cpu_power_core_cstate. So a server exposing only energy-pkg/energy-ram still emits cpu_igpu_energy and cpu_cores_energy at every package ordinal, and a Skylake exposing only c1/c3/c6/c7 still emits core_c2/c8/c9/c10_residency for every core. That contradicts docs/metrics.md's "the rest are absent rather than zero" and the stats.rs comment about fabricated zeros, and it makes the dashboard render empty "Integrated Graphics Power" / "Core C2" subgroups.
W1 and W4 have a single fix: one group per RAPL domain and per C-state level (principle 18's "families do not collapse"), each bracketed group-major over just its own readers. That yields genuinely distinct windows and correct membership. Per-metric membership within one group isn't something the machinery supports today, so splitting the groups is the path.
Dashboard: three percentages are scaled wrong on any SMT host
crates/dashboard/src/dashboard/cpu.rs:434, :466, :501 — one root cause.
sum(irate(core_cstate_residency[5m])) / sum(irate(cpu_tsc[5m])) sums the numerator over the core-scope cpumask — docs/metrics.md:177 states that PMU is one CPU per physical core with SMT siblings excluded — against a denominator written per logical CPU (CPU_TSC.set(self.id, tsc), src/agent/samplers/cpu/linux/frequency/mod.rs:228).
- A fully idle 2-way SMT box reads 0.50, not 1.00.
- The hybrid host in your own test matrix (10 physical cores / 16 threads) reads 0.625.
:501is worse:package_cN_residencyis a single per-package counter ticking at TSC rate, divided by the TSC sum over all logical CPUs — a package sitting 100% in PC6 renders as 8.3% on a 12-thread single-socket host.:512has the same scaling error.
Every one of these is .percentage_range(), so the result looks plausible rather than obviously broken. The sum by (id) / sum by (id) companions at :444 and :476 are correct — the join drops unmatched ids — which is exactly what hides the aggregate's mismatch in testing.
Smaller items
energy-psysdouble-counts on multi-socket (power/mod.rs:297-303). It usesIndex::Zerowhile its siblingpower-PMU domains useIndex::Ordinalprecisely because that cpumask has one CPU per package. On a two-socket part exposing PSYS, both readersadd()intocpu_platform_energy{id="0"}→ 2×. PSYS is platform-wide, so that's the same quantity twice, not two halves. You note multi-socket is untested; either open PSYS on the first cpumask entry only, or index it by ordinal.- Missing
DOMAIN_ALIASESentries (src/analysis/extract/context.rs:327-330).domain_of("core_c6_residency")→"core"anddomain_of("package_c6_residency")→"package"; neither aliases to"cpu". On a non-"rezolus"source those land inuncertain_domains, socpu_powerescapesbuild_coverage's pruning and gets reported insubsystems_absentwhile its own metrics are present in the recording. Needs("core","cpu")and("package","cpu")— the same pattern asdrive→drivehealth. Index::resolvewarns on an over-capacity index but still builds the reader (power/mod.rs:352). AboveMAX_PACKAGES = 8that's aread()syscall per event per refresh whose result is always discarded. ReturningNonewould make the ceiling visible as "no data" instead of silent per-tick work.- Lone half-width plots. When
per_idis false (single-package — the common case) the domain's only plot usesplot_promql, leaving a half-empty row;cpu.rsusesplot_promql_fullfor exactly this at:38,:317,:347. Same at:397,:433,:463,:498. - Stale references to removed gauges. The comment at
cpu.rs:361and the PR description both refer tocpu_*_powergauges as "still exported", but no power gauge exists instats.rs(anddocs/metrics.mdcorrectly says there is no power metric). The module header still opens "Collects CPU energy, power, and idle-state residency".Unit::Energy(plot.rs:580) and theenergyunit system inunits.jsare added but unused by any plot.
What's already right
Stamp-last ordering, one writer (the Mutex-serialized refresh), sparse index registration declared explicitly rather than as a 0..n prefix (with a good comment on why), like-entity grouping within each declared group, graceful absence under virtualization, and the measured overhead. The sum by (id) variants of every affected plot are correct as written.
🤖 Review assisted by Claude Code
…idency plots Addresses review of iopsystems#1071. Acquisition groups (W1-W4). The five entity-space groups became one group per metric, because the exposition layer applies a group's member set to every metric routed to it: a shared group declares the union of its metrics' populations, and any metric short of that union publishes fabricated zeros at the surplus indices. A server exposing only energy-pkg/energy-ram was emitting cpu_igpu_energy and cpu_cores_energy at every package ordinal, and a Skylake exposing c1/c3/c6/c7 was emitting core_c2/c8/c9/c10 for every core -- contradicting docs/metrics.md's "the rest are absent rather than zero". refresh() now brackets each group over only its own readers rather than acquiring all five guards around one flat sweep, so the windows carry distinct information instead of five byte-identical spans (principle 18's "no new information, pure schema bloat"). init() partitions the readers once; these are independent perf fds with no per-device handle, so group-major costs exactly what the flat sweep did -- which is also why this does not need the device-visit archetype's stale-value dispensation. A bracket that wrote nothing now discards instead of stamping. On the first refresh every reader takes the baseline path, so the old code stamped a fresh window over counters still reading 0. core_cstate_residency keeps a group whose members are the union of the core levels -- the one place a union is right, since every core reader writes it. Dashboard residency scaling. core_cstate_residency is core-scope (one CPU per physical core, SMT siblings excluded) while cpu_tsc is written per logical CPU, so sum/sum read ncores/nproc: 0.50 on a 2-way SMT box. The aggregate now averages the per-id ratio, whose keyed join drops logical CPUs with no core-scope counterpart. Package plots divide by avg(cpu_tsc), not sum: a package counter ticks at one package's TSC rate, so the matching whole is one CPU's worth, not the sum over every thread. Also: energy-psys opens only the first cpumask entry, since it is one platform-wide quantity that a package-scope cpumask would have added into index 0 once per package; Index::resolve returns None above capacity so no reader is built for an index whose every read is discarded; DOMAIN_ALIASES maps core/package to cpu so cpu_power stops landing in subsystems_absent; lone plots use plot_promql_full; and the unused Unit::Energy plus the energy unit system are removed, with the stale cpu_*_power gauge references corrected -- no power gauge is exported. Verified on sky (12t/6c), hyb (16t/10c) and amd (24t) via systemslab: - The recorded metric set equals the PMU-exposed set exactly on all three, checked against sysfs. amd exposes energy-pkg alone and now reports "Metric not found" for the other three domains rather than zeros. - Idle residency reads 0.989 (sky) and 0.995 (hyb) where the pre-fix query reads 0.495 and 0.622. - Package power tracks turbostat within ~1.5% (amd 72.2W vs 71.1W). Package C-state residency scaling is correct by construction but not demonstrated by measurement: every package C-state counter read flat zero on both Intel hosts, which never enter a package idle state while an agent, recorder and turbostat are running. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks — all of it was right, and the two blocking findings were wrong rather than wide as you said. Pushed as fa04be6. Acquisition groups (W1–W4)Went further than one group per RAPL domain and per C-state level: one group per metric, 23 in total. The reason is W4's mechanism —
You were also right that Dashboard scalingThe engine has no Package plots divide by Smaller itemsAll five taken. PSYS opens only the first cpumask entry (kept VerificationRan the sampler on sky (12t/6c), hyb (16t/10c) and amd (24t) through systemslab, staged sysbench idle→1→half→all→idle, turbostat alongside. Membership — exact match on all three, recorded metric set vs PMU-exposed set checked programmatically against sysfs. amd is the W4 case: its Residency scaling — matches your arithmetic:
Power vs turbostat — amd 72.2 W vs 71.1 W; hyb traces 3.7 W idle → 50 W all-cores → 3.8 W. One thing I could not demonstrate. The package-residency |
brayniac
left a comment
There was a problem hiding this comment.
Re-reviewed in a worktree with the branch merged onto current main (6c014d3d, ~30 commits past its merge base). Two independent passes — a manual read and a /code-review high agent — converged on the same top two findings.
Merge/build state: merges cleanly. cargo check --workspace --all-targets clean; cargo test --bins 689 pass, cargo test -p dashboard 80 pass, analysis/extract 91 pass; scripts/check-viewer-symlinks.sh resolves. The Linux-only sampler body is not compiled on the review host (macOS).
Last round's two blocking findings are fixed, correctly. set_member_set replaced the prefix bound, and the split went past what I asked for — one group per metric, which is the right granularity for exactly the reason given: snapshot.rs applies member_set() per metric, not per group.
Three things below; the first two are the ones I'd want changed before merge.
1. A host with no RAPL PMU is reported as disabled, not unsupported
src/agent/samplers/cpu/linux/power/mod.rs:180
Ok(None) lands in set_disabled() (src/agent/mod.rs:150) — the same state enabled = false produces. So on every VM, which is the case the comment right above it names, /samplers and rezolus status will report cpu_power as turned off by config.
SamplerState::Unsupported's own doc comment rules this out in as many words:
Deliberately not
Disabledeither: nobody turned it off, and an operator readingdisabledwould reasonably go looking for the config that did.
The established pattern for hardware absence is Err(sampler_status::Unsupported(...)) — l3/mod.rs:276 (the exact analogue: "no L3 cache domains"), tlb_flush/mod.rs:81, drivehealth/linux/mod.rs:92. This branch already contains the commits that drew that line (#1102 / #1103). docs/metrics.md carries the wrong word through too ("where the sampler disables itself").
2. core_cstate_residency is permanently windowless
src/agent/samplers/cpu/linux/power/mod.rs:243
CPU_POWER_CORE_CSTATE_ACQ is registered and gets a member set, but nothing ever brackets it. open_cstate throws the group away (total: total.map(|(metric, _)| metric), line 569), and refresh iterates self.groups, built from an ACQ_GROUPS list that deliberately excludes it. So no acquire()/finish() ever touches its slot.
The comment at line 232 says its window "is not stamped separately: the readers that feed it are bracketed by their own level groups." That is true of the values, but a level group's bracket stamps that level's own GroupWindowSlot, not this one. Concretely: AcquisitionGroup::window() returns None for cpu_power/cpu_power_core_cstate for the life of the process, so create_v3 emits it as GroupSnapshot { window: None } (snapshot.rs:1694), and the .rez table for core_cstate_residency carries null :window_begin/:window_width for the whole recording.
Consequence: no acquisition-window uncertainty band on rate()/irate() for that metric in the viewer or mcp query, while all eight per-level siblings beside it have one — and it is the metric behind the dashboard's headline "Total C-State Residency" plot. Rows are still written, so this is silent degradation rather than data loss. It is also the operational-checklist item verbatim: are the member metrics tagged acq_group 1:1 with the builder wiring?
Fix is small: one outer CORE_CSTATE bracket around the core-cstate portion of the sweep. The union of the level reads is the honest window for a summed metric.
3. The sweep is serial, where both nearest precedents are parallel
src/agent/samplers/cpu/linux/power/mod.rs:289
PowerInner::refresh() issues every Counter::read() inline on the async worker. The two structurally identical perf samplers in this tree — cpu_frequency (frequency/mod.rs:94) and cpu_l3 (l3/mod.rs:91) — both fan their reads out to dedicated per-core threads and join_all them inside the acquisition bracket.
That difference already shows in this PR's own numbers: cpu_frequency measured lower (316 / 585 us) than cpu_power (1002 us) on the same hosts, despite comparable counter counts.
It matters because ~11-27 us per counter is high for a read(2), and the reason is structural: a perf event opened with .one_cpu(N) and read from another CPU goes through smp_call_function_single — a cross-CPU IPI per counter, which costs the reading thread and perturbs the core being measured. cstate_core opens one counter per physical core per level, so a 2x32-core server exposing c1+c6 is ~140 serialized reads and ~140 IPIs per refresh, against a default snapshot ttl of 10ms, with the sampler on by default (only gpu_amd_pmu is opt-in).
The hosts measured are 12t / 16t / 24t. The reviewing-samplers checklist asks for the number at max CPU count rather than the dev box, and the PR already lists multi-socket as untested. Either match the thread pattern, or post the high-core-count measurement so the serial sweep is a known quantity.
Smaller
src/viewer/assets/lib/features/explorers.js:6—UNIT_OPTIONSwas not extended withpower. Line 333 seedsunitOverridefromplot.opts.format.unit_system, so opening the plot explorer on any of the six new Power plots selects a value the<select>has no<option>for: it displays "Auto (none)", and the first touch of the dropdown silently drops the watt formatting. Shared frontend module, so it affects both viewers. Add{ value: 'power', label: 'Power (W)' }.units.jspower system has no sub-watt scale. It starts atWwith precision 2, so the idle-iGPU microwatt domain — the case cited to justify deriving power from energy rather than the milliwatt gauge — still renders0 W.mW/uWsteps would close that.power/mod.rs:493— a whole energy domain can drop silently.event_scale()returningNonebails with no log, andDynamic::builder(pmu).ok()?is silent in both helpers.scale()returnsOk(None)when the.scalesidecar is merely absent, so an event the PMU does expose but whose sysfs lacks.scaledisappears with zero diagnostic — while every other failure path in the file emits adebug!. Adebug!here makes "why iscpu_dram_energymissing on this box?" answerable fromRUST_LOG=debug.power/mod.rs:472— warn amplification.Index::resolvewarns per(pmu, event, cpu)triple.Index::Cpuis used across every core-scope cpumask entry and every exposed level, so a host with core ids pastMAX_CPUS = 1024emits one line per level per out-of-range core. A once-per-(pmu, event)summary ("N of M CPUs beyond the 1024-entry ceiling") carries the same information.- No unit tests.
Index::resolveis pure, and it covers exactly the multi-socket ordinal path the PR flags as untested — testable without the hardware.
Checked and correct, so not listed above
The microjoule carry-remainder accumulator (no truncation drift); the counter-reset / checked_sub re-baseline; std::ptr::eq group partitioning (every AcquisitionGroup is a static, so address identity is sound); the core_cstate_residency member-set union; Index::Zero's single-reader rule for energy-psys; PmuKind::Rapl being unbudgeted (plan() grants every non-Core kind); the DOMAIN_ALIASES additions; and the new PromQL — avg(<binary>) is supported, and sum by (id)(x) / avg(y) matches via matrix_matrix_op's single-right broadcast fallback, so the package residency plots do work despite the label-set mismatch.
🤖 Generated with Claude Code
… summed residency window Addresses the second review round. `init` returned `Ok(None)` when no energy or c-state PMU was present, which lands in `set_disabled()` -- the same state `enabled = false` produces. Every VM therefore reported `cpu_power` as turned off by config, sending an operator looking for the setting that did it. Hardware absence is what `Unsupported` exists to say, and it is the call `cpu_l3` already makes for a part with no L3 domains. `docs/metrics.md` carried the wrong word through too. `core_cstate_residency` is written by the core-scope level readers rather than by a group of its own, so nothing ever bracketed `CPU_POWER_CORE_CSTATE_ACQ`: `window()` returned `None` for the life of the process and every row landed with a null `:window_begin`/`:window_width`. The metric behind the dashboard's headline "Total C-State Residency" plot was the only one of its nine siblings with no acquisition-window uncertainty band. `refresh` now opens an outer bracket over the whole sweep and stamps it when any core-scope level wrote -- the union of the level reads, which is the honest span for a summed metric -- and discards it otherwise, the same rule the per-group brackets follow. Measured on hardware, reading the column out of a `.rez`: 0 of 21 rows stamped before, 161 of 161 after, on both hosts that expose `cstate_core`. Also from that round: - `event_scale` logged nothing on any failure path. `scale()` returns `Ok(None)` when the `.scale` sidecar is merely absent, so an event the PMU does expose could drop a whole energy domain with no diagnostic at all. - `Index::resolve` warned per `(pmu, event, cpu)`. `Index::Cpu` is used across every core-scope cpumask entry and every exposed level, so a host with ids past the ceiling emitted one line per level per core to carry one fact. The callers now count skips and emit one summary per `(pmu, event)`. - `UNIT_OPTIONS` was missing `power`, so opening the plot explorer on any of the six Power plots selected a value with no matching `<option>` and the first touch of the dropdown silently dropped the watt formatting. - The `power` unit system started at `W`, rendering a microwatt-scale domain as `0 W` -- the exact failure that motivated deriving power from the energy counters rather than the milliwatt gauges. Measured 16 uW on a live idle iGPU here. - No unit tests on `Index::resolve`, which is pure and covers the multi-socket ordinal path this branch flags as untested. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks — all three of the top findings were right, and the second one was wrong rather than merely wide, exactly as you said. Two are fixed; the third I'm pushing back on, with measurements. Pushed as a2d2253. 1.
|
| rows | stamped | null | |
|---|---|---|---|
| pre-fix (hyb) | 21 | 0 | 21 |
| post-fix (hyb) | 161 | 161 | 0 |
| post-fix (sky) | 161 | 161 | 0 |
Post-fix window width p50 is 775 us on hyb, 426 us on sky.
One note worth recording, because it nearly cost me the regression test: I first tried asserting on mcp query's uncertainty band, and that band is present even when every window is null — the query engine falls back to a sampler-level window for a windowless V3 table. It would have passed both before and after the fix. The null count in the column is the ground truth; the band is not.
3. The serial sweep — measured four ways, and I'd like to keep it
This is the one I'm disagreeing with, so here is the data rather than an argument.
I built the two structural alternatives plus a third, selectable at runtime so every arm runs the same binary, and measured n≈619 per cell on all three hosts, idle and under full sysbench load. The instrument is cpu_power sampling latency from refresh_with_logging.
serial— current code, reads inline on the async worker.thread1— one unpinned worker thread. This is whatcpu_frequency/cpu_l3actually do on bare metal:spawn_threads()only picks the pinned per-core path whenis_virt().pin0— one worker pinned to cpu0, where every package-scope counter is opened.percpu— one worker per CPU, each pinned to the CPU its counters were opened on. The only arm that removes the IPI rather than relocating it.
Under load — serial wins on every host
| host | serial | thread1 | pin0 | percpu |
|---|---|---|---|---|
| hyb (38 ctr) | 52 us | 65 (+25%) | 59 (+14%) | 64 (+23%) |
| sky (28 ctr) | 52 us | 71 (+37%) | 70 (+35%) | 82 (+58%) |
| amd (13 ctr) | 24 us | 33 (+38%) | 35 (+46%) | 38 (+58%) |
p99 is worse than the median: on amd percpu is 79 us against serial's 26 (+204%).
Idle — percpu wins big
| host | serial | percpu |
|---|---|---|
| hyb | 1002 us | 241 us (−76%) |
| sky | ~794 us | 522 us (−34%) |
| amd | 44 us | 36 us (−18%) |
Why the two regimes disagree
The cost is C-state exit latency, not IPI compute. hyb's deepest idle state has a 1048 us exit latency and its serial idle p50 is 1002 us — each cross-core read wakes a sleeping core. Under load the target core is already awake, so a read costs ~1.4–1.9 us and that rate is flat across all three hosts; what's left is pure handshake overhead.
So: idle cost = exit latency × remote counters (pinning removes it); busy cost = ~1.6 us × counters (nothing to remove, threading only adds).
Two things fall out of this that I think matter more than the specific numbers:
Copying the cpu_frequency pattern would have accomplished nothing. thread1 — same single unpinned thread that sampler uses on bare metal — moved hyb's idle p50 by −0.5% to +0.0%. It relocates the IPIs off the async worker without eliminating one.
Idle is the expensive case, ~19× busy on hyb (1002 vs 52 us). Measuring a perf sampler under load, which is the intuitive test, hides almost the whole cost. That seems worth putting in the reviewing-samplers checklist independently of this PR.
Extrapolating serial's flat ~1.6 us/counter to a 2×32-core server gives roughly 320 us at ~200 counters under load. Real, but not alarming against a 10 ms ttl — and percpu would add ~64 handshakes on top of reads that were already cheap.
Still unmeasured: multi-socket. All three hosts are single-socket desktop parts, so I can offer counter-count and C-state scaling across three points, not the 2×32-core number you asked for. Flagging that rather than extrapolating past it.
Also unmeasured: partial load. My busy condition pegs every thread, which is the most favourable case for serial since no core is ever idle. A half-loaded machine sits between the two regimes and is arguably the most realistic fleet state. Happy to measure that if you'd rather decide on it.
Smaller items — all five taken
explorers.jsUNIT_OPTIONS— added{ value: 'power', label: 'Power (W)' }.units.jssub-watt — addeduW/mWsteps belowW. The idle-iGPU microwatt case now renders as500 uWinstead of0 W; checked across ten orders of magnitude. Keptthreshold: 0on the lowest scale, since the selector falls back toscales[0].event_scalesilent drop — all three failure paths nowdebug!, including theOk(None)case where the.scalesidecar is merely absent, which was the one that could drop a whole domain with no diagnostic.- Warn amplification —
Index::resolvereturns the rejection; callers count skips and emit onewarn!per(pmu, event)naming the count and the ceiling, instead of one line per level per core. - Unit tests — five on
Index::resolve, covering exactly the multi-socket ordinal path this PR flags as untested: that a0,64cpumask becomes ordinals 0 and 1, that core scope indexes by cpu id instead, thatZerocollapses, and that an over-capacity index is declined rather than clamped.
Verification
cargo fmt --check clean, cargo clippy --bins zero warnings, cargo test --bins 749 passed, node --test tests/*.mjs 242 passed (wasm_viewer_histogram_kpis still needs a prebuilt site/viewer/pkg/, fails identically on a clean tree), check-viewer-symlinks.sh 57 modules resolve.
Re-validated on all three hosts under the staged sysbench profile — VERDICT: pass on sky, amd and hyb. Each exercises a different shape, which is what makes the membership assertions worth anything: amd has power_core/energy-core and no cstate PMUs at all, sky exposes seven package C-state levels, hyb has three core levels behind an SMT-sparse cpumask (0,2,4,6,8,10,12-15). Every present/absent check matched the PMU's own event list.
Two incidental confirmations from those runs:
- sky's
cpu_igpu_energymeasures 15.7 uJ/s ≈ 16 uW — a live instance of the microwatt domain that motivated the sub-watt scales above. It renders16 uWnow instead of0 W. - hyb's
cpu_igpu_energyis exactly zero for a whole run: it has a discrete RTX 5080, so the integrated GPU is powered down and the counter never advances. Worth knowing that a zero there is a hardware fact rather than a fabricated one — the membership assertions, which check against the PMU event list, are what catch the fabricated case.
Summary
cpu_powersampler reporting CPU energy, power, and idle-state residency, read entirely through perf (no/dev/cpu/N/msr).power,power_core,cstate_core,cstate_pkg) are discovered from/sys/bus/event_source/deviceswith no CPU vendor or model detection - each PMU'scpumasksupplies both the permitted CPUs and the counter's scope, so only the domains and C-state levels a part actually implements are reported.power/energyunit systems. Every subgroup is gated on the metric being present, so the section adapts per host.Details
Package-scope metrics are indexed by the CPU's ordinal position in the PMU cpumask rather than its CPU id: the ids are sparse (
0,64on a two-socket host) while the metric groups are sized to a newMAX_PACKAGES = 8.Two choices worth calling out:
powerPMU covers every implemented domain, needsCAP_PERFMONrather thanCAP_SYS_RAWIO, handles the 32-bit counter wrap, and reports pre-scaled energy. ThemsrPMU is not an alternative route to RAPL - despite a 64-bitconfig,msr_event_init()rejects anything outside a fixed allowlist of eight architectural counters, none of them energy registers.cpu_*_powergauges. The gauges average over the sampler's own refresh interval, so a scrape landing mid-interval returns a stale value, and being integer milliwatts they read0for a microwatt-scale domain (an idle iGPU).irate(cpu_<domain>_energy) / 1e6is correct over any window. The gauges are still exported and documented with that caveat.Test plan
cargo build,cargo clippy --all-targets,cargo fmt --checkclean (one pre-existing unrelatedtoo many argumentswarning incrates/dashboard)cargo test- 587 + 7 + 5 pass, 0 failuresnode --test tests/*.mjs- all pass exceptwasm_viewer_histogram_kpis, which needs a prebuiltsite/viewer/pkg/artifact absent locally (fails identically on a clean tree)scripts/check-viewer-symlinks.sh- 57 shared modules resolveValidated against turbostat on three architecturally distinct hosts under a staged sysbench load (idle, 1 thread, half cores, all cores, idle), comparing mean package power over the same window:
Overhead measured (n=600 per host, scrape-driven): per-refresh p50 of 146 us for amd's 13 counters, 451 us for sky's 28, 1002 us for hyb's 37 - roughly 11-27 us per counter, each being one
read()on a perf fd.cpu_frequencymeasured 316 us / 585 us on amd / hyb in the same windows for comparison. A/B against the same scrape load with the sampler disabled puts its marginal process cost at +0.28 to +1.84 pp of one core at 10 scrapes/s.Discovery verified per host: sky 28 counters, amd 13, hyb 37 - with zero vendor detection. The hybrid host is the interesting case:
core_c7_residencycovers only its 6 P-cores whilecore_cstate_residencycovers all 10 physical cores, which is why the summed metric exists.Multi-socket path (ordinal indexing, per-package heatmaps) is untested - all three hosts are single-package.
Generated with Claude Code