Avoid HashSet allocations for singleton conditional dependencies - #132934

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

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

@awakecodingawakecoding commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

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)BaselineChangedDelta
Graph wall time4,137.22 ms3,762.04 ms-9.07%
Mark time4,113.62 ms3,728.33 ms-9.37%
Process CPU7,218.75 ms6,429.69 ms-10.93%
Managed allocation1,286.96 MiB1,040.04 MiB-246.92 MiB (-19.19%)
Peak private memory1,628.80 MiB1,305.68 MiB-323.12 MiB (-19.84%)
Peak working set1,549.35 MiB1,245.26 MiB-304.09 MiB (-19.63%)
Gen0 / Gen1 / Gen2 collections4 / 2 / 25 / 3 / 3varies 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 metricControlChangedDelta
Total ILC wall time598.29 s588.31 s-9.97 s (-1.67%)
Dependency graph and code generation411.86 s405.10 s-6.76 s (-1.64%)
Mark stack366.08 s356.96 s-9.13 s (-2.49%)
Managed allocation114.08 GiB113.38 GiB-0.70 GiB (-0.61%)
Gen0 / Gen1 / Gen2 collections154 / 78 / 35151 / 76 / 33lower
Peak private memory33.77 GiB36.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.
CopilotAI lite review requested due to automatic review settings August 29, 2026 23:10
@dotnet-policy-servicedotnet-policy-serviceBot 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.

CopilotAI 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
FileDescription
src/coreclr/tools/aot/ILCompiler.DependencyAnalysisFramework/DependencyAnalyzer.csIntroduces 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.csAdds 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

@MichalStrehovskyMichalStrehovsky 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
ContributorAuthor

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.
CopilotAI review requested due to automatic review settings August 31, 2026 01:24
@awakecoding

Copy link
Copy Markdown
ContributorAuthor

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):

MetricControlChangedDelta
Total ILC wall598.29 s588.31 s-9.97 s (-1.67%)
Dependency graph/codegen411.86 s405.10 s-6.76 s (-1.64%)
Mark stack366.08 s356.96 s-9.13 s (-2.49%)
Managed allocation114.08 GiB113.38 GiB-0.70 GiB (-0.61%)
Gen0 / Gen1 / Gen2154 / 78 / 35151 / 76 / 33lower
Peak private memory33.77 GiB36.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.

CopilotAI 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.

MetricBaselineCompare
Mean14.1053 s14.0980 s
Median14.0599 s14.0922 s
Standard deviation0.1374 s0.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
ContributorAuthor

@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.

@jkotas

Copy link
Copy Markdown
Member

GC is pretty good at getting rid of short-term garbage. Trading reduced allocations for more complicated code is not always a win.

It is suspect that you have seen peak working set to grow from 33.77 GB to 36.88 GB. Was it a single run or average of multiple runs?

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

Those were single-run peaks, not averages. One correction to my table: 33.77/36.88 GiB were peak private-memory values; the corresponding working sets were 33.31/36.04 GiB.

Across five controls, peak private memory ranged from 33.77 to 35.72 GiB. The two changed runs measured 34.74 and 36.88 GiB. I therefore cannot claim a peak-memory improvement; the second result is concerning, although two candidate samples are not enough to establish a regression statistically.

The only result that repeated reliably was about 0.7 GiB less cumulative managed allocation. That does not imply less peak live memory because collection timing can change. Given the additional implementation complexity without demonstrated wall-clock or peak-memory improvement, I agree this is not a compelling tradeoff and I am closing the PR.

Note

This response was drafted with GitHub Copilot.

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

Labels

area-NativeAOT-coreclrcommunity-contributionIndicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@awakecoding@MichalStrehovsky@jkotas
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Avoid HashSet allocations for singleton conditional dependencies - #132934

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

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

@awakecodingawakecoding commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

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)BaselineChangedDelta
Graph wall time4,137.22 ms3,762.04 ms-9.07%
Mark time4,113.62 ms3,728.33 ms-9.37%
Process CPU7,218.75 ms6,429.69 ms-10.93%
Managed allocation1,286.96 MiB1,040.04 MiB-246.92 MiB (-19.19%)
Peak private memory1,628.80 MiB1,305.68 MiB-323.12 MiB (-19.84%)
Peak working set1,549.35 MiB1,245.26 MiB-304.09 MiB (-19.63%)
Gen0 / Gen1 / Gen2 collections4 / 2 / 25 / 3 / 3varies 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 metricControlChangedDelta
Total ILC wall time598.29 s588.31 s-9.97 s (-1.67%)
Dependency graph and code generation411.86 s405.10 s-6.76 s (-1.64%)
Mark stack366.08 s356.96 s-9.13 s (-2.49%)
Managed allocation114.08 GiB113.38 GiB-0.70 GiB (-0.61%)
Gen0 / Gen1 / Gen2 collections154 / 78 / 35151 / 76 / 33lower
Peak private memory33.77 GiB36.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.
CopilotAI lite review requested due to automatic review settings August 29, 2026 23:10
@dotnet-policy-servicedotnet-policy-serviceBot 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.

CopilotAI 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
FileDescription
src/coreclr/tools/aot/ILCompiler.DependencyAnalysisFramework/DependencyAnalyzer.csIntroduces 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.csAdds 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

@MichalStrehovskyMichalStrehovsky 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
ContributorAuthor

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.
CopilotAI review requested due to automatic review settings August 31, 2026 01:24
@awakecoding

Copy link
Copy Markdown
ContributorAuthor

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):

MetricControlChangedDelta
Total ILC wall598.29 s588.31 s-9.97 s (-1.67%)
Dependency graph/codegen411.86 s405.10 s-6.76 s (-1.64%)
Mark stack366.08 s356.96 s-9.13 s (-2.49%)
Managed allocation114.08 GiB113.38 GiB-0.70 GiB (-0.61%)
Gen0 / Gen1 / Gen2154 / 78 / 35151 / 76 / 33lower
Peak private memory33.77 GiB36.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.

CopilotAI 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.

MetricBaselineCompare
Mean14.1053 s14.0980 s
Median14.0599 s14.0922 s
Standard deviation0.1374 s0.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
ContributorAuthor

@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.

@jkotas

Copy link
Copy Markdown
Member

GC is pretty good at getting rid of short-term garbage. Trading reduced allocations for more complicated code is not always a win.

It is suspect that you have seen peak working set to grow from 33.77 GB to 36.88 GB. Was it a single run or average of multiple runs?

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

Those were single-run peaks, not averages. One correction to my table: 33.77/36.88 GiB were peak private-memory values; the corresponding working sets were 33.31/36.04 GiB.

Across five controls, peak private memory ranged from 33.77 to 35.72 GiB. The two changed runs measured 34.74 and 36.88 GiB. I therefore cannot claim a peak-memory improvement; the second result is concerning, although two candidate samples are not enough to establish a regression statistically.

The only result that repeated reliably was about 0.7 GiB less cumulative managed allocation. That does not imply less peak live memory because collection timing can change. Given the additional implementation complexity without demonstrated wall-clock or peak-memory improvement, I agree this is not a compelling tradeoff and I am closing the PR.

Note

This response was drafted with GitHub Copilot.

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

Labels

area-NativeAOT-coreclrcommunity-contributionIndicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@awakecoding@MichalStrehovsky@jkotas
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Avoid HashSet allocations for singleton conditional dependencies - #132934

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

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

@awakecodingawakecoding commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

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)BaselineChangedDelta
Graph wall time4,137.22 ms3,762.04 ms-9.07%
Mark time4,113.62 ms3,728.33 ms-9.37%
Process CPU7,218.75 ms6,429.69 ms-10.93%
Managed allocation1,286.96 MiB1,040.04 MiB-246.92 MiB (-19.19%)
Peak private memory1,628.80 MiB1,305.68 MiB-323.12 MiB (-19.84%)
Peak working set1,549.35 MiB1,245.26 MiB-304.09 MiB (-19.63%)
Gen0 / Gen1 / Gen2 collections4 / 2 / 25 / 3 / 3varies 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 metricControlChangedDelta
Total ILC wall time598.29 s588.31 s-9.97 s (-1.67%)
Dependency graph and code generation411.86 s405.10 s-6.76 s (-1.64%)
Mark stack366.08 s356.96 s-9.13 s (-2.49%)
Managed allocation114.08 GiB113.38 GiB-0.70 GiB (-0.61%)
Gen0 / Gen1 / Gen2 collections154 / 78 / 35151 / 76 / 33lower
Peak private memory33.77 GiB36.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.
CopilotAI lite review requested due to automatic review settings August 29, 2026 23:10
@dotnet-policy-servicedotnet-policy-serviceBot 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.

CopilotAI 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
FileDescription
src/coreclr/tools/aot/ILCompiler.DependencyAnalysisFramework/DependencyAnalyzer.csIntroduces 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.csAdds 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

@MichalStrehovskyMichalStrehovsky 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
ContributorAuthor

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.
CopilotAI review requested due to automatic review settings August 31, 2026 01:24
@awakecoding

Copy link
Copy Markdown
ContributorAuthor

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):

MetricControlChangedDelta
Total ILC wall598.29 s588.31 s-9.97 s (-1.67%)
Dependency graph/codegen411.86 s405.10 s-6.76 s (-1.64%)
Mark stack366.08 s356.96 s-9.13 s (-2.49%)
Managed allocation114.08 GiB113.38 GiB-0.70 GiB (-0.61%)
Gen0 / Gen1 / Gen2154 / 78 / 35151 / 76 / 33lower
Peak private memory33.77 GiB36.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.

CopilotAI 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.

MetricBaselineCompare
Mean14.1053 s14.0980 s
Median14.0599 s14.0922 s
Standard deviation0.1374 s0.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
ContributorAuthor

@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.

@jkotas

Copy link
Copy Markdown
Member

GC is pretty good at getting rid of short-term garbage. Trading reduced allocations for more complicated code is not always a win.

It is suspect that you have seen peak working set to grow from 33.77 GB to 36.88 GB. Was it a single run or average of multiple runs?

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

Those were single-run peaks, not averages. One correction to my table: 33.77/36.88 GiB were peak private-memory values; the corresponding working sets were 33.31/36.04 GiB.

Across five controls, peak private memory ranged from 33.77 to 35.72 GiB. The two changed runs measured 34.74 and 36.88 GiB. I therefore cannot claim a peak-memory improvement; the second result is concerning, although two candidate samples are not enough to establish a regression statistically.

The only result that repeated reliably was about 0.7 GiB less cumulative managed allocation. That does not imply less peak live memory because collection timing can change. Given the additional implementation complexity without demonstrated wall-clock or peak-memory improvement, I agree this is not a compelling tradeoff and I am closing the PR.

Note

This response was drafted with GitHub Copilot.

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

Labels

area-NativeAOT-coreclrcommunity-contributionIndicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@awakecoding@MichalStrehovsky@jkotas
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Avoid HashSet allocations for singleton conditional dependencies - #132934

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

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

@awakecodingawakecoding commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

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)BaselineChangedDelta
Graph wall time4,137.22 ms3,762.04 ms-9.07%
Mark time4,113.62 ms3,728.33 ms-9.37%
Process CPU7,218.75 ms6,429.69 ms-10.93%
Managed allocation1,286.96 MiB1,040.04 MiB-246.92 MiB (-19.19%)
Peak private memory1,628.80 MiB1,305.68 MiB-323.12 MiB (-19.84%)
Peak working set1,549.35 MiB1,245.26 MiB-304.09 MiB (-19.63%)
Gen0 / Gen1 / Gen2 collections4 / 2 / 25 / 3 / 3varies 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 metricControlChangedDelta
Total ILC wall time598.29 s588.31 s-9.97 s (-1.67%)
Dependency graph and code generation411.86 s405.10 s-6.76 s (-1.64%)
Mark stack366.08 s356.96 s-9.13 s (-2.49%)
Managed allocation114.08 GiB113.38 GiB-0.70 GiB (-0.61%)
Gen0 / Gen1 / Gen2 collections154 / 78 / 35151 / 76 / 33lower
Peak private memory33.77 GiB36.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.
CopilotAI lite review requested due to automatic review settings August 29, 2026 23:10
@dotnet-policy-servicedotnet-policy-serviceBot 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.

CopilotAI 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
FileDescription
src/coreclr/tools/aot/ILCompiler.DependencyAnalysisFramework/DependencyAnalyzer.csIntroduces 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.csAdds 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

@MichalStrehovskyMichalStrehovsky 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
ContributorAuthor

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.
CopilotAI review requested due to automatic review settings August 31, 2026 01:24
@awakecoding

Copy link
Copy Markdown
ContributorAuthor

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):

MetricControlChangedDelta
Total ILC wall598.29 s588.31 s-9.97 s (-1.67%)
Dependency graph/codegen411.86 s405.10 s-6.76 s (-1.64%)
Mark stack366.08 s356.96 s-9.13 s (-2.49%)
Managed allocation114.08 GiB113.38 GiB-0.70 GiB (-0.61%)
Gen0 / Gen1 / Gen2154 / 78 / 35151 / 76 / 33lower
Peak private memory33.77 GiB36.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.

CopilotAI 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.

MetricBaselineCompare
Mean14.1053 s14.0980 s
Median14.0599 s14.0922 s
Standard deviation0.1374 s0.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
ContributorAuthor

@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.

@jkotas

Copy link
Copy Markdown
Member

GC is pretty good at getting rid of short-term garbage. Trading reduced allocations for more complicated code is not always a win.

It is suspect that you have seen peak working set to grow from 33.77 GB to 36.88 GB. Was it a single run or average of multiple runs?

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

Those were single-run peaks, not averages. One correction to my table: 33.77/36.88 GiB were peak private-memory values; the corresponding working sets were 33.31/36.04 GiB.

Across five controls, peak private memory ranged from 33.77 to 35.72 GiB. The two changed runs measured 34.74 and 36.88 GiB. I therefore cannot claim a peak-memory improvement; the second result is concerning, although two candidate samples are not enough to establish a regression statistically.

The only result that repeated reliably was about 0.7 GiB less cumulative managed allocation. That does not imply less peak live memory because collection timing can change. Given the additional implementation complexity without demonstrated wall-clock or peak-memory improvement, I agree this is not a compelling tradeoff and I am closing the PR.

Note

This response was drafted with GitHub Copilot.

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

Labels

area-NativeAOT-coreclrcommunity-contributionIndicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@awakecoding@MichalStrehovsky@jkotas
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Avoid HashSet allocations for singleton conditional dependencies - #132934

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

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

@awakecodingawakecoding commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

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)BaselineChangedDelta
Graph wall time4,137.22 ms3,762.04 ms-9.07%
Mark time4,113.62 ms3,728.33 ms-9.37%
Process CPU7,218.75 ms6,429.69 ms-10.93%
Managed allocation1,286.96 MiB1,040.04 MiB-246.92 MiB (-19.19%)
Peak private memory1,628.80 MiB1,305.68 MiB-323.12 MiB (-19.84%)
Peak working set1,549.35 MiB1,245.26 MiB-304.09 MiB (-19.63%)
Gen0 / Gen1 / Gen2 collections4 / 2 / 25 / 3 / 3varies 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 metricControlChangedDelta
Total ILC wall time598.29 s588.31 s-9.97 s (-1.67%)
Dependency graph and code generation411.86 s405.10 s-6.76 s (-1.64%)
Mark stack366.08 s356.96 s-9.13 s (-2.49%)
Managed allocation114.08 GiB113.38 GiB-0.70 GiB (-0.61%)
Gen0 / Gen1 / Gen2 collections154 / 78 / 35151 / 76 / 33lower
Peak private memory33.77 GiB36.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.
CopilotAI lite review requested due to automatic review settings August 29, 2026 23:10
@dotnet-policy-servicedotnet-policy-serviceBot 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.

CopilotAI 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
FileDescription
src/coreclr/tools/aot/ILCompiler.DependencyAnalysisFramework/DependencyAnalyzer.csIntroduces 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.csAdds 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

@MichalStrehovskyMichalStrehovsky 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
ContributorAuthor

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.
CopilotAI review requested due to automatic review settings August 31, 2026 01:24
@awakecoding

Copy link
Copy Markdown
ContributorAuthor

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):

MetricControlChangedDelta
Total ILC wall598.29 s588.31 s-9.97 s (-1.67%)
Dependency graph/codegen411.86 s405.10 s-6.76 s (-1.64%)
Mark stack366.08 s356.96 s-9.13 s (-2.49%)
Managed allocation114.08 GiB113.38 GiB-0.70 GiB (-0.61%)
Gen0 / Gen1 / Gen2154 / 78 / 35151 / 76 / 33lower
Peak private memory33.77 GiB36.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.

CopilotAI 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.

MetricBaselineCompare
Mean14.1053 s14.0980 s
Median14.0599 s14.0922 s
Standard deviation0.1374 s0.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
ContributorAuthor

@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.

@jkotas

Copy link
Copy Markdown
Member

GC is pretty good at getting rid of short-term garbage. Trading reduced allocations for more complicated code is not always a win.

It is suspect that you have seen peak working set to grow from 33.77 GB to 36.88 GB. Was it a single run or average of multiple runs?

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

Those were single-run peaks, not averages. One correction to my table: 33.77/36.88 GiB were peak private-memory values; the corresponding working sets were 33.31/36.04 GiB.

Across five controls, peak private memory ranged from 33.77 to 35.72 GiB. The two changed runs measured 34.74 and 36.88 GiB. I therefore cannot claim a peak-memory improvement; the second result is concerning, although two candidate samples are not enough to establish a regression statistically.

The only result that repeated reliably was about 0.7 GiB less cumulative managed allocation. That does not imply less peak live memory because collection timing can change. Given the additional implementation complexity without demonstrated wall-clock or peak-memory improvement, I agree this is not a compelling tradeoff and I am closing the PR.

Note

This response was drafted with GitHub Copilot.

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

Labels

area-NativeAOT-coreclrcommunity-contributionIndicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@awakecoding@MichalStrehovsky@jkotas
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Avoid HashSet allocations for singleton conditional dependencies - #132934

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

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

@awakecodingawakecoding commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

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)BaselineChangedDelta
Graph wall time4,137.22 ms3,762.04 ms-9.07%
Mark time4,113.62 ms3,728.33 ms-9.37%
Process CPU7,218.75 ms6,429.69 ms-10.93%
Managed allocation1,286.96 MiB1,040.04 MiB-246.92 MiB (-19.19%)
Peak private memory1,628.80 MiB1,305.68 MiB-323.12 MiB (-19.84%)
Peak working set1,549.35 MiB1,245.26 MiB-304.09 MiB (-19.63%)
Gen0 / Gen1 / Gen2 collections4 / 2 / 25 / 3 / 3varies 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 metricControlChangedDelta
Total ILC wall time598.29 s588.31 s-9.97 s (-1.67%)
Dependency graph and code generation411.86 s405.10 s-6.76 s (-1.64%)
Mark stack366.08 s356.96 s-9.13 s (-2.49%)
Managed allocation114.08 GiB113.38 GiB-0.70 GiB (-0.61%)
Gen0 / Gen1 / Gen2 collections154 / 78 / 35151 / 76 / 33lower
Peak private memory33.77 GiB36.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.
CopilotAI lite review requested due to automatic review settings August 29, 2026 23:10
@dotnet-policy-servicedotnet-policy-serviceBot 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.

CopilotAI 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
FileDescription
src/coreclr/tools/aot/ILCompiler.DependencyAnalysisFramework/DependencyAnalyzer.csIntroduces 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.csAdds 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

@MichalStrehovskyMichalStrehovsky 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
ContributorAuthor

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.
CopilotAI review requested due to automatic review settings August 31, 2026 01:24
@awakecoding

Copy link
Copy Markdown
ContributorAuthor

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):

MetricControlChangedDelta
Total ILC wall598.29 s588.31 s-9.97 s (-1.67%)
Dependency graph/codegen411.86 s405.10 s-6.76 s (-1.64%)
Mark stack366.08 s356.96 s-9.13 s (-2.49%)
Managed allocation114.08 GiB113.38 GiB-0.70 GiB (-0.61%)
Gen0 / Gen1 / Gen2154 / 78 / 35151 / 76 / 33lower
Peak private memory33.77 GiB36.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.

CopilotAI 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.

MetricBaselineCompare
Mean14.1053 s14.0980 s
Median14.0599 s14.0922 s
Standard deviation0.1374 s0.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
ContributorAuthor

@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.

@jkotas

Copy link
Copy Markdown
Member

GC is pretty good at getting rid of short-term garbage. Trading reduced allocations for more complicated code is not always a win.

It is suspect that you have seen peak working set to grow from 33.77 GB to 36.88 GB. Was it a single run or average of multiple runs?

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

Those were single-run peaks, not averages. One correction to my table: 33.77/36.88 GiB were peak private-memory values; the corresponding working sets were 33.31/36.04 GiB.

Across five controls, peak private memory ranged from 33.77 to 35.72 GiB. The two changed runs measured 34.74 and 36.88 GiB. I therefore cannot claim a peak-memory improvement; the second result is concerning, although two candidate samples are not enough to establish a regression statistically.

The only result that repeated reliably was about 0.7 GiB less cumulative managed allocation. That does not imply less peak live memory because collection timing can change. Given the additional implementation complexity without demonstrated wall-clock or peak-memory improvement, I agree this is not a compelling tradeoff and I am closing the PR.

Note

This response was drafted with GitHub Copilot.

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

Labels

area-NativeAOT-coreclrcommunity-contributionIndicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@awakecoding@MichalStrehovsky@jkotas
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Avoid HashSet allocations for singleton conditional dependencies - #132934

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

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

@awakecodingawakecoding commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

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)BaselineChangedDelta
Graph wall time4,137.22 ms3,762.04 ms-9.07%
Mark time4,113.62 ms3,728.33 ms-9.37%
Process CPU7,218.75 ms6,429.69 ms-10.93%
Managed allocation1,286.96 MiB1,040.04 MiB-246.92 MiB (-19.19%)
Peak private memory1,628.80 MiB1,305.68 MiB-323.12 MiB (-19.84%)
Peak working set1,549.35 MiB1,245.26 MiB-304.09 MiB (-19.63%)
Gen0 / Gen1 / Gen2 collections4 / 2 / 25 / 3 / 3varies 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 metricControlChangedDelta
Total ILC wall time598.29 s588.31 s-9.97 s (-1.67%)
Dependency graph and code generation411.86 s405.10 s-6.76 s (-1.64%)
Mark stack366.08 s356.96 s-9.13 s (-2.49%)
Managed allocation114.08 GiB113.38 GiB-0.70 GiB (-0.61%)
Gen0 / Gen1 / Gen2 collections154 / 78 / 35151 / 76 / 33lower
Peak private memory33.77 GiB36.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.
CopilotAI lite review requested due to automatic review settings August 29, 2026 23:10
@dotnet-policy-servicedotnet-policy-serviceBot 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.

CopilotAI 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
FileDescription
src/coreclr/tools/aot/ILCompiler.DependencyAnalysisFramework/DependencyAnalyzer.csIntroduces 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.csAdds 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

@MichalStrehovskyMichalStrehovsky 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
ContributorAuthor

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.
CopilotAI review requested due to automatic review settings August 31, 2026 01:24
@awakecoding

Copy link
Copy Markdown
ContributorAuthor

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):

MetricControlChangedDelta
Total ILC wall598.29 s588.31 s-9.97 s (-1.67%)
Dependency graph/codegen411.86 s405.10 s-6.76 s (-1.64%)
Mark stack366.08 s356.96 s-9.13 s (-2.49%)
Managed allocation114.08 GiB113.38 GiB-0.70 GiB (-0.61%)
Gen0 / Gen1 / Gen2154 / 78 / 35151 / 76 / 33lower
Peak private memory33.77 GiB36.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.

CopilotAI 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.

MetricBaselineCompare
Mean14.1053 s14.0980 s
Median14.0599 s14.0922 s
Standard deviation0.1374 s0.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
ContributorAuthor

@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.

@jkotas

Copy link
Copy Markdown
Member

GC is pretty good at getting rid of short-term garbage. Trading reduced allocations for more complicated code is not always a win.

It is suspect that you have seen peak working set to grow from 33.77 GB to 36.88 GB. Was it a single run or average of multiple runs?

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

Those were single-run peaks, not averages. One correction to my table: 33.77/36.88 GiB were peak private-memory values; the corresponding working sets were 33.31/36.04 GiB.

Across five controls, peak private memory ranged from 33.77 to 35.72 GiB. The two changed runs measured 34.74 and 36.88 GiB. I therefore cannot claim a peak-memory improvement; the second result is concerning, although two candidate samples are not enough to establish a regression statistically.

The only result that repeated reliably was about 0.7 GiB less cumulative managed allocation. That does not imply less peak live memory because collection timing can change. Given the additional implementation complexity without demonstrated wall-clock or peak-memory improvement, I agree this is not a compelling tradeoff and I am closing the PR.

Note

This response was drafted with GitHub Copilot.

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

Labels

area-NativeAOT-coreclrcommunity-contributionIndicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@awakecoding@MichalStrehovsky@jkotas
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Avoid HashSet allocations for singleton conditional dependencies - #132934

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

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

@awakecodingawakecoding commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

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)BaselineChangedDelta
Graph wall time4,137.22 ms3,762.04 ms-9.07%
Mark time4,113.62 ms3,728.33 ms-9.37%
Process CPU7,218.75 ms6,429.69 ms-10.93%
Managed allocation1,286.96 MiB1,040.04 MiB-246.92 MiB (-19.19%)
Peak private memory1,628.80 MiB1,305.68 MiB-323.12 MiB (-19.84%)
Peak working set1,549.35 MiB1,245.26 MiB-304.09 MiB (-19.63%)
Gen0 / Gen1 / Gen2 collections4 / 2 / 25 / 3 / 3varies 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 metricControlChangedDelta
Total ILC wall time598.29 s588.31 s-9.97 s (-1.67%)
Dependency graph and code generation411.86 s405.10 s-6.76 s (-1.64%)
Mark stack366.08 s356.96 s-9.13 s (-2.49%)
Managed allocation114.08 GiB113.38 GiB-0.70 GiB (-0.61%)
Gen0 / Gen1 / Gen2 collections154 / 78 / 35151 / 76 / 33lower
Peak private memory33.77 GiB36.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.
CopilotAI lite review requested due to automatic review settings August 29, 2026 23:10
@dotnet-policy-servicedotnet-policy-serviceBot 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.

CopilotAI 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
FileDescription
src/coreclr/tools/aot/ILCompiler.DependencyAnalysisFramework/DependencyAnalyzer.csIntroduces 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.csAdds 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

@MichalStrehovskyMichalStrehovsky 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
ContributorAuthor

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.
CopilotAI review requested due to automatic review settings August 31, 2026 01:24
@awakecoding

Copy link
Copy Markdown
ContributorAuthor

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):

MetricControlChangedDelta
Total ILC wall598.29 s588.31 s-9.97 s (-1.67%)
Dependency graph/codegen411.86 s405.10 s-6.76 s (-1.64%)
Mark stack366.08 s356.96 s-9.13 s (-2.49%)
Managed allocation114.08 GiB113.38 GiB-0.70 GiB (-0.61%)
Gen0 / Gen1 / Gen2154 / 78 / 35151 / 76 / 33lower
Peak private memory33.77 GiB36.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.

CopilotAI 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.

MetricBaselineCompare
Mean14.1053 s14.0980 s
Median14.0599 s14.0922 s
Standard deviation0.1374 s0.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
ContributorAuthor

@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.

@jkotas

Copy link
Copy Markdown
Member

GC is pretty good at getting rid of short-term garbage. Trading reduced allocations for more complicated code is not always a win.

It is suspect that you have seen peak working set to grow from 33.77 GB to 36.88 GB. Was it a single run or average of multiple runs?

@awakecoding

Copy link
Copy Markdown
ContributorAuthor

Those were single-run peaks, not averages. One correction to my table: 33.77/36.88 GiB were peak private-memory values; the corresponding working sets were 33.31/36.04 GiB.

Across five controls, peak private memory ranged from 33.77 to 35.72 GiB. The two changed runs measured 34.74 and 36.88 GiB. I therefore cannot claim a peak-memory improvement; the second result is concerning, although two candidate samples are not enough to establish a regression statistically.

The only result that repeated reliably was about 0.7 GiB less cumulative managed allocation. That does not imply less peak live memory because collection timing can change. Given the additional implementation complexity without demonstrated wall-clock or peak-memory improvement, I agree this is not a compelling tradeoff and I am closing the PR.

Note

This response was drafted with GitHub Copilot.

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

Labels

area-NativeAOT-coreclrcommunity-contributionIndicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@awakecoding@MichalStrehovsky@jkotas