cpu_attribution on the top-CPU rankings: what fraction of the box the ranking explains (#2320) - #2330
Conversation
… ranking explains The last unshipped item from #2235's wishlist, split out as #2320. get_top_queries_by_cpu and get_top_procedures_by_cpu (both SKUs) now return the returned rows' summed CPU-seconds, the SQL process's measured CPU-seconds for the same window (avg cpu_utilization % x core count x window - both stores already collect every piece), and attributed_cpu_ratio. Twice earned per the issue: pre-#2290 the reads explained ~10% of the box and nothing said so, and the ratio catches impossible claims at a glance - the Datadog comparison died when its worker_time sum divided out to 137% of the box's available CPU-seconds. Above the process's own measured consumption the note says to distrust the numbers; below half it explains where unattributable CPU goes (evictions between snapshots, rows outside the top-N or filters, zero-cost rows, non-query CPU). The degrade rule is explicit and pinned: missing CPU series, missing core count, or coverage under 90% of the window omits the ratio rather than inventing one. One computation in PerformanceMonitor.Common (CpuAttribution), decision-table tested identically in both test projects and executed against the built assembly in a local harness (all cases pass); the denominator read windows on collection_time with the same bounds as the rankings, so numerator and denominator share collection gaps. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| var cpuAggregate = await DarlingDataReader.GetCpuWindowAggregateAsync(postgres, resolved.ServerId, now.AddHours(-hours_back), now); | ||
| var properties = await DarlingDataReader.GetLatestServerPropertiesAsync(postgres, resolved.ServerId); | ||
| var attribution = CpuAttribution.Compute( | ||
| filtered.Sum(r => r.TotalCpuUs) / 1_000_000.0, | ||
| now.AddHours(-hours_back), now, | ||
| cpuAggregate.SampleCount, cpuAggregate.FirstSample, cpuAggregate.LastSample, cpuAggregate.AvgSqlCpuPercent, | ||
| properties?.CpuCount ?? 0); |
There was a problem hiding this comment.
Minor perf: GetCpuWindowAggregateAsync and GetLatestServerPropertiesAsync are independent reads (different tables, no data dependency) but run sequentially, adding two full round-trips to every get_top_queries_by_cpu call (and the same pattern repeats below in GetTopProceduresByCpu, plus the Lite twin). Since NpgsqlDataSource pools connections, these can run concurrently:
var cpuAggregateTask = DarlingDataReader.GetCpuWindowAggregateAsync(postgres, resolved.ServerId, now.AddHours(-hours_back), now);
var propertiesTask = DarlingDataReader.GetLatestServerPropertiesAsync(postgres, resolved.ServerId);
await Task.WhenAll(cpuAggregateTask, propertiesTask);
var cpuAggregate = cpuAggregateTask.Result;
var properties = propertiesTask.Result;Not a correctness issue, just avoids doubling the added latency for a disclosure block that's now on every call to these two tools.
There was a problem hiding this comment.
All three points taken in 958dec3: both SKUs' tools now run the two independent reads under Task.WhenAll; Lite's GetCpuWindowAggregateAsync takes the window explicitly and one nowUtc backs both the aggregate read and the ratio math (the ranking read keeps its internal clock — its shared signature isn't worth threading a timestamp through for sub-millisecond drift, and the aggregate+Compute pair is where the invariant matters); and the Result doc now states the measured-zero exception instead of claiming both-null-together.
| /* #2320: what fraction of the box's measured CPU the RETURNED rows explain — numerator is | ||
| the caller-visible ranking (post top-N, post filters), denominator is measured, and the | ||
| ratio is omitted rather than invented when a denominator piece is missing. */ | ||
| var cpuAggregate = await dataService.GetCpuWindowAggregateAsync(resolved.ServerId, hours_back); | ||
| var properties = await dataService.GetLatestServerPropertiesAsync(resolved.ServerId); | ||
| var nowUtc = DateTime.UtcNow; | ||
| var attribution = CpuAttribution.Compute( | ||
| filtered.Sum(r => r.TotalCpuMs) / 1000.0, | ||
| nowUtc.AddHours(-hours_back), nowUtc, | ||
| cpuAggregate.SampleCount, cpuAggregate.FirstSample, cpuAggregate.LastSample, cpuAggregate.AvgSqlCpuPercent, | ||
| properties?.CpuCount ?? 0); |
There was a problem hiding this comment.
Two things here, both minor:
-
Same sequential-await perf point as the Darling twin —
GetCpuWindowAggregateAsyncandGetLatestServerPropertiesAsyncare independent and could run viaTask.WhenAllinstead of two back-to-back awaits. -
Window-drift vs. Darling's design: Darling captures a single
nowup front and threads it through the ranking query, the CPU aggregate query, andCpuAttribution.Compute's window bounds (seeDarlingMcpDataTools.GetTopQueriesByCpu), so numerator and denominator are guaranteed to share the exact same window — which is the invariantCpuAttribution.cs's doc comment calls out explicitly ("numerator and denominator share collection gaps"). Here,nowUtcis a freshDateTime.UtcNowcall taken afterdataService.GetTopQueriesByCpuAsyncandGetCpuWindowAggregateAsynchave already each independently calledDateTime.UtcNowinternally (viaGetTimeRange). So three separately-sampled timestamps back the ranking rows, the CPU aggregate, and the ratio math instead of one shared value. Athours_back >= 1the drift is sub-millisecond against an hour-plus window, so it's not user-visible today — but it's a real inconsistency between the two SKUs implementing the same disclosure, and worth threading onenowthrough here too for the same guarantee Darling has by construction.
| if (sqlCpuSeconds <= 0) | ||
| { | ||
| return new Result(ranked, Math.Round(sqlCpuSeconds, 1), null, | ||
| "the SQL process's measured CPU in the window is zero; ratio omitted"); | ||
| } |
There was a problem hiding this comment.
This branch returns a non-null SqlCpuSecondsInWindow (0) alongside a null AttributedCpuRatio, which contradicts the Result record's doc comment above (line 46-49): "SqlCpuSecondsInWindow and AttributedCpuRatio are null together". It's intentional — ZeroMeasuredCpu_OmitsRatio_ReportsTheZero in both test suites pins exactly this (measured zero is reported, ratio omitted) — but the doc comment states an invariant that isn't actually true. Worth updating the summary so a future caller/maintainer doesn't rely on "both null or both present" as a real contract.
| var cpuAggregate = await DarlingDataReader.GetCpuWindowAggregateAsync(postgres, resolved.ServerId, now.AddHours(-hours_back), now); | ||
| var properties = await DarlingDataReader.GetLatestServerPropertiesAsync(postgres, resolved.ServerId); | ||
| var attribution = CpuAttribution.Compute( | ||
| rows.Sum(r => r.TotalCpuUs) / 1_000_000.0, | ||
| now.AddHours(-hours_back), now, | ||
| cpuAggregate.SampleCount, cpuAggregate.FirstSample, cpuAggregate.LastSample, cpuAggregate.AvgSqlCpuPercent, | ||
| properties?.CpuCount ?? 0); |
There was a problem hiding this comment.
Same sequential-await pattern as GetTopQueriesByCpu above — cpuAggregate/properties could run concurrently via Task.WhenAll here too.
| var cpuAggregate = await dataService.GetCpuWindowAggregateAsync(resolved.ServerId, hours_back); | ||
| var properties = await dataService.GetLatestServerPropertiesAsync(resolved.ServerId); | ||
| var nowUtc = DateTime.UtcNow; | ||
| var attribution = CpuAttribution.Compute( | ||
| rows.Sum(r => r.TotalCpuMs) / 1000.0, | ||
| nowUtc.AddHours(-hours_back), nowUtc, | ||
| cpuAggregate.SampleCount, cpuAggregate.FirstSample, cpuAggregate.LastSample, cpuAggregate.AvgSqlCpuPercent, | ||
| properties?.CpuCount ?? 0); |
There was a problem hiding this comment.
Same two points as GetTopQueriesByCpu above (sequential awaits that could be Task.WhenAll'd, and nowUtc sampled independently from the two GetTimeRange-driven DateTime.UtcNow calls already made by GetTopProceduresByCpuAsync and GetCpuWindowAggregateAsync) — repeating here rather than re-explaining.
Review summaryReviewed the Overall: solid. The degrade-rather-than-invent logic in Left 5 inline nits, none blocking:
|
…te, honest Result doc The CPU aggregate and server-properties reads are independent, so both SKUs' tools now run them under Task.WhenAll instead of paying two sequential round-trips on every call. Lite's GetCpuWindowAggregateAsync takes the window explicitly and the tools capture one nowUtc backing both the aggregate read and the ratio math - Darling had that by construction, Lite sampled UtcNow three times for one disclosure. The Result record's doc no longer claims SqlCpuSecondsInWindow and AttributedCpuRatio are null together - the measured-zero case deliberately reports the zero, as the tests pin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| ratio is omitted rather than invented when a denominator piece is missing. One nowUtc | ||
| backs the aggregate read AND the ratio math, and the two independent reads run | ||
| concurrently (review catches; Darling has both by construction). */ | ||
| var nowUtc = DateTime.UtcNow; |
There was a problem hiding this comment.
Lite/Darling drift: attribution window isn't actually the same window as the ranking.
nowUtc here is captured after rows was already fetched via dataService.GetTopQueriesByCpuAsync(resolved.ServerId, hours_back, top, ...) (line 34). That call resolves its own window internally through GetTimeRange(hoursBack, null, null), which does its own independent DateTime.UtcNow calls. So the window that produced rows/filtered (the numerator: ranked_cpu_seconds) and the nowUtc window used for the CPU aggregate query + CpuAttribution.Compute (the denominator) are two different windows — offset by however long the ranking query took. The identical pattern repeats in GetTopProceduresByCpu at line 152.
Darling avoids this: it captures a single now = DateTime.UtcNow once at the top of the method and reuses it for both the ranking query (DarlingDataReader.GetTopQueriesByCpuAsync(postgres, resolved.ServerId, now.AddHours(-hours_back), now, ...)) and the attribution reads. Lite doesn't have an equivalent explicit-window overload of GetTopQueriesByCpuAsync/GetTopProceduresByCpuAsync to do the same.
Practically the drift is one DB round-trip against an hours-long window, so it won't visibly move the ratio in most cases. But it does contradict the PR's stated invariant ("the denominator read windows on collection_time with the SAME bounds as the rankings") for Lite specifically, and it's the exact class of bug the comment two lines below warns about ("three independently-sampled UtcNow calls backing one disclosure is drift by construction") — the fix only unified 2 of the 3 UtcNow calls, not all of them.
| /// SKUs' tools hand the caller numerator, denominator, and ratio instead of leaving the division to be | ||
| /// re-derived — ONE computation here, so the two cannot disagree. | ||
| /// | ||
| /// <para>The denominator is measured, not theoretical: the SQL process's average CPU%% over the window |
There was a problem hiding this comment.
Stray double percent sign: CPU%% should be CPU%. The same %% doubling appears in several other new doc comments in this PR — DarlingDataReader.cs:181 ("CPU%% over the window"), and both CpuAttributionTests.cs files (Darling and Lite) at lines 21/29/44/57 ("137%%-of-the-box claim", "25%% of 8 cores", "~10%% of the box", "The 137%% case"). No existing comment in the codebase uses this pattern, so it looks like a leftover escaping artifact (e.g. from a printf-style template) rather than an intentional convention. Doesn't affect runtime behavior, but it'll render as a literal double percent sign in IntelliSense/generated docs — worth a find/replace across the new files before merge.
Review summaryReviewed the Overall: solid design — one shared computation in Two things worth a look, left as inline comments:
Nothing else stood out — instructions docs, CHANGELOG, and MCP tool descriptions are consistently updated across both SKUs. |
…% doc artifacts The ranking read windows on its own internal UtcNow, so capturing nowUtc after it left the numerator and denominator skewed by the ranking query's duration. Hoisting the capture above the read shrinks the skew to call-entry overhead - zeroing it entirely would mean threading an instant into the shared ranking read's signature, which sub-microsecond drift against an hours window does not buy. The %% doc-comment artifacts (a template-escaping leftover) become %. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Both round-two points addressed in f1ba666: Lite's |
|
Reviewed the diff (CpuAttribution.cs, both SKUs' DataReader/McpDataTools/McpQueryTools, both instructions docs, both test projects). Correctness
Lite/Darling parity
Security / boundaries
Nothing to flag — this is clean, well-tested, parity-preserving work. No missing-index-DMV suggestions here either way. |
Closes #2320 — the last unshipped item from #2235's wishlist.
What
get_top_queries_by_cpuandget_top_procedures_by_cpu(both SKUs) gain a top-levelcpu_attributionobject:ranked_cpu_seconds— the RETURNED rows' summed windowed CPU (post top-N, post filters): what the caller-visible ranking claims.sql_cpu_seconds_in_window— measured, not theoretical: avgcpu_utilization% over the window × core count (server_properties) × window seconds. Both stores already collect every piece.attributed_cpu_ratio+note— under half, the note explains where unattributable CPU goes (evictions between snapshots, rows outside the top-N, zero-cost rows, non-query CPU); above the process's own measured consumption (>1.1) it flags the impossible claim — the 137% marker from the issue — and says to distrust the absolute numbers.Degrade rule (the issue's explicit requirement): missing CPU series, missing core count, or a series covering under 90% of the window ⇒ ratio omitted with a reason, never invented.
How
PerformanceMonitor.Common(CpuAttribution) so the SKUs cannot disagree — same placement asQueryStatExtremes, which these same tools already share.collection_timewith the SAME bounds as the rankings, so numerator and denominator share collection gaps (and Lite's server-localsample_timeskew stays irrelevant).Tests
CpuAttributionTests): healthy/low/impossible/slightly-over, no-samples, no-cores, partial-coverage, edge-clamping, zero-CPU, empty-window, and rounding.CpuWindowAggregateSqlpinned (collection_time both edges, aggregate shape) and added to the PG-dialect theory.PerformanceMonitor.Commonin a throwaway net10.0 harness — all cases pass (the Windows test projects only build on this Mac).🤖 Generated with Claude Code