Skip to content

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

Open
T-Gro wants to merge 1 commit 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 1 commit 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.

A single match clause of N disjuncts that share one when guard, whose disjuncts contain partial active patterns, compiled in exponential (2^N) time and space and eventually overflowed the stack. This happens at analysis time, so besides fsc/fsi it also hit dotnet build and the editor language service on file-open.

Where it exploded

Pattern-match compilation expands a frontier of active patterns. Each guarded disjunct contributes both a match-fail edge and a guard-false edge into the same residual decision state, so the compiler re-investigated one identical state along all 2^N paths (InvestigateFrontiers in PatternMatchCompilation.fs). Nothing shared those identical residuals, and nothing bounded the recursion.

Fix

Memoize the residual states (a Maranget-style join point). Each distinct residual state is keyed by structural identity of the patterns it still has to test plus the locals it captures; the first time a state is reached it is compiled inline exactly as before, and once a state has been reached often enough it is compiled once into a let-bound join function that every later equal-keyed path calls instead of re-emitting the subtree.

Two guarantees keep the change safe:

  • Ordinary code is byte-for-byte identical to the previous compiler. A state is only shared after it has been re-investigated more than a fixed threshold (32) of times. Ordinary hand-written matches reach any single decision state only a handful of times (measured: ≤16 even for a contrived 6-disjunct shared-guard active-pattern match), so nothing is promoted and the emitted IL is unchanged. Only the exponential ActivePatterns: Compilation issues (memory and speed) #18425 shape reaches one state hundreds-to-millions of times, and only those states are shared.
  • byref results stay inline. A join is an FSharpFunc over the captured locals returning the match result, and the CLR forbids a byref-like type (byref/inref/outref or a ref struct such as Span) as a generic argument. When the match result type is byref-like, memoization is switched off for the whole match, so such matches compile exactly as before.

Active patterns are therefore evaluated the same number of times, in the same order, with the same side effects as the previous compiler.

Numbers

match a, b with (A p, E 1 _) | … | (A p, E N _) when g p -> p | _ -> -1, --optimize+, measured end-to-end (process launch included, so ~2.4 s is 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 did not finish (stack overflow) 3.9 s / 221 KB

Before, both compile time and assembly size grow exponentially until the compiler overflows the stack around N≈24; after, both grow polynomially and the assembly the compiler could not produce at all is emitted in a few seconds.

Runtime impact

None for realistic code: every match below the promotion threshold (i.e. everything a human writes) is byte-identical to the previous compiler, so generated programs are unchanged. A promoted match — only the pathological shapes that previously produced hundreds of KB of IL or failed to compile — calls its active patterns exactly as many times as the old fully-inlined tree would have; sharing removes duplicated code, not evaluations, so it never adds active-pattern calls. The only difference on a promoted match is one extra join-function call per residual on the fall-through path, which is precisely the code that previously did not exist because the compiler crashed.

Tests

  • GuardedOrPatternComplexity.fs: the 24-disjunct shape now compiles, runs, and returns the exact left-to-right result; a shared guard that binds a variable at a different tuple position per disjunct is not over-fused; a byref-returning guarded shared-or match stays inline and compiles.
  • QuotationRenderingTests.fs: a 6-disjunct control stays inline (no join thunk in the quotation), while 8-disjunct scalar and tuple-with-binding shapes promote and render correctly.

@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

Copy link
Copy Markdown
Contributor

🔍 Tooling Safety Check — Affects-Compiler-Output
Affects-Compiler-Output: modifies PatternMatchCompilation.fs (pattern compilation logic)

Generated by PR Tooling Safety Check · opus46 3.3M ·

…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 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
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

Projects

Status: New

Development

Successfully merging this pull request may close these issues.

ActivePatterns: Compilation issues (memory and speed)

1 participant