-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Perf: Reimplement Lookup.Scope tables without ItemDictionary #12320
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Perf: Reimplement Lookup.Scope tables without ItemDictionary #12320
Conversation
There was a problem hiding this comment.
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 optimizes performance by replacing the ItemDictionary
implementation in Lookup.Scope
with a lighter-weight ItemDictionarySlim
, reducing CPU usage and memory allocations. The changes eliminate locking overhead and simplify data structures used for tracking item additions, removals, and empty markers within lookup scopes.
- Introduces
ItemDictionarySlim
to replaceItemDictionary
for scope-specific item tracking - Replaces empty markers with a frozen set-based truncation approach using
ItemTypesToTruncateAtThisScope
- Optimizes item merging and removal logic to avoid duplicate checking and intermediate allocations
Reviewed Changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.
Show a summary per file
File | Description |
---|---|
Lookup.cs | Core performance optimization replacing ItemDictionary with ItemDictionarySlim and implementing new truncation mechanism |
ItemDictionary.cs | Removes unused methods (AddRange, Replace, AddEmptyMarker, HasEmptyMarker) to simplify API |
IItemDictionary.cs | Updates interface by removing unused method signatures |
ImmutableItemDictionary.cs | Removes overridden implementations of deleted interface methods |
ItemBucket.cs | Updates to use new truncation API instead of empty markers |
BatchingEngine.cs | Changes to use FrozenSet for item names in bucket creation |
TargetEntry.cs | Adds explicit truncation calls for incremental build scenarios |
TargetUpToDateChecker.cs | Replaces AddEmptyMarker calls with ImportItemsOfType |
ItemGroupIntrinsicTask.cs | Updates RemoveItems call signature to include item type parameter |
Test files | Updates test code to work with new APIs and data structures |
/// </remarks> | ||
internal void TruncateLookupsForItemTypes(ICollection<string> itemTypes) | ||
{ | ||
ErrorUtilities.VerifyThrow(_lookupScopes.ItemTypesToTruncateAtThisScope == null, "Cannot add an itemgroup of this type."); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The error message "Cannot add an itemgroup of this type." is misleading and doesn't accurately describe the validation being performed. The check is verifying that truncation hasn't been set up yet, not that an item group can't be added.
ErrorUtilities.VerifyThrow(_lookupScopes.ItemTypesToTruncateAtThisScope == null, "Cannot add an itemgroup of this type."); | |
ErrorUtilities.VerifyThrow(_lookupScopes.ItemTypesToTruncateAtThisScope == null, "Item type truncation has already been set up for this scope."); |
Copilot uses AI. Check for mistakes.
// Start with the item spec length as a fast filter for false matches. | ||
if (evaluatedInclude.Length == removeAsTaskItem.EvaluatedIncludeEscaped.Length | ||
&& StringComparer.Ordinal.Equals(evaluatedInclude, removeAsTaskItem.EvaluatedIncludeEscaped) | ||
&& itemAsTaskItem == removeAsTaskItem) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The reference equality check itemAsTaskItem == removeAsTaskItem
should come before the string comparison for better performance. Reference equality is much faster than string comparison, so checking it first can short-circuit the more expensive operations.
&& itemAsTaskItem == removeAsTaskItem) | |
if (itemAsTaskItem == removeAsTaskItem | |
|| (evaluatedInclude.Length == removeAsTaskItem.EvaluatedIncludeEscaped.Length | |
&& StringComparer.Ordinal.Equals(evaluatedInclude, removeAsTaskItem.EvaluatedIncludeEscaped))) |
Copilot uses AI. Check for mistakes.
@@ -1025,7 +1082,7 @@ private void MustNotBeInAnyTables(ProjectItemInstance item) | |||
/// </summary> | |||
private void MustNotBeOuterScope() | |||
{ | |||
ErrorUtilities.VerifyThrow(_lookupScopes.Count > 1, "Operation in outer scope not supported"); | |||
ErrorUtilities.VerifyThrow(_lookupScopes.Parent != null, "Operation in outer scope not supported"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The condition _lookupScopes.Parent != null
seems inverted. Based on the error message "Operation in outer scope not supported" and the previous implementation that checked _lookupScopes.Count > 1
, this should likely be checking that we're NOT in the outer scope, which would be _lookupScopes.Parent == null
.
ErrorUtilities.VerifyThrow(_lookupScopes.Parent != null, "Operation in outer scope not supported"); | |
ErrorUtilities.VerifyThrow(_lookupScopes.Parent == null, "Operation in outer scope not supported"); |
Copilot uses AI. Check for mistakes.
b5d1045
to
82177b1
Compare
Fixes
Reduces a significant amount of CPU and allocations in
Lookup
related toItemDictionary
overhead.Before:

After (-2.8s of CPU - most of the remaining time is related to metadata

Modifies
,PropertyDictionary
, and the outer scope which still usesItemDictionary
)Allocations are harder to 1:1 granularly, but you can see on the left a total ~870MB reduction.
Before:

After (-870MB):

Context
Lookup
uses a stack ofScope
objects which each track multiple optional tables ofProjectItemInstance
(implemented viaItemDictionary
).For the context of the PR, here's a quick overview of how this currently works:
Items
is merged into the next scope. After this point, the popped scope is effectively discarded (seeMergeScopeIntoNotLastScope()
).Items
(seeMergeScopeIntoLastScope()
). Unlike other scopes, this base table is externally provided at the construction of the firstLookup
.GetItems()
returns a non-destructive merge down the scope stack, stopping if either the outer scope is reached, or a scope is found with itsItems
table set.ItemDictionary
, even though we only return a single item type list.Items
tables generally only contain one item type with items, with others containing "empty markers". These are used to mask the base item dictionary, primarily inItemBucket
where each bucket (and therefore scope) holds a single type.Currently, each of these intermediate tables are implemented via
ItemDictionary
, which internally uses aDictionary<string, LinkedList>
to represent item lists and aDictionary<ProjectItemInstance, LinkedList>
to provide O(1) access to indices. Empty markers are implemented by creating empty entries. Every method also takes a lock, sinceItemDictionary
is designed for use in other areas of MSBuild - whereasLookup
is only used in single-threaded contexts.This all adds overhead that can be avoided by replacing
ItemDicitonary
with basic collections for all inner scopes.Changes Made
ItemDictionarySlim
to replaceItemDictionary
inside ofLookup
, while still providing similar helper methods.ItemTypesToTruncateAtThisScope
GetItems