Avoid boxing the struct enumerator in PropertyDictionary<T>.GetEnumerator() - #14272
Conversation
There was a problem hiding this comment.
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 enumeratesRetrievableValuedEntryHashSet<T>directly soforeachbinds to the struct-returningGetEnumerator()(no interface boxing). - Preserve the existing interface-based enumeration behavior for non-
RetrievableValuedEntryHashSet<T>implementations ofIRetrievableValuedEntryHashSet<T>. - Make non-generic
IEnumerable.GetEnumerator()delegate to the genericGetEnumerator()to eliminate duplicate enumeration logic (and an extra boxing site).
|
/review |
|
✅ Expert Code Review (command) completed successfully! Caution agentic threat detected Code review of PR #14272 complete — three dimensions evaluated against the actual diff and source. |
There was a problem hiding this comment.
Caution
agentic threat detected
Threat detection flagged this output in warn mode. Manual review is REQUIRED before any follow-up automation.
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
🤖 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 theIEnumerable<T>interface — it iterates_properties.Values, andRetrievableEntryHashSet<T>.Valuesreturnsthistyped asICollection<T>. Because the backingRetrievableEntryHashSet<T>exposes a value-typeEnumerator, dispatching through the interface boxes that struct onto the heap on every enumeration:IEnumerable<T>.GetEnumerator()doesreturn 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
ProjectInstanceconstruction (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% asEnumerator[…Execution.ProjectPropertyInstance]):Callers on hot path as per Perfwatson traces (all funnel through
PropertyDictionary<T>.GetEnumerator):All variants converge on the same boxed value-type-enumerator leaf at
RetrievableEntryHashSet<T>.IEnumerable<T>.GetEnumerator, rooted atProjectSnapshotService.GenerateProjectInstance → ProjectInstance..ctor → CreateItemsSnapshotand 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 concreteRetrievableValuedEntryHashSet<T>type andforeachover it, so the C#foreachbinds to the struct-returningpublic Enumerator GetEnumerator()— no boxing. Anelsebranch preserves the original interface enumeration for the non-derivingIRetrievableValuedEntryHashSet<T>implementers (ImmutableGlobalPropertiesCollectionConverter,ImmutableProjectPropertyCollectionConverter). The non-genericIEnumerable.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 byPropertyDictionary<T>.Filter, is internal-only, and is behavior-preserving — identical enumeration order, version/concurrent-modification check, and read-lock scope (both paths drive the sameRetrievableEntryHashSet<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