Don't discard the XML cache after restore when it reloads from disk - #14558
Conversation
The cross-build survival table claimed that parsed project XML always survives in a reused server node. It does not: the implicit restore issues ClearCachesAfterBuild, which discards the entire ProjectRootElementCache, so the build half of every `dotnet build` re-parses the whole import closure. Only --no-restore actually got the cross-build cache. Correct the table row, add a section covering the mechanism and the measured cost (~250 ms / ~11.9 MB, paid once per build rather than once per project), and link the fix in #14558. Also settle a question the doc previously left open: step 2 of IsInvalidEntry returns before the timestamp comparison in step 4 ever runs, so for a file under an immutable root the timestamp is never consulted. An SDK edit observed taking effect in testing can only have been structural eviction, not invalidation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 19d11264-4b3d-4578-9e62-1439c078c89d
The implicit restore issues its build request with ClearCachesAfterBuild, which flushes the entire ProjectRootElementCache. That is necessary when the cache cannot notice that a file changed on disk, because restore rewrites part of the import graph (nuget.g.props/nuget.g.targets) and nothing else would pick that up. It is unnecessary when the cache does reload from disk, which is how the MSBuild Server entry node configures the cache it reuses across builds: there the timestamp check already covers restore's edits, so the flush only forces the build that follows restore to re-parse the whole import closure. Skip only the ProjectRootElementCache.Clear() in that case, behind change wave 18.10. This mirrors DiscardImplicitReferences(), which already returns early for an auto-reloading cache for the same reason. The FileMatcher and FileUtilities caches keep being cleared unconditionally: they hold negative results (a file that did not exist, a glob that matched nothing) that no timestamp check can invalidate, and that restore invalidates precisely by creating files. Measured on a dotnet new console project (116 files, 1.4 MB of XML) by timing the evaluation that follows a restore-like submission, comparing two builds rather than toggling the change wave (opting out of a wave also disables unrelated features gated on it): cache reloads from disk, 1 project +250.2 ms / +11.88 MB -> ~0 ms / +0.16 MB cache reloads from disk, 12 projects +380.2 ms / +12.73 MB -> ~0 ms / +0.25 MB cache does not reload, 1 project +274.2 ms / +11.87 MB -> unchanged The allocation figures are the reliable signal: 11.9 MB matches parsing the project's entire import closure from scratch. The cost is paid once per build by whichever project is evaluated first, which then repopulates the cache for the rest, so it is a fixed per-build tax rather than a per-project one. Fixes #14556 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 19d11264-4b3d-4578-9e62-1439c078c89d
…ve 18.11 Address review feedback: leave BuildManager's call unconditional and let the cache itself decide whether it needs to be discarded, mirroring how DiscardImplicitReferences already returns early for an auto-reloading cache. The AutoReloadFromDisk property the previous shape needed is gone again. Also rebase onto main, which now targets 18.11, and move the change wave and its documentation entry to that wave. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1e6c7a05-eb4c-4c03-94ed-52c0817a0a94
49d1abe to
1546d71
Compare
…() directly Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1e6c7a05-eb4c-4c03-94ed-52c0817a0a94
There was a problem hiding this comment.
Pull request overview
This PR adjusts MSBuild’s ClearCachesAfterBuild behavior so that an auto-reloading ProjectRootElementCache (as used by MSBuild Server) is not unnecessarily discarded after the implicit restore submission, avoiding a full re-parse of the import closure on the subsequent build.
Changes:
- Introduces
ProjectRootElementCacheBase.ClearCachesAfterBuild()to let caches decide how to respond toBuildRequestDataFlags.ClearCachesAfterBuild. - Overrides
ClearCachesAfterBuild()inProjectRootElementCacheto preserve cache contents whenautoReloadFromDiskis enabled (behind a change wave). - Adds unit tests covering both the preserved-cache (wave enabled) and legacy (wave opted out / non-reloading cache) behaviors, and documents the change in
ChangeWaves.md.
Show a summary per file
| File | Description |
|---|---|
| src/Build/Evaluation/ProjectRootElementCacheBase.cs | Adds an overridable ClearCachesAfterBuild() hook for ClearCachesAfterBuild-flag handling. |
| src/Build/Evaluation/ProjectRootElementCache.cs | Implements wave-gated “don’t clear if auto-reloading” behavior for the main PRE cache. |
| src/Build/BackEnd/BuildManager/BuildManager.cs | Switches from calling Clear() directly to calling ClearCachesAfterBuild() on the PRE cache. |
| src/Build.UnitTests/BackEnd/BuildManager_Tests.cs | Adds regression tests validating preserved/cleared behavior across wave enablement and autoReload settings. |
| documentation/wiki/ChangeWaves.md | Documents the behavior change under the relevant wave section. |
Copilot's findings
- Files reviewed: 5/5 changed files
- Comments generated: 1
|
Ran a self-review over the current state of the branch (rebased onto main, wave moved to 18.11, cache decision pushed inside 🔴 The justifying comment is factually wrong for NuGet-cache paths
// ProjectRootElementCache.cs:174-178
if (!Traits.Instance.EscapeHatches.AlwaysDoImmutableFilesUpToDateCheck
&& FileClassifier.Shared.IsNonModifiable(projectFile))
{
return false; // never revalidated
}
Concrete scenario on a Server entry node with the wave enabled: package re-extracted at the same path between two builds (local repack + Worth noting this hole already exists for reused worker nodes — Two options, and I'd like input on which:
My inclination is (2) plus the comment fix, since it keeps essentially all of the measured win while closing the one gap restore can actually cause. 🔴 Neither test proves the correctness invariantBoth new tests assert only cache retention mechanics ( Adding an end-to-end test: a 🟡 Moderate
Things that checked out
Nits I'll fold in
|
Note that this is FINE. We assume that files in that directory are not modified across builds except they can be created by restore. The files that restore may modify in place on disk should be in |
|
Retracting the first major from my previous comment — it was wrong, and the reasoning is worth writing down because it's the crux of why this change is safe. I claimed the So, going through everything a restore actually writes:
The only genuinely mutable thing restore produces is the generated imports next to the project, and those are exactly what the timestamp check covers. There is no category left where restore invalidates an entry that auto-reload would miss — which means the narrow Same correction applies to the deleted-import moderate I raised. Restore-generated imports are The rest of the previous list stands and is being addressed: the end-to-end regression test (which is the thing that actually pins this invariant down, and matters more now that the argument rests entirely on the timestamp check), the public |
|
Hah you are fast. I was just telling Copilot the same after it posted the expert reviewer analysis. |
|
That said, I often updated nuget package cache files myself in the past when doing innerloop stuff / tests. I wonder if we need to come up with a better innerloop pattern or somehow force the cache invalidation in these cases. Or maybe just document that you need to kill the msbuild processes in such a case. |
|
Yeah, "use |
|
Is EDIT: OK thinking about it, it should be. Cause the server wouldn't accept other session during that time as implemented today. |
|
Yeah, this kind of cache flush stuff will be one of the big design hurdles to get to a multiplexed server but I think it's fine now. |
|
Agreed. And I think a |
…ixes The comment justifying the skipped flush claimed auto reload covers whatever restore rewrote. That is vaguer than what the code does: IsInvalidEntry does not revalidate entries under a FileClassifier-immutable location. It does not need to - a package's content is fixed by its version and the SDK and VS install are not written to by a build - but the comment should say so rather than leave the reader to work out whether there is a hole. Add the test that pins down the invariant the optimization rests on: a submission that rewrites an imported file, followed by a build that must observe the rewrite even though the cache was never flushed. It also asserts that the rewritten entry is the one dropped on the next read. Rename to ClearCachesAfterBuildIfNeeded, since the derived implementation's whole job is to not clear, and move it next to Clear() in the base class. Update the public BuildRequestDataFlags.ClearCachesAfterBuild documentation, whose stated contract is now conditional. Trace the skip through DebugTraceCache like every other decision this cache makes. Fold the duplicated setup in the two mechanics tests into a helper, drop the InlineData column that always equalled the first, and drop a ResetStateForTests call in a test that sets no environment variable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1e6c7a05-eb4c-4c03-94ed-52c0817a0a94
Code review — 🔴 Request changesReviewed 🔴 Blocking 1 — a file deleted by restore is still served from the retained cache
FileInfo fileInfo = FileUtilities.GetFileInfoNoThrow(projectFile);
// If the file doesn't exist on disk, go ahead and use the cached version.
// It's an in-memory project that hasn't been saved yet.
if (fileInfo == null)
{
return false; // <-- deletion is invisible to "reloads from disk"
}
This is reachable: NuGet deletes Repro — Suggested fix: make the auto-reload path sweep rather than keep — walk 🟠 Blocking 2 — the "immutable locations can't be invalidated by a restore" comment is not correct// The entries it never revalidates are the ones
// under a location FileClassifier treats as immutable - the NuGet cache, where a package's content is
// fixed by its version and is never rewritten in place, ...
"A package's content is fixed by its version and is never rewritten in place" is a NuGet convention, not an enforced invariant. It's violated by the standard local-package inner loop (repack a Caveat on this one: my repro registers a scratch folder as Non-blockingBlast radius is wider than the description suggests. Memory. The restore boundary was the only point the server node's cache was ever emptied — No perf data. The ChangeWaves.md entry claims the following build "does not re-parse the import closure" — a measurement for the server scenario would help justify a wave feature. No binlog signal. Tests. Hygiene is good —
Thread safety (minor). Naming (nit). Comments (nit). The three added blocks assert facts about NuGet, the SDK and VS — exactly the claims that rot, and one is already wrong. Trimming to the mechanism ("entries are revalidated by timestamp on read; entries under immutable locations are not") would age better. What's cleanChange-wave discipline is solid: Suggested actions
|
Follow-up: retracting one finding, narrowing the otherAfter discussion with @ViktorHofer, a correction to my review above. ❌ Retracting "Blocking 2" (FileClassifier-immutable locations)This was wrong and I withdraw it. Immutability is a design contract, not an unproven assumption:
So this PR doesn't introduce a hole here — it inherits an existing, deliberate contract. My review treated a documented design invariant as an unverified claim, which was the wrong lens. Apologies for the noise. The comment in ✅ "Blocking 1" (deletion) stands — and it is unrelated to immutabilityWorth being explicit that the deletion case has nothing to do with the above: the file in question ( // ProjectRootElementCache.cs:181-186
FileInfo fileInfo = FileUtilities.GetFileInfoNoThrow(projectFile);
// If the file doesn't exist on disk, go ahead and use the cached version.
// It's an in-memory project that hasn't been saved yet.
if (fileInfo == null)
{
return false; // deliberately "valid" - for the unsaved in-memory-project case
}That I re-verified this myself, task-free, against this branch — Rewrite behaves identically with and without the wave, exactly as the PR claims. Delete diverges. Suggested fix, preserving the optimization: in the auto-reload branch of Everything else in my original review is unchangedSpecifically still worth considering, all non-blocking: scoping the ChangeWaves.md entry and public XML doc to the configurations that actually change (server / hosts using |
Perf data + test hardeningTwo follow-ups from the review: measurements for the optimization, and the test changes that don't depend on a product fix. Perf: measured on this branchI couldn't find a good home for this in Setup. Bootstrap built from this branch. A host configured exactly like the MSBuild Server entry node (
So the claim in Memory. Peak working set was lower with the cache kept (81 MB vs 95 MB single project, 93 MB vs 95 MB at 100 projects), because discarding forces a re-parse that allocates a fresh set of One anomaly worth a look, not a blocker. When I ran repeated restore+build cycles inside one long-lived process (which is the warm-MSBuild-Server shape, not the cold Tests: two assertions made load-bearingPushed as a separate commit — no product code touched, so this is independent of how the deletion issue gets resolved.
result.OverallResult.ShouldBe(BuildResultCode.Success);
ProjectRootElement reloadedImport = cache.TryGet(importPath);
reloadedImport.ShouldNotBeNull();
reloadedImport.Properties.ShouldContain(property => property.Name == "PropertyFromImport" && property.Value == "after");The helper now takes All 4 tests still pass ( Not done, since it needs the product change: the delete-during-restore regression test. Unrelated to this PR: |
ClearCachesAfterBuildStillClearsCacheThatDoesNotReloadFromDisk asserted only that the cache was empty, which also passes if a build of that shape never populated it. Add a control run without the flag that asserts the entry is present, so the discard is what the test measures. ClearCachesAfterBuildKeptCacheStillSeesRewrittenImport probed the rewritten import with TryGet before the second build. That probe is not passive: TryGet revalidates the timestamp and forgets the stale entry, so the eviction the test attributes to the build had already happened. Assert the end-to-end build first, then assert the cache holds the rewritten content. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 85433908-4c51-4e0e-af30-01918a40d613
Re-measured with the real MSBuild Server (warm) — the anomaly does not reproduceMy previous numbers came from an in-process approximation of the entry node, and the machine was busy while they ran. Redone properly against a warm MSBuild Server, with a noise-resistant methodology. Retracting the possible-regression note from that comment: with the real server there is no inversion at any size, in either parallelism mode. MethodBootstrap built from this branch. Because the box has ambient load (~29% from browsers/Teams), a straight sequential A/B was uselessly noisy — my first attempt spread from 4.3 s to 8.2 s and produced a spurious inversion. So: A/B interleaved in alternating blocks, MSBuild Server killed and restarted on every switch (so Results (warm server, ms)
Every order statistic in every configuration favours the change, and at 100 projects the two distributions barely overlap. The optimization is real and it is a wall-clock win on the warm server, which is the configuration it targets. Reading the numbersThe saving is a near-constant ~120-230 ms regardless of repo size, which is exactly what you'd expect: the SDK import closure is parsed once and shared by every project in the build, so what the old That makes the single-project case the headline: One thing worth knowing for future measurementsWith default parallelism, most project evaluation happens on worker nodes, and Correction to my earlier comments
|
Perf follow-up: MSBuild Server +
|
| scenario | n | on min/p25/med | off min/p25/med | delta min/p25/med | paired median | runs favoring PR |
|---|---|---|---|---|---|---|
| 1 proj, server only | 12 | 583 / 593 / 618 | 740 / 754 / 783 | −21.2% / −21.4% / −21.1% | −162 ms | 12/12 |
1 proj, server + -mt |
12 | 651 / 656 / 683 | 795 / 802 / 821 | −18.1% / −18.2% / −16.8% | −131 ms | 12/12 |
| 100 proj, server only | 12 | 6800 / 7294 / 8002 | 7532 / 7548 / 8379 | −9.7% / −3.4% / −4.5% | −526 ms | 8/12 |
100 proj, server + -mt |
24 | 4544 / 4879 / 5293 | 4708 / 5047 / 5778 | −3.5% / −3.3% / −8.4% | −301 ms | 21/24 |
Every order statistic in every configuration favors the change. No inversions.
Reading of the numbers
The absolute saving is essentially unchanged by -mt — ~130-160 ms at one project, ~300-530 ms at one
hundred. It scales strongly sublinearly with project count, which fits the mechanism: what is saved is one
re-parse of the shared SDK import closure (~250 files, parsed once and shared by all projects), plus the
project files and nuget.g.props/.targets themselves. Not per-project work.
The -mt percentage at 100 projects is smaller only because -mt is itself much faster on this tree
(median 5293 ms vs 8002 ms with the change on, ~34% faster). Same milliseconds saved, larger denominator.
So -mt does not change the conclusion, and does not regress anything. The one thing it does change is who
benefits: under -mt the saving is realized on the node that does all the evaluation, so the win is
structural rather than incidental to the entry node.
Methodology (and a correction to my own first attempt)
This box has ~29% ambient load, so straight sequential A/B is not usable. Protocol:
- Interleaved A/B blocks, with the leading config alternating per block to cancel monotonic drift.
- Server killed on every config switch, so
MSBUILDDISABLEFEATURESFROMVERSIONis picked up by a fresh
server (ChangeWavescaches statically at first use — reusing the server across configs silently
measures the wrong thing). - Paired analysis by (block, run index) in addition to pooled order statistics.
Correction: my first -mt 100-project run used a single discarded warm-up and reported +1.9% at run
index 1. That was an artifact — the server keeps getting faster over its first ~4 builds (tiered JIT), so
run 1 was still on the ramp. Decomposing by run index made it obvious:
| run index within block | on median | off median | delta |
|---|---|---|---|
| 1 | 7657 | 7513 | +1.9% (still ramping) |
| 2 | 6915 | 7357 | −6.0% |
| 3 | 4872 | 5047 | −3.5% |
Raising the discarded warm-up to 3 runs per block removed it. All numbers in the table above use 3 warm-ups.
This also supersedes the 100-project anomaly I flagged two comments ago — that one came from an
in-process harness looping many build cycles with accumulated GC state, and does not reproduce against a
real server in any configuration, with or without -mt.
|
Post-merge note, for the record: I isolated the evaluation-only delta. Measured by summing
The useful part is the agreement between the two rows in each pair: evaluation drops by essentially the same absolute amount as total build time (−113 vs −121 ms, −471 vs −512 ms). So the entire win is evaluation, and nothing else got faster or slower as a side effect. Evaluation count is identical across configs (5 vs 5, and 302 vs 302), so it's the same work done cheaper — one avoided re-parse of the shared SDK import closure — rather than work being skipped. That also explains why the headline percentages shrink with repo size: the saving is a near-constant number of milliseconds against a growing denominator, not something that scales with project count. Worth keeping in mind if anyone quotes the −21% single-project figure as a general expectation. Two measurement caveats for anyone reproducing this:
|
The cross-build survival table claimed that parsed project XML always survives in a reused server node. It does not: the implicit restore issues ClearCachesAfterBuild, which discards the entire ProjectRootElementCache, so the build half of every `dotnet build` re-parses the whole import closure. Only --no-restore actually got the cross-build cache. Correct the table row, add a section covering the mechanism and the measured cost (~250 ms / ~11.9 MB, paid once per build rather than once per project), and link the fix in #14558. Also settle a question the doc previously left open: step 2 of IsInvalidEntry returns before the timestamp comparison in step 4 ever runs, so for a file under an immutable root the timestamp is never consulted. An SDK edit observed taking effect in testing can only have been structural eviction, not invalidation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 19d11264-4b3d-4578-9e62-1439c078c89d
Fixes #14556.
Context
The implicit restore issues its build request with
BuildRequestDataFlags.ClearCachesAfterBuild, andBuildManager.CheckAllSubmissionsCompleteresponds by discarding the entireProjectRootElementCache— every.props/.targetsin the import closure, not just what restore touched.That is load-bearing for the classic cache:
autoReloadFromDiskisfalsethere, so nothing else would notice that restore rewrotenuget.g.props/nuget.g.targets, and the process exits after the build anyway so there is no cross-build value to lose.Both facts invert for the cache the MSBuild Server entry node reuses across builds, which is constructed with
autoReloadFromDisk: true(XMake.cspassesreuseProjectRootElementCache: s_isServerNode). The timestamp check inIsInvalidEntryalready covers restore's edits, and the cache being discarded is precisely the one meant to survive. The flush there buys nothing and costs a full re-parse of the import closure on the build half of everydotnet build.Change
Skip only the
ProjectRootElementCache.Clear()when the cache reloads from disk, behind change wave 18.10:This mirrors
DiscardImplicitReferences(), which already returns early for an auto-reloading cache with the same justification.FileMatcherandFileUtilitieskeep being cleared unconditionally, deliberately: they cache negative results (a file that did not exist, a glob that matched nothing) that no timestamp check can invalidate, and that restore invalidates precisely by creating files.AutoReloadFromDiskis new onProjectRootElementCacheBase(virtualfalse, overridden byProjectRootElementCache). No public API change.Measurement
Timing the evaluation that follows a restore-like submission on a
dotnet new consoleproject (116 files, 1.4 MB of XML), in-process so that JIT and process startup do not mask the effect. Measured by building two binaries rather than toggling the change wave, since opting out of a wave also disables unrelated features gated on it:The allocation column is the signal worth trusting; 11.9 MB matches parsing the whole import closure from scratch, and it was identical on every round. The millisecond deltas after the fix land slightly negative, which is measurement noise (the post-flush pass runs later and is better warmed) — read them as zero, not as a speedup.
Two things worth knowing about the shape of the cost: it is paid once per build, by whichever project is evaluated first, which then repopulates the cache for everything behind it — so it is a fixed tax rather than a per-project one, and it shrinks as a fraction of a large build. And it lands only on the entry node; worker nodes were already exempt.
Testing
ClearCachesAfterBuildKeepsCacheThatReloadsFromDisk— an auto-reloading cache survives the flush, and the wave-opt-out case asserts the old behavior is preserved.ClearCachesAfterBuildStillClearsCacheThatDoesNotReloadFromDisk— a cache that cannot notice disk changes is still discarded.Both use
autoReloadFromDisk: truefor the positive/negative wave pair so thatDiscardImplicitReferences(which early-returns in that configuration) cannot be the reason an entry disappears, leaving the flush as the only variable.Existing
*ProjectRootElementCache*tests pass.