Perf: restore O(N+M) item filtering in Lookup.GetItems#13688
Perf: restore O(N+M) item filtering in Lookup.GetItems#13688JanProvaznik wants to merge 2 commits intodotnet:mainfrom
Conversation
PR dotnet#12320 reimplemented Lookup.Scope tables with ItemDictionarySlim+List<T> for allocation/CPU wins. As part of that, the result-construction path in Lookup.GetItems switched from an ItemDictionary (which gave O(1) hash-based remove via _nodes) to a List<T> filtered by a static ShouldRemoveItem helper that linearly scans every list-of-removes for every item in groupFound and every add. This made each GetItems call O((N+A)*M) instead of O(N+A+M). For large item collections with many batched removes - e.g. C++ projects with thousands of source files using extensions that partition sources via repeated <ItemGroup><Item Remove='...'/></ItemGroup> intrinsic operations - this turned multi-minute builds into multi-hour builds. Fix: in Lookup.GetItems, when allRemoves total exceeds 8, materialize a HashSet<ProjectItemInstance> with the default reference-equality comparer (matching ShouldRemoveItem's identity check and the pre-dotnet#12320 Dictionary behavior) and use O(1) Contains. Below the threshold, keep the original linear scan to avoid HashSet allocation overhead. Validated with new BenchmarkDotNet benchmark LookupGetItemsBenchmark (2000 items, 100 batches of 10 removes, --job short, .NET 10): Method Before After Speedup SingleScope 456.9 ms 5.2 ms ~87x MergedSubScopes 273.6 ms 3.3 ms ~83x Allocations rise modestly (HashSet per call) but CPU win dominates. New unit tests cover: many batched removes, the linear/hashset boundary (8 vs 9), reference-equality semantics for items with identical includes, and the merged-subscope variant. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… adds-path tests Per .github/instructions/tests.instructions.md, new and modified test code uses Shouldly assertions. Adds two new scenarios surfaced in expert review: - GetItems_RemovesNotInGroup_AreNoOps: confirms HashSet path is correct when every remove targets an item that does not exist in groupFound. - GetItems_RemoveSubsetOfAdds_ReturnsRemainingAdds: covers the second HashSet branch (the loop over allAdds, in addition to the loop over groupFound). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Round 1 of multi-agent review addressed in commit 13e0265: From the expert reviewer (
Test count is now 35/35 in From the diff-only reviewer (
Will hold the draft until the follow-up issue exists and is linked. |
Summary
PR #12320 (merged Sep 2025) reimplemented
Lookup.Scopetables to useItemDictionarySlim+ plainList<T>instead ofItemDictionary, for nice allocation/CPU wins on most workloads. As part of that change, the result-construction path insideLookup.GetItemswas rewritten — and an algorithmic regression slipped in:ItemDictionary. Removes were applied viaItemDictionary.RemoveItems(allRemoves)which uses an internalDictionary<T, LinkedListNode<T>>for O(1) hash-keyed deletes. Total per-call cost: O(N + A + M).List<ProjectItemInstance>. For every item ingroupFoundand every add, the code calls a staticShouldRemoveItem(item, allRemoves)that linearly scans every list insideallRemoves. Total per-call cost: O((N + A) × M).Where
N= items in the matched group,A= total adds across scopes,M= total removes across scopes.For projects with thousands of items in one collection that go through many small batched-remove operations (a common pattern in C++ build extensions that partition source files via repeated
<ItemGroup><Item Remove="…"/></ItemGroup>intrinsics inside batched targets), this turns multi-minute builds into multi-hour builds. There are external reports of exactly this regression after upgrading to a VS 2026 /vs18.3+version of MSBuild.The customer-facing report correctly bisected the regression to PR #12320 by direct line-pointing at the
ItemDictionary→List<T>switch.Architecture change
flowchart LR subgraph Before["pre-#12320 (correct, slower allocation profile)"] direction TB A1[GetItems] --> A2["result = new ItemDictionary"] A2 --> A3["ImportItemsOfType(groupFound)"] A3 --> A4["ImportItemsOfType(allAdds)"] A4 --> A5["RemoveItems(allRemoves)<br/>O(M) hashed removes via _nodes"] A5 --> A6[return result] end subgraph Bug["after #12320 (regressed)"] direction TB B1[GetItems] --> B2["result = new List"] B2 --> B3["foreach item in groupFound:<br/>ShouldRemoveItem(item, allRemoves)<br/><b>O(M) linear scan per item</b>"] B3 --> B4["foreach add in allAdds:<br/>ShouldRemoveItem(item, allRemoves)<br/><b>O(M) linear scan per item</b>"] B4 --> B5[return result] style B3 fill:#fdd style B4 fill:#fdd end subgraph Fix["this PR"] direction TB C1[GetItems] --> C2["result = new List"] C2 --> C3{"totalRemoves > 8?"} C3 -- "no (small)" --> C4["foreach item: ShouldRemoveItem<br/>O(M) linear, M is tiny"] C3 -- "yes (large)" --> C5["removeSet = HashSet<ProjectItemInstance><br/>(default = reference equality,<br/>matches ShouldRemoveItem &<br/>pre-#12320 _nodes)"] C5 --> C6["foreach item: removeSet.Contains<br/><b>O(1) per item</b>"] C4 --> C7[return result] C6 --> C7 style C5 fill:#dfd style C6 fill:#dfd endFix
In
Lookup.GetItems(src/Build/BackEnd/Components/RequestBuilder/Lookup.cs):allRemovescount (cheap — sum the list-of-lists sizes).> 8), build aHashSet<ProjectItemInstance>populated with all removes. UseremoveSet.Contains(item)instead ofShouldRemoveItemwhen filteringgroupFoundandallAdds.Why reference-equality semantics are preserved
ProjectItemInstancedoes not overrideEquals/GetHashCode, soHashSet<ProjectItemInstance>with the default comparer keys by reference.ShouldRemoveItemisitemAsTaskItem == removeAsTaskItem(reference equality on an interface with no==overload). The precedingEvaluatedIncludeEscapedlength + ordinal checks are early-out false filters; they cannot promote a value-equal-but-reference-different pair to a match.ItemDictionarykeyed its_nodesbyDictionary<T, LinkedListNode<T>>— also default reference equality onProjectItemInstance. So this fix restores the historical behavior, not changes it. There is a regression test (GetItems_RemoveUsesReferenceEquality_NotValueEquality) asserting this contract.Evidence
A new BenchmarkDotNet benchmark
MSBuild.Benchmarks.LookupGetItemsBenchmarkmodels the QtMsBuild-style workload: a base scope with N items of one type, plus M small batches of removes, callingGetItemsafter each batch. Two scenarios:SingleScope— removes accumulate in one scope'sPrimaryRemoveTable(worst case for the regression).MergedSubScopes— each batch enters a child scope, removes, callsGetItems, then leaves; subsequent calls see remove lists from multiple scope levels (representative of how batched intrinsic-task removes accumulate during a real build).Measured on .NET 10, Windows 11 (Hyper-V),
--job short, parametersBaseItemCount=2000, RemoveBatchCount=100, RemoveBatchSize=10:SingleScopeMergedSubScopesAllocations rise modestly (per-call
HashSetallocation), but the CPU win dominates by orders of magnitude. The speedup grows multiplicatively withN × M— atN=5000, M=200the broken code took ~5 s per op while the fix completes in ~27 ms (~180×), confirming the regression was quadratic.To reproduce:
Tests
All assertions use Shouldly per
tests.instructions.md.GetItemsAfterManyBatchedRemoves_ReturnsCorrectItems— 200 base items, 50 batches of 4 removes; asserts exact remaining membership (exercises HashSet path ongroupFound).GetItems_LinearAndHashSetPathsAgree([8], [9])— boundaryTheoryproving both code paths produce identical results across the threshold.GetItems_RemoveUsesReferenceEquality_NotValueEquality— 30 distinctProjectItemInstancereferences all sharing the sameEvaluatedInclude; removing 10 leaves exactly the other 20. Pins the reference-identity contract.GetItemsAfterMergedSubScopeRemoves_ReturnsCorrectItems— multi-scope variant: 20 child scopes each contributing 3 removes via merge; verifies correctness whenallRemovescontains multiple lists.GetItems_RemovesNotInGroup_AreNoOps— 30 base items + 20 phantom removes that don't match anything; asserts all 30 originals survive.GetItems_RemoveSubsetOfAdds_ReturnsRemainingAdds— empty base, 20AddNewItemcalls, 15 reference-equal removes; asserts the HashSet branch onallAddskeeps exactly the 5 unremoved adds.Lookup_Tests, plus 154IntrinsicTask_TestsandBatchingEngine_Tests, continue to pass. New count: 35 inLookup_Tests.Scope notes
Lookup.MergeScopeIntoNotLastScopeno longer dedups removes when merging up — it justImportItemsthe list ("for perf, deferring until used" per its comment). That can inflateMover time. The fix in this PR makesGetItemstolerate inflatedMcheaply (HashSet dedups for free during construction), so this is now an allocation/footprint concern, not an asymptotic-CPU concern. Worth a separate follow-up if there's evidence it matters in practice.MSBuild.Benchmarksproject (uses the sameMicrosoftstrong-name public key asMicrosoft.NET.StringTools.Benchmarkalready does forStringTools).Backport candidates
Servicing branches that contain PR #12320 and would benefit:
vs18.3,vs18.4,vs18.5,vs18.6,vs18.7Suggested after this lands in
main.Fixes performance regression introduced by #12320.