From d4c633bebec63b58145b1380cb4f1c281e9660b5 Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Sun, 11 Oct 2026 19:23:33 +0000 Subject: [PATCH 1/4] Bound how deep inlining nests Simplifying an inlined body could inline the functions it calls without limit, so a chain of small functions was inlined in full into every function. Stop after two nested levels; @inline functions are exempt. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Christoph Knittel --- compiler/core/lam_pass_remove_alias.ml | 25 +++++++-- tests/tests/src/inline_chain_test.mjs | 76 ++++++++++++++++++++++++++ tests/tests/src/inline_chain_test.res | 31 +++++++++++ 3 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 tests/tests/src/inline_chain_test.mjs create mode 100644 tests/tests/src/inline_chain_test.res diff --git a/compiler/core/lam_pass_remove_alias.ml b/compiler/core/lam_pass_remove_alias.ml index 83cbc15f06..9471e3a486 100644 --- a/compiler/core/lam_pass_remove_alias.ml +++ b/compiler/core/lam_pass_remove_alias.ml @@ -46,7 +46,15 @@ let is_const_some (cst : Lambda.structured_constant) : bool = | Const_some _ -> true | _ -> false +(* How deep inlining may nest: simplifying an inlined body can inline the + functions it calls, and so on. Without a bound, a chain of small functions + that each call the previous one gets the whole chain inlined into every + function, which is quadratic in the length of the chain. Functions marked + [@inline] are always inlined. *) +let max_inline_depth = 2 + let simplify_alias (meta : Lam_stats.t) (lam : Lambda.t) : Lambda.t = + let inline_depth = ref 0 in let rec simpl (lam : Lambda.t) : Lambda.t = match lam with (* Keep a JSX tag like [M.make] as it is: flattening it may give a variable @@ -204,10 +212,19 @@ let simplify_alias (meta : Lam_stats.t) (lam : Lambda.t) : Lambda.t = Lam_hit.hit_variable v lam) (*avoid nontermination, e.g, `g(g)`*) then normal () - else - simpl - (Lam_beta_reduce.propagate_beta_reduce_with_map meta - param_map params body ap_args)) + else if + !inline_depth >= max_inline_depth + && m.attr.inline <> Always_inline + then normal () + else ( + incr inline_depth; + let inlined = + simpl + (Lam_beta_reduce.propagate_beta_reduce_with_map meta + param_map params body ap_args) + in + decr inline_depth; + inlined)) | _ -> normal () else normal () else normal () diff --git a/tests/tests/src/inline_chain_test.mjs b/tests/tests/src/inline_chain_test.mjs new file mode 100644 index 0000000000..f9bf18c2e9 --- /dev/null +++ b/tests/tests/src/inline_chain_test.mjs @@ -0,0 +1,76 @@ +// Generated by ReScript, PLEASE EDIT WITH CARE + +import * as Mocha from "mocha"; +import * as Test_utils from "./test_utils.mjs"; + +function f0(x) { + return x + 1 | 0; +} + +function f1(x) { + return (x + 1 | 0) + 1 | 0; +} + +function f2(x) { + return ((x + 1 | 0) + 1 | 0) + 2 | 0; +} + +function f3(x) { + return (((x + 1 | 0) + 1 | 0) + 2 | 0) + 3 | 0; +} + +function f4(x) { + return ((f1(x) + 2 | 0) + 3 | 0) + 4 | 0; +} + +function f5(x) { + return ((f2(x) + 3 | 0) + 4 | 0) + 5 | 0; +} + +function f6(x) { + return ((f3(x) + 4 | 0) + 5 | 0) + 6 | 0; +} + +function g0(x) { + return (x << 1); +} + +function g1(x) { + return ((x << 1) << 1); +} + +function g2(x) { + return (((x << 1) << 1) << 1); +} + +function g3(x) { + return ((((x << 1) << 1) << 1) << 1); +} + +function g4(x) { + return ((((x << 1) << 1) << 1) << 1) + 1 | 0; +} + +Mocha.describe("Inline_chain_test", () => { + Mocha.test("chain of small functions", () => { + Test_utils.eq("File \"inline_chain_test.res\", line 25, characters 7-14", (f4(0) + 5 | 0) + 6 | 0, 22); + Test_utils.eq("File \"inline_chain_test.res\", line 26, characters 7-14", 8, 8); + }); + Mocha.test("chain of @inline functions", () => Test_utils.eq("File \"inline_chain_test.res\", line 29, characters 7-14", 17, 17)); +}); + +export { + f0, + f1, + f2, + f3, + f4, + f5, + f6, + g0, + g1, + g2, + g3, + g4, +} +/* Not a pure module */ diff --git a/tests/tests/src/inline_chain_test.res b/tests/tests/src/inline_chain_test.res new file mode 100644 index 0000000000..fff4cfc18b --- /dev/null +++ b/tests/tests/src/inline_chain_test.res @@ -0,0 +1,31 @@ +// Inlining a call can inline the functions that the inlined body calls, but +// only a few levels deep: in a chain of small functions that each call the +// previous one, a later function calls an earlier one instead of containing +// the whole chain. Functions marked @inline are still inlined all the way. + +let f0 = x => x + 1 +let f1 = x => f0(x) + 1 +let f2 = x => f1(x) + 2 +let f3 = x => f2(x) + 3 +let f4 = x => f3(x) + 4 +let f5 = x => f4(x) + 5 +let f6 = x => f5(x) + 6 + +@inline let g0 = x => x * 2 +@inline let g1 = x => g0(x) * 2 +@inline let g2 = x => g1(x) * 2 +@inline let g3 = x => g2(x) * 2 +let g4 = x => g3(x) + 1 + +open Mocha +open Test_utils + +describe(__MODULE__, () => { + test("chain of small functions", () => { + eq(__LOC__, f6(0), 22) + eq(__LOC__, f3(1), 8) + }) + test("chain of @inline functions", () => { + eq(__LOC__, g4(1), 17) + }) +}) From b2a9eb0cf3ab6b9cd47f3660f06bb66b12a6e3fa Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Mon, 12 Oct 2026 09:18:15 +0000 Subject: [PATCH 2/4] Add changelog fragment Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LFDffswgCJNtcpW4vKw6et Signed-off-by: Christoph Knittel --- changes/bound-inline-depth.polish.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/bound-inline-depth.polish.md diff --git a/changes/bound-inline-depth.polish.md b/changes/bound-inline-depth.polish.md new file mode 100644 index 0000000000..8f1fbcd8fa --- /dev/null +++ b/changes/bound-inline-depth.polish.md @@ -0,0 +1 @@ +- Stop inlining nested calls more than two levels deep, so a chain of small functions that call each other no longer makes compile time and output size grow quadratically. https://github.com/rescript-lang/rescript/pull/8801 From f920a000f335ec2758b6acac1f875b6d446eafc9 Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Mon, 12 Oct 2026 12:00:17 +0000 Subject: [PATCH 3/4] Raise the inlining depth limit to 4 It is a safety bound against quadratic blowup; 4 leaves more room for nested inlining and still produces the same output for existing code. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LFDffswgCJNtcpW4vKw6et Signed-off-by: Christoph Knittel --- changes/bound-inline-depth.polish.md | 2 +- compiler/core/lam_pass_remove_alias.ml | 2 +- tests/tests/src/inline_chain_test.mjs | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/changes/bound-inline-depth.polish.md b/changes/bound-inline-depth.polish.md index 8f1fbcd8fa..9fdc024a7f 100644 --- a/changes/bound-inline-depth.polish.md +++ b/changes/bound-inline-depth.polish.md @@ -1 +1 @@ -- Stop inlining nested calls more than two levels deep, so a chain of small functions that call each other no longer makes compile time and output size grow quadratically. https://github.com/rescript-lang/rescript/pull/8801 +- Stop inlining nested calls more than four levels deep, so a chain of small functions that call each other no longer makes compile time and output size grow quadratically. https://github.com/rescript-lang/rescript/pull/8801 diff --git a/compiler/core/lam_pass_remove_alias.ml b/compiler/core/lam_pass_remove_alias.ml index 9471e3a486..44b879ab35 100644 --- a/compiler/core/lam_pass_remove_alias.ml +++ b/compiler/core/lam_pass_remove_alias.ml @@ -51,7 +51,7 @@ let is_const_some (cst : Lambda.structured_constant) : bool = that each call the previous one gets the whole chain inlined into every function, which is quadratic in the length of the chain. Functions marked [@inline] are always inlined. *) -let max_inline_depth = 2 +let max_inline_depth = 4 let simplify_alias (meta : Lam_stats.t) (lam : Lambda.t) : Lambda.t = let inline_depth = ref 0 in diff --git a/tests/tests/src/inline_chain_test.mjs b/tests/tests/src/inline_chain_test.mjs index f9bf18c2e9..1c31efa24c 100644 --- a/tests/tests/src/inline_chain_test.mjs +++ b/tests/tests/src/inline_chain_test.mjs @@ -20,15 +20,15 @@ function f3(x) { } function f4(x) { - return ((f1(x) + 2 | 0) + 3 | 0) + 4 | 0; + return ((((x + 1 | 0) + 1 | 0) + 2 | 0) + 3 | 0) + 4 | 0; } function f5(x) { - return ((f2(x) + 3 | 0) + 4 | 0) + 5 | 0; + return (((((x + 1 | 0) + 1 | 0) + 2 | 0) + 3 | 0) + 4 | 0) + 5 | 0; } function f6(x) { - return ((f3(x) + 4 | 0) + 5 | 0) + 6 | 0; + return ((((f1(x) + 2 | 0) + 3 | 0) + 4 | 0) + 5 | 0) + 6 | 0; } function g0(x) { @@ -53,7 +53,7 @@ function g4(x) { Mocha.describe("Inline_chain_test", () => { Mocha.test("chain of small functions", () => { - Test_utils.eq("File \"inline_chain_test.res\", line 25, characters 7-14", (f4(0) + 5 | 0) + 6 | 0, 22); + Test_utils.eq("File \"inline_chain_test.res\", line 25, characters 7-14", 22, 22); Test_utils.eq("File \"inline_chain_test.res\", line 26, characters 7-14", 8, 8); }); Mocha.test("chain of @inline functions", () => Test_utils.eq("File \"inline_chain_test.res\", line 29, characters 7-14", 17, 17)); From eb153d4999a31d1b8d041a0708a91bced15a7781 Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Mon, 12 Oct 2026 12:11:22 +0000 Subject: [PATCH 4/4] Exercise a call that is not inlined in the chain test The arguments were constants, so the calls were evaluated at compile time. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LFDffswgCJNtcpW4vKw6et Signed-off-by: Christoph Knittel --- tests/tests/src/inline_chain_test.mjs | 11 ++++++++--- tests/tests/src/inline_chain_test.res | 9 ++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/tests/tests/src/inline_chain_test.mjs b/tests/tests/src/inline_chain_test.mjs index 1c31efa24c..4aea3e5f20 100644 --- a/tests/tests/src/inline_chain_test.mjs +++ b/tests/tests/src/inline_chain_test.mjs @@ -51,12 +51,16 @@ function g4(x) { return ((((x << 1) << 1) << 1) << 1) + 1 | 0; } +let zero = { + contents: 0 +}; + Mocha.describe("Inline_chain_test", () => { Mocha.test("chain of small functions", () => { - Test_utils.eq("File \"inline_chain_test.res\", line 25, characters 7-14", 22, 22); - Test_utils.eq("File \"inline_chain_test.res\", line 26, characters 7-14", 8, 8); + Test_utils.eq("File \"inline_chain_test.res\", line 28, characters 7-14", (((f2(zero.contents) + 3 | 0) + 4 | 0) + 5 | 0) + 6 | 0, 22); + Test_utils.eq("File \"inline_chain_test.res\", line 29, characters 7-14", ((((zero.contents + 1 | 0) + 1 | 0) + 1 | 0) + 2 | 0) + 3 | 0, 8); }); - Mocha.test("chain of @inline functions", () => Test_utils.eq("File \"inline_chain_test.res\", line 29, characters 7-14", 17, 17)); + Mocha.test("chain of @inline functions", () => Test_utils.eq("File \"inline_chain_test.res\", line 32, characters 7-14", (((((zero.contents + 1 | 0) << 1) << 1) << 1) << 1) + 1 | 0, 17)); }); export { @@ -72,5 +76,6 @@ export { g2, g3, g4, + zero, } /* Not a pure module */ diff --git a/tests/tests/src/inline_chain_test.res b/tests/tests/src/inline_chain_test.res index fff4cfc18b..96a088426f 100644 --- a/tests/tests/src/inline_chain_test.res +++ b/tests/tests/src/inline_chain_test.res @@ -17,15 +17,18 @@ let f6 = x => f5(x) + 6 @inline let g3 = x => g2(x) * 2 let g4 = x => g3(x) + 1 +// Read through a ref so the calls below are not evaluated at compile time +let zero = ref(0) + open Mocha open Test_utils describe(__MODULE__, () => { test("chain of small functions", () => { - eq(__LOC__, f6(0), 22) - eq(__LOC__, f3(1), 8) + eq(__LOC__, f6(zero.contents), 22) + eq(__LOC__, f3(zero.contents + 1), 8) }) test("chain of @inline functions", () => { - eq(__LOC__, g4(1), 17) + eq(__LOC__, g4(zero.contents + 1), 17) }) })