Skip to content

Avoid unnecessary argument-builder work in generic type substitution - #85230

Merged
jaredpar merged 1 commit into
mainfrom
jaredpar-compiler-performance-exploration
Sep 10, 2026
Merged

Avoid unnecessary argument-builder work in generic type substitution#85230
jaredpar merged 1 commit into
mainfrom
jaredpar-compiler-performance-exploration

Conversation

@jaredpar

@jaredpar jaredpar commented Sep 9, 2026

Copy link
Copy Markdown
Member

Motivation

AbstractTypeMap.SubstituteNamedType eagerly rents and fills an ArrayBuilder<TypeWithAnnotations> before determining whether substitution changes any arguments.

The existing implementation avoids materializing an immutable array when nothing changes, but uses one changed flag for two independent conditions: the containing type changes, or one or more type arguments change. This means unchanged arguments still get a new array when only the containing type changes.

For example, consider a nested generic return type:

class Cache<TKey>
{
    public sealed class Entry<TValue>(TKey key, TValue value)
    {
        public TKey Key { get; } = key;
        public TValue Value { get; } = value;
    }

    public Entry<string> GetText(TKey key) => new(key, "cached text");
}

When binding new Cache<int>().GetText(42), the compiler substitutes TKey → int into the declared return type:

Cache<TKey>.Entry<string> → Cache<int>.Entry<string>

The containing type changes, but Entry's own argument array remains [string]. Outer type arguments are represented through ContainingType, not included in the inner type's own argument array.

Previously, the containing-type change set changed = true, bypassing the Free(); return previous; path and calling ToImmutableAndFree(). The new symbol therefore received a newly materialized argument array even though the original immutable array could be reused.

This is not a failure of pooling. The fully unchanged path already returned the builder to the pool and was allocation-free in steady state.

Change

Defer renting the builder until the first changed argument. At that point, copy the unchanged prefix and append the substituted arguments.

Substitution outcome Previous behavior New behavior
Nothing changes Rent, populate, and free a builder; return the original symbol Return the original symbol without builder work
Only the containing type changes Materialize an argument array for the new symbol Reuse the original immutable argument array
Arguments change Materialize a changed argument array Materialize a changed argument array

This separates two benefits: avoiding pool traffic and array writes for no-op substitutions, and avoiding an argument-array allocation for containing-type-only substitutions.

The change preserves the existing TypeWithAnnotations.IsSameAs comparison, including nullable annotations and custom modifiers. Tuple handling is unchanged. It does not change alpha renaming or how substituted containing types are constructed.

Performance

BenchmarkDotNet measurements on .NET 10.0.11, Windows, Ryzen 9 5950X. Baseline and candidate used saved Release builds and the in-process toolchain to avoid rebuilding the baseline against modified sources. Stronger runs used candidate then baseline order, six warmup iterations and 15 measurement iterations with a 250 ms iteration time.

Scenario Before After
No change, arity 0 10.16 ns 6.99 ns
No change, arity 1 49.38 ns 34.45 ns
No change, arity 2 82.34 ns 57.04 ns
No change, arity 8 286.59 ns 203.09 ns
Containing type only, arity 1 1,192 B/op 1,144 B/op
Containing type only, arity 2 1,440 B/op 1,368 B/op
Containing type only, arity 8 3,056 B/op 2,840 B/op

No-op execution time decreases approximately 29–31%, with zero steady-state allocation before and after. Containing-type-only cases save 48, 72, and 216 bytes per operation, respectively. Changed-argument cases are not uniformly faster; two cases have approximately 2% slower point estimates with overlapping error intervals.

For a Replay workload containing 1,648 compiler requests, sampled allocation attribution for C# TypeWithAnnotations[] decreased from 1.78 GiB to 1.67 GiB, approximately 6.3%. These are type-wide sampled estimates, not allocations attributed exclusively to this method. The trace reader reported invalid negative lost-event counters, so event loss is unknown.

Full-workload timings remain inconclusive: baseline runs took 184/163 seconds and candidate runs took 156/176 seconds in baseline/candidate/candidate/baseline order. No end-to-end compilation speedup is claimed.

Coverage

Adds 14 benchmark cases covering no-op, first/last changed arguments, containing-type-only changes, and nested substitutions. Grants the existing Benchmarks assembly access to compiler internals for these measurements.

Adds 18 regression cases covering symbol identity, nested types, tuple names, nullable annotations, and custom modifiers. The original algorithm passed all 22 TypeMap cases; the candidate passed 34 focused/related cases across TypeMapTests and NoPiaLocalHideAndTypeSubstitutionTests. Release Replay and Benchmarks builds passed with analyzers enabled.

All four Replay runs completed 1,648 compilations successfully, and all 2,164 emitted DLL/EXE/PDB files matched byte-for-byte across the four runs.

Performance-tracing documentation is intentionally deferred to a separate change.

Microsoft Reviewers: Open in CodeFlow

Defer argument builder creation until the first changed argument and reuse immutable arguments when only the containing type changes. Add focused regression coverage and substitution benchmarks.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 2 pipeline(s).
There may be pipelines that require an authorized user to comment /azp run to run.

@jaredpar
jaredpar marked this pull request as ready for review September 9, 2026 20:24
@jaredpar
jaredpar requested a review from a team as a code owner September 9, 2026 20:24
Copilot AI lite review requested due to automatic review settings September 9, 2026 20:24
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 2 pipeline(s).
There may be pipelines that require an authorized user to comment /azp run to run.

@jaredpar

jaredpar commented Sep 9, 2026

Copy link
Copy Markdown
Member Author

@dotnet/roslyn-compiler PTAL

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

No unresolved review issues were identified.

Review tier: Lite
Findings: None

What changed in this PR

This pull request optimizes generic type substitution by lazily rebuilding type arguments and reusing unchanged arrays.

Changes:

  • Defers argument-builder allocation until an argument changes.
  • Adds substitution regression tests.
  • Adds benchmarks and required internals access.
File Description
src/​Tools/​Benchmarks/​GenericTypeSubstitutionBenchmarks.cs Adds performance scenarios.
src/​Compilers/​CSharp/​Test/​Symbol/​Symbols/​Source/​TypeMapTests.cs Adds substitution regression coverage.
src/​Compilers/​CSharp/​Portable/​Symbols/​AbstractTypeMap.cs Implements lazy argument construction and reuse.
src/​Compilers/​CSharp/​Portable/​Microsoft.CodeAnalysis.CSharp.csproj Grants benchmark access to C# internals.
src/​Compilers/​Core/​Portable/​Microsoft.CodeAnalysis.csproj Grants benchmark access to core internals.

@AlekseyTs AlekseyTs left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM (commit 1)

@jaredpar

Copy link
Copy Markdown
Member Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 2 pipeline(s).

@jaredpar
jaredpar merged commit 9ed7fa7 into main Sep 10, 2026
28 of 29 checks passed
@dotnet-policy-service dotnet-policy-service Bot added this to the Next milestone Sep 10, 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.

4 participants