Skip to content

[MicroPerf] Perf: eliminate per-call closure in StackGuard.Guard via InlineIfLambda - #20368

Open
T-Gro wants to merge 3 commits into
mainfrom
t-gro-stackguard-closure-spike
Open

[MicroPerf] Perf: eliminate per-call closure in StackGuard.Guard via InlineIfLambda#20368
T-Gro wants to merge 3 commits into
mainfrom
t-gro-stackguard-closure-spike

Conversation

@T-Gro

@T-Gro T-Gro commented Aug 26, 2026

Copy link
Copy Markdown
Member

StackGuard.Guard allocated a heap closure for its fun () -> body argument on every call at 18 hot sites, even though the common path just runs f () inline. Made Guard an inline member with [<InlineIfLambda>] f; the cold path (new-thread jump) moved to a non-inline RunOnNewStack, depth accounting to non-inline EnterGuard/ExitGuard (realsig-safe, no private-field access from inlined body). The 12 Guard <| fun () -> … sites became Guard(fun () -> …) so InlineIfLambda can fire (see note).

Closure allocation, 65k-LOC input, Release/net11 (MB per compile):

closure before after
remapExprImpl@1749 311.9 0
exprF@559 (ExprFolders) 125.3 0
accFreeInExprNonLinear@1099 105.2 0
OptimizeExpr@2519 59.0 0
accExpr@45 40.1 0
TransExpr@1130 20.2 0
total alloc / compile 10630.7 9979.0

651 MB/compile (−6.1%).

Per-call microbench (GC.GetAllocatedBytesForCurrentThread, 1M calls):

call form B/call
Guard(fun () -> …) inline 0
Guard <| fun () -> … 24
old non-inline 24

Emitted output byte-identical (IL disassembly 0 diffs; single-file --deterministic+ outputs hash-identical). Deep recursion still jumps threads (200000-deep → 73 jumps, no FieldAccessException/MethodAccessException under --realsig+). FSharp.Compiler.Service.dll +85.5 KB (+0.41%).

Regression test (EmittedIL/Inlining/StackGuardInlineIfLambda.fs + .il.bsl baseline): a vanilla StackGuard emulation (inline Guard + [<InlineIfLambda>] + separate non-inline RunOnNewStack the lambda escapes into) pins that callDirect inlines the lambda on the common path with the closure newobj confined to the cold else-branch, while callPiped (<|) hoists it to method entry.

Note — why the call sites changed: <|/|> take their function operand as a first-class value, which eta-expands the instance member Guard into fun x -> guard.Guard x. [<InlineIfLambda>] doesn't survive that eta-expansion, so the lambda stays a heap closure (verified: piping into a plain let inline function keeps it inlined; only instance member + pipe regresses). Direct application Guard(fun () -> …) avoids the eta-expansion and lets the optimizer inline the lambda. The slow-path closure still exists, but its newobj sits inside the cold else-branch, so it only allocates when the guard actually jumps stacks.

T-Gro and others added 2 commits August 26, 2026 17:24
StackGuard.Guard was called at 18 hot compiler sites as
`guard.Guard <| fun () -> body`, allocating a heap closure for the
`fun () -> body` on EVERY call even though the common path just runs
`f ()` inline. This was the #1 allocator in the compiler
(remapExprImpl guard closure alone ~312 MB/compile of a 65k-LOC input).

Make Guard an `inline` member with `[<InlineIfLambda>] f`, so the fast
path inlines the body (zero closure). The rare stack-insufficient path
(metrics + async SwitchToNewThread) moves to a non-inline
`RunOnNewStack` member so the heavy code isn't duplicated at call sites.
Depth accounting moves to non-inline `EnterGuard`/`ExitGuard` members so
the inlined body touches no private field (realsig-safe; no FS1113).

Since the `<|` operator defeats InlineIfLambda, the 12 `<|` call sites
are converted to direct-call `Guard(fun () -> ...)` syntax; the 4
existing direct-call sites benefit unchanged.

Result on a 65k-LOC compile (Release, net11): total allocation
10630.7 -> 9979.0 MB/compile (~651 MB, 6.1% saved); the guard closures
remapExprImpl@1749 (312 MB), accFreeInExprNonLinear@1099 (105 MB),
OptimizeExpr@2519 (59 MB), accExpr@45 (40 MB), TransExpr@1130 (20 MB)
all drop to 0. Emitted output is byte-identical (IL disassembly 0 diffs;
single-file outputs hash-identical). Deep-recursion behavior preserved:
200000-deep guarded recursion still jumps threads (73 jumps) and
completes with no Field/MethodAccessException under --realsig+.
IL size delta +85.5 KB (+0.41%).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@T-Gro
T-Gro requested a review from a team as a code owner August 26, 2026 15:25
@T-Gro T-Gro added the NO_RELEASE_NOTES Label for pull requests which signals, that user opted-out of providing release notes label Aug 26, 2026
@T-Gro
T-Gro requested a review from abonie August 26, 2026 15:25
@github-actions github-actions Bot added the AI-Tooling-Check-Bypassed Tooling check: non-fork PR, not diff-analyzed label Aug 26, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Release notes required, but author opted out

Warning

Author opted out of release notes, check is disabled for this pull request.
cc @dotnet/fsharp-team-msft

@T-Gro T-Gro changed the title Perf: eliminate per-call closure in StackGuard.Guard via InlineIfLambda [MicroPerf] Perf: eliminate per-call closure in StackGuard.Guard via InlineIfLambda Aug 27, 2026
@T-Gro
T-Gro enabled auto-merge (squash) August 27, 2026 08:29
@T-Gro
T-Gro force-pushed the t-gro-stackguard-closure-spike branch from 10f431e to 17d2321 Compare August 27, 2026 08:40
…seline)

EmittedIL/Inlining baseline (StackGuardInlineIfLambda.fs + .il.bsl) over a vanilla
StackGuard emulation: inline Guard + [<InlineIfLambda>] + a separate non-inline
RunOnNewStack the lambda is handed to, guarded by RuntimeHelpers.TryEnsureSufficientExecutionStack
(as in the real StackGuard). The baseline pins that `callDirect` (direct application)
inlines the lambda on the common path with the closure `newobj` confined to the cold
else-branch, while `callPiped` (`<|`) hoists the `newobj` to method entry. A regression
that reintroduces a common-path allocation changes callDirect's IL and fails the baseline.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4862d2e9-7740-4d0b-ba96-113e99466711
@T-Gro
T-Gro force-pushed the t-gro-stackguard-closure-spike branch from 17d2321 to 1aec61a Compare August 27, 2026 08:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI-Tooling-Check-Bypassed Tooling check: non-fork PR, not diff-analyzed NO_RELEASE_NOTES Label for pull requests which signals, that user opted-out of providing release notes Theme-Performance

Projects

Status: New

Development

Successfully merging this pull request may close these issues.

1 participant