Skip to content

Reuse the per-method allocation scan for optimization opportunities#2261

Merged
richlander merged 1 commit into
mainfrom
feature/dedup-allocation-scan
Jul 3, 2026
Merged

Reuse the per-method allocation scan for optimization opportunities#2261
richlander merged 1 commit into
mainfrom
feature/dedup-allocation-scan

Conversation

@richlander

Copy link
Copy Markdown
Owner

Result: ~20% less CPU work on Performance Triage

Performance Triage computed each method's allocation occurrences twice: once in the main path with escape classification (classifyEscapes: true), and again inside CollectOptimizationOpportunities without it (classifyEscapes: false). Both run the identical instruction/token scan — classifyEscapes only controls whether a final ClassifyAllocationEscapes pass runs on top of the same raw occurrences.

This scans once (raw), classifies that result for the main allocation output, and hands the raw occurrences to the opportunity pass (which keys them by IL offset and never reads escape state) — removing one full allocation scan per method whenever opportunities are computed.

Command Assembly CPU before CPU after Less work
-S "Performance Triage" System.Private.CoreLib (16 MB) 7.45s 6.01s 19%
-S "Performance Triage" ILInspector.Decompiler 3.45s 2.65s 23%

Savings scale with allocation density. Note on wall-clock: on an idle box, large-assembly wall-clock is unchanged because that path is already parallel across cores (#2247) and the sequential render/merge dominates its wall time — but the ~20% CPU reduction translates directly to wall-clock under core contention (this environment runs many concurrent analyses).

Why it's byte-identical

classifyEscapes: false returns the raw collected occurrences directly; true returns ClassifyAllocationEscapes(collected, …) on top. So the base occurrences (offsets, kinds, sizes, types) are identical between the two — only the escape field differs. The opportunity pass consumed the false (raw, Unknown-escape) occurrences and keys them by IL offset without reading escape state, so handing it the shared raw scan gives it exactly what it computed before.

Validation

  • Byte-identical CLI: 21 diffs across 7 real framework assemblies (including System.Private.CoreLib) for Performance Triage / Allocation Facts / combined, plus type-level Allocation-Safety-Cost and Performance Triage.
  • SemanticFacts / ParallelBuildDeterminism / IndexBuildInvariant / Targeted / TypeTargeted tests pass.

Risk / review focus

Behavior-preserving. Attack point: does CollectOptimizationOpportunities ever read allocation escape state (which would differ between the raw scan it did before and… still the raw scan now — so this is actually a no-op risk), or does the main allocation output still get classification applied exactly as before? Confirm the raw-vs-classified split is faithful.

Performance Triage computed each method's allocation occurrences twice: once
in the main path with escape classification (classifyEscapes: true) and again
inside CollectOptimizationOpportunities without it (classifyEscapes: false).
The two calls run the identical instruction/token scan -- classifyEscapes only
controls whether a final ClassifyAllocationEscapes pass runs on top of the same
raw occurrences.

Scan once (raw, escape = Unknown), classify that result for the main allocation
output, and hand the raw occurrences to CollectOptimizationOpportunities so it
keys them by IL offset instead of rescanning. The opportunity pass never reads
escape state, so it receives exactly the occurrences it computed before -- output
is byte-identical.

This removes one full allocation scan (instruction walk + newobj/newarr token
resolution + size estimation) per method whenever opportunities are computed,
i.e. the Performance Triage path.

Validated:
- Byte-identical CLI: 21 diffs across 7 real framework assemblies (incl.
  System.Private.CoreLib) for Performance Triage / Allocation Facts / combined,
  plus type-level Allocation-Safety-Cost and Performance Triage.
- SemanticFacts / ParallelBuildDeterminism / IndexBuildInvariant /
  Targeted / TypeTargeted tests pass.
- CPU (total work) on Performance Triage: System.Private.CoreLib 7.45s -> 6.01s
  (19%), ILInspector.Decompiler 3.45s -> 2.65s (23%); scales with allocation
  density. Wall-clock is unchanged on an idle box for large assemblies (the path
  is already parallel across cores), but the reduced CPU work translates to
  wall-clock under core contention (many concurrent analyses).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@richlander

Copy link
Copy Markdown
Owner Author

Adversarial review reconciliation

Two reviewers, rotated from #2247 (GPT-5.5 + MAI) → this round GPT-5.5 + Gemini 3.1 Pro (MAI rotated out; I am Claude and cannot self-review).

Reviewer Model Verdict
gpt-dedup-review GPT-5.5 No significant issues.
gemini-dedup-review Gemini 3.1 Pro No significant issues.

Both independently confirmed byte-identical output (empty diffs on System.Private.CoreLib Performance Triage and the requested library/type selectors) and passing tests, and verified:

  • The opportunity pass is passed rawAllocations (classifyEscapes:false, Escape == Unknown) — the exact occurrences it recomputed before — not the classified result.Allocations.
  • result.Allocations still equals the old classifyEscapes:true output (ClassifyAllocationEscapes(rawAllocations) when nonempty; empty short-circuit and includeAllocations==false preserved).
  • rawAllocations is always populated when opportunities run (Build(includeAllocations || includeOpportunities, …)), so .ToDictionary never runs on a default array.

One reviewer divergence — verified

GPT-5.5 noted CollectOptimizationOpportunities does read .Escape (LibraryBodyIndex.cs:3564, boxAllocation!.Escape == AllocationEscape.ThrowPath); Gemini stated it reads only Kind/AllocatedType/Detail/Frequency. I verified GPT-5.5 is correct — there is one .Escape read. It is not a problem: boxAllocation is sourced from allocationByOffset (:3554), i.e. the raw occurrences, whose Escape is always Unknown, so feedsThrow is always false. This was equally true on origin/main (which passed the same raw occurrences into this logic), so the read yields an identical result before and after. Both reviewers' bottom line (byte-identical) holds.

No actionable findings; no resolution commits required.

Ready to merge.

@richlander richlander merged commit b7d8f56 into main Jul 3, 2026
5 checks passed
@richlander richlander deleted the feature/dedup-allocation-scan branch July 3, 2026 19: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