Skip to content

Fix super-linear compilation of guarded shared-or active-pattern matches - #20244

Open
T-Gro wants to merge 5 commits into
dotnet:mainfrom
T-Gro:t-gro-fix-patmatch-frontier-explosion
Open

Fix super-linear compilation of guarded shared-or active-pattern matches#20244
T-Gro wants to merge 5 commits into
dotnet:mainfrom
T-Gro:t-gro-fix-patmatch-frontier-explosion

Conversation

@T-Gro

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

Copy link
Copy Markdown
Member

Fixes #18425.

N disjuncts sharing one when guard, with partial active patterns in the disjuncts: each disjunct feeds both a match-fail edge and a guard-false edge into the same residual decision state, which was re-investigated (unbounded) along all 2^N paths and re-emitted each time. Analysis-time, so it hit fsc/fsi, dotnet build and the editor language service on file-open.

Fix: memoize the residual states (a Maranget-style join point). A state reached more than 32 times is compiled once into a shared join that later equal-keyed paths call instead of re-emitting the subtree.

Emitted IL / runtime impact

case promotes IL vs pre-fix added runtime
any human-written match no byte-identical (verified under --deterministic+) none
#18425 shape, --optimize+ yes shared residual → direct static call, AP result passed as arg 1 call per shared residual, no allocation
#18425 shape, --optimize- yes shared residual → one local FSharpFunc closure 1 call + 1 allocation per shared residual
byref-like result never inline, unchanged none

Active patterns are evaluated the same number of times, order and side effects — sharing removes duplicated code, not evaluations. Side-effect-counting AP, two inputs, pre-fix vs post-fix:

N promotes A evals before/after E evals before/after
6 no 2 / 2 7 / 7
8 yes 2 / 2 9 / 9
16 yes 2 / 2 17 / 17

Compile time / size

match a, b with (A p, E 1 _) | … | (A p, E N _) when g p -> p | _ -> -1, --optimize+, end-to-end (~2.4 s fixed startup):

N before (compile / dll) after (compile / dll)
6 3.7 s / 8 KB 2.6 s / 8 KB (identical IL)
8 2.5 s / 17 KB 3.8 s / 12 KB
12 3.2 s / 183 KB 2.5 s / 25 KB
16 9.6 s / 2.9 MB 2.8 s / 61 KB
20 did not finish (>90 s) 3.3 s / 126 KB
24 stack overflow 3.9 s / 221 KB

2^N and stack overflow around N≈24 → polynomial (empirically ~cubic in N).

@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

❗ Release notes required

You can open this PR in browser to add release notes: open in github.dev


✅ Found changes and release notes in following paths:

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

@github-actions github-actions Bot added the ⚠️ Affects-Compiler-Output Tooling check: PR touches IL emission or codegen label Aug 11, 2026
@github-actions

This comment has been minimized.

@T-Gro
T-Gro force-pushed the t-gro-fix-patmatch-frontier-explosion branch from 1262508 to 9b684ae Compare August 13, 2026 15:12
@T-Gro T-Gro changed the title Fix super-linear compilation of guarded top-level or-patterns Fix super-linear compilation of guarded shared-or active-pattern matches Aug 13, 2026
@T-Gro
T-Gro force-pushed the t-gro-fix-patmatch-frontier-explosion branch from 9b684ae to 4ac7356 Compare August 17, 2026 10:48
…rn matches (dotnet#18425)

A single match clause of N disjuncts sharing one `when` guard, whose disjuncts
contain partial active patterns, compiled in exponential (2^N) time and assembly
size and eventually overflowed the stack at analysis time.

Each guarded disjunct contributes both a match-fail edge and a guard-false edge
into the same residual decision state, which InvestigateFrontiers re-investigated
along all 2^N paths with nothing sharing the identical residuals.

Memoize the residual states (Maranget-style join point): each distinct residual
state is keyed by structural identity plus captured locals and, once it has been
reached more than a fixed threshold (32) of times, compiled once into a let-bound
join function that later equal-keyed paths call. Below the threshold the emitted
IL is byte-for-byte identical to before, so ordinary code is unchanged; byref-like
result types disable memoization for the whole match (a join is an FSharpFunc and
the CLR forbids byref-like generic arguments). Active patterns are evaluated the
same number of times, in the same order, with the same side effects.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 08c1a339-a621-4b09-8ac5-92f7b6b337b3
@T-Gro
T-Gro force-pushed the t-gro-fix-patmatch-frontier-explosion branch from 4ac7356 to 0b7b7d8 Compare August 17, 2026 11:30
T-Gro and others added 2 commits August 18, 2026 09:43
…Sharp.Core

The dotnet#18425 join-point memoization eagerly evaluated `isThunkableTy resultTy`
once per match. That predicate (isByrefLikeTy/isByrefTy) forces resolution of
well-known types which are not yet available while the compiler bootstraps
FSharp.Core itself, so the Proto compiler miscompiled FSharp.Core with
FS0193 "... did not contain ... 'unit'" and every self-host CI job failed.

Evaluate thunkability lazily, at the moment a residual state actually crosses
the promotion threshold, instead of once per match up front. Ordinary code
(including all of FSharp.Core) never reaches the threshold, so isThunkableTy is
never evaluated for it and self-host compilation is unaffected. The set of
promoted states and the emitted IL are unchanged.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 08c1a339-a621-4b09-8ac5-92f7b6b337b3
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 08c1a339-a621-4b09-8ac5-92f7b6b337b3
@github-actions github-actions Bot added the AI-Tooling-Check-Scanned-Clean Tooling check: diff analyzed, no interesting infrastructure files label Aug 18, 2026
@T-Gro

T-Gro commented Aug 18, 2026

Copy link
Copy Markdown
Member Author

cc @Thorium : This was a tough beast to tackle.
Especially reproducing before/after and measuring did make my machine unusable multiple times, due to both CPU as well as RAM usage.

If you have any OSS repo using similar pattern (i.e. many guards of APs ), I will be happy to include it in in our regression testing matrix for OSS F# repos.

@T-Gro
T-Gro requested a review from abonie August 18, 2026 10:58
@T-Gro
T-Gro enabled auto-merge (squash) August 18, 2026 12:28
@github-actions

Copy link
Copy Markdown
Contributor

🔍 Tooling Safety Check — Affects-Compiler-Output
Affects-Compiler-Output: modifies PatternMatchCompilation, changes IL emission for shared-or patterns

Generated by PR Tooling Safety Check · opus46 3.1M ·

let joinBody = mkAndSimplifyMatch DebugPointAtBinding.NoneAtInvisible mExpr mMatch resultTy subtree (matchBuilder.CloseTargets())
let paramVals = caps |> List.map (fun v -> fst (mkCompGenLocal mMatch "joinCap" v.Type))
let body =
if caps.IsEmpty then joinBody

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.

Suggestion: clone unconditionally here.

remapExpr g CloneAll does two things — substitutes caps → paramVals and deep-clones with fresh binder Val stamps. Skipping it when caps.IsEmpty also skips the clone, so the thunk body then shares the same binder Val objects with the inline first-path copy of subtree (e.g. the activePatternResult temporaries in the promoted no-capture case).

That puts one binder stamp into two different methods, which breaks the unique-stamp assumption behind the optimizer''s compilation-wide, stamp-keyed localInternalVals cache (Optimizer.fs). It''s benign today (both occurrences derive from the same source, so analyses converge), but it''s fragile. Cloning unconditionally is strictly safer and only runs once per promoted state, so there''s no meaningful cost:

let remap =
    { Remap.Empty with
        valRemap = ValMap.OfList (List.map2 (fun (c: Val) (p: Val) -> (c, mkLocalValRef p)) caps paramVals) }
let body = remapExpr g CloneAll remap joinBody   // empty valRemap still freshens binder stamps

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

Thorium commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

The original issue came with https://github.com/Thorium/Linq.Expression.Optimizer meanwile I had to modify the source code to a format where it compiles (via intermediate "->" clauses in pattern matching).

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

Labels

⚠️ Affects-Compiler-Output Tooling check: PR touches IL emission or codegen AI-Tooling-Check-Scanned-Clean Tooling check: diff analyzed, no interesting infrastructure files

Projects

Status: New

Development

Successfully merging this pull request may close these issues.

ActivePatterns: Compilation issues (memory and speed)

3 participants