Improve MSBuild expansion performance - #14697
Conversation
Replace the monolithic Expander benchmark with targeted property, item, metadata, mixed-expression, condition, shredder, and lazy-item suites. Add reusable setup helpers and make cache-state expectations explicit. Add a cross-TFM PowerShell runner with broad and granular benchmark sets, storing results under the repository artifacts directory.
Create fresh item instances for each iteration so cold and repeated modifier lookups measure the intended cache state. Clear the shared defining-project cache after setup to prevent state leaking between iterations. Batch item accesses and normalize results with OperationsPerInvoke. Move item discovery and materialization out of the measured code.
Separate cached escaping into explicit cache-hit and cache-miss cases. Prime hit inputs before measurement and generate unique miss inputs for each iteration so warmup cannot change the measured cache state. Batch operations to reduce harness overhead while preserving input length and escaping characteristics.
Expose launch count through the benchmark runner for higher-confidence comparisons across independent processes. Keep power-plan enforcement opt-in so dedicated machines retain their configured plan. Document smoke, exploratory, and confirmation runs, including execution cost and cross-framework comparison constraints.
Measure fixed property-expansion shapes with 10 and 100 unrelated properties in the backing bag. Include a no-expansion baseline to separate lookup effects from fixed benchmark overhead. Add a dedicated category and runner set while preserving the broader property-expansion scaling groups.
Resolve explicit relative artifact paths against PowerShell's current location instead of the process working directory, which may not track Set-Location. This ensures paths such as .\artifacts are created under the directory from which Run-Benchmarks.ps1 was invoked. Document the relative path behavior for the ArtifactsPath parameter.
Document the intentional cold-path and cache-isolation behavior of the focused evaluation benchmarks. Use the shared temporary-directory helper for modifier benchmarks to centralize setup and cleanup.
MSBuild repeatedly searches evaluation strings for the `$(`, `@(`, and
`%(` syntax markers. Existing call sites used culture-aware searches,
ordinal substring searches, broad single-character checks, and
character-by-character loops. This duplicated work and made the common
marker-absent path pay for slower two-character searches.
Add named marker constants and specialized `Contains*Marker` and
`IndexOf*Marker` APIs to `ExpressionShredder`. The index helpers search
for a single-character prefix and check the following `(` directly.
They support both unbounded and count-bounded ranges while leaving full
expression validation to the existing parsers.
Use the helpers throughout property, metadata, and item-vector
expansion; SDK-reference property detection; item provenance; and
intrinsic item metadata handling. This removes the unnecessary
invariant-culture SDK-reference search, replaces broad `Contains('@')`
checks with exact marker detection, and lets expansion loops jump
directly between candidate references.
Refactor `ReferencedItemExpressionsEnumerator` to jump between bounded
`@(` markers while preserving resumable state. Refactor
`GetReferencedItemNamesAndMetadata` to perform one bounded `IndexOfAny`
scan for `@` and `%` instead of scanning the remaining input twice. Its
explicit scan position preserves marker ordering, subrange boundaries,
and malformed-expression recovery without manipulating a `for` loop
counter.
Add focused coverage for marker constants, all marker types, missing
and trailing prefixes, rejected prefix characters, repeated markers,
start indexes, bounded ranges, range boundaries, and containment
checks.
Replace the stateful item expression enumerator with an indexed TryGetNextItemVectorExpression API. Update expansion callers, tests, and benchmarks to consume captures directly and avoid redundant marker scans.
Use the Try pattern with an out parameter when expanding a single item vector expression. This avoids wrapping the relatively large capture struct in Nullable<T> and copying it through Nullable<T>.Value.
Specialize literal, exact, embedded-single, and multiple-match transforms so common cases avoid general-purpose scanning and builders. Jump directly between metadata markers and hoist invariant boundaries while preserving existing expansion and allocation behavior.
Separate property-specific parenthesis scanning from the general function-argument scanner. Classify property and registry function candidates while scanning so ordinary properties go directly to lookup. Move uncommon compatibility and function handling into ExpandProperty, use string-based marker and quote scans, and replace magic comparison lengths with named constants. This reduces work in common property expansion paths and keeps specialized behavior isolated.
Encapsulate property expansion context in a readonly ref struct so helper methods no longer pass the same state through each call. Cache the truncation policy for reuse during expansion while preserving the existing static entry points.
Avoid materializing intermediate entries and LINQ iterators when joining item vectors with explicit separators. Use ValueStringBuilder on .NET and StringBuilderCache on .NET Framework. Preserve transform classification for empty item vectors while still evaluating Count and AnyHaveMetadataValue on empty lists.
Resolve item-spec modifier kinds when parsing quoted transform metadata instead of classifying the same name for every item. Add a non-caching enum overload of GetItemSpecModifier so quoted transforms can call the modifier implementation directly.
Dispatch unnamed quoted transforms directly instead of synthesizing an intrinsic function name and one-element argument array. This avoids modifier classification and intrinsic dictionary lookup. Keep explicitly named ExpandQuotedExpressionFunction invocations on the existing validated argument path.
Resolve project directory and defining-project metadata only for item-spec modifiers that consume those values. Common quoted transforms such as Filename now skip both per-item lookups.
There was a problem hiding this comment.
No blocking issues found in the snippet reviewed. GetItemSpecModifier already tolerates a null currentDirectory for Filename, Extension, RelativeDir, and Identity, and RecursiveDir is not derivable (TryGetDerivableModifierKind excludes it), so the new switch does not appear to introduce a functional regression. The change is likely performance-positive on this hot path because it avoids eagerly computing project context and DefiningProjectFullPath for modifiers that do not need them.
One concern: I could not find targeted regression coverage for this new dispatch in GetMetadataValueFromMatch, especially for quoted transform expansion of %(Filename), %(RelativeDir), and the %(DefiningProject*) modifiers. Existing tests cover ItemSpecModifiers.GetItemSpecModifier directly and some transform behavior, but not this exact expander-layer routing. A focused table-driven test here would better lock in the intended mapping and guard future additions to derivable modifiers.
Generated by Expert Code Review (on open) for #14697 · sonnet46 · 63.4 AIC · ⌖ 7.62 AIC · ⊞ 5.3K
There was a problem hiding this comment.
Pull request overview
This pull request optimizes MSBuild’s evaluation-time expansion hot paths (properties, item vectors, metadata, and quoted transforms) to reduce per-expression CPU overhead, and updates/expands the BenchmarkDotNet benchmark suite + runner script to measure these scenarios more directly.
Changes:
- Refactors expansion marker detection and item-vector parsing (new indexing APIs in
ExpressionShredder, new item-vector capture flow inExpander). - Optimizes item transform execution paths (quoted transforms dispatch, separator joins, metadata-match classification, and reduced context resolution for item-spec modifiers).
- Reworks benchmark infrastructure and adds focused benchmark suites plus a cross-TFM runner script and updated documentation.
Reviewed changes
Copilot reviewed 43 out of 43 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| src/MSBuild.Benchmarks/Run-Benchmarks.ps1 | Adds a cross-TFM benchmark runner with filter/set selection and common options. |
| src/MSBuild.Benchmarks/readme.md | Documents the new runner, named sets, and run modes. |
| src/MSBuild.Benchmarks/Program.cs | Adds --enforce-power-plan plumbing into the BenchmarkDotNet config overrides. |
| src/MSBuild.Benchmarks/ItemSpecModifiersCachingBenchmark.cs | Updates modifier-caching benchmark setup + per-iteration item creation. |
| src/MSBuild.Benchmarks/DefiningProjectModifiersBenchmark.cs | Updates defining-project modifier benchmarks to use per-iteration setup and new temp-dir infra. |
| src/MSBuild.Benchmarks/EscapingUtilitiesBenchmark.cs | Removes the caching cases (now covered by the dedicated caching benchmark). |
| src/MSBuild.Benchmarks/EscapingUtilitiesCachingBenchmark.cs | Adds cache-hit vs cache-miss escaping benchmarks with per-iteration input generation. |
| src/MSBuild.Benchmarks/Infrastructure/TemporaryDirectory.cs | Adds reusable temp-directory lifetime management for benchmarks. |
| src/MSBuild.Benchmarks/Infrastructure/BenchmarkProject.cs | Adds helper for creating project instances/items for expander benchmarks. |
| src/MSBuild.Benchmarks/Infrastructure/ExpanderBuilder.cs | Adds a builder for constructing expanders with explicit properties/items/metadata. |
| src/MSBuild.Benchmarks/Infrastructure/ExpanderBenchmarkFixture.cs | Adds a fixture to keep expander + project state alive for benchmark lifetime. |
| src/MSBuild.Benchmarks/ExpanderBenchmark.cs | Removes the previous monolithic expander benchmark (replaced by focused suites). |
| src/MSBuild.Benchmarks/Evaluation/Expansion/PropertyExpansionBenchmark.cs | Adds focused property-shape expansion benchmarks. |
| src/MSBuild.Benchmarks/Evaluation/Expansion/PropertyFunctionExpansionBenchmark.cs | Adds focused property-function benchmarks. |
| src/MSBuild.Benchmarks/Evaluation/Expansion/PropertyExpansionScalingBenchmark.cs | Adds scaling benchmarks for property reference counts. |
| src/MSBuild.Benchmarks/Evaluation/Expansion/PropertyBagCardinalityBenchmark.cs | Adds scaling benchmarks for unrelated property-bag cardinality. |
| src/MSBuild.Benchmarks/Evaluation/Expansion/ItemExpansionBenchmark.cs | Adds focused item-vector benchmarks (incl. quoted transforms and separators). |
| src/MSBuild.Benchmarks/Evaluation/Expansion/ItemFunctionExpansionBenchmark.cs | Adds focused item-function/transform benchmarks. |
| src/MSBuild.Benchmarks/Evaluation/Expansion/MetadataExpansionBenchmark.cs | Adds focused metadata expansion benchmarks. |
| src/MSBuild.Benchmarks/Evaluation/Expansion/MetadataExpansionScalingBenchmark.cs | Adds scaling benchmarks for metadata reference counts. |
| src/MSBuild.Benchmarks/Evaluation/Expansion/MixedExpansionBenchmark.cs | Adds focused mixed property/item/metadata expansion benchmarks. |
| src/MSBuild.Benchmarks/Evaluation/ExpressionShredder/ExpressionShredderBenchmark.cs | Adds focused ExpressionShredder throughput benchmarks. |
| src/MSBuild.Benchmarks/Evaluation/ExpressionShredder/ExpressionShredderAllocationBenchmark.cs | Adds opt-in cold-cache allocation diagnostics for ExpressionShredder. |
| src/MSBuild.Benchmarks/Evaluation/Conditions/ConditionStrings.cs | Adds shared condition strings for parsing/evaluation benchmarks. |
| src/MSBuild.Benchmarks/Evaluation/Conditions/ConditionParsingBenchmark.cs | Adds focused condition parsing benchmarks. |
| src/MSBuild.Benchmarks/Evaluation/Conditions/ConditionEvaluationBenchmark.cs | Adds focused end-to-end condition evaluation benchmarks with warmed production cache. |
| src/MSBuild.Benchmarks/Evaluation/Items/LazyItemEvaluationBenchmark.cs | Adds end-to-end evaluation benchmarks for lazy item operations. |
| src/Framework/ItemSpecModifiers.cs | Adds an overload using ItemSpecModifierKind to avoid repeated name classification and enable more direct dispatch. |
| src/Build/Evaluation/ExpressionShredder.cs | Adds specialized marker indexing APIs and a new indexed item-vector parser. |
| src/Build/Evaluation/Expander.cs | Updates item-vector detection API usage and simplifies parenthesis scanning signature usage in function parsing. |
| src/Build/Evaluation/Expander.Function.cs | Updates closing-paren scanning call site after signature change. |
| src/Build/Evaluation/Expander.PropertyExpander.cs | Refactors property expansion into a readonly ref struct and uses new marker indexing + specialized paren scan. |
| src/Build/Evaluation/Expander.MetadataExpander.cs | Uses new marker detection + indexed item-vector scanning to expand metadata in gaps. |
| src/Build/Evaluation/Expander.ItemExpander.cs | Introduces TryExpandSingleItemVectorExpression, refactors item-vector expansion/joining, and optimizes quoted-transform dispatch. |
| src/Build/Evaluation/Expander.ItemExpander.Transforms.MetadataMatch.cs | Caches derivable modifier classification/kind per metadata match. |
| src/Build/Evaluation/Expander.ItemExpander.Transforms.cs | Special-cases quoted transform shapes, reduces allocation, and avoids unnecessary context resolution for modifiers. |
| src/Build/Evaluation/LazyItemEvaluator.cs | Switches to the new TryExpandSingleItemVectorExpression API for item reference detection. |
| src/Build/Evaluation/ItemSpec.cs | Switches item-spec parsing to the new TryExpandSingleItemVectorExpression API. |
| src/Build/Evaluation/Evaluator.cs | Uses new property-marker detection API for SDK reference property expansion gating. |
| src/Build/Definition/Project.cs | Uses new property-marker detection API for provenance checks. |
| src/Build/BackEnd/Components/RequestBuilder/IntrinsicTasks/ItemGroupIntrinsicTask.cs | Uses new item-vector marker detection API for metadata self-reference logging behavior. |
| src/Build.UnitTests/Evaluation/Expander_Tests.cs | Adds coverage to ensure empty item vectors still report whether a transform was present. |
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
Cover every derivable item-spec modifier through quoted transform expansion. Verify each modifier reads only the project and defining project context it requires.
Added coverage with d7eaa2f. |
|
Nice! lgtm |
|
https://dev.azure.com/devdiv/DevDiv/_git/VS/pullrequest/770759 |
It looks like there are wins related to this PR. Speedometer shows four highlights in CPlusPlus scenarios and it looks like all of them are attributable to vcxprojreader. |
| /// <returns> | ||
| /// The zero-based index of the marker, or <c>-1</c> if it is not found. | ||
| /// </returns> | ||
| public static int IndexOfPropertyMarker(string expression) |
There was a problem hiding this comment.
nit: Since we already have named constants for the full markers, should we also introduce PropertyMarkerPrefix , ItemVectorMarkerPrefix , and MetadataMarkerPrefix for the repeated $ , @ , and % ?
|
|
||
| // PERF NOTE: pre-scanning the string for "%(" is cheaper than a full scan. | ||
| if (expression.IndexOf("%(", StringComparison.Ordinal) < 0) | ||
| if (!ExpressionShredder.ContainsMetadataMarker(expression)) |
There was a problem hiding this comment.
Could we avoid scanning for the first metadata marker twice?ExpandMetadataLeaveEscaped first calls ContainsMetadataMarker, and ScanAndExpandMetadata later starts again with IndexOfMetadataMarker. Perhaps we could find the first index up front and pass it into the expansion path?
| /// The CultureInfo from the invariant culture. Used to avoid allocations for | ||
| /// performing IndexOf etc. | ||
| /// </summary> | ||
| private static readonly CompareInfo s_invariantCompareInfo = CultureInfo.InvariantCulture.CompareInfo; |
There was a problem hiding this comment.
I guess we do not need it now at all. Only remaining use of it is
if (s_invariantCompareInfo.IndexOf(_expression, "::", CompareOptions.OrdinalIgnoreCase) > -1)
Important
This depends on #14660. Until that PR merges, this PR includes its benchmark commits; the expansion changes begin after bd61887.
Tip
Each commit is self-contained and has a good description. Consider reviewing commit-by-commit starting at 71d8b82.
Context
Expansion repeatedly scans strings for property, item, and metadata markers and performs parsing, dispatch, and context resolution for every match. These operations are pervasive during project evaluation, so even small per-expression or per-item costs accumulate across large solutions.
This change optimizes those hot paths while preserving existing expansion behavior.
Note
This is the first in a series of expansion performance PRs. It focuses on targeted, behavior-preserving reductions in CPU overhead; allocation-focused work and more fundamental architectural changes are intentionally left for follow-up PRs. There is still substantial low-hanging fruit in these paths.
Changes Made
$(,@(, and%(expressions, replacing duplicated and broader searches throughout evaluation.Nullable<T>.readonly ref structand cache invariant expansion policy.ValueStringBuilderon .NET andStringBuilderCacheon .NET Framework, avoiding intermediate collections and LINQ.Benchmark Results
The baseline is
bd61887f13; the optimized implementation is34c6229708. Negative timing percentages indicate faster execution. The four benchmark classes were unchanged between these commits.🚀 Performance Highlights
NoExpansion).NET 11.0 — RyuJIT x64
ItemExpansionBenchmark
MetadataExpansionBenchmark
MixedExpansionBenchmark
PropertyExpansionBenchmark
.NET Framework 4.8.1 — LegacyJIT x86
ItemExpansionBenchmark
MetadataExpansionBenchmark
MixedExpansionBenchmark
PropertyExpansionBenchmark
Analysis
Testing
ItemExpansionBenchmark,MetadataExpansionBenchmark,MixedExpansionBenchmark, andPropertyExpansionBenchmarkon .NET 11.0 x64 and .NET Framework 4.8.1 LegacyJIT x86.Notes
This change intentionally preserves existing expansion semantics and public API behavior. The focused benchmark infrastructure is reviewed separately in #14660.