Skip to content

Avoid HashSet allocations for singleton conditional dependencies - #132934

Open
awakecoding wants to merge 2 commits into
dotnet:mainfrom
awakecoding:copilot/nativeaot-conditional-dependency-buckets
Open

Avoid HashSet allocations for singleton conditional dependencies#132934
awakecoding wants to merge 2 commits into
dotnet:mainfrom
awakecoding:copilot/nativeaot-conditional-dependency-buckets

Conversation

@awakecoding

@awakecoding awakecoding commented Aug 29, 2026

Copy link
Copy Markdown

Summary

  • Store the first pending conditional dependency directly in a small reference-backed bucket.
  • Promote the bucket to a HashSet only when a second distinct dependency is added.
  • Preserve the existing condition-key equality, dependency equality/deduplication, and replay behavior.

Motivation

DependencyAnalyzer currently allocates a HashSet<CombinedDependencyListEntry> for every
condition that has a pending conditional dependency, even when that condition has only one
dependency.

In a full Remote Desktop Manager NativeAOT compilation using the matching .NET 10.0.11
toolchain, the analyzer created 5,724,010 conditional buckets, but only 1,189,313 (20.78%)
needed more than one distinct entry. A reference-backed singleton representation reduced
managed allocation by 0.66-0.71 GiB in both measured runs. An earlier value-type prototype
was rejected because copying the large dictionary value erased the timing benefit; keeping
the bucket behind a reference avoids those dictionary-value copies.

Implementation

The dictionary key and its equality behavior are unchanged. Each dictionary value is now a
small ConditionalDependencyBucket object:

  • The first CombinedDependencyListEntry is stored directly.
  • A duplicate of that entry is ignored with the same equality contract used by the old
    HashSet.
  • A second distinct entry creates a HashSet containing both entries.
  • Further insertions go directly to the promoted set, without additional wrappers.
  • Satisfying a condition removes its bucket from the dictionary before replaying the stored
    dependencies.

The change does not alter conditional dependency production, graph sorting, profiling,
capacity policy, or any other NativeAOT optimization.

Validation

  • ILCompiler.Compiler.Tests: 22/22 passed in Release.
  • ILCompiler.Compiler.Tests: 22/22 passed in Debug.
  • Release NativeAOT smoke tree: all 43 projects built.
  • Release NativeAOT smoke execution: 28/28 passed.
  • NativeAOT determinism test: both 11,021,593-byte object files had SHA-256
    B09CDFB966C0306D667EEA682A54D8D71816ACB6CA2F5487952D66B65877CE02.

Current-main stress benchmark

The benchmark used current main at c210d82dbc1ab432b9369604a1caef9a0ab763d2.
It constructed 2,000,000 pending conditional buckets and promoted 415,552 (20.7776%), matching
the full-RDM promotion ratio. Owners were marked before a trigger marked every condition.
Baseline and changed analyzer assemblies were run in alternating order for 12 measured pairs
after one warmup per variant.

Metric (median of 12 runs) Baseline Changed Delta
Graph wall time 4,137.22 ms 3,762.04 ms -9.07%
Mark time 4,113.62 ms 3,728.33 ms -9.37%
Process CPU 7,218.75 ms 6,429.69 ms -10.93%
Managed allocation 1,286.96 MiB 1,040.04 MiB -246.92 MiB (-19.19%)
Peak private memory 1,628.80 MiB 1,305.68 MiB -323.12 MiB (-19.84%)
Peak working set 1,549.35 MiB 1,245.26 MiB -304.09 MiB (-19.63%)
Gen0 / Gen1 / Gen2 collections 4 / 2 / 2 5 / 3 / 3 varies with GC timing

Every measured run produced the same 25,662,216-byte logical marked-node output with
SHA-256 03357BB1EB8FE3A673546DA58681BD6EB651E5799330BD1908508B02BEECB9D5.

Managed allocation fell by 246.91-246.93 MiB in every pair. Timing was much noisier:
individual paired graph deltas ranged from -38.26% to +63.35%. The median timing result is
directionally favorable, but the repeatable allocation reduction and output identity are
the primary current-main evidence.

Retained full-application evidence

The separate .NET 10.0.11 experiment compiled the full Remote Desktop Manager application:
30,666,605 marked nodes and a 3,744,339,247-byte object. It observed 5,724,010 buckets and
1,189,313 promotions.

The first candidate run began at 78% reported maximum frequency versus 83% and 88% for its
adjacent controls. It was slower than their midpoint, so that timing result is confounded.
Managed allocation still fell from 114.05-114.09 GiB to 113.39 GiB, a repeatable
0.66-0.70 GiB reduction.

A confirmation began at 91% reported maximum frequency versus 90% for its control:

Full RDM metric Control Changed Delta
Total ILC wall time 598.29 s 588.31 s -9.97 s (-1.67%)
Dependency graph and code generation 411.86 s 405.10 s -6.76 s (-1.64%)
Mark stack 366.08 s 356.96 s -9.13 s (-2.49%)
Managed allocation 114.08 GiB 113.38 GiB -0.70 GiB (-0.61%)
Gen0 / Gen1 / Gen2 collections 154 / 78 / 35 151 / 76 / 33 lower
Peak private memory 33.77 GiB 36.88 GiB +3.11 GiB

Every control and candidate produced the same 3,744,339,247-byte object with SHA-256
C71AA7E9BAC37D9F2A2B2E2D75C695947D70CBD4EE39464C3623C4C9A8B75668.

The allocation reduction repeated, but the timing evidence consists of one favorable
frequency-matched confirmation and one confounded run. Peak private memory also varied with
GC timing and was higher in the confirmation. I therefore consider the full-application timing
directional rather than proof of a repeatable wall-clock improvement.

Note

This pull request description was drafted with GitHub Copilot.

Store the first pending conditional dependency directly in a small reference-backed bucket. Promote to a HashSet only when a second distinct dependency is added, avoiding per-bucket HashSet storage for the common singleton case.
Copilot AI lite review requested due to automatic review settings August 29, 2026 23:10
@dotnet-policy-service dotnet-policy-service Bot added the community-contribution Indicates that the PR has been added by a community member label Aug 29, 2026
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @agocke, @dotnet/ilc-contrib
See info in area-owners.md if you want to be subscribed.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟢 Approval recommended

Pull request overview

This PR optimizes NativeAOT’s dependency analysis by avoiding a HashSet<CombinedDependencyListEntry> allocation for the common case where a condition only accumulates a single pending conditional dependency, while preserving existing deduplication and replay semantics.

Changes:

  • Replaces the conditional-dependency dictionary value from HashSet<CombinedDependencyListEntry> to a small reference-backed ConditionalDependencyBucket that stores the first entry inline and promotes to HashSet on the second distinct add.
  • Uses Dictionary.Remove(key, out value) to remove-and-replay stored conditional dependencies in one lookup when a condition node becomes marked.
  • Adds focused unit tests covering singleton vs promoted buckets, deduplication (including across promotion), owner/condition identity semantics, deferred dependency computation, and replay behavior.
File summaries
File Description
src/coreclr/tools/aot/ILCompiler.DependencyAnalysisFramework/DependencyAnalyzer.cs Introduces a singleton-then-promote bucket to reduce per-condition allocations and replays stored dependencies when conditions are satisfied.
src/coreclr/tools/aot/ILCompiler.Compiler.Tests/DependencyGraphTests.cs Adds regression tests validating conditional dependency storage, deduplication, replay, and ordering/identity invariants.
Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 0
  • Review effort level: Lite

@MichalStrehovsky MichalStrehovsky left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Are there any benchmark number for real E2E compilations? I wonder if this has a measurable effect. (It will have a measurable effect for a microbenchmark but we don't ship microbenchmark results.)

using System;
using System.Collections.Generic;
using System.Text;
using ILCompiler.DependencyAnalysisFramework;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is not the right spot for dependency framework tests. The right spot is in #130849. We haven't really touched the framework in a long time.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

maybe I would be better off removing the test for now? The real test for me has been to build Remote Desktop Manager, which is a massive .NET enterprise application that tends to really push the limits of the .NET compiler toolchain.

The dependency analysis framework tests belong in the dedicated test project being added by dotnet#130849, not ILCompiler.Compiler.Tests.
Copilot AI review requested due to automatic review settings August 31, 2026 01:24
@awakecoding

Copy link
Copy Markdown
Author

Yes — I ran this against the full Remote Desktop Manager NativeAOT compilation (30,666,605 marked nodes, 3,744,339,247-byte object), not just the synthetic graph.

The repeatable result was allocation: both full-app runs saved 0.66–0.70 GiB. In the frequency-matched confirmation (91% reported max frequency vs. 90% for its control):

Metric Control Changed Delta
Total ILC wall 598.29 s 588.31 s -9.97 s (-1.67%)
Dependency graph/codegen 411.86 s 405.10 s -6.76 s (-1.64%)
Mark stack 366.08 s 356.96 s -9.13 s (-2.49%)
Managed allocation 114.08 GiB 113.38 GiB -0.70 GiB (-0.61%)
Gen0 / Gen1 / Gen2 154 / 78 / 35 151 / 76 / 33 lower
Peak private memory 33.77 GiB 36.88 GiB +3.11 GiB

The first full-app candidate run began at 78% reported max frequency versus 83%/88% for its adjacent controls and was 21.25 s slower than their midpoint, so I consider that timing confounded. All controls and candidates produced the same object bytes and SHA-256 (C71AA7E...B75668).

So the honest conclusion is: measurable and repeated allocation reduction; one favorable matched-frequency E2E timing pair, but not enough evidence to claim a repeatable wall-clock speedup. I updated the PR description with the full table and caveats.

Note

This response was drafted with GitHub Copilot.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟢 Approval recommended

Review details
  • Files reviewed: 1/1 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@MichalStrehovsky

Copy link
Copy Markdown
Member

No statistically significant improvement was detected. Across 20 interleaved pairs, compare was only 7.3 ms (0.052%) faster on average—far smaller than run-to-run noise.

Metric Baseline Compare
Mean 14.1053 s 14.0980 s
Median 14.0599 s 14.0922 s
Standard deviation 0.1374 s 0.1156 s

The paired mean difference was −0.0073 s, with a 95% CI of −0.1078 to +0.0932 s (approximately −0.73% to +0.57%). The exact paired permutation test gave p = 0.881, so the observed difference is entirely consistent with noise. Execution order had negligible impact.

Raw measurements are saved at C:\Users\Michal\.copilot\session-state\4d8223e9-3512-493c-8830-ad3a10d80d6f\files\ilc-wallclock.csv. Under these test conditions, there is no evidence that compare improves ILC wall-clock performance.

Note

This response was drafted with GitHub Copilot.

@awakecoding

Copy link
Copy Markdown
Author

@MichalStrehovsky the only observed gain is in total ILC memory allocations, not in ILC wall-clock time. In my builds of RDM this resulted in 700MB less managed memory allocated in ILC. This is just one of many other similar memory allocation reduction improvements I found, but if you feel this isn't worth it, I can close this PR to focus on the other ones. ILC is very memory hungry, so I anything that can shave off memory allocations on very large builds is appreciated.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-NativeAOT-coreclr community-contribution Indicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants