Skip to content

Defer display class allocation for async local functions to call site#82430

Open
benaadams wants to merge 4 commits intodotnet:mainfrom
benaadams:local-alloc
Open

Defer display class allocation for async local functions to call site#82430
benaadams wants to merge 4 commits intodotnet:mainfrom
benaadams:local-alloc

Conversation

@benaadams
Copy link
Member

Fixes #18946

When an async local function captures variables via closure, the display class is heap-allocated at method entry unconditionally - even when the local function is never called. This means the fast path (where the async work completes synchronously) pays for an allocation it doesn't need.

This PR defers display class allocation to the call site for eligible async local functions. Captured variables remain as locals on the fast path; the closure frame is only allocated when the local function is actually invoked.

Analysis phase (ClosureConversion.Analysis.cs):

  • IsDeferrableEnvironment identifies eligible environments using conservative guards: class closure (not struct), single closure in scope, Release build, async local function, not delegate-converted, no this capture, no captured local function calls, and all call sites are top-level return await
  • LocalFunctionCallSiteAnalyzer walks the lowered body to verify call-site constraints

Code generation (ClosureConversion.cs):

  • DeferredCapturedToFrameSymbolReplacement provides dual-mode proxy replacement: returns the original local/parameter in the containing method, returns the field access in the lowered local function body
  • CreateDeferredFrameInitializationSequence wraps the local function call in a BoundSequence that allocates the display class and copies captured variable values into its fields
  • IntroduceFrame skips frame constructor and InitVariableProxy for deferred environments

Proxy context (MethodToClassRewriter.cs):

  • ProxyReplacementContext struct replaces the anonymous tuple passed to TryReplaceWithProxy, enabling the deferred replacement to distinguish calling contexts via structured type checking

Tests

  • 2 positive tests: basic deferred allocation, generic method deferred allocation - verify newobj DisplayClass appears after get_IsCompleted in IL
  • 7 negative tests: delegate conversion, non-return call site, multiple closures sharing environment, call in loop, this capture, debug mode, generic with constraints - verify allocation is not deferred
  • Existing closure conversion test suite passes without regressions

When async local functions capture variables via closure, the display
class is heap-allocated at method entry unconditionally — even when the
local function is never called. This defers display class allocation to
the call site for eligible async local functions, keeping captured
variables as locals on the fast path and only allocating the closure
frame when the local function is actually invoked.
Copilot AI review requested due to automatic review settings February 17, 2026 13:36
@benaadams benaadams requested a review from a team as a code owner February 17, 2026 13:36
@dotnet-policy-service dotnet-policy-service bot added the Community The pull request was submitted by a contributor who is not a Microsoft employee. label Feb 17, 2026
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR updates C# closure conversion to defer heap allocation of the display-class (closure frame) for eligible async local functions until the local function is actually invoked, avoiding an unconditional method-entry allocation on the synchronous fast-path.

Changes:

  • Adds analysis to conservatively detect “deferrable” top-level closure environments and validate call sites (must be top-level return ... positions).
  • Updates closure conversion rewriting to (1) avoid proxying captured locals/parameters on the top-level fast path, and (2) inject frame allocation + captured-value copying at the local-function call site.
  • Adds IL-based codegen tests validating deferred vs non-deferred allocation behavior across several scenarios.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/Compilers/CSharp/Test/Emit/CodeGen/CodeGenLocalFunctionTests.cs Adds positive/negative IL tests asserting where the display-class newobj appears relative to get_IsCompleted().
src/Compilers/CSharp/Portable/Lowering/MethodToClassRewriter.cs Introduces ProxyReplacementContext so proxy replacements can distinguish rewriting context.
src/Compilers/CSharp/Portable/Lowering/ClosureConversion/ClosureConversion.cs Implements deferred frame initialization and dual-mode proxy replacement for deferrable captures; wraps eligible call sites in a sequence that allocates/copies.
src/Compilers/CSharp/Portable/Lowering/ClosureConversion/ClosureConversion.Analysis.cs Adds deferrable-environment detection and a walker to validate call-site constraints.
src/Compilers/CSharp/Portable/Lowering/ClosureConversion/ClosureConversion.Analysis.Tree.cs Extends ClosureEnvironment to track IsDeferrable.

Replace SymbolEqualityComparer.ConsiderEverything.Equals with
ReferenceEquals for local function identity checks — both sides are
OriginalDefinition from the same compilation. Tighten generic test
method lookup from "Program.M" to "Program.M<" to avoid matching Main.
…version

Switch _deferredEnvironmentsByLocalFunction dictionary from
SymbolEqualityComparer.ConsiderEverything to ReferenceEqualityComparer.Instance
to make the reference equality intent explicit. Add null-forgiving operators
where BoundExpression.Type is known to be non-null.
@jcouv jcouv self-assigned this Feb 17, 2026
VisualizeIL formats property getters as "IsCompleted.get", not
"get_IsCompleted()".
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area-Compilers Community The pull request was submitted by a contributor who is not a Microsoft employee.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

async Local function use with locals allocate (even if never called)

2 participants

Comments