Skip to content

Self CPU: subtract whole batch zones, and sum threads for an operator's own CPU - #662

Merged
erikdarlingdata merged 4 commits into
devfrom
fix/self-cpu-batch-zones
Oct 19, 2026
Merged

erikdarlingdata merged 4 commits into
devfrom
fix/self-cpu-batch-zones

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Oct 19, 2026 •

Copy link
Copy Markdown
Owner

The CLI's "Expensive operators" list and the GUI's advice cards each did their own row-mode subtraction. It took only a batch child's own time, so a row-mode parent was charged the rest of the batch zone, for elapsed and CPU both. On param-sniffing-posttypeid2.sqlplan the Stream Aggregate (node 4) showed 14,771 ms CPU and 14,720 ms elapsed. PlanAnalyzer already gave it 3,059 ms of elapsed. The listed CPU shares added up to 159%.

Changes

  • PlanAnalyzer.GetOperatorOwnCpuMs (new): own CPU summed across threads, with per-thread subtraction and batch zones summed.
  • OperatorResult gets OwnCpuMs and OwnElapsedMs (own_cpu_ms and own_elapsed_ms in JSON) from ResultMapper. The text formatter and the advice cards read those instead of subtracting again.
  • GetEffectiveChildCpuMs sums a batch child's zone, as the elapsed path does. This also fixes the serial path of GetOperatorMaxThreadOwnCpuMs.
  • GetOperatorMaxThreadOwnCpuMs returns a batch operator's busiest thread without subtracting its children, as GetOperatorOwnElapsedMs does.
  • NodeTimeAttribution.GetOwnCpuMs sums batch zones too.
  • Two comments updated to match: the advice cards one (it no longer copies TextFormatter's subtraction) and NodeTimeAttribution's remark.
  • From the round-1 review: the analyzer's own-time functions now fall back to the estimated execution mode when a node has no actual mode, as the child paths already did.
  • From the round-2 review: GetOperatorMaxThreadOwnCpuMs keeps a batch node's total CPU when the node has no per-thread rows. Parsed plans always have them, so this only matters for nodes built in code.

Testing

  • param-sniffing-posttypeid2.sqlplan, node 4: 3,112 ms CPU and 3,059 ms elapsed. The shares add up to 98%.
  • Across the 60 test plans, the Expensive operators text changes on 10. Two plans listed CPU shares over 105% before, and none do now.
  • Four new tests in OperatorSelfTimeTests.
  • Core tests: 1,503 total, 0 failed, 40 skipped on Linux (31 on Windows). Before: 1,499 and 40.
  • dotnet build of PlanViewer.sln: 0 warnings, 0 errors.

The same two CPU problems are fixed in the sqlserver-query-plans plugin (claude-plugins, version 1.5.6).

🤖 Generated with Claude Code

https://claude.ai/code/session_01UoaQ2xKMpZEXvm4HncT8ND

erikdarlingdata and others added 2 commits October 19, 2026 11:54
…'s own CPU

The CLI's "Expensive operators" list and the GUI's advice cards each did their
own row-mode subtraction. It took only a batch child's own time, so a row-mode
parent was charged the rest of the batch zone, for elapsed and CPU both. On
param-sniffing-posttypeid2.sqlplan the Stream Aggregate (node 4) showed
14,771 ms CPU and 14,720 ms elapsed. PlanAnalyzer already gave it 3,059 ms of
elapsed. The listed CPU shares added up to 159%.

- PlanAnalyzer.GetOperatorOwnCpuMs (new): own CPU summed across threads,
  per-thread subtraction, batch zones summed. OperatorResult gets OwnCpuMs and
  OwnElapsedMs (own_cpu_ms / own_elapsed_ms in JSON) from ResultMapper, and the
  text formatter and advice cards read those instead of subtracting again.
- GetEffectiveChildCpuMs sums a batch child's zone, like the elapsed path does.
  This also fixes the serial path of GetOperatorMaxThreadOwnCpuMs.
- GetOperatorMaxThreadOwnCpuMs returns a batch operator's busiest thread
  without subtracting children, like GetOperatorOwnElapsedMs.
- NodeTimeAttribution.GetOwnCpuMs sums batch zones too.

After: node 4 shows 3,112 ms CPU and 3,059 ms elapsed, and the shares add up
to 98%. Across the 60 test plans, the Expensive operators text changes on 10.
Two plans listed CPU shares over 105% before, and none do now.

Four new tests in OperatorSelfTimeTests. Core tests: 1,503 total, 0 failed,
40 skipped (1,499 and 40 before).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The advice cards no longer mirror TextFormatter's attribution, only its
filtering, ranking and formatting; both read OwnCpuMs and OwnElapsedMs from
OperatorResult. NodeTimeAttribution's remark now names GetOperatorOwnCpuMs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoaQ2xKMpZEXvm4HncT8ND
if (node.ActualExecutionMode == "Batch")
return node.ActualCPUMs;

if (node.PerThreadStats.Count > 1)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Low: the batch check here (and in GetOperatorMaxThreadOwnCpuMs) reads node.ActualExecutionMode only. GetEffectiveChildCpuMs, AddEffectiveChildByThread and SumBatchSubtreeCpuMs use ActualExecutionMode ?? ExecutionMode. A node with no actual mode attribute but an estimated mode of Batch is subtracted as a child but treated as row mode when it is the parent. That mismatch would double-subtract. Use the same ?? ExecutionMode expression in both places, or put it in a small helper.

Tests are fine for the changed behavior. They cover the serial, parallel and batch cases and the CLI text.

GetOperatorOwnElapsedMs, GetOperatorMaxThreadOwnCpuMs and GetOperatorOwnCpuMs
read only ActualExecutionMode for the node itself, while the child paths read
ActualExecutionMode ?? ExecutionMode. The parser fills ActualExecutionMode from
a per-thread attribute that can be missing, so a batch node without it was
subtracted as a batch child but treated as row mode as a parent. All three now
use the same fallback as the child paths and NodeTimeAttribution.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoaQ2xKMpZEXvm4HncT8ND
// Batch mode reports each thread's own CPU directly. Subtracting the
// children would take their standalone CPU off a number that never held it.
if ((node.ActualExecutionMode ?? node.ExecutionMode) == "Batch")
return WorkThreads(node).Select(t => t.ActualCPUMs).DefaultIfEmpty(0).Max();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Regression risk: when a batch-mode node has no PerThreadStats (serial plan, or per-thread info absent), WorkThreads(node) is empty and DefaultIfEmpty(0).Max() returns 0. Previously the code fell through to the serial path and returned ActualCPUMs. GetOperatorOwnCpuMs right below handles this by returning node.ActualCPUMs. Fall back to node.ActualCPUMs when there are no threads, e.g. var threads = WorkThreads(node); if (threads.Count == 0) return node.ActualCPUMs;. The new tests only cover a parallel plan, so none of them catches this.

GetOperatorMaxThreadOwnCpuMs returned 0 for a batch node with no
PerThreadStats. Parsed plans always have at least one thread row when
ActualCPUMs is above 0, but a node built in code may not, so fall back to
ActualCPUMs as GetOperatorOwnCpuMs does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoaQ2xKMpZEXvm4HncT8ND
@claude

claude Bot commented Oct 19, 2026

Copy link
Copy Markdown

Reviewed the diff. I found no blocking issues.

  • Correctness: The CLI text report, the advice cards and the JSON output now read one set of own-time figures from PlanAnalyzer. That removes the two private row-mode subtraction copies. The new batch-zone CPU subtraction (SumBatchSubtreeCpuMs) stops at Parallelism boundaries, and the ActualExecutionMode ?? ExecutionMode fallback is applied consistently. I saw no null or empty-plan hazards, because the mapper only fills the new fields inside the actual-stats branch.
  • Conventions: No new files, so PlanViewer.Web.csproj linking and the SSMS version files are unaffected. No SQL is generated, and I saw no new NoWarn.
  • Tests: Coverage is good: serial-over-batch, parallel sum versus max-thread, batch max-thread, and the CLI text output.
  • Minor, non-blocking:
    • own_elapsed_ms and own_cpu_ms are new JSON fields. Anything consuming the JSON should treat them as nullable, because they are null for estimated plans.
    • NodeTimeAttribution.SumBatchSubtreeCpuMs duplicates the analyzer's version. The remarks call the two paths intentionally separate, so this is acceptable.

@erikdarlingdata
erikdarlingdata merged commit ec6573c into dev Oct 19, 2026
4 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/self-cpu-batch-zones branch October 19, 2026 17:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant