Skip to content

WIP: DO NOT REVIEW — ExpressionShredder weak-interning experiment (negative result, do not merge)#14346

Closed
artl93 wants to merge 4 commits into
dotnet:mainfrom
artl93:artl93-expressionshredder-weak-intern
Closed

WIP: DO NOT REVIEW — ExpressionShredder weak-interning experiment (negative result, do not merge)#14346
artl93 wants to merge 4 commits into
dotnet:mainfrom
artl93:artl93-expressionshredder-weak-intern

Conversation

@artl93

@artl93 artl93 commented Jul 13, 2026

Copy link
Copy Markdown
Member

I am running an experiment - full analysis to follow. Contact @artl93


Status: DRAFT — WIP — DO NOT REVIEW. This PR is an experiment container and will remain a draft permanently. Please do not review or merge.

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 replacing expression.Substring(start, len) with the existing span-based overload Strings.WeakIntern(expression.AsSpan(start, len)), reusing the identical (start, length) indices:

  • quoted item-transform text (capture Value),
  • item-vector separator,
  • transform FunctionName,
  • function subexpression text (capture Value).

The method already weak-interned item names and top-level captures via the same overload; this extended that pattern to the four remaining Substring sites. 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. A f3814cad…, B cceebe85….

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.

distinct transforms A alloc/eval B alloc/eval Δ alloc A retained B retained Δ retained
1 (all identical) 6,175,272 6,175,272 0 (identical to the byte) 1,041,232 1,041,776 +544 B
8 6,176,544 6,176,544 0 1,047,392 1,049,392 +2,000 B
2000 (all unique) 6,442,024 6,442,024 0 2,623,160 3,039,448 +416,288 B

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)

  • No global shred cache, but tiny absolute cost: GetReferencedItemExpressions runs fresh per Expander call, yet the captured substrings are a negligible share of evaluation allocation, so removing them is unmeasurable at scale.
  • Retention is a strong reference, not weak: in WeakStringCache, strings ≤ 500 chars (all transforms/separators/function names) are stored in a ConcurrentDictionary<int,string> as a strong entry (one per hashcode, overwritten on collision). They are not weakly held or scavenged — they persist until Strings.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.
  • CPU/lock: each call adds an FNV hash + ConcurrentDictionary lookup/compare; misses still allocate the string (like Substring) 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 to Substring (ordinal SequenceCompareTo; ExpensiveConvertToString = span.ToString(); hits content-verified, collisions recreate). All downstream consumers of Value/Separator/FunctionName are ordinal (no ReferenceEquals/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.
  • New WeakInternedCaptures_ReturnSharedInstanceAcrossDistinctExpressions asserts 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-change Substring would 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.

Art Leonard and others added 2 commits July 12, 2026 01:54
…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
@artl93

artl93 commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

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 Microsoft.Build.dll (sha256 verified before/after) against the exact parent commit 37d66e704.

Decisive result: no build-scale benefit.

  • Allocation per evaluation is identical to the byte A vs B across all transform cardinalities (1 / 8 / 2000 distinct); time within noise.
  • The transform/separator/function-name 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 a permanent retained-memory increase (+416 KB at 2000 distinct transforms). Capture strings ≤500 chars are held by a strong reference in WeakStringCache (_stringsByHashCode) until engine shutdown — not weakly scavenged.

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 WeakInternedCaptures_ReturnSharedInstanceAcrossDistinctExpressions test proves the interning path and dedup), but it delivers no measurable win and adds permanent retention for varied/unique transforms. Closing as a documented negative result. The branch and evidence remain available on my fork for reference.

Art Leonard and others added 2 commits July 19, 2026 11:02
…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
@artl93 artl93 changed the title WIP: DO NOT REVIEW — ExpressionShredder weak-interning of transform captures (allocation experiment) WIP: DO NOT REVIEW — ExpressionShredder weak-interning experiment (negative result, do not merge) Jul 19, 2026
@artl93

artl93 commented Jul 20, 2026

Copy link
Copy Markdown
Member Author

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.

@artl93 artl93 closed this Jul 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant