Skip to content

Fix operator self-time: exclude coordinator thread, look through batch subtrees#387

Merged
erikdarlingdata merged 1 commit into
devfrom
fix/plan-timing-coordinator-and-batch
Jul 10, 2026
Merged

Fix operator self-time: exclude coordinator thread, look through batch subtrees#387
erikdarlingdata merged 1 commit into
devfrom
fix/plan-timing-coordinator-and-batch

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

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.cs faithfully — 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 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.RelOp also 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

GetSerialOwnElapsed subtracts GetEffectiveChildElapsedMs, which looks through Compute Scalar pass-throughs and sums contiguous batch zones via SumBatchSubtreeElapsedMs.

GetPerThreadOwnElapsed subtracted each child's raw per-thread value, special-casing 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 at all, 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 (0 ms) hiding a batch Hash 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 OperatorSelfTimeTests with 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 existing join_or_clause_plan.sqlplan fixture — so this was live on real plans already in the repo, not only on synthetic ones.

Full suite: 101 passed, 0 failed.

PlanViewer.Core.csproj now grants InternalsVisibleTo the test project. PlanAnalyzer.Timing is internal, but it is the source of every operator timing the UI and BenefitScorer report, and it had no tests.

Baseline change

WarningBaseline.txt moves by exactly one line:

- Sort took 19,602ms (59.3% of statement elapsed) ...
+ Sort took 18,580ms (56.2% of statement elapsed) ...

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

DetectCeGuess labels 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

…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>
@erikdarlingdata erikdarlingdata merged commit 232acba into dev Jul 10, 2026
2 checks passed
@erikdarlingdata erikdarlingdata deleted the fix/plan-timing-coordinator-and-batch branch July 10, 2026 04:37
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