Build Performance Baseline & Optimization
Overview
Before optimizing a build, you need a baseline. Without measurements, optimization is guesswork. This skill covers how to establish baselines and apply systematic optimization techniques.
Related skills:
build-perf-diagnostics— binlog-based bottleneck identificationincremental-build— Inputs/Outputs and up-to-date checksbuild-parallelism— parallel and graph build tuningeval-performance— glob and import chain optimization
Step 1: Establish a Performance Baseline
Measure three scenarios to understand where time is spent. Keep the SDK, configuration, machine, environment variables, restore state, and build command consistent. Run each scenario at least three times, repeating its setup before every measured sample, and report the median plus the observed range; a single timing is not a baseline. Keep setup outside the timed interval. Save each sample's binlog under a unique name, outside generated output directories.
Cold Build (First Build)
No previous build output exists. Measures the full end-to-end time including restore, compilation, and all targets.
Before every cold sample, restore the same source state, run dotnet clean
with the measured configuration, and remove only the confirmed, disposable
output and intermediate directories for the measured projects. Include custom
artifact paths, not just bin and obj, and obtain approval before deletion.
Verify those outputs are absent before timing the next build.
This is an output-cold build, not necessarily a cold NuGet, OS, or compiler server cache. Choose and record a consistent cache/server policy for all samples; do not clear shared caches. If measuring uncached restore, use a separate, empty package cache for each sample.
Warm Build (Incremental Build)
Build output exists, some files have changed. Measures how well incremental build works.
Before every warm sample, restore the same baseline source contents and build successfully without timing it. Then apply the same small, build-relevant edit to the same source file and time the build. Keep the changed file set and edit identical across samples; do not let edits accumulate. Restore the baseline contents before the next sample and rebuild them outside the timed interval. Do not clean between that setup build and its measured build.
No-Op Build (Nothing Changed)
Build output exists, nothing has changed. Compilation and correctly incremental targets should skip; compare timing with this build's other samples.
Before every no-op sample, restore the same baseline source contents and run an untimed setup build successfully. Then measure an identical build without edits, touching inputs, cleaning outputs, or changing properties. Keep restore and cache/server policy consistent with the other samples.
What Good Looks Like
Red flags:
- No-op time is repeatedly close to warm/cold time, or compilation targets rerun → investigate incrementality (see
incremental-build) - Warm build recompiles everything → project dependency chain forces full rebuild
- Restore dominates cold samples → measure
dotnet restoreanddotnet build --no-restoreseparately before changing project structure
Do not use universal duration or percentage thresholds to declare a bottleneck. Rank costs against the controlled samples and the build's own target/task timings.
Capture analyzer evidence
A binlog shows compiler/task timing, but granular analyzer timing requires an analyzer-reporting run. When supported by the SDK/compiler, capture:
Open the binlog in MSBuild Structured Log Viewer and inspect the analyzer
summary under the compiler task. If granular timing is unavailable, compare
otherwise identical samples with /p:RunAnalyzers=false as an attribution
experiment; do not present disabling analyzers as the fix. Preserve analyzer
enforcement in CI.
Recording Baselines
Record baselines in a structured way before and after optimization:
Step 2: Artifacts Output Layout
The UseArtifactsOutput feature (introduced in .NET 8) changes the output directory structure to avoid bin/obj clash issues and enable better caching.
Enabling Artifacts Output
Before vs After
Benefits
- No bin/obj clash: Each project+configuration gets a unique path automatically
- Easier to cache: Single
artifacts/directory to cache/restore in CI - Cleaner .gitignore: Just ignore
artifacts/ - Multi-targeting safe: Each TFM gets its own subdirectory
Customizing
Step 3: Deterministic Builds
Deterministic builds produce byte-for-byte identical output given the same inputs. This is essential for build caching and reproducibility.
Enabling Deterministic Builds
What Deterministic Affects
- Removes timestamps from PE headers
- Uses consistent file paths in PDBs
- Produces identical output for identical input
Why It Matters for Performance
- Build caching: If outputs are deterministic, you can cache and reuse them across builds and machines
- CI optimization: Skip rebuilding unchanged projects by comparing inputs
- Distributed builds: Safe to cache compilation results in shared storage
Step 4: Dependency Graph Trimming
Reducing unnecessary project references shortens the critical path and reduces what gets built.
Audit the Dependency Graph
Techniques
Remove Redundant Transitive References
Build-Order-Only References
When you need a project to build before yours but don't need its assembly output:
Prevent Transitive Flow
When a dependency is an internal implementation detail that shouldn't flow to consumers:
Disable Transitive Project References
For explicit-only dependency management (extreme measure for very large repos):
Caution: This requires all dependencies to be listed explicitly. Only use in large repos where transitive closure is causing excessive rebuilds.
Step 5: Static Graph Builds (/graph)
Static graph mode evaluates the entire project graph before building, enabling better scheduling and isolation.
Enabling Graph Build
Benefits
- Better parallelism: MSBuild knows the full graph upfront and can schedule optimally
- Build isolation: Each project builds in isolation (no cross-project state leakage)
- Caching potential: With isolation, individual project results can be cached
When to Use
Troubleshooting Graph Build
Graph build requires that all ProjectReference items are statically determinable (no dynamic references computed in targets). If graph build fails:
Fix: Ensure all ProjectReference items are declared in <ItemGroup> outside of targets (not dynamically computed inside <Target> blocks).
Step 6: Parallel Build Tuning
MaxCpuCount
Identifying Parallelism Bottlenecks
In a binlog, look for:
- Long sequential chains: Projects that must build one after another due to dependencies
- Uneven load: Some build nodes idle while others are overloaded
- Single-project bottleneck: One large project on the critical path that blocks everything
Use grep 'Target Performance Summary' -A 30 full.log in binlog analysis to see build node utilization.
Reducing the Critical Path
The critical path is the longest chain of dependent projects. To shorten it:
- Break large projects into smaller ones that can build in parallel
- Remove unnecessary ProjectReferences (see Step 5)
- Use
ReferenceOutputAssembly="false"for build-order-only dependencies - Move shared code to a base library that builds first, then parallelize consumers
Step 7: Additional Quick Wins
Separate Restore from Build
Skip Unnecessary Targets
Use these switches to measure contribution before changing configuration.
Do not recommend permanently disabling analyzers from this baseline step;
route measured analyzer bottlenecks to build-perf-diagnostics and preserve
CI enforcement.
Use Project-Level Filtering
Binary Log for All Investigations
Always start with a binlog:
Then use the build-perf-diagnostics skill and binlog tools for systematic bottleneck identification.


