Skip to content

Avoid boxing the struct enumerator in PropertyDictionary<T>.GetEnumerator() - #14272

Merged
AlesProkop merged 1 commit into
dotnet:mainfrom
nareshjo:dev/nareshjo/PropertyDictionaryGetEnumerator-Enumerator-Allocs
Jul 7, 2026
Merged

AlesProkop merged 1 commit into
dotnet:mainfrom
nareshjo:dev/nareshjo/PropertyDictionaryGetEnumerator-Enumerator-Allocs

Conversation

@nareshjo

@nareshjo nareshjo commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

🤖 AI-Generated Pull Request 🤖

This pull request was generated by the VS Perf Rel AI Agent. Please review this AI-generated PR with extra care! For more information, visit our wiki.


  • Issue:
    PropertyDictionary<T>.GetEnumerator() enumerates its backing set through the IEnumerable<T> interface — it iterates _properties.Values, and RetrievableEntryHashSet<T>.Values returns this typed as ICollection<T>. Because the backing RetrievableEntryHashSet<T> exposes a value-type Enumerator, dispatching through the interface boxes that struct onto the heap on every enumeration: IEnumerable<T>.GetEnumerator() does return new Enumerator(this), and the struct is boxed to satisfy the interface return type.
    This runs once per project item that carries direct metadata during ProjectInstance construction (VS design-time / project-snapshot builds), making it a meaningful source of GC pressure during solution load.

    Allocation sites (TypeAllocated!Enumerator[Microsoft.Build.Evaluation.ProjectMetadata], ~97% of sampled allocations; a further ~3% as Enumerator[…Execution.ProjectPropertyInstance]):

    PropertyDictionary<T>.GetEnumerator   (yield iterator)
     → foreach over _properties.Values          ← ICollection<T>, forces interface dispatch
       → RetrievableEntryHashSet<T>.IEnumerable<T>.GetEnumerator   ← return new Enumerator(this)
         → clr.dll!JIT_New → boxed Enumerator[ProjectMetadata]     ← discarded after enumeration
    

    Callers on hot path as per Perfwatson traces (all funnel through PropertyDictionary<T>.GetEnumerator):

    InstantiateProjectItemInstance → ImmutableDictionaryExtensions.SetItems → PropertyDictionary.GetEnumerator          (~67%)
    InstantiateProjectItemInstance → CopyOnWritePropertyDictionary.ImportProperties → PropertyDictionary.GetEnumerator  (~31%)
    InstantiateProjectItemInstance → PropertyDictionary..ctor(IEnumerable<T>) → PropertyDictionary.GetEnumerator          (~3%)
    

    All variants converge on the same boxed value-type-enumerator leaf at RetrievableEntryHashSet<T>.IEnumerable<T>.GetEnumerator, rooted at ProjectSnapshotService.GenerateProjectInstance → ProjectInstance..ctor → CreateItemsSnapshot and attributed to GC pause time.

    See related failure in PRISM

  • Issue type: Avoid boxing a value-type enumerator when an allocation-free struct enumerator exists on the same concrete type.

  • Proposed fix: In PropertyDictionary<T>.GetEnumerator(), pattern-match the backing field to its concrete RetrievableValuedEntryHashSet<T> type and foreach over it, so the C# foreach binds to the struct-returning public Enumerator GetEnumerator() — no boxing. An else branch preserves the original interface enumeration for the non-deriving IRetrievableValuedEntryHashSet<T> implementers (ImmutableGlobalPropertiesCollectionConverter, ImmutableProjectPropertyCollectionConverter). The non-generic IEnumerable.GetEnumerator() now delegates to the generic method, removing a second boxing site. Because every hot-path caller routes through this single sink, one change covers all of them. The change mirrors the allocation-free pattern already used by PropertyDictionary<T>.Filter, is internal-only, and is behavior-preserving — identical enumeration order, version/concurrent-modification check, and read-lock scope (both paths drive the same RetrievableEntryHashSet<T>.Enumerator). Net effect: −1 heap allocation per enumeration on the concrete path, 0 new allocations introduced.

Best practices wiki
See related failure in PRISM
ADO work item

Copilot AI review requested due to automatic review settings July 6, 2026 20:51
@nareshjo
nareshjo temporarily deployed to copilot-pat-pool July 6, 2026 20:51 — with GitHub Actions Inactive
@nareshjo
nareshjo temporarily deployed to copilot-pat-pool July 6, 2026 20:51 — with GitHub Actions Inactive

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR reduces GC pressure during hot-path PropertyDictionary<T> enumeration by avoiding boxing of the struct enumerator from RetrievableEntryHashSet<T> when _properties is backed by the common concrete RetrievableValuedEntryHashSet<T> implementation.

Changes:

  • Add a fast path in PropertyDictionary<T>.GetEnumerator() that enumerates RetrievableValuedEntryHashSet<T> directly so foreach binds to the struct-returning GetEnumerator() (no interface boxing).
  • Preserve the existing interface-based enumeration behavior for non-RetrievableValuedEntryHashSet<T> implementations of IRetrievableValuedEntryHashSet<T>.
  • Make non-generic IEnumerable.GetEnumerator() delegate to the generic GetEnumerator() to eliminate duplicate enumeration logic (and an extra boxing site).

@ViktorHofer

Copy link
Copy Markdown
Member

/review

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Expert Code Review (command) completed successfully!

Caution

agentic threat detected
Threat detection flagged this output in warn mode. Manual review is REQUIRED before any follow-up automation.

Details

The threat detection results could not be parsed.

Review the workflow run logs for details.

Code review of PR #14272 complete — three dimensions evaluated against the actual diff and source.

@github-actions github-actions Bot 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.

Caution

agentic threat detected
Threat detection flagged this output in warn mode. Manual review is REQUIRED before any follow-up automation.

Details

The threat detection results could not be parsed.

Review the workflow run logs for details.

MSBuild Expert Review — 24-Dimension Analysis

24/24 dimensions clean — no findings.

Summary

This PR applies a well-scoped micro-optimization to PropertyDictionary<T>.GetEnumerator(). The change is correct, safe, and follows an established pattern already present in the same file (Filter() at line 609 uses the identical is RetrievableValuedEntryHashSet<T> hashSet fast path).


What the PR does

Hot path (common case): When _properties is a RetrievableValuedEntryHashSet<T> (all normally-constructed dictionaries), the new code calls the concrete type's public Enumerator GetEnumerator() directly. The compiler resolves this via pattern-based foreach, storing the struct Enumerator as a field of the iterator state machine — no boxing. The old code called _properties.Values.GetEnumerator() via IEnumerable<T>.GetEnumerator(), which returned new Enumerator(this) as IEnumerator<T>one heap allocation per enumeration (boxing).

Else path (rare case): ImmutableGlobalPropertiesCollectionConverter and ImmutableProjectPropertyCollectionConverter fall back to _properties.Values, preserving the original behaviour.

IEnumerable.GetEnumerator() simplification: The old 10-line duplicate iterator (own lock + own yield return) is replaced by a 1-line delegation => GetEnumerator(). The non-generic path now also gets the struct-enumerator fast path for free.


Dimension results

Group Dimensions Result
Blocking Backwards Compatibility, ChangeWave, Concurrency, Evaluation Model, Security ✅ All LGTM
Major Performance, Test Coverage, API Surface, Design, Cross-Platform, Correctness ✅ All LGTM
Moderate Code Simplification, Documentation, Scope, Logging, Build Infrastructure ✅ All LGTM
Nit String Comparison, Naming, Idiomatic C#, SDK Integration, Dependency Mgmt, Error Messages ✅ All LGTM

Concurrency: Lock semantics are preserved. The using (_lock.EnterDisposableReadLock()) block wraps both new branches identically to the old single branch. The iterator state machine's Dispose() correctly releases the lock on early exit or full enumeration.

