Fix operator self-time: exclude coordinator thread, look through batch subtrees#387
Merged
Merged
Conversation
…h subtrees
Two bugs in self-time attribution, both of which inflate an operator until it is
reported as the hottest in its plan. Found while building a portable query-plan
skill that mirrored this code, and confirmed against the fixture set.
1. THREAD 0 IS THE COORDINATOR.
In a parallel plan thread 0 carries no rows, and its ActualElapsedms is the wall
clock of the whole parallel branch. GetPerThreadOwnElapsed iterated every thread
including it, so any operator near the top of a parallel branch was handed the
branch's entire duration as its own work, and its self CPU underflowed to zero
because the coordinator burns none. ShowPlanParser also took the node-level
elapsed as the max across all threads, including the coordinator.
Measured across the .internal/examples corpus: of 1,014 non-exchange parallel
operators, the coordinator reports rows in 0 cases and elapsed in 65. Those 65
are precisely the operators that win a ranking by self time.
Serial plans have a single thread numbered 0, which IS a worker, so thread 0 is
only excluded when other threads exist.
2. THE PARALLEL PATH DID NOT LOOK THROUGH BATCH SUBTREES OR PASS-THROUGHS.
GetSerialOwnElapsed subtracts GetEffectiveChildElapsedMs, which looks through
Compute Scalar pass-throughs and sums contiguous batch zones. GetPerThreadOwnElapsed
subtracted each child's raw per-thread value instead, handling only Parallelism
children. So:
- a batch-mode child reports STANDALONE time, so only its topmost value came
off and the rest of the zone stayed inside the parent;
- a pass-through child carries no runtime stats, so zero came off and the
parent absorbed the entire subtree beneath it.
On cross-apply-point-in-time-slow.sqlplan a row-mode Nested Loops above a batch
Compute Scalar (0ms) hiding a batch Hash Aggregate (45,683ms) reported 45,737ms
of self time and led the ranking. Its real self time is 1ms, and the aggregate's
time was double-counted into both rows.
The same helpers now serve both paths, for elapsed and CPU. NodeTimeAttribution
(the display path) gets the matching batch-subtree summation so the graphical
plan and the analyzer agree.
TESTS
New OperatorSelfTimeTests, with a small synthetic fixture that encodes both traps
in one shape. Three of the six fail against the unfixed code, including
ParallelPassThroughChildDoesNotInflateItsParent, which uses the EXISTING
join_or_clause_plan fixture -- this was live on real plans.
PlanViewer.Core.csproj now grants InternalsVisibleTo the test project;
PlanAnalyzer.Timing is internal but is the source of every operator timing the UI
and BenefitScorer report, and it was untested.
Warning baseline: one line changes. join_or_clause_plan's Sort drops from
19,602ms to 18,580ms of self time -- the Compute Scalar subtree it had absorbed.
The warning still fires; the number is now correct. No warnings appear or
disappear on any other plan.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two bugs in self-time attribution. Both inflate an operator until it is reported as the hottest in its plan, which is the single most damaging thing a plan analyzer can get wrong.
Found while building a portable query-plan skill that transcribed this code. I mirrored
PlanAnalyzer.Timing.csfaithfully — including its bugs — and only found them when the Python copy started blaming the wrong operators on real plans.1. Thread 0 is the coordinator
In a parallel plan, thread 0 carries no rows, and its
ActualElapsedmsis the wall clock of the whole parallel branch.GetPerThreadOwnElapsediterated every thread including it, so any operator near the top of a parallel branch was handed the branch's entire duration as its own work — and its self CPU underflowed to zero, because the coordinator burns none.ShowPlanParser.RelOpalso took node-level elapsed as the max across all threads, coordinator included.Measured across
.internal/examples: of 1,014 non-exchange parallel operators, the coordinator reports rows in 0 cases and elapsed in 65. Those 65 are precisely the operators that win a ranking by self time, so the error was concentrated exactly where it does the most damage.Serial plans have a single thread numbered 0, which is a worker. Thread 0 is only excluded when other threads exist.
2. The parallel path didn't look through batch subtrees or pass-throughs
GetSerialOwnElapsedsubtractsGetEffectiveChildElapsedMs, which looks throughCompute Scalarpass-throughs and sums contiguous batch zones viaSumBatchSubtreeElapsedMs.GetPerThreadOwnElapsedsubtracted each child's raw per-thread value, special-casing onlyParallelismchildren. So:On
cross-apply-point-in-time-slow.sqlplan, a row-modeNested Loopsabove a batchCompute Scalar(0 ms) hiding a batchHash Match (Aggregate)(45,683 ms) reported 45,737 ms of self time and led the ranking. Its real self time is 1 ms, and the aggregate's time was double-counted into both rows.Both paths now share the same helpers, for elapsed and CPU.
NodeTimeAttribution(the display path) gets the matching batch-subtree summation, so the graphical plan and the analyzer agree.Test plan
New
OperatorSelfTimeTestswith a small synthetic fixture (parallel_row_over_batch_plan.sqlplan) that encodes both traps in one shape: a parallel row-mode Nested Loops, a coordinator thread reporting the branch wall clock, a batch zone hidden behind a stats-less Compute Scalar, and a row-mode sibling. Correct self time is 50 ms; the unfixed code reports 1000 ms.Three of the six new tests fail against the unfixed code, verified by stashing the source changes and re-running. One of the three,
ParallelPassThroughChildDoesNotInflateItsParent, uses the existingjoin_or_clause_plan.sqlplanfixture — so this was live on real plans already in the repo, not only on synthetic ones.Full suite: 101 passed, 0 failed.
PlanViewer.Core.csprojnow grantsInternalsVisibleTothe test project.PlanAnalyzer.Timingisinternal, but it is the source of every operator timing the UI andBenefitScorerreport, and it had no tests.Baseline change
WarningBaseline.txtmoves by exactly one line:That is
join_or_clause_plan's Sort giving back the Compute Scalar subtree it had absorbed. The warning still fires; the number is now correct. No warnings appear or disappear on any other plan, and no other plan's digest changes.Not addressed here
DetectCeGuesslabels its selectivity bands "30% equality guess" / "10% inequality guess". The thresholds fire correctly; the names look transposed (9% is 0.3 × 0.3 and 16.4% is 0.3 × √0.3, which only reconstruct if the base inequality guess is 30%). Left alone per Erik — PlanViewer is slightly different and that's fine.🤖 Generated with Claude Code