WIP: DO NOT REVIEW — ExpressionShredder weak-interning experiment (negative result, do not merge)#14346
WIP: DO NOT REVIEW — ExpressionShredder weak-interning experiment (negative result, do not merge)#14346artl93 wants to merge 4 commits into
Conversation
…essionShredder Replace four expression.Substring(...) calls with the existing span-based Strings.WeakIntern overload for quoted transforms, separators, item function names, and function subexpressions. These transform texts frequently repeat across items and projects, so weak interning reuses a single string instance instead of allocating a fresh one each time. WeakIntern returns text equal to Substring and ItemExpressionCapture consumers use string contents (not identity), so behavior is preserved. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e77e4ddb-6d03-430c-b1d4-4d1f4e4f0bbe
…roduct) Adds a deterministic, self-contained probe under src/MSBuild.Benchmarks that measures ExpressionShredder transform/separator/function-name capture allocations so the weak-interning product change can be validated with a true A/B: the same probe binary runs against different Microsoft.Build.dll builds (exact-parent vs final) by swapping only that product DLL. Provides a warm (primed-table) mode and a cold single-scenario mode (fresh process per scenario) that isolates cold-miss cost, plus reference-identity dedup counting and loaded-binary SHA256 provenance. This touches only MSBuild.Benchmarks; it does not change any product assembly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e77e4ddb-6d03-430c-b1d4-4d1f4e4f0bbe
|
WITHDRAWN — closing this draft (NO-GO). Not for review or merge. Final decision after full A/B validation. I re-ran this as an end-to-end MSBuild evaluation A/B plus BenchmarkDotNet, swapping only Decisive result: no build-scale benefit.
A skeptical memory/lifetime review concurred and corrected two earlier claims (short strings are strongly retained; there is no global Expander shred cache). No plausible no-regression redesign exists — even the isolated −99.5% separator win is eval-scale-negligible. The change is correct and behavior-preserving (verified; new |
…lify PR) The hand-rolled ShredderProbe A/B harness used net-only APIs (GC.GetAllocatedBytesForCurrentThread, string.Create, span WeakIntern) that do not exist under net472, which MSBuild.Benchmarks also targets on Windows. This broke all Windows CI legs (Core/Full/Full-Release) with CS0117/CS1503 while Linux/macOS Core (net10.0 only) passed. The probe was local evidence tooling, not product. Its canonical A/B data is preserved in session artifacts, and proper BenchmarkDotNet coverage of item transforms/separators already exists in ExpanderBenchmark. Removing it restores the benchmark project to its net472-clean state and reduces this draft experiment PR to just the four-line product change in ExpressionShredder (Substring -> WeakIntern(AsSpan)). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e77e4ddb-6d03-430c-b1d4-4d1f4e4f0bbe
Adds WeakInternedCaptures_ReturnSharedInstanceAcrossDistinctExpressions to validate that quoted transform text, separators, and function names captured by ExpressionShredder are weak-interned: the same text in two distinct backing expression strings yields the exact same string instance (Assert.Same). This documents the deduplication behavior and proves the interning path is exercised — the pre-change Substring implementation allocated a distinct instance per expression and would fail these reference-identity checks. It is the key falsification check for the A/B benchmark evidence attached to the PR. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e77e4ddb-6d03-430c-b1d4-4d1f4e4f0bbe
|
Closing this WIP experiment draft as WITHDRAWN / NO-GO — decisive A/B evidence shows no build-scale allocation or time benefit and a small permanent retained-memory cost. Documented negative result; see final analysis comment above. Not for review or merge. |
I am running an experiment - full analysis to follow. Contact @artl93
TL;DR — decisive result: no build-scale benefit; recommend withdrawal
The change works exactly as designed (it deduplicates repeated capture strings — see the new reference-identity test), but at realistic evaluation/build scale it produces no measurable allocation or time improvement, and it adds a small permanent retained-memory cost. Evidence below is now end-to-end and BenchmarkDotNet-based, superseding the earlier isolated-probe numbers.
What this changes
src/Build/Evaluation/ExpressionShredder.cs— four edits, each replacingexpression.Substring(start, len)with the existing span-based overloadStrings.WeakIntern(expression.AsSpan(start, len)), reusing the identical(start, length)indices:Value),separator,FunctionName,Value).The method already weak-interned item names and top-level captures via the same overload; this extended that pattern to the four remaining
Substringsites. Nullable/absent function and separator branches are untouched. Product diff: 4 insertions(+), 4 deletions(-).Evidence (A = exact parent
37d66e704, B = this change)A/B performed by swapping only
Microsoft.Build.dll(the only assembly that differs); DLL sha256 verified before and after every run. Env: macOS 26.5.2, Apple M5 Pro arm64, .NET SDK 10.0.300 / runtime 10.0.8, Workstation GC, BenchmarkDotNet v0.13.12. Af3814cad…, Bcceebe85….1. End-to-end evaluation scale (representative) — the headline
MSBuildLocator-hosted harness evaluating a large generated project 30×/process (50 source items + 2000 item-vector transforms with separators + 200 properties).
cardinality= number of distinct transform texts.Allocation per evaluation is identical to the byte in every case; wall-time is within noise. The transform/separator substrings are a negligible fraction of the ~6 MB allocated per evaluation, so interning them changes nothing measurable at scale. The only measurable effect is increased retained memory that grows with the number of distinct transform texts.
2. BenchmarkDotNet micro (in-tree
ExpanderBenchmark,[MemoryDiagnoser], ShortRun)ItemList_WithTransform/ItemList_WithSeparator: Allocated identical A vs B in every stable case (1.16/9.43 KB transform, 1.86/9.95 KB separator); times noise-dominated. (The Expander caches the shred result within an operation, so the substrings aren't re-allocated in the measured loop.)The earlier "cold isolated probe" numbers (−31% to −99% on repeated cases) measured substring allocation in isolation, which the E2E result shows is irrelevant at build scale.
Why no benefit + a real cost (post-review, corrected)
GetReferencedItemExpressionsruns fresh per Expander call, yet the captured substrings are a negligible share of evaluation allocation, so removing them is unmeasurable at scale.WeakStringCache, strings ≤ 500 chars (all transforms/separators/function names) are stored in aConcurrentDictionary<int,string>as a strong entry (one per hashcode, overwritten on collision). They are not weakly held or scavenged — they persist untilStrings.ClearCachedStrings()/ build-engine shutdown. In evaluation-only, long-lived hosts this is effectively process-lifetime retention. Bounded (per distinct hashcode, not per occurrence) — no unbounded leak — but a real, permanent cost with no offsetting benefit.ConcurrentDictionarylookup/compare; misses still allocate the string (likeSubstring) plus bookkeeping.Recommendation
Withdraw (kept here as a documented negative result). The change is correct and behavior-preserving, but delivers no measurable build-scale allocation or time win while adding permanent retained memory for varied/unique transforms. A skeptical senior memory/lifetime review reached the same conclusion.
Correctness / compatibility (verified)
WeakIntern(ReadOnlySpan<char>)returns text byte-identical toSubstring(ordinalSequenceCompareTo;ExpensiveConvertToString=span.ToString(); hits content-verified, collisions recreate). All downstream consumers ofValue/Separator/FunctionNameare ordinal (noReferenceEquals/identity-keyed maps). Null/empty branches untouched. Short-string path is lock-free and already exercised by the pre-existing interning in the same method.Tests
dotnet test src/Build.UnitTests/Microsoft.Build.Engine.UnitTests.csproj -c Release --filter-method "*ExpressionShredder*"→ 52/52 pass.WeakInternedCaptures_ReturnSharedInstanceAcrossDistinctExpressionsasserts that identical transform/separator/function-name text in two distinct backing strings returns the same interned instance (Assert.Same) — documenting the dedup behavior and proving the interning path is exercised (the pre-changeSubstringwould fail this). This is the falsification check that validates the A/B evidence.Provenance / artifacts
A/B DLL hashes, E2E harness + raw CSV, BenchmarkDotNet reports, and the skeptical review are retained as experiment artifacts (hashes recorded). CI on the product+test commit is green on Linux/macOS/Source-Build; Windows legs validated after removing the net472-incompatible probe harness from the branch.