Correctness: The is type check correctly handles subclasses of RetrievableValuedEntryHashSet<T>. _properties is readonly and set in every constructor, so it can never be null at runtime.

Documentation: The inline comment is accurate and complete — it names the allocation it avoids, identifies why the fast path is safe to assume, and names the concrete types that reach the else branch.

Generated by Expert Code Review (command) for #14272 · 1.4K AIC · ⊞ 31.9K
Comment /review to run again

@AlesProkop
AlesProkop merged commit 868e9c0 into dotnet:main Jul 7, 2026
21 checks passed
WarperSan pushed a commit to WarperSan/ThunderPipe that referenced this pull request Sep 17, 2026
Updated
[Microsoft.Build.Utilities.Core](https://github.com/dotnet/msbuild) from
18.9.6 to 18.10.1.

<details>
<summary>Release notes</summary>

_Sourced from [Microsoft.Build.Utilities.Core's
releases](https://github.com/dotnet/msbuild/releases)._

## 18.10.1

## What's Changed
* [vs16.11] Update dependencies from dotnet/arcade by
@​dotnet-maestro[bot] in dotnet/msbuild#13103
* [vs17.12] Update dependencies from dotnet/arcade by
@​dotnet-maestro[bot] in dotnet/msbuild#13796
* [vs17.8] Update dependencies from dotnet/arcade by
@​dotnet-maestro[bot] in dotnet/msbuild#13902
* [vs17.11] Update dependencies from dotnet/arcade by
@​dotnet-maestro[bot] in dotnet/msbuild#13903
* [vs17.12] Update dependencies from dotnet/arcade by
@​dotnet-maestro[bot] in dotnet/msbuild#13909
* [vs17.12] Update dependencies from dotnet/arcade by
@​dotnet-maestro[bot] in dotnet/msbuild#13986
* Add vs18.9 to merge-flow config; retire vs18.3 by @​JanProvaznik in
dotnet/msbuild#14214
* Bump labeler-cache-retention to use issue-labeler v2.1.0 by
@​jeffhandley in dotnet/msbuild#14171
* Bump main to 18.10.0 after vs18.9 snap by @​JanProvaznik in
dotnet/msbuild#14216
* Improve release skill: Phase 2 DARC rules, VMR backflow, deterministic
baseline by @​JanProvaznik in
dotnet/msbuild#14220
* Determinize release: hardcode OptProf baseline + Phase 3.2 baseline
resolver by @​JanProvaznik in
dotnet/msbuild#14222
* Serialize BuildRequestConfiguration.RequestedTargets to fix solution
metaproject MSB4057 in parallel builds by @​ViktorHofer in
dotnet/msbuild#14223
* [main] Update dependencies from nuget/nuget.client by
@​dotnet-maestro[bot] in dotnet/msbuild#14203
* Core support for AbsolutePath/FileInfo/DirectoryInfo and ITaskItem<T>
as task parameters by @​baronfel in
dotnet/msbuild#13971
* [main] Update dependencies from dotnet/roslyn by @​dotnet-maestro[bot]
in dotnet/msbuild#14206
* Fix existence cache kind poisoning by @​AlesProkop in
dotnet/msbuild#14249
* [main] Source code updates from dotnet/dotnet by @​dotnet-maestro[bot]
in dotnet/msbuild#14226
* Don't disable the MSBuild server for /mt builds when node reuse is off
by @​AR-May in dotnet/msbuild#14248
* Enhance expert reviewer guidelines with additional checks. by @​AR-May
in dotnet/msbuild#14255
* [main] Source code updates from dotnet/dotnet by @​dotnet-maestro[bot]
in dotnet/msbuild#14253
* [main] Update dependencies from dotnet/roslyn by @​dotnet-maestro[bot]
in dotnet/msbuild#14268
* [main] Update dependencies from nuget/nuget.client by
@​dotnet-maestro[bot] in dotnet/msbuild#14267
* Bump github/gh-aw-actions/setup from 0.81.6 to 0.82.2 by
@​dependabot[bot] in dotnet/msbuild#14266
* Avoid boxing the struct enumerator in
PropertyDictionary<T>.GetEnumerator() by @​nareshjo in
dotnet/msbuild#14272
* Refresh copy marker when implementation output changes by @​AlesProkop
in dotnet/msbuild#14231
* Send task-host build process environment as delta by @​OvesN in
dotnet/msbuild#14126
* Add regression coverage for metadata newline preservation by
@​VolPlita in dotnet/msbuild#14261
* Fix EmbedInBinlog items with relative paths from child projects by
@​huulinhnguyen-dev in dotnet/msbuild#13990
* Stop requiring VersionPrefix updates in servicing - insert prerelease
versions to VS by @​ViktorHofer in
dotnet/msbuild#14277
* Fix WriteLinesToFile rewriting unchanged file when custom encoding is
used by @​huulinhnguyen-dev in
dotnet/msbuild#14146
* Enable trim/AOT analyzers for Microsoft.Build and clean up annotations
by @​JeremyKuhne in dotnet/msbuild#14064
* [automated] Merge branch 'vs18.9' => 'main' by @​github-actions[bot]
in dotnet/msbuild#14291
* Fix MicroBuild plugin feed URL to use allowed pkgs.dev.azure.com
format by @​AlesProkop in dotnet/msbuild#14295
* Pass ExcludeRestorePackageImports during restore to avoid redundant
evaluations by @​ViktorHofer with @​Copilot in
dotnet/msbuild#14274
* [vs18.7] Update dependencies from dotnet/arcade by
@​dotnet-maestro[bot] in dotnet/msbuild#13988
* Adopt Clever Test Selection (CTS) as parallel, non-blocking PR
pipeline by @​jankratochvilcz in
dotnet/msbuild#14212
* Harden exceptions when connecting to server by @​JanProvaznik in
dotnet/msbuild#14292
* Update MicrosoftBuildVersion in analyzer template by
@​github-actions[bot] in dotnet/msbuild#13886
* Fix MSBuild Server client dropping build result under WaitAny race
(#​14172) by @​JanProvaznik in
dotnet/msbuild#14251
* Partially revert #​13660: remove NuGet RestoreTask transient TaskHost
workaround by @​JanProvaznik in
dotnet/msbuild#14297
* Disable daily AI credits guardrail for Expert Code Review workflow by
@​JanProvaznik with @​Copilot in
dotnet/msbuild#14314
* Localized file check-in by OneLocBuild Task: Build definition ID 9434:
Build ID 14614733 by @​dotnet-bot in
dotnet/msbuild#14246
* Add opt-in partial (stop-after-pass) project evaluation by
@​ViktorHofer in dotnet/msbuild#14290
* Use partial evaluation for -getProperty/-getItem without a target by
@​ViktorHofer in dotnet/msbuild#14296
* [main] Source code updates from dotnet/dotnet by @​dotnet-maestro[bot]
in dotnet/msbuild#14324
* [main] Update dependencies from dotnet/roslyn by @​dotnet-maestro[bot]
in dotnet/msbuild#14333
* [main] Update dependencies from nuget/nuget.client by
@​dotnet-maestro[bot] in dotnet/msbuild#14330
* Bump github/gh-aw-actions/setup from 0.82.2 to 0.82.8 by
@​dependabot[bot] in dotnet/msbuild#14328
* Restrict partial evaluation to ProjectInstance by @​ViktorHofer in
dotnet/msbuild#14340
 ... (truncated)

Commits viewable in [compare
view](dotnet/msbuild@v18.9.6...v18.10.1).
</details>

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=Microsoft.Build.Utilities.Core&package-manager=nuget&previous-version=18.9.6&new-version=18.10.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This was referenced Sep 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants