Skip to content

Inline conditional runtime-async callbacks in a separate lowering pass - #20647

Open
majocha wants to merge 41 commits into
dotnet:mainfrom
majocha:majocha-runtime-async-outlining
Open

majocha wants to merge 41 commits into
dotnet:mainfrom
majocha:majocha-runtime-async-outlining

Conversation

@majocha

@majocha majocha commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #20577

When an InlineIfLambda function or delegate still contains an Await after inlining, it used to be expanded at every call, which repeated its setup code and split up the state it captures. Now its setup runs once, recording which branch was chosen and the values that branch uses. Each call runs an inlined copy of the chosen body. Everything stays in the enclosing runtime-async method, so AsyncLocal changes made by the callback remain visible to the caller.

  • Callback inlining and body preparation run in a lowering pass after the first optimization loop and before LowerLocalMutables, so they see the final inlined shape. Each marker body is prepared once, after the callbacks are inlined. As a result, an Await that ends up inside a with handler is now rewritten; before, it was missed.
  • Inside runtime-async expansions, the optimizer moves captures wrapping an InlineIfLambda lambda out of its binding, so the normal inliner handles callbacks that don't branch.
  • Each call copies every branch body. When the copies would exceed 2000 expression nodes, the callback is left as a closure.
  • Suspensions left in unmarked delegate methods, in a callback over the size limit, or in a callback that is returned from try/with or called from inside a closure are reported as FS3918.

Composes with #20655.

Checklist

  • Test cases added
  • Performance benchmarks added in case of performance changes
  • Release notes entry updated

@github-actions

github-actions Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

✅ Release notes checked


✅ Found changes and release notes in following paths:

Change path Release notes path Description
`src/Compiler` docs/release-notes/.FSharp.Compiler.Service/11.0.200.md

majocha and others added 6 commits September 27, 2026 14:05
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Continue outlining past bindings that cannot be outlined, copy arguments
duplicated into match targets, restrict sequential handling to NormalSeq,
guard the outline walk against deep let chains, and share the Await
builder and match/quotation helpers.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Outline suspending InlineIfLambda callbacks and prepare runtime-async bodies in a dedicated pass after the first optimization loop, so every body is prepared once in its final shape.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@majocha majocha changed the title Runtime-async add outlining fallback Runtime-async add separate lowering pass, outlining fallback Sep 28, 2026
majocha and others added 2 commits September 28, 2026 19:31
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Outlined callbacks ran as separate runtime-async methods, so AsyncLocal writes they made were discarded on return. A callback selected by if/match is now constructed once, recording its branch and captures in locals, and each invocation dispatches to an inlined copy of the selected body.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@majocha majocha changed the title Runtime-async add separate lowering pass, outlining fallback Runtime-async add separate lowering pass Sep 28, 2026
Every invocation copies every branch body. Skip the rewrite when the copies would exceed 2000 expression nodes, leaving the callback as a closure so a remaining suspension is reported as FS3918. Add regression tests for callbacks constructed in a loop and nested branch-selected callbacks.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@majocha majocha changed the title Runtime-async add separate lowering pass Inline conditional runtime-async callbacks in a separate lowering pass Sep 28, 2026
@majocha
majocha marked this pull request as ready for review September 28, 2026 20:13
@majocha
majocha requested a review from a team as a code owner September 28, 2026 20:13
@github-actions

This comment has been minimized.

@github-actions github-actions Bot added ⚠️ Affects-Compiler-Output Tooling check: PR touches IL emission or codegen ⚠️ Affects-Build-Infra Tooling check: PR touches build infrastructure labels Sep 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🔍 Tooling Safety Check — Affects-Build-Infra, Affects-Compiler-Output
Affects-Build-Infra: Changes compiler build project structure.
Affects-Compiler-Output: Changes emitted IL metadata behavior.

Generated by PR Tooling Safety Check · gpt56 2.5M · ◷

@T-Gro T-Gro left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 🕵️ AI review — verify independently.

Comment thread src/Compiler/Optimize/RuntimeAsyncAnalysis.fs Outdated
Comment thread src/Compiler/Optimize/Optimizer.fs
@T-Gro T-Gro added the AI-reviewed PR reviewed by AI review council label Sep 29, 2026
@T-Gro
T-Gro self-requested a review September 29, 2026 09:41

@T-Gro T-Gro left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 🕵️ Re-reviewed all recent changes as of 123ba9f; 1 finding

| _ ->
let hoisted =
if v.IsMutable then
Construct.NewModifiedVal id v

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖🕵️ [P2] Outer count becomes count (shadowed) at the first printfn, before the inner declaration, with --debug:portable --optimize-.

open System.Threading.Tasks
open System.Runtime.CompilerServices
open Microsoft.FSharp.Core.CompilerServices

let inline invoke ([<InlineIfLambda>] callback: unit -> int) : Task<int> =
    StateMachineHelpers.__runtimeAsyncReturn (callback ())

let run flag (ready: Task<int>) =
    let mutable count = 99
    invoke (
        if flag then
            printfn "outer = %d" count
            let mutable count = 10
            fun () ->
                let value = AsyncHelpers.Await ready
                count <- count + value
                count
        else
            fun () -> 0)

@majocha

majocha commented Oct 9, 2026

Copy link
Copy Markdown
Contributor Author

The size and complexity of this is getting out of hand. I'm thinking about returning to the outlining concept, with saving and restoring execution context at suspend boundaries. Ideally the runtime should provide a public way to transparently await a thunk, which is what we need here.

@T-Gro

T-Gro commented Oct 9, 2026

Copy link
Copy Markdown
Member

Can we do outlining safely also w.r.t to order of effects and mutation of locals?
I wonder if this isnt changing one set of issues for another.

@majocha

majocha commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor Author

Can we do outlining safely also w.r.t to order of effects and mutation of locals? I wonder if this isnt changing one set of issues for another.

I guess this all should be covered, because we no longer inline. The main issue is ExecutionContext: we need to continue on the outlined child context. Capturing the final state inside the outlined function and restoring it on resume does it. This will not prevent side effects like AsyncLocal change notifications. This means the execution will still be subtly different for outlined vs inlined callbacks.

@majocha

majocha commented Oct 9, 2026

Copy link
Copy Markdown
Contributor Author

I'll make a separate branch with a prototype, retaining all the regression tests from here.

@majocha

majocha commented Oct 10, 2026

Copy link
Copy Markdown
Contributor Author

Including this PR, there is now a sizeable catalogue of custom optimizations used by the runtime-async feature:

Custom optimization What it does Implementation
Await-input local elimination Folds a single-use binding directly into an await/suspension argument, including through intermediate calls, coercions and constructions. Only moves past safe expressions, preserving evaluation order. Optimizer.fs:1894–1947, inlineAwaitInput / ImmediateRuntimeAwait
Callback-capture floating Rewrites let callback = (let capture = expr in fun x -> ...) into let capture = expr in let callback = fun x -> .... Also handles delegates and leading sequential expressions. This exposes the callback to ordinary inlining without duplicating construction effects. Optimizer.fs:2721–2754, FloatInlineIfLambdaCaptures; runtime-async-only invocation at line 3298
Returned-closure application fusion Pushes applications through let, let rec, sequencing and branch-selected returned functions, then beta-reduces the exposed lambdas. This brings suspensions out of returned closures and into the enclosing runtime-async body. Branch distribution and curried reduction have effect-safety checks. RuntimeAsyncAnalysis.fs:140–216, ReduceRuntimeAsyncReturnedClosureApplications
Single-use suspending delegate fusion Eliminates a residual delegate construction plus Invoke, including supported wrapped or conditional constructions, by substituting the delegate body at its sole invocation. Construction must be safe to move. LowerRuntimeAsync.fs:297–365, 410–419, tryPrepareDelegateInvocation / inlineDelegate
Branch-selected callback defunctionalization Replaces a suspending [<InlineIfLambda>] function or delegate selected by a conditional/match with a branch tag and hoisted captures. Each invocation becomes branch dispatch with copied bodies, rather than closure invocation. Construction runs once; mutable captures retain shared storage. Total copied branch-body size is capped at 2,000 expression nodes across invocations. LowerRuntimeAsync.fs:90–270, 421–465, tryDefunctionalizeCallback / mkCallbackDispatch

There is also runtime-async-specific reoptimization of debug pipe chains once synthetic pipe-input bindings are in scope (Optimizer.fs:4547–4553). That enables existing optimizations rather than introducing another independent rewrite.

@majocha
majocha force-pushed the majocha-runtime-async-outlining branch from 5f8b7ad to 994d184 Compare October 10, 2026 09:07
@T-Gro
T-Gro enabled auto-merge (squash) October 13, 2026 10:46

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

⚠️ Affects-Build-Infra Tooling check: PR touches build infrastructure ⚠️ Affects-Compiler-Output Tooling check: PR touches IL emission or codegen AI-reviewed PR reviewed by AI review council

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

Preserve stateful conditional callbacks in runtime async

2 participants