Skip to content

Commit d1c4b04

Browse files
cristianocclaude
andcommitted
Compile character range patterns in linear time (#8868)
Why: A character range pattern is expanded into one or-pattern alternative per character, and typing, the exhaustiveness and unused-case checks, default matrices and switch compilation each took time quadratic in the number of alternatives: a range over the CJK block ran out of memory (#8866). Matching modified (character range patterns): Example: let f = c => switch c { | '\u{100}' .. '\u{1100}' => 1 | _ => 0 } Before (lambda): (let (f/1001 = (function c/1002 (catch (catch (if (|| (>[int] c/1002 4352) (<[int] c/1002 256)) (exit 2) (exit 1)) with (2) 0) with (1) 1))) (makeblock module/exports f/1001)) After (lambda): (let (f/1001 = (function c/1002 (catch (catch (if (|| (>[int] c/1002 4352) (<[int] c/1002 256)) (exit 2) (exit 1)) with (2) 0) with (1) 1))) (makeblock module/exports f/1001)) Before (text): `/usr/bin/time -l bsc` on the example, three runs 2.22 real 2.03 user 0.18 sys 1273511936 maximum resident set size 2.20 real 2.02 user 0.16 sys 1271463936 maximum resident set size 2.21 real 2.03 user 0.17 sys 1273659392 maximum resident set size After (text): `/usr/bin/time -l bsc` on the example, three runs 0.02 real 0.02 user 0.00 sys 25395200 maximum resident set size 0.02 real 0.02 user 0.00 sys 25624576 maximum resident set size 0.03 real 0.02 user 0.00 sys 25591808 maximum resident set size Review: Where did the time go? Typing copied the right subtree of each nested or-pattern to rename variables even when there are none; the checks searched the groups linearly for each row and compared all pairs of rows for the default matrix minima; switch compilation found each key's cell with `List.find`. Groups, minima and int, char and string cells are now found by hashing, and the copy is skipped when nothing is renamed. Review: Does the generated code change? No: #8868 reports byte-identical JavaScript and warnings across `tests/tests/src`, the `super_errors` fixtures and the runtime. Re-applies #8868 by @cknitt for illustration. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Cristiano Calcagno <cristianoc@users.noreply.github.com>
1 parent 1ec3df8 commit d1c4b04

7 files changed

Lines changed: 234 additions & 33 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Fix quadratic compile time and memory of large character range patterns, and speed up switches over many constants. https://github.com/rescript-lang/rescript/pull/8868

‎compiler/ml/matching.ml‎

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -621,7 +621,7 @@ let rec what_is_cases cases =
621621
| _ -> assert false
622622

623623
(* A few operations on default environments *)
624-
let as_matrix cases = get_mins le_pats (List.map (fun (ps, _) -> ps) cases)
624+
let as_matrix cases = get_mins_pats (List.map (fun (ps, _) -> ps) cases)
625625

626626
let cons_default matrix raise_num default =
627627
match matrix with
@@ -1147,13 +1147,28 @@ let add make_matching_fun division eq_key key patl_action args =
11471147
cell.pm.cases <- [patl_action];
11481148
(key, cell) :: division
11491149

1150-
let divide make eq_key get_key get_args ctx pm =
1150+
(* Cells whose key is [hashable] are also found by hashing, which must agree
1151+
with [eq_key]. This keeps a column of many distinct keys linear. *)
1152+
let divide ?(hashable = fun _ -> false) make eq_key get_key get_args ctx pm =
1153+
let cells = lazy (Hashtbl.create 16) in
11511154
let rec divide_rec = function
1152-
| (p :: patl, action) :: rem ->
1155+
| (p :: patl, action) :: rem -> (
11531156
let this_match = divide_rec rem in
1154-
add (make p pm.default ctx) this_match eq_key (get_key p)
1155-
(get_args p patl, action)
1156-
pm.args
1157+
let key = get_key p in
1158+
let patl_action = (get_args p patl, action) in
1159+
if not (hashable key) then
1160+
add (make p pm.default ctx) this_match eq_key key patl_action pm.args
1161+
else
1162+
let cells = Lazy.force cells in
1163+
match Hashtbl.find_opt cells key with
1164+
| Some cell ->
1165+
cell.pm.cases <- patl_action :: cell.pm.cases;
1166+
this_match
1167+
| None ->
1168+
let cell = make p pm.default ctx pm.args in
1169+
cell.pm.cases <- [patl_action];
1170+
Hashtbl.add cells key cell;
1171+
(key, cell) :: this_match)
11571172
| _ -> []
11581173
in
11591174
@@ -1210,7 +1225,11 @@ let make_constant_matching p def ctx = function
12101225
{pm = {cases = []; args = argl; default = def}; ctx; pat = normalize_pat p}
12111226
12121227
let divide_constant ctx m =
1213-
divide make_constant_matching
1228+
divide
1229+
~hashable:(function
1230+
| Asttypes.Const_int _ | Const_char _ | Const_string _ -> true
1231+
| Const_float _ | Const_bigint _ -> false)
1232+
make_constant_matching
12141233
(fun c d -> const_compare c d = 0)
12151234
(get_key_constant "divide")
12161235
get_args_constant ctx m

‎compiler/ml/parmatch.ml‎

Lines changed: 98 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -730,26 +730,61 @@ let filter_extra pss =
730730
*)
731731

732732
let filter_all pat0 pss =
733-
let rec insert q qs env =
734-
match env with
735-
| [] ->
736-
let q0 = normalize_pat q in
737-
[(q0, [simple_match_args q0 q @ qs])]
738-
| ((q0, pss) as c) :: env ->
739-
if simple_match q0 q then
740-
(q0, (simple_match_args q0 q @ qs) :: pss) :: env
741-
else c :: insert q qs env
733+
(* The groups, most recently created first. Each row goes to the group
734+
whose head [simple_match]es it; there is at most one such group, since a
735+
group is only created when no other matches. *)
736+
let groups =
737+
ref
738+
(match pat0.pat_desc with
739+
| Tpat_record _ | Tpat_tuple _ -> [(pat0, ref [])]
740+
| _ -> [])
741+
in
742+
(* For int, char and string constants and for polymorphic variants,
743+
[simple_match] is equality, so their groups are also found by hashing.
744+
This keeps a column of many distinct heads, e.g. an expanded character
745+
range, linear instead of quadratic. The tables are only created when needed,
746+
as most calls are on small matrices. *)
747+
let const_groups = lazy (Hashtbl.create 16) in
748+
let variant_groups = lazy (Hashtbl.create 16) in
749+
let add_row (q0, rows) q qs =
750+
rows := (simple_match_args q0 q @ qs) :: !rows
751+
in
752+
let new_group q qs =
753+
let q0 = normalize_pat q in
754+
let group = (q0, ref []) in
755+
add_row group q qs;
756+
groups := group :: !groups;
757+
group
758+
in
759+
let find_or_add tbl key q qs =
760+
let tbl = Lazy.force tbl in
761+
match Hashtbl.find_opt tbl key with
762+
| Some group -> add_row group q qs
763+
| None -> Hashtbl.add tbl key (new_group q qs)
764+
in
765+
let insert q qs =
766+
match q.pat_desc with
767+
| Tpat_constant ((Const_int _ | Const_char _ | Const_string _) as c) ->
768+
find_or_add const_groups c q qs
769+
| Tpat_variant (l, _, _) -> find_or_add variant_groups l q qs
770+
| _ -> (
771+
match List.find_opt (fun (q0, _) -> simple_match q0 q) !groups with
772+
| Some group -> add_row group q qs
773+
| None -> ignore (new_group q qs))
742774
in
743775

744-
let rec filter_rec env = function
776+
let rec filter_rec = function
745777
| ({pat_desc = Tpat_alias (p, _, _)} :: ps) :: pss ->
746-
filter_rec env ((p :: ps) :: pss)
778+
filter_rec ((p :: ps) :: pss)
747779
| ({pat_desc = Tpat_or (p1, p2, _)} :: ps) :: pss ->
748-
filter_rec env ((p1 :: ps) :: (p2 :: ps) :: pss)
749-
| ({pat_desc = Tpat_any | Tpat_var _} :: _) :: pss -> filter_rec env pss
750-
| (p :: ps) :: pss -> filter_rec (insert p ps env) pss
751-
| _ -> env
752-
and filter_omega env = function
780+
filter_rec ((p1 :: ps) :: (p2 :: ps) :: pss)
781+
| ({pat_desc = Tpat_any | Tpat_var _} :: _) :: pss -> filter_rec pss
782+
| (p :: ps) :: pss ->
783+
insert p ps;
784+
filter_rec pss
785+
| _ -> ()
786+
in
787+
let rec filter_omega env = function
753788
| ({pat_desc = Tpat_alias (p, _, _)} :: ps) :: pss ->
754789
filter_omega env ((p :: ps) :: pss)
755790
| ({pat_desc = Tpat_or (p1, p2, _)} :: ps) :: pss ->
@@ -764,13 +799,8 @@ let filter_all pat0 pss =
764799
| [] -> env
765800
in
766801

767-
filter_omega
768-
(filter_rec
769-
(match pat0.pat_desc with
770-
| Tpat_record _ | Tpat_tuple _ -> [(pat0, [])]
771-
| _ -> [])
772-
pss)
773-
pss
802+
filter_rec pss;
803+
filter_omega (List.rev_map (fun (q0, rows) -> (q0, !rows)) !groups) pss
774804

775805
(* Variant related functions *)
776806

@@ -1672,6 +1702,49 @@ let get_mins le ps =
16721702
in
16731703
select_rec [] (select_rec [] ps)
16741704

1705+
(* [get_mins le_pats pss], but faster for many rows starting with distinct
1706+
constants, e.g. an expanded character range. A row starting with an int,
1707+
char or string constant can only be covered by rows starting with the same
1708+
constant or with another kind of pattern, so only those are compared. *)
1709+
let get_mins_pats pss =
1710+
let key = function
1711+
| {
1712+
pat_desc =
1713+
Tpat_constant ((Const_int _ | Const_char _ | Const_string _) as c);
1714+
}
1715+
:: _ ->
1716+
Some c
1717+
| _ -> None
1718+
in
1719+
(* [select_rec [] pss] of [get_mins le_pats]: drops each row covered by a
1720+
later row, and reverses the order. *)
1721+
let select pss =
1722+
let later = ref [] in
1723+
let later_by_key = lazy (Hashtbl.create 16) in
1724+
let later_unkeyed = ref [] in
1725+
let kept =
1726+
List.fold_left
1727+
(fun kept ps ->
1728+
let below qs = le_pats qs ps in
1729+
let key = key ps in
1730+
let dominated =
1731+
match key with
1732+
| None -> List.exists below !later
1733+
| Some c ->
1734+
List.exists below (Hashtbl.find_all (Lazy.force later_by_key) c)
1735+
|| List.exists below !later_unkeyed
1736+
in
1737+
later := ps :: !later;
1738+
(match key with
1739+
| None -> later_unkeyed := ps :: !later_unkeyed
1740+
| Some c -> Hashtbl.add (Lazy.force later_by_key) c ps);
1741+
if dominated then kept else ps :: kept)
1742+
[] (List.rev pss)
1743+
in
1744+
List.rev kept
1745+
in
1746+
select (select pss)
1747+
16751748
(*
16761749
lub p q is a pattern that matches all values matched by p and q
16771750
may raise Empty, when p and q are not compatible
@@ -2117,7 +2190,7 @@ let check_unused pred casel =
21172190
| {c_lhs = q; c_guard} :: rem ->
21182191
let qs = [q] in
21192192
(try
2120-
let pss = get_mins le_pats (Ext_list.filter pref (compats qs)) in
2193+
let pss = get_mins_pats (Ext_list.filter pref (compats qs)) in
21212194
(* First look for redundant or partially redundant patterns *)
21222195
let r = every_satisfiables (make_rows pss) (make_row qs) in
21232196
(* Do not warn for unused [pat -> .] *)
@@ -2196,7 +2269,7 @@ let irrefutable pat = le_pat (Dict_pattern.lowering [pat] pat) omega
21962269

21972270
let check_partial_param do_check_partial do_check_fragile loc casel =
21982271
let pss = initial_matrix casel in
2199-
let pss = get_mins le_pats pss in
2272+
let pss = get_mins_pats pss in
22002273
let total = do_check_partial loc casel pss in
22012274
if total = Total && Warnings.is_active (Warnings.Fragile_match "") then
22022275
do_check_fragile loc casel pss;

‎compiler/ml/parmatch.mli‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ val lub : pattern -> pattern -> pattern
4949
val lubs : pattern list -> pattern list -> pattern list
5050

5151
val get_mins : ('a -> 'a -> bool) -> 'a list -> 'a list
52+
val get_mins_pats : pattern list list -> pattern list list
5253

5354
(* Those two functions recombine one pattern and its arguments:
5455
For instance:

‎compiler/ml/typecore.ml‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1762,8 +1762,11 @@ and type_pat_aux ~constrs ~labels ~no_existentials ~mode ~explode ~env sp
17621762
in
17631763
pattern_variables_ref () := p1_variables;
17641764
module_variables_ref () := p1_module_variables;
1765+
(* Without variables there is nothing to rename. Skipping the copy
1766+
keeps nested or-patterns, e.g. expanded character ranges, linear. *)
1767+
let p2 = if alpha_env = [] then p2 else alpha_pat alpha_env p2 in
17651768
{
1766-
pat_desc = Tpat_or (p1, alpha_pat alpha_env p2, None);
1769+
pat_desc = Tpat_or (p1, p2, None);
17671770
pat_loc = loc;
17681771
pat_extra = [];
17691772
pat_type = expected_ty;
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Generated by ReScript, PLEASE EDIT WITH CARE
2+
3+
import * as Mocha from "mocha";
4+
import * as Test_utils from "./test_utils.mjs";
5+
6+
function isCjk(c) {
7+
return !(c > 40959 || c < 19968);
8+
}
9+
10+
function classify(c) {
11+
if (c >= 95) {
12+
if (c < 123) {
13+
if (c !== 96) {
14+
if (c >= 96) {
15+
return "lower";
16+
} else {
17+
return "word";
18+
}
19+
} else {
20+
return "other";
21+
}
22+
} else if (c > 65535 || c < 256) {
23+
return "other";
24+
} else {
25+
return "bmp";
26+
}
27+
} else if (c >= 58) {
28+
if (c > 90 || c < 65) {
29+
return "other";
30+
} else {
31+
return "upper";
32+
}
33+
} else if (c >= 48) {
34+
return "word";
35+
} else {
36+
return "other";
37+
}
38+
}
39+
40+
Mocha.describe("Char_range_test", () => {
41+
Mocha.test("large range", () => {
42+
Test_utils.eq("File \"char_range_test.res\", line 23, characters 7-14", false, false);
43+
Test_utils.eq("File \"char_range_test.res\", line 24, characters 7-14", true, true);
44+
Test_utils.eq("File \"char_range_test.res\", line 25, characters 7-14", true, true);
45+
Test_utils.eq("File \"char_range_test.res\", line 26, characters 7-14", true, true);
46+
Test_utils.eq("File \"char_range_test.res\", line 27, characters 7-14", false, false);
47+
});
48+
Mocha.test("several ranges", () => {
49+
Test_utils.eq("File \"char_range_test.res\", line 31, characters 7-14", classify(/* 'q' */113), "lower");
50+
Test_utils.eq("File \"char_range_test.res\", line 32, characters 7-14", classify(/* 'Q' */81), "upper");
51+
Test_utils.eq("File \"char_range_test.res\", line 33, characters 7-14", classify(/* '7' */55), "word");
52+
Test_utils.eq("File \"char_range_test.res\", line 34, characters 7-14", classify(/* '_' */95), "word");
53+
Test_utils.eq("File \"char_range_test.res\", line 35, characters 7-14", classify(/* 'ō' */333), "bmp");
54+
Test_utils.eq("File \"char_range_test.res\", line 36, characters 7-14", classify(/* '￿' */65535), "bmp");
55+
Test_utils.eq("File \"char_range_test.res\", line 37, characters 7-14", classify(/* '𐀀' */65536), "other");
56+
Test_utils.eq("File \"char_range_test.res\", line 38, characters 7-14", classify(/* '!' */33), "other");
57+
});
58+
});
59+
60+
export {
61+
isCjk,
62+
classify,
63+
}
64+
/* Not a pure module */
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
open Mocha
2+
open Test_utils
3+
4+
// A range is expanded into one alternative per character. This one has about
5+
// 21,000, which used to take a minute and many GB of memory to compile.
6+
let isCjk = c =>
7+
switch c {
8+
| '\u{4E00}' .. '\u{9FFF}' => true
9+
| _ => false
10+
}
11+
12+
let classify = c =>
13+
switch c {
14+
| 'a' .. 'z' => "lower"
15+
| 'A' .. 'Z' => "upper"
16+
| '0' .. '9' | '_' => "word"
17+
| '\u{100}' .. '\u{FFFF}' => "bmp"
18+
| _ => "other"
19+
}
20+
21+
describe(__MODULE__, () => {
22+
test("large range", () => {
23+
eq(__LOC__, isCjk('\u{4DFF}'), false)
24+
eq(__LOC__, isCjk('\u{4E00}'), true)
25+
eq(__LOC__, isCjk('中'), true)
26+
eq(__LOC__, isCjk('\u{9FFF}'), true)
27+
eq(__LOC__, isCjk('\u{A000}'), false)
28+
})
29+
30+
test("several ranges", () => {
31+
eq(__LOC__, classify('q'), "lower")
32+
eq(__LOC__, classify('Q'), "upper")
33+
eq(__LOC__, classify('7'), "word")
34+
eq(__LOC__, classify('_'), "word")
35+
eq(__LOC__, classify('ō'), "bmp")
36+
eq(__LOC__, classify('\u{FFFF}'), "bmp")
37+
eq(__LOC__, classify('\u{10000}'), "other")
38+
eq(__LOC__, classify('!'), "other")
39+
})
40+
})

0 commit comments

Comments
 (0)