optimizing-msbuild-performance
MSBuild Performance Guidelines
MSBuild evaluates and builds thousands of projects in enterprise solutions. Performance is an architectural concern, not an afterthought.
Guiding Principle
Profile before optimizing; measure, do not guess. Use BenchmarkDotNet, the evaluation profiler (/profileevaluation), or ETW traces to identify actual bottlenecks before optimizing.
See evaluation-profiling.md and General_perf_onepager.md for profiling tools.
Allocation Awareness on Hot Paths
The evaluation and execution engines process millions of operations per build. Unnecessary allocations cause GC pressure that compounds across large solutions.
Rules
-
Avoid LINQ in hot paths.
Where,Select,Any,Firstall allocate enumerator objects and delegate closures. Useforeachloops instead.// BAD — allocates iterator + delegate var match = items.FirstOrDefault(i => i.Name == name); // GOOD — zero allocations foreach (var item in items) { if (item.Name == name) { /* found */ break; } } -
Avoid string allocations in formatting. Use
string.Concat, interpolation withSpan<T>, orStringBuilderreuse — notstring.Formaton hot paths. -
Prefer
Span<T>andstackallocfor short-lived buffers when parsing or slicing strings. AvoidSubstringwhen you only need to compare or inspect a portion. -
Cache computed values. If a value is computed in a loop but doesn't change, hoist it out. If it's expensive and reused across calls, use a field or
Lazy<T>.
String Comparison Rules
MSBuild property, item, and target names are case-insensitive. Getting this wrong causes subtle bugs and perf issues.
| Scenario | Use |
|---|---|
| Property/item/target names | MSBuildNameIgnoreCaseComparer |
| General MSBuild identifiers | StringComparison.OrdinalIgnoreCase |
| Dictionary keys for MSBuild names | MSBuildNameIgnoreCaseComparer as comparer |
| File paths on Windows | StringComparison.OrdinalIgnoreCase |
| File paths on Linux | StringComparison.Ordinal |
Never use ToLower()/ToUpper() for comparisons — they allocate a new string every time.
Never use CurrentCulture for MSBuild identifiers — build behavior must not vary by locale.
Collection Type Selection
| Access Pattern | Recommended Type |
|---|---|
| Small fixed set (< ~8 items) | Array or ReadOnlySpan<T> |
| Build-once, read-many | ImmutableArray<T> (not ImmutableList<T>) |
| Keyed lookup, many items | Dictionary<TKey, TValue> with appropriate comparer |
| Keyed lookup, few items (< 5) | Linear scan over array (cache-friendly, avoids dict overhead) |
| Concurrent reads, rare writes | ImmutableDictionary or snapshot pattern |
| Ordered iteration needed | List<T> or array; avoid HashSet<T> if order matters |
ImmutableList<T> is almost never the right choice — it has O(log n) access vs O(1) for ImmutableArray<T>.
Hot Path Identification
These areas are performance-critical and require extra scrutiny:
Expander.cs— property/item/metadata expansion during evaluationEvaluator.cs— project evaluation orchestrationItemSpec.cs/LazyItemEvaluator.cs— item evaluation and globbingTaskExecutionHost.cs— task parameter marshaling- File I/O paths —
FileMatcher.cs, glob operations, project loading
Inlining Considerations
For extremely hot methods, consider:
[MethodImpl(MethodImplOptions.AggressiveInlining)]for small methods called millions of times- Avoiding virtual dispatch in inner loops
- Using
structenumerators to avoid heap allocation
Evaluation Performance Specifics
- Import chain depth affects evaluation time linearly. Minimize unnecessary imports.
- Glob patterns are evaluated per-project. Overly broad globs (
**/*) are expensive. - Condition evaluation should use short-circuit logic — put cheap checks first.
- Property functions (
$([System.IO.Path]::...)) are interpreted and slower than built-in operations.
Anti-Patterns
| Anti-Pattern | Why It's Bad | Fix |
|---|---|---|
items.Count() > 0 |
Enumerates entire collection | items.Any() or check .Count property |
string.Format in log messages at Low importance |
Allocates even if message is filtered | Use structured logging or guard with verbosity check |
new List<T>(enumerable).ToArray() |
Double allocation | enumerable.ToArray() directly |
dict.ContainsKey(k) then dict[k] |
Double lookup | dict.TryGetValue(k, out var v) |
Regex in a loop without RegexOptions.Compiled |
Reinterprets pattern each time | Compile or use static Regex field |