Skip to content

Check large switches for unused cases without comparing every pair - #9261

Draft
cknitt wants to merge 2 commits into
masterfrom
switch-unused-check-perf
Draft

cknitt wants to merge 2 commits into
masterfrom
switch-unused-check-perf

Conversation

@cknitt

@cknitt cknitt commented Nov 7, 2026

Copy link
Copy Markdown
Member

Part of #9256 (unused-case check of large switches).

Since #8900 removed the hash tables, the unused-case check of a switch with n constant or polymorphic variant cases is quadratic in several places:

  • check_unused filters all earlier rows for each case;
  • get_mins compares all pairs of the rows it is given;
  • filter_all finds a row's group by scanning all groups (this dominated: the last | _ row is checked against all n cases);
  • for polymorphic variants, each case's uncovered part is typed again against the whole n-tag type, to look for cases that GADTs make unreachable.

Rows are now indexed by the constant or tag at their head, in a Map ordered by const_compare, so no hashing key needs to agree with it. Two such heads are compatible, and one includes the other, only when they are equal, so a row with one is only compared with the rows of the same head and the rows whose head has none (wildcards, ranges, or-patterns, constructors). A range with equal bounds can share a group with a constant, so filter_all also looks through the groups without a key. get_mins_pats replaces get_mins le_pats in all three callers, including the exhaustiveness check and Matching.as_matrix. The GADT check now runs only when the pattern has a GADT constructor, since without one the pattern built from the case's own subpatterns always types.

Measured with bsc on linux-arm64, allocated words and wall time:

Switch Before After
3,000 int cases 111M, 437 ms 23.4M, 172 ms
6,000 int cases 430M, 1.65 s 73.7M, 555 ms
800 polymorphic variant cases 58.3M, 298 ms 31.8M, 182 ms
1,600 polymorphic variant cases 242M, 1.31 s 128M, 735 ms

Warnings are unchanged for the test suites and 2,630 generated switches over ints, chars, floats, strings, options, tuples, polymorphic variants and GADTs, with ranges, or-patterns, aliases and guards.

What remains: the match compiler (Matching.divide, Matching.add, the switch clustering) for constant switches, which grows 3.4× per doubling with the warnings off.

New test: warning_11_unused_constants covers a constant after an equal-bounds range, constants inside or-patterns, strings and polymorphic variants with payloads.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Kbf3gK1dcjbe97yY1wRMgs

@github-actions

github-actions Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

Changelog

💅 Polish

  • Speed up the unused-case check of switches with many constant or polymorphic variant cases.

Semantic Diff

2 commits · Full page

Check large switches for unused cases without comparing every pair

5d69ca2 · cknitt + Claude · Nov 07, 2026, 10:11 UTC

Why

The unused-case check compared each case with every earlier one and retyped part of each case, and its usefulness checks grouped rows by scanning all groups, so a switch with n constant or polymorphic variant cases cost O(n²) in several places: 6,000 int cases took 1.65 s and allocated 430M words, 1,600 polymorphic variant cases 1.31 s and 242M. They now take 0.56 s with 74M words, and 0.74 s with 128M.

Rows are indexed by the constant or tag at their head, which a row can only share with rows of the same head or of a head without one, such as a wildcard or a range: the earlier rows compatible with a case, the groups of filter_all and the most general rows of get_mins_pats are found through that index. A case is retyped to find GADT cases that can't match only when its pattern has a GADT constructor, as no other pattern can fail to type. Warnings are the same for the test suites and 2,600 generated switches.

Review

  • What remains superlinear? The match compiler (Matching.divide and the switch clustering) for constant switches: 6,000 int cases allocate 69M words with the warnings off, 3.4× the words for 3,000.
✨ Skip the row index for matches with one case

063dd71 · cknitt + Claude · Nov 07, 2026, 19:07 UTC · new

The benchmarks showed small matches, such as function parameters and let patterns, allocating up to 0.6% more for the index. get_mins_pats now returns fewer than two rows as they are, and the unused-case check doesn't index the last case, so such matches allocate less than before.

@codecov

codecov Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.78082% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.74%. Comparing base (bf35764) to head (063dd71).

Files with missing lines Patch % Lines
compiler/ml/parmatch.ml 91.66% 6 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##           master    #9261   +/-   ##
=======================================
  Coverage   81.74%   81.74%           
=======================================
  Files         457      457           
  Lines       65713    65775   +62     
=======================================
+ Hits        53715    53766   +51     
- Misses      11998    12009   +11     
Files with missing lines Coverage Δ
compiler/ml/matching.ml 77.92% <100.00%> (ø)
compiler/ml/parmatch.ml 77.88% <91.66%> (+0.21%) ⬆️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@pkg-pr-new

pkg-pr-new Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

rescript

npm i https://pkg.pr.new/rescript@9261

@rescript/belt

npm i https://pkg.pr.new/@rescript/belt@9261

@rescript/darwin-arm64

npm i https://pkg.pr.new/@rescript/darwin-arm64@9261

@rescript/linux-arm64

npm i https://pkg.pr.new/@rescript/linux-arm64@9261

@rescript/linux-x64

npm i https://pkg.pr.new/@rescript/linux-x64@9261

@rescript/runtime

npm i https://pkg.pr.new/@rescript/runtime@9261

@rescript/win32-x64

npm i https://pkg.pr.new/@rescript/win32-x64@9261

commit: 063dd71

@github-actions

github-actions Bot commented Nov 7, 2026

Copy link
Copy Markdown

Why:
    The unused-case check compared each case with every earlier one and
    retyped part of each case, and its usefulness checks grouped rows by
    scanning all groups, so a switch with n constant or polymorphic
    variant cases cost O(n²) in several places: 6,000 int cases took
    1.65 s and allocated 430M words, 1,600 polymorphic variant cases
    1.31 s and 242M. They now take 0.56 s with 74M words, and 0.74 s with
    128M.

Rows are indexed by the constant or tag at their head, which a row can
only share with rows of the same head or of a head without one, such as a
wildcard or a range: the earlier rows compatible with a case, the groups
of `filter_all` and the most general rows of `get_mins_pats` are found
through that index. A case is retyped to find GADT cases that can't match
only when its pattern has a GADT constructor, as no other pattern can fail
to type. Warnings are the same for the test suites and 2,600 generated
switches.

Review: What remains superlinear? The match compiler (`Matching.divide`
and the switch clustering) for constant switches: 6,000 int cases allocate
69M words with the warnings off, 3.4× the words for 3,000.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kbf3gK1dcjbe97yY1wRMgs
Signed-off-by: Christoph Knittel <ck@cca.io>
@cknitt
cknitt force-pushed the switch-unused-check-perf branch from 5cf0013 to 5d69ca2 Compare November 7, 2026 18:06
@github-actions

github-actions Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

🎉 Compiler benchmarks

Allocated words

In total: 456,989,425 → 426,589,061 (-6.65%).

🎉 Improvement: 35 benchmarks allocate more than 1% less.

Benchmark Base Head Change
stress/IntSwitch.res 30,067,493 8,432,773 -71.95%
stress/PolyVariantSwitch.res 14,563,254 8,393,105 -42.37%
packages/@rescript/belt/src/Belt_internalAVLtree.res 4,426,588 4,116,151 -7.01%
packages/@rescript/runtime/Stdlib_Result.res 1,903,060 1,772,903 -6.84%
packages/@rescript/belt/src/Belt_internalAVLset.res 3,657,958 3,457,391 -5.48%
packages/@rescript/belt/src/Belt_MapDict.res 2,359,280 2,242,544 -4.95%
packages/@rescript/belt/src/Belt_internalMapInt.res 2,090,736 1,987,320 -4.95%
packages/@rescript/belt/src/Belt_internalMapString.res 2,092,574 1,989,156 -4.94%
packages/@rescript/belt/src/Belt_MutableSetInt.res 2,313,192 2,205,605 -4.65%
packages/@rescript/belt/src/Belt_MutableSetString.res 2,314,432 2,206,807 -4.65%
50 more changes
Benchmark Base Head Change
packages/@rescript/belt/src/Belt_MapInt.res 1,816,494 1,741,069 -4.15%
packages/@rescript/belt/src/Belt_MapString.res 1,817,038 1,741,613 -4.15%
packages/@rescript/belt/src/Belt_MutableMapInt.res 1,773,700 1,701,156 -4.09%
packages/@rescript/belt/src/Belt_MutableMapString.res 1,773,969 1,701,439 -4.09%
packages/@rescript/belt/src/Belt_MutableSet.res 2,609,727 2,505,185 -4.01%
packages/@rescript/belt/src/Belt_SetInt.res 1,895,032 1,820,424 -3.94%
packages/@rescript/belt/src/Belt_SetString.res 1,895,759 1,821,149 -3.94%
packages/@rescript/belt/src/Belt_SetDict.res 2,121,382 2,046,337 -3.54%
packages/@rescript/belt/src/Belt_MutableMap.res 1,941,992 1,879,876 -3.20%
packages/@rescript/belt/src/Belt_HashMapInt.res 1,671,321 1,624,264 -2.82%
packages/@rescript/belt/src/Belt_HashMapString.res 1,672,033 1,624,972 -2.81%
packages/@rescript/belt/src/Belt_HashMap.res 1,848,233 1,801,927 -2.51%
packages/@rescript/belt/src/Belt_internalSetInt.res 1,267,668 1,236,057 -2.49%
packages/@rescript/belt/src/Belt_internalSetString.res 1,268,854 1,237,243 -2.49%
packages/@rescript/runtime/Stdlib_Option.res 1,288,904 1,257,681 -2.42%
packages/@rescript/belt/src/Belt_Result.res 920,631 898,770 -2.37%
packages/@rescript/belt/src/Belt_internalBuckets.res 1,556,445 1,519,960 -2.34%
packages/@rescript/belt/src/Belt_MutableQueue.res 1,328,287 1,298,722 -2.23%
packages/@rescript/runtime/Stdlib_List.res 3,396,284 3,327,528 -2.02%
packages/@rescript/belt/src/Belt_List.res 3,950,091 3,871,324 -1.99%
packages/@rescript/belt/src/Belt_HashSetInt.res 1,412,456 1,385,871 -1.88%
packages/@rescript/belt/src/Belt_HashSetString.res 1,412,763 1,386,182 -1.88%
packages/@rescript/belt/src/Belt_HashSet.res 1,573,656 1,547,629 -1.65%
packages/@rescript/belt/src/Belt_MutableStack.res 893,146 879,170 -1.56%
packages/@rescript/belt/src/Belt_internalSetBuckets.res 1,239,575 1,224,945 -1.18%
packages/@rescript/belt/src/Belt_Option.res 885,745 878,452 -0.82%
packages/@rescript/runtime/Primitive_object.res 1,720,232 1,712,180 -0.47%
stress/InlineChain.res 3,977,341 3,960,708 -0.42%
packages/@rescript/runtime/Primitive_module.res 834,826 831,557 -0.39%
stress/LetRec.res 35,120,632 35,008,799 -0.32%
packages/@rescript/belt/src/Belt_Array.res 2,842,879 2,834,295 -0.30%
packages/@rescript/runtime/Stdlib_Nullable.res 828,570 826,073 -0.30%
packages/@rescript/runtime/Stdlib_Null.res 830,463 827,966 -0.30%
packages/@rescript/runtime/Stdlib_AsyncIterableIterator.res 719,827 717,742 -0.29%
packages/@rescript/runtime/Stdlib_AsyncIterator.res 792,369 790,142 -0.28%
packages/@rescript/runtime/Primitive_hash.res 1,501,651 1,497,433 -0.28%
packages/@rescript/runtime/Stdlib_JSON.res 784,466 782,324 -0.27%
packages/@rescript/runtime/Stdlib_Array.res 1,864,605 1,859,527 -0.27%
packages/@rescript/runtime/Primitive_list.res 690,682 688,935 -0.25%
packages/@rescript/runtime/Stdlib_Type.res 682,516 684,231 +0.25%
packages/@rescript/belt/src/Belt_SortArray.res 2,045,354 2,040,601 -0.23%
packages/@rescript/belt/src/Belt_Map.res 1,561,134 1,557,533 -0.23%
packages/@rescript/runtime/Stdlib_Int.res 953,023 950,980 -0.21%
packages/@rescript/belt/src/Belt_Set.res 1,419,186 1,416,156 -0.21%
packages/@rescript/belt/src/Belt_SortArrayInt.res 1,929,237 1,925,307 -0.20%
packages/@rescript/belt/src/Belt_SortArrayString.res 1,931,027 1,927,097 -0.20%
packages/@rescript/runtime/Primitive_bool.res 626,751 625,599 -0.18%
packages/@rescript/runtime/Stdlib_Intl_NumberFormat_Grouping.res 638,049 637,072 -0.15%
packages/@rescript/runtime/Primitive_option.res 793,631 792,479 -0.15%
packages/@rescript/belt/src/Belt_Range.res 780,043 779,258 -0.10%

Instructions

In total: 10,226,962,100 → 8,709,795,247 (-14.83%).

🎉 Improvement: 4 benchmarks execute more than 1% fewer instructions.

Benchmark Base Head Change
stress/IntSwitch.res 1,742,110,561 517,655,291 -70.29%
stress/PolyVariantSwitch.res 645,608,631 364,414,154 -43.55%
packages/@rescript/belt/src/Belt_internalAVLtree.res 166,149,547 155,368,941 -6.49%
packages/@rescript/belt/src/Belt_List.res 145,580,923 143,146,416 -1.67%
stress/ObjectFields.res 957,097,806 958,457,576 +0.14%
packages/@rescript/runtime/Stdlib_Math.resi 44,160,992 44,216,549 +0.13%
packages/@rescript/runtime/Stdlib_List.resi 47,643,168 47,700,067 +0.12%
parse/RedBlackTree.res 26,360,354 26,390,088 +0.11%
packages/@rescript/runtime/Stdlib_Date.resi 57,209,106 57,269,898 +0.11%
parse/RedBlackTreeNoComments.res 25,954,530 25,981,406 +0.10%

Pull request 063dd71 compared with base bf35764 in this run.

The benchmarks showed small matches, such as function parameters and let
patterns, allocating up to 0.6% more for the index. get_mins_pats now
returns fewer than two rows as they are, and the unused-case check doesn't
index the last case, so such matches allocate less than before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kbf3gK1dcjbe97yY1wRMgs
Signed-off-by: Christoph Knittel <ck@cca.io>

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant