From 66a78105cab39b5e27907d8b27d30f83faedcc1e Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Wed, 26 Aug 2026 17:03:51 +0200 Subject: [PATCH 01/10] Perf: eliminate closure allocs in List.mapq / List.lengthsEqAndForall2 Make both identity/equality List primitives `inline` with `[]` and rewrite their bodies to apply the function argument directly (single-pass while-loop) instead of forwarding it to the non-inline `List.map` / `List.forall2`. This lets the optimizer beta-reduce the partial-application closures passed at the hot remap/type-equivalence call sites (e.g. `List.mapq (remapTypeAux tyenv) types`) into direct calls, with no call-site changes required. Self-build gc-verbose trace (120 files / 65,880 LOC): the driven closures collapse to 0 MB - typesAEquivAux@1646 599.6->0, remapTypes@419 591.5->0, remapExprs@2045 493.8->0, remapTypesAux@276 386.5->0, remapDecisionTree@2076-1 68.3->0; ~1.88 GB net closure-allocation reduction. Pure allocation optimization, output-identical: 3,000,000-trial in-process differential (value + same-instance identity preservation, 0 mismatches) and --deterministic+ self-compile SHA-256 byte-identity on two inputs. Adds an EmittedIL characterization test (InlineIfLambdaClosureForms) documenting when a HOF call site allocates a closure for its function argument. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Compiler/Utilities/illib.fs | 58 +++++++++- src/Compiler/Utilities/illib.fsi | 4 +- .../Inlining/InlineIfLambdaClosureForms.fs | 107 ++++++++++++++++++ .../FSharp.Compiler.ComponentTests.fsproj | 1 + 4 files changed, 163 insertions(+), 7 deletions(-) create mode 100644 tests/FSharp.Compiler.ComponentTests/EmittedIL/Inlining/InlineIfLambdaClosureForms.fs diff --git a/src/Compiler/Utilities/illib.fs b/src/Compiler/Utilities/illib.fs index a5d44c12bdd..c60c1850ad1 100644 --- a/src/Compiler/Utilities/illib.fs +++ b/src/Compiler/Utilities/illib.fs @@ -458,8 +458,37 @@ module List = loop 0 xs - let lengthsEqAndForall2 p l1 l2 = - List.length l1 = List.length l2 && List.forall2 p l1 l2 + let inline lengthsEqAndForall2 ([] p) l1 l2 = + // Single pass that applies `p` directly (rather than forwarding it to the non-inline + // `List.forall2`), so that under [] no closure is allocated for `p`. + // Returns true iff the lists have equal length and every pair satisfies `p`. + let mutable r1 = l1 + let mutable r2 = l2 + let mutable ok = true + let mutable go = true + + while go do + match r1 with + | h1 :: t1 -> + match r2 with + | h2 :: t2 -> + if p h1 h2 then + r1 <- t1 + r2 <- t2 + else + ok <- false + go <- false + | [] -> + ok <- false + go <- false + | [] -> + match r2 with + | [] -> go <- false + | _ -> + ok <- false + go <- false + + ok let rec findi n f l = match l with @@ -482,7 +511,7 @@ module List = | h1 :: t1, h2 :: t2 -> h1 === h2 && checkq t1 t2 | _ -> true - let mapq (f: 'T -> 'T) inp = + let inline mapq ([] f: 'T -> 'T) inp = assert not typeof<'T>.IsValueType match inp with @@ -505,8 +534,27 @@ module List = else [ h2a; h2b; h2c ] | _ -> - let res = List.map f inp - if checkq inp res then inp else res + // Apply `f` directly (rather than forwarding it to the non-inline `List.map`), so + // that under [] no closure is allocated for `f`. Identity preserving: + // the original `inp` instance is returned when every element is physically unchanged. + let mutable changed = false + let mutable acc = [] + let mutable rest = inp + let mutable go = true + + while go do + match rest with + | h :: t -> + let h2 = f h + + if not (h === h2) then + changed <- true + + acc <- h2 :: acc + rest <- t + | [] -> go <- false + + if changed then List.rev acc else inp let frontAndBack l = let rec loop acc l = diff --git a/src/Compiler/Utilities/illib.fsi b/src/Compiler/Utilities/illib.fsi index a4bba551042..32c030b010f 100644 --- a/src/Compiler/Utilities/illib.fsi +++ b/src/Compiler/Utilities/illib.fsi @@ -160,7 +160,7 @@ module internal List = val existsi: f: (int -> 'a -> bool) -> xs: 'a list -> bool - val lengthsEqAndForall2: p: ('a -> 'b -> bool) -> l1: 'a list -> l2: 'b list -> bool + val inline lengthsEqAndForall2: p: ('a -> 'b -> bool) -> l1: 'a list -> l2: 'b list -> bool val findi: n: int -> f: ('a -> bool) -> l: 'a list -> ('a * int) option @@ -168,7 +168,7 @@ module internal List = val checkq: l1: 'a list -> l2: 'a list -> bool when 'a: not struct - val mapq: f: ('T -> 'T) -> inp: 'T list -> 'T list when 'T: not struct + val inline mapq: f: ('T -> 'T) -> inp: 'T list -> 'T list when 'T: not struct val frontAndBack: l: 'a list -> 'a list * 'a diff --git a/tests/FSharp.Compiler.ComponentTests/EmittedIL/Inlining/InlineIfLambdaClosureForms.fs b/tests/FSharp.Compiler.ComponentTests/EmittedIL/Inlining/InlineIfLambdaClosureForms.fs new file mode 100644 index 00000000000..fb5d114c3c7 --- /dev/null +++ b/tests/FSharp.Compiler.ComponentTests/EmittedIL/Inlining/InlineIfLambdaClosureForms.fs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace EmittedIL + +open Xunit +open FSharp.Test.Compiler + +/// Characterization of when a higher-order-function call site allocates a heap closure for +/// its function argument, established by reading the emitted IL (`newobj` of a closure). +/// +/// Each test compiles its own `module Test` with --optimize+ and asserts whether a closure +/// `newobj` appears. The function argument captures a runtime value (`env`) so that any +/// closure is a real per-call allocation, and the HOFs here return a bool (no list building) +/// so that the ONLY possible `newobj` in the caller is the function closure itself. +/// +/// The contract these tests lock in (verified against emitted IL, F# 11, --optimize+): +/// +/// 1. Vanilla `List.map` is NOT inline, so its mapping function must be materialised as a +/// value => a closure is allocated in EVERY syntactic form (lambda literal or partial +/// application, direct or piped). Eta-expanding a `List.map` call site buys nothing. +/// +/// 2. An `inline` + `[]` HOF whose body FORWARDS the function to another +/// non-inline callee (e.g. `List.forall2 p` / `List.map f`) STILL allocates the closure, +/// because the callee forces the function into value position. Eta-expanding the call +/// site does NOT help here either. +/// +/// 3. An `inline` + `[]` HOF whose body APPLIES the function directly (a +/// while-loop, no forwarding, no nested closure) allocates NOTHING - and this holds +/// whether the call site passes a lambda literal OR a partial application. The optimizer +/// beta-reduces the partial application into a direct call. Therefore the closure win +/// comes from the HOF body applying the function directly, NOT from the call-site form. +/// +/// Note on `<|`: a separately-observed "`prim <| (fun ..)` allocates" effect is context +/// dependent (it needs a HOF the optimizer cannot fully reduce, e.g. one touching private +/// state) and does NOT reproduce in minimal code - the optimizer recovers the saturated +/// call - so it is deliberately not asserted here. +module InlineIfLambdaClosureForms = + + // Shared definitions. `eqf env` is the partial application used by the "partial + // application" call sites; capturing `env` makes any allocated closure per-call. + let private prelude = """ +module Test + +let eqf (env: int) (a: string) (b: string) = a.Length = b.Length + env + +// (2) inline HOF that FORWARDS the function to a non-inline callee (mirrors the current +// List.lengthsEqAndForall2 body `List.length l1 = List.length l2 && List.forall2 p l1 l2`). +let inline forall2Forward ([] p: string -> string -> bool) (l1: string list) (l2: string list) = + List.length l1 = List.length l2 && List.forall2 p l1 l2 + +// (3) inline HOF that APPLIES the function directly in a while-loop (mirrors the proposed fix). +// Nested matches (not `match r1, r2 with`) avoid a per-iteration tuple allocation. +let inline forall2Direct ([] p: string -> string -> bool) (l1: string list) (l2: string list) = + let mutable r1 = l1 + let mutable r2 = l2 + let mutable ok = true + let mutable go = true + while go do + match r1 with + | h1 :: t1 -> + match r2 with + | h2 :: t2 -> if p h1 h2 then r1 <- t1; r2 <- t2 else (ok <- false; go <- false) + | [] -> ok <- false; go <- false + | [] -> + match r2 with + | [] -> go <- false + | _ -> ok <- false; go <- false + ok +""" + + let private assertClosure src = + FSharp (prelude + src) |> withOptimize |> compile |> shouldSucceed |> verifyILPresent [ "newobj" ] + + let private assertNoClosure src = + FSharp (prelude + src) |> withOptimize |> compile |> shouldSucceed |> verifyILNotPresent [ "newobj" ] + + // ---- (1) Vanilla List.map: allocates the mapping closure in every form ---- + + [] + let ``Vanilla List.map + lambda literal allocates a closure`` () = + assertClosure "let test (env: int) (xs: string list) = List.map (fun (s: string) -> string (s.Length + env)) xs" + + [] + let ``Vanilla List.map + partial application allocates a closure`` () = + assertClosure "let f (env: int) (s: string) = string (s.Length + env)\nlet test (env: int) (xs: string list) = List.map (f env) xs" + + // ---- (2) Forwarding inline HOF: still allocates; eta-expansion does not help ---- + + [] + let ``Forwarding inline HOF + partial application allocates a closure`` () = + assertClosure "let test (env: int) (a: string list) (b: string list) = forall2Forward (eqf env) a b" + + [] + let ``Forwarding inline HOF + eta-expanded lambda still allocates a closure`` () = + // Eta-expanding the call site does not help: `List.forall2` forces `p` into value position. + assertClosure "let test (env: int) (a: string list) (b: string list) = forall2Forward (fun x y -> eqf env x y) a b" + + // ---- (3) Direct-apply inline HOF: allocates nothing, regardless of call-site form ---- + + [] + let ``Direct-apply inline HOF + partial application allocates no closure`` () = + // The partial application `(eqf env)` is beta-reduced into a direct call; no closure. + assertNoClosure "let test (env: int) (a: string list) (b: string list) = forall2Direct (eqf env) a b" + + [] + let ``Direct-apply inline HOF + eta-expanded lambda allocates no closure`` () = + assertNoClosure "let test (env: int) (a: string list) (b: string list) = forall2Direct (fun x y -> eqf env x y) a b" diff --git a/tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj b/tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj index cc7e109373f..d4bdfc6ceae 100644 --- a/tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj +++ b/tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj @@ -288,6 +288,7 @@ + From 7ee4c6b70311fbb3be06a377f90c0bec743ad185 Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Wed, 26 Aug 2026 17:38:00 +0200 Subject: [PATCH 02/10] Compaction: drop dead checkq, simplify lengthsEqAndForall2, trim comments/test - Remove now-unused List.checkq (both .fs and .fsi) - mapq no longer calls it. - lengthsEqAndForall2: drop the `ok` mutable; derive the result from `List.isEmpty` on the remainders after the single-pass loop. - Trim over-explanatory comments to the load-bearing why. - Characterization test: cut a redundant vanilla List.map case (6 -> 5 facts), compress the header doc block. Behaviour unchanged: 3,000,000-trial differential still 0 mismatches; build clean. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Compiler/Utilities/illib.fs | 27 +++--------- src/Compiler/Utilities/illib.fsi | 2 - .../Inlining/InlineIfLambdaClosureForms.fs | 42 +++---------------- 3 files changed, 11 insertions(+), 60 deletions(-) diff --git a/src/Compiler/Utilities/illib.fs b/src/Compiler/Utilities/illib.fs index c60c1850ad1..ab8ef36b284 100644 --- a/src/Compiler/Utilities/illib.fs +++ b/src/Compiler/Utilities/illib.fs @@ -459,12 +459,9 @@ module List = loop 0 xs let inline lengthsEqAndForall2 ([] p) l1 l2 = - // Single pass that applies `p` directly (rather than forwarding it to the non-inline - // `List.forall2`), so that under [] no closure is allocated for `p`. - // Returns true iff the lists have equal length and every pair satisfies `p`. + // Apply `p` directly (not via the non-inline `List.forall2`) so [] allocates no closure for `p`. let mutable r1 = l1 let mutable r2 = l2 - let mutable ok = true let mutable go = true while go do @@ -476,19 +473,11 @@ module List = r1 <- t1 r2 <- t2 else - ok <- false go <- false - | [] -> - ok <- false - go <- false - | [] -> - match r2 with | [] -> go <- false - | _ -> - ok <- false - go <- false + | [] -> go <- false - ok + List.isEmpty r1 && List.isEmpty r2 let rec findi n f l = match l with @@ -506,11 +495,6 @@ module List = ch [] [] l - let rec checkq l1 l2 = - match l1, l2 with - | h1 :: t1, h2 :: t2 -> h1 === h2 && checkq t1 t2 - | _ -> true - let inline mapq ([] f: 'T -> 'T) inp = assert not typeof<'T>.IsValueType @@ -534,9 +518,8 @@ module List = else [ h2a; h2b; h2c ] | _ -> - // Apply `f` directly (rather than forwarding it to the non-inline `List.map`), so - // that under [] no closure is allocated for `f`. Identity preserving: - // the original `inp` instance is returned when every element is physically unchanged. + // Apply `f` directly (not via the non-inline `List.map`) so [] allocates no closure for `f`. + // Identity-preserving: returns the original `inp` when every element is physically unchanged. let mutable changed = false let mutable acc = [] let mutable rest = inp diff --git a/src/Compiler/Utilities/illib.fsi b/src/Compiler/Utilities/illib.fsi index 32c030b010f..cc61f1ccd18 100644 --- a/src/Compiler/Utilities/illib.fsi +++ b/src/Compiler/Utilities/illib.fsi @@ -166,8 +166,6 @@ module internal List = val splitChoose: select: ('a -> Choice<'b, 'c>) -> l: 'a list -> 'b list * 'c list - val checkq: l1: 'a list -> l2: 'a list -> bool when 'a: not struct - val inline mapq: f: ('T -> 'T) -> inp: 'T list -> 'T list when 'T: not struct val frontAndBack: l: 'a list -> 'a list * 'a diff --git a/tests/FSharp.Compiler.ComponentTests/EmittedIL/Inlining/InlineIfLambdaClosureForms.fs b/tests/FSharp.Compiler.ComponentTests/EmittedIL/Inlining/InlineIfLambdaClosureForms.fs index fb5d114c3c7..60209ef9e04 100644 --- a/tests/FSharp.Compiler.ComponentTests/EmittedIL/Inlining/InlineIfLambdaClosureForms.fs +++ b/tests/FSharp.Compiler.ComponentTests/EmittedIL/Inlining/InlineIfLambdaClosureForms.fs @@ -5,39 +5,13 @@ namespace EmittedIL open Xunit open FSharp.Test.Compiler -/// Characterization of when a higher-order-function call site allocates a heap closure for -/// its function argument, established by reading the emitted IL (`newobj` of a closure). -/// -/// Each test compiles its own `module Test` with --optimize+ and asserts whether a closure -/// `newobj` appears. The function argument captures a runtime value (`env`) so that any -/// closure is a real per-call allocation, and the HOFs here return a bool (no list building) -/// so that the ONLY possible `newobj` in the caller is the function closure itself. -/// -/// The contract these tests lock in (verified against emitted IL, F# 11, --optimize+): -/// -/// 1. Vanilla `List.map` is NOT inline, so its mapping function must be materialised as a -/// value => a closure is allocated in EVERY syntactic form (lambda literal or partial -/// application, direct or piped). Eta-expanding a `List.map` call site buys nothing. -/// -/// 2. An `inline` + `[]` HOF whose body FORWARDS the function to another -/// non-inline callee (e.g. `List.forall2 p` / `List.map f`) STILL allocates the closure, -/// because the callee forces the function into value position. Eta-expanding the call -/// site does NOT help here either. -/// -/// 3. An `inline` + `[]` HOF whose body APPLIES the function directly (a -/// while-loop, no forwarding, no nested closure) allocates NOTHING - and this holds -/// whether the call site passes a lambda literal OR a partial application. The optimizer -/// beta-reduces the partial application into a direct call. Therefore the closure win -/// comes from the HOF body applying the function directly, NOT from the call-site form. -/// -/// Note on `<|`: a separately-observed "`prim <| (fun ..)` allocates" effect is context -/// dependent (it needs a HOF the optimizer cannot fully reduce, e.g. one touching private -/// state) and does NOT reproduce in minimal code - the optimizer recovers the saturated -/// call - so it is deliberately not asserted here. +/// Characterization (emitted IL, --optimize+) of when a higher-order-function call site +/// allocates a heap closure for its function argument (`newobj` of a closure). Each test +/// compiles its own `module Test`; the argument captures `env` so any allocated closure is a +/// real per-call allocation, and the HOFs return bool so the only possible caller `newobj` is +/// the closure itself. The test names state the contract being locked in. module InlineIfLambdaClosureForms = - // Shared definitions. `eqf env` is the partial application used by the "partial - // application" call sites; capturing `env` makes any allocated closure per-call. let private prelude = """ module Test @@ -74,11 +48,7 @@ let inline forall2Direct ([] p: string -> string -> bool) (l1: s let private assertNoClosure src = FSharp (prelude + src) |> withOptimize |> compile |> shouldSucceed |> verifyILNotPresent [ "newobj" ] - // ---- (1) Vanilla List.map: allocates the mapping closure in every form ---- - - [] - let ``Vanilla List.map + lambda literal allocates a closure`` () = - assertClosure "let test (env: int) (xs: string list) = List.map (fun (s: string) -> string (s.Length + env)) xs" + // ---- (1) Vanilla (non-inline) List.map: always allocates the mapping closure ---- [] let ``Vanilla List.map + partial application allocates a closure`` () = From 9873f2350a4c15c2cf3808be9ec5fcf347cd9298 Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Wed, 26 Aug 2026 19:03:08 +0200 Subject: [PATCH 03/10] Simplify primitives and make the characterization test readable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lengthsEqAndForall2: 2 mutables driven by the while-condition (no `go` flag, no nested match). - mapq: restore recursive `checkq` and reuse it; the general arm builds via a 2-mutable loop that applies `f` directly, then `checkq` preserves identity. - InlineIfLambdaClosureForms: snippets as formatted multiline `"""` code; add the `<|` and piped forms (both closure-free for a module-level `let inline`). Verified in isolation (fsc + ildasm): the while/checkq forms allocate 0 closures and 0 tuples, whereas a recursive inner function allocates 2 closures and a tupled match allocates a per-iteration tuple. Behaviour unchanged: 3,000,000-trial differential still 0 mismatches; the driven closures still collapse to 0 in the self-build trace; −294 MB per compile. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Compiler/Utilities/illib.fs | 48 +++--- src/Compiler/Utilities/illib.fsi | 2 + .../Inlining/InlineIfLambdaClosureForms.fs | 139 ++++++++++++------ 3 files changed, 115 insertions(+), 74 deletions(-) diff --git a/src/Compiler/Utilities/illib.fs b/src/Compiler/Utilities/illib.fs index ab8ef36b284..f22ccb660ad 100644 --- a/src/Compiler/Utilities/illib.fs +++ b/src/Compiler/Utilities/illib.fs @@ -462,20 +462,12 @@ module List = // Apply `p` directly (not via the non-inline `List.forall2`) so [] allocates no closure for `p`. let mutable r1 = l1 let mutable r2 = l2 - let mutable go = true - - while go do - match r1 with - | h1 :: t1 -> - match r2 with - | h2 :: t2 -> - if p h1 h2 then - r1 <- t1 - r2 <- t2 - else - go <- false - | [] -> go <- false - | [] -> go <- false + + while not (List.isEmpty r1) + && not (List.isEmpty r2) + && p (List.head r1) (List.head r2) do + r1 <- List.tail r1 + r2 <- List.tail r2 List.isEmpty r1 && List.isEmpty r2 @@ -495,6 +487,11 @@ module List = ch [] [] l + let rec checkq l1 l2 = + match l1, l2 with + | h1 :: t1, h2 :: t2 -> h1 === h2 && checkq t1 t2 + | _ -> true + let inline mapq ([] f: 'T -> 'T) inp = assert not typeof<'T>.IsValueType @@ -518,26 +515,17 @@ module List = else [ h2a; h2b; h2c ] | _ -> - // Apply `f` directly (not via the non-inline `List.map`) so [] allocates no closure for `f`. - // Identity-preserving: returns the original `inp` when every element is physically unchanged. - let mutable changed = false + // Build the result applying `f` directly (not via the non-inline `List.map`) so [] + // allocates no closure for `f`; `checkq` then preserves identity when nothing changed. let mutable acc = [] let mutable rest = inp - let mutable go = true - - while go do - match rest with - | h :: t -> - let h2 = f h - - if not (h === h2) then - changed <- true - acc <- h2 :: acc - rest <- t - | [] -> go <- false + while not (List.isEmpty rest) do + acc <- f (List.head rest) :: acc + rest <- List.tail rest - if changed then List.rev acc else inp + let res = List.rev acc + if checkq inp res then inp else res let frontAndBack l = let rec loop acc l = diff --git a/src/Compiler/Utilities/illib.fsi b/src/Compiler/Utilities/illib.fsi index cc61f1ccd18..32c030b010f 100644 --- a/src/Compiler/Utilities/illib.fsi +++ b/src/Compiler/Utilities/illib.fsi @@ -166,6 +166,8 @@ module internal List = val splitChoose: select: ('a -> Choice<'b, 'c>) -> l: 'a list -> 'b list * 'c list + val checkq: l1: 'a list -> l2: 'a list -> bool when 'a: not struct + val inline mapq: f: ('T -> 'T) -> inp: 'T list -> 'T list when 'T: not struct val frontAndBack: l: 'a list -> 'a list * 'a diff --git a/tests/FSharp.Compiler.ComponentTests/EmittedIL/Inlining/InlineIfLambdaClosureForms.fs b/tests/FSharp.Compiler.ComponentTests/EmittedIL/Inlining/InlineIfLambdaClosureForms.fs index 60209ef9e04..9dc982aa80a 100644 --- a/tests/FSharp.Compiler.ComponentTests/EmittedIL/Inlining/InlineIfLambdaClosureForms.fs +++ b/tests/FSharp.Compiler.ComponentTests/EmittedIL/Inlining/InlineIfLambdaClosureForms.fs @@ -5,73 +5,124 @@ namespace EmittedIL open Xunit open FSharp.Test.Compiler -/// Characterization (emitted IL, --optimize+) of when a higher-order-function call site -/// allocates a heap closure for its function argument (`newobj` of a closure). Each test -/// compiles its own `module Test`; the argument captures `env` so any allocated closure is a -/// real per-call allocation, and the HOFs return bool so the only possible caller `newobj` is -/// the closure itself. The test names state the contract being locked in. +/// Characterization (emitted IL, --optimize+) of when a higher-order-function call site allocates a +/// heap closure for its function argument (a `newobj` of a closure). Each test compiles the shared +/// `prelude` plus one `test` function; the argument captures `env`, so any closure it needs is a real +/// per-call allocation, and the probe HOFs return `bool` (no list building) so the only `newobj` a +/// caller could show is the function closure itself. module InlineIfLambdaClosureForms = - let private prelude = """ + let private prelude = + """ module Test let eqf (env: int) (a: string) (b: string) = a.Length = b.Length + env -// (2) inline HOF that FORWARDS the function to a non-inline callee (mirrors the current -// List.lengthsEqAndForall2 body `List.length l1 = List.length l2 && List.forall2 p l1 l2`). -let inline forall2Forward ([] p: string -> string -> bool) (l1: string list) (l2: string list) = +// Forwards the function to a non-inline callee (the OLD List.lengthsEqAndForall2 shape). +let inline forall2Forward ([] p: string -> string -> bool) l1 l2 = List.length l1 = List.length l2 && List.forall2 p l1 l2 -// (3) inline HOF that APPLIES the function directly in a while-loop (mirrors the proposed fix). -// Nested matches (not `match r1, r2 with`) avoid a per-iteration tuple allocation. -let inline forall2Direct ([] p: string -> string -> bool) (l1: string list) (l2: string list) = +// Applies the function directly in a loop (the NEW shape). +let inline forall2Direct ([] p: string -> string -> bool) l1 l2 = let mutable r1 = l1 let mutable r2 = l2 - let mutable ok = true - let mutable go = true - while go do - match r1 with - | h1 :: t1 -> - match r2 with - | h2 :: t2 -> if p h1 h2 then r1 <- t1; r2 <- t2 else (ok <- false; go <- false) - | [] -> ok <- false; go <- false - | [] -> - match r2 with - | [] -> go <- false - | _ -> ok <- false; go <- false - ok + while not (List.isEmpty r1) && not (List.isEmpty r2) && p (List.head r1) (List.head r2) do + r1 <- List.tail r1 + r2 <- List.tail r2 + List.isEmpty r1 && List.isEmpty r2 + +// Single-argument inline HOF, used to probe `<|`. +let inline applyDirect ([] f: unit -> int) = f () """ - let private assertClosure src = - FSharp (prelude + src) |> withOptimize |> compile |> shouldSucceed |> verifyILPresent [ "newobj" ] + let private allocatesClosure body = + FSharp(prelude + body) |> withOptimize |> compile |> shouldSucceed |> verifyILPresent [ "newobj" ] - let private assertNoClosure src = - FSharp (prelude + src) |> withOptimize |> compile |> shouldSucceed |> verifyILNotPresent [ "newobj" ] + let private allocatesNoClosure body = + FSharp(prelude + body) |> withOptimize |> compile |> shouldSucceed |> verifyILNotPresent [ "newobj" ] - // ---- (1) Vanilla (non-inline) List.map: always allocates the mapping closure ---- + // Vanilla List.map is not inline, so the mapping function is always materialised as a value: + // a closure is allocated whatever the syntactic form. [] - let ``Vanilla List.map + partial application allocates a closure`` () = - assertClosure "let f (env: int) (s: string) = string (s.Length + env)\nlet test (env: int) (xs: string list) = List.map (f env) xs" + let ``vanilla List.map, lambda literal -> closure`` () = + allocatesClosure + """ +let test (env: int) (xs: string list) = + List.map (fun (s: string) -> string (s.Length + env)) xs +""" + + [] + let ``vanilla List.map, partial application -> closure`` () = + allocatesClosure + """ +let g (env: int) (s: string) = string (s.Length + env) +let test (env: int) (xs: string list) = + List.map (g env) xs +""" - // ---- (2) Forwarding inline HOF: still allocates; eta-expansion does not help ---- + // An inline + InlineIfLambda HOF that forwards the function to a non-inline callee still allocates, + // and eta-expanding the call site does not change that. [] - let ``Forwarding inline HOF + partial application allocates a closure`` () = - assertClosure "let test (env: int) (a: string list) (b: string list) = forall2Forward (eqf env) a b" + let ``forwarding inline HOF, partial application -> closure`` () = + allocatesClosure + """ +let test (env: int) (a: string list) (b: string list) = + forall2Forward (eqf env) a b +""" [] - let ``Forwarding inline HOF + eta-expanded lambda still allocates a closure`` () = - // Eta-expanding the call site does not help: `List.forall2` forces `p` into value position. - assertClosure "let test (env: int) (a: string list) (b: string list) = forall2Forward (fun x y -> eqf env x y) a b" + let ``forwarding inline HOF, eta-expanded lambda -> closure`` () = + allocatesClosure + """ +let test (env: int) (a: string list) (b: string list) = + forall2Forward (fun x y -> eqf env x y) a b +""" - // ---- (3) Direct-apply inline HOF: allocates nothing, regardless of call-site form ---- + // An inline + InlineIfLambda HOF that applies the function directly allocates nothing - for a lambda + // literal, a partial application, or a piped call alike. The optimizer beta-reduces the partial + // application into a direct call, so no call-site eta-expansion is needed. [] - let ``Direct-apply inline HOF + partial application allocates no closure`` () = - // The partial application `(eqf env)` is beta-reduced into a direct call; no closure. - assertNoClosure "let test (env: int) (a: string list) (b: string list) = forall2Direct (eqf env) a b" + let ``direct-apply inline HOF, lambda literal -> no closure`` () = + allocatesNoClosure + """ +let test (env: int) (a: string list) (b: string list) = + forall2Direct (fun x y -> eqf env x y) a b +""" [] - let ``Direct-apply inline HOF + eta-expanded lambda allocates no closure`` () = - assertNoClosure "let test (env: int) (a: string list) (b: string list) = forall2Direct (fun x y -> eqf env x y) a b" + let ``direct-apply inline HOF, partial application -> no closure`` () = + allocatesNoClosure + """ +let test (env: int) (a: string list) (b: string list) = + forall2Direct (eqf env) a b +""" + + [] + let ``direct-apply inline HOF, piped -> no closure`` () = + allocatesNoClosure + """ +let test (env: int) (a: string list) (b: string list) = + (a, b) ||> forall2Direct (eqf env) +""" + + // `<|` does not defeat InlineIfLambda for a module-level `let inline`: the optimizer recovers the + // saturated call, so both the direct and back-piped forms are closure-free. + + [] + let ``direct-apply inline HOF, direct call -> no closure`` () = + allocatesNoClosure + """ +let test (env: int) = + applyDirect (fun () -> eqf env "a" "b" |> System.Convert.ToInt32) +""" + + [] + let ``direct-apply inline HOF, back-piped with <| -> no closure`` () = + allocatesNoClosure + """ +let test (env: int) = + applyDirect <| (fun () -> eqf env "a" "b" |> System.Convert.ToInt32) +""" From da95c6a33bd336484cfaf6de0e8a414939c13830 Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Wed, 26 Aug 2026 19:52:33 +0200 Subject: [PATCH 04/10] Extract inline list combinators into a ListInline module Add `module ListInline` (in illib) with `inline` + `[]` counterparts of the `FSharp.Core` list combinators that take a function (`map`, `forall2`). Because the built-ins are not inline, they force the function argument into a heap `FSharpFunc`; the ListInline versions apply it directly, and `[]` chains through the enclosing inline function, so the closure is beta-reduced away at the call site. `mapq` and `lengthsEqAndForall2` then return to their original functional bodies with only the combinator swapped (`List.map` -> `ListInline.map`, `List.forall2` -> `ListInline.forall2`) plus the `inline` annotation - no hand-rolled mutable loops in either. Verified in isolation (fsc + ildasm): InlineIfLambda chains through the nested inline call, so a partial-application call site allocates 0 closures. Behaviour unchanged: 3,000,000-trial differential 0 mismatches; the driven closures still collapse to 0 in the self-build trace. InlineIfLambdaClosureForms gains a test for the chaining case. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Compiler/Utilities/illib.fs | 68 +++++++++++++------ src/Compiler/Utilities/illib.fsi | 6 ++ .../Inlining/InlineIfLambdaClosureForms.fs | 13 ++++ 3 files changed, 66 insertions(+), 21 deletions(-) diff --git a/src/Compiler/Utilities/illib.fs b/src/Compiler/Utilities/illib.fs index f22ccb660ad..2fd5f69e940 100644 --- a/src/Compiler/Utilities/illib.fs +++ b/src/Compiler/Utilities/illib.fs @@ -436,6 +436,51 @@ module Option = module internal ValueTuple = let inline map1Of2 ([] f) struct (a1, a2) = struct (f a1, a2) +/// Inline counterparts to the `FSharp.Core` list combinators that take a function argument. +/// The built-ins are not `inline`, so they force that argument into a heap `FSharpFunc`; marking +/// these `inline` + `[]` and applying the function directly lets the optimizer +/// beta-reduce it at the call site - even through an enclosing inline function - so no closure is +/// allocated. Use in place of `List.map` / `List.forall2` on hot paths where the argument is a +/// lambda or partial application. +module ListInline = + + /// As `List.map`. + let inline map ([] mapping: 'T -> 'U) (list: 'T list) = + let mutable acc = [] + let mutable rest = list + + while not (List.isEmpty rest) do + acc <- mapping (List.head rest) :: acc + rest <- List.tail rest + + List.rev acc + + /// As `List.forall2` (raising `ArgumentException` when the lists have different lengths). + let inline forall2 ([] predicate: 'T1 -> 'T2 -> bool) (list1: 'T1 list) (list2: 'T2 list) = + let mutable r1 = list1 + let mutable r2 = list2 + let mutable result = true + let mutable go = true + + while go do + match r1 with + | h1 :: t1 -> + match r2 with + | h2 :: t2 -> + if predicate h1 h2 then + r1 <- t1 + r2 <- t2 + else + result <- false + go <- false + | [] -> invalidArg "list2" "The lists had different lengths." + | [] -> + match r2 with + | [] -> go <- false + | _ -> invalidArg "list2" "The lists had different lengths." + + result + module List = let sortWithOrder (c: IComparer<'T>) elements = @@ -459,17 +504,7 @@ module List = loop 0 xs let inline lengthsEqAndForall2 ([] p) l1 l2 = - // Apply `p` directly (not via the non-inline `List.forall2`) so [] allocates no closure for `p`. - let mutable r1 = l1 - let mutable r2 = l2 - - while not (List.isEmpty r1) - && not (List.isEmpty r2) - && p (List.head r1) (List.head r2) do - r1 <- List.tail r1 - r2 <- List.tail r2 - - List.isEmpty r1 && List.isEmpty r2 + List.length l1 = List.length l2 && ListInline.forall2 p l1 l2 let rec findi n f l = match l with @@ -515,16 +550,7 @@ module List = else [ h2a; h2b; h2c ] | _ -> - // Build the result applying `f` directly (not via the non-inline `List.map`) so [] - // allocates no closure for `f`; `checkq` then preserves identity when nothing changed. - let mutable acc = [] - let mutable rest = inp - - while not (List.isEmpty rest) do - acc <- f (List.head rest) :: acc - rest <- List.tail rest - - let res = List.rev acc + let res = ListInline.map f inp if checkq inp res then inp else res let frontAndBack l = diff --git a/src/Compiler/Utilities/illib.fsi b/src/Compiler/Utilities/illib.fsi index 32c030b010f..499c1c6844c 100644 --- a/src/Compiler/Utilities/illib.fsi +++ b/src/Compiler/Utilities/illib.fsi @@ -152,6 +152,12 @@ module internal Option = val attempt: f: (unit -> 'T) -> 'T option +module internal ListInline = + + val inline map: mapping: ('T -> 'U) -> list: 'T list -> 'U list + + val inline forall2: predicate: ('T1 -> 'T2 -> bool) -> list1: 'T1 list -> list2: 'T2 list -> bool + module internal List = val sortWithOrder: c: IComparer<'T> -> elements: 'T list -> 'T list diff --git a/tests/FSharp.Compiler.ComponentTests/EmittedIL/Inlining/InlineIfLambdaClosureForms.fs b/tests/FSharp.Compiler.ComponentTests/EmittedIL/Inlining/InlineIfLambdaClosureForms.fs index 9dc982aa80a..57e1ee7e614 100644 --- a/tests/FSharp.Compiler.ComponentTests/EmittedIL/Inlining/InlineIfLambdaClosureForms.fs +++ b/tests/FSharp.Compiler.ComponentTests/EmittedIL/Inlining/InlineIfLambdaClosureForms.fs @@ -126,3 +126,16 @@ let test (env: int) = let test (env: int) = applyDirect <| (fun () -> eqf env "a" "b" |> System.Convert.ToInt32) """ + + // InlineIfLambda chains: an inline HOF that delegates to another inline + InlineIfLambda + // combinator is still closure-free. This is what lets List.mapq / lengthsEqAndForall2 keep + // their elegant bodies and call the ListInline combinators without allocating. + + [] + let ``inline HOF delegating to another inline combinator -> no closure`` () = + allocatesNoClosure + """ +let inline forall2Chained ([] p: string -> string -> bool) l1 l2 = forall2Direct p l1 l2 +let test (env: int) (a: string list) (b: string list) = + forall2Chained (eqf env) a b +""" From b99d69e4f756cb1453439c795d350d6880647578 Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Wed, 26 Aug 2026 20:03:45 +0200 Subject: [PATCH 05/10] Also carry [] on the ListInline / mapq / lengthsEqAndForall2 signatures Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Compiler/Utilities/illib.fsi | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Compiler/Utilities/illib.fsi b/src/Compiler/Utilities/illib.fsi index 499c1c6844c..ad2605f6a17 100644 --- a/src/Compiler/Utilities/illib.fsi +++ b/src/Compiler/Utilities/illib.fsi @@ -154,9 +154,9 @@ module internal Option = module internal ListInline = - val inline map: mapping: ('T -> 'U) -> list: 'T list -> 'U list + val inline map: [] mapping: ('T -> 'U) -> list: 'T list -> 'U list - val inline forall2: predicate: ('T1 -> 'T2 -> bool) -> list1: 'T1 list -> list2: 'T2 list -> bool + val inline forall2: [] predicate: ('T1 -> 'T2 -> bool) -> list1: 'T1 list -> list2: 'T2 list -> bool module internal List = @@ -166,7 +166,7 @@ module internal List = val existsi: f: (int -> 'a -> bool) -> xs: 'a list -> bool - val inline lengthsEqAndForall2: p: ('a -> 'b -> bool) -> l1: 'a list -> l2: 'b list -> bool + val inline lengthsEqAndForall2: [] p: ('a -> 'b -> bool) -> l1: 'a list -> l2: 'b list -> bool val findi: n: int -> f: ('a -> bool) -> l: 'a list -> ('a * int) option @@ -174,7 +174,7 @@ module internal List = val checkq: l1: 'a list -> l2: 'a list -> bool when 'a: not struct - val inline mapq: f: ('T -> 'T) -> inp: 'T list -> 'T list when 'T: not struct + val inline mapq: [] f: ('T -> 'T) -> inp: 'T list -> 'T list when 'T: not struct val frontAndBack: l: 'a list -> 'a list * 'a From d6d2c8747923bcdf4bac0ac2c657bc157e3bfc4c Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Wed, 26 Aug 2026 20:51:47 +0200 Subject: [PATCH 06/10] Characterize that an instance member inline preserves InlineIfLambda through <| Verified (fsc + ildasm) that the member/back-pipe shape is not itself a closure hazard - members inline exactly like functions; the closure sometimes blamed on it comes from the lambda escaping into a non-inline callee, which the forwarding cell already covers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Inlining/InlineIfLambdaClosureForms.fs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/FSharp.Compiler.ComponentTests/EmittedIL/Inlining/InlineIfLambdaClosureForms.fs b/tests/FSharp.Compiler.ComponentTests/EmittedIL/Inlining/InlineIfLambdaClosureForms.fs index 57e1ee7e614..be7637f962e 100644 --- a/tests/FSharp.Compiler.ComponentTests/EmittedIL/Inlining/InlineIfLambdaClosureForms.fs +++ b/tests/FSharp.Compiler.ComponentTests/EmittedIL/Inlining/InlineIfLambdaClosureForms.fs @@ -138,4 +138,18 @@ let test (env: int) = let inline forall2Chained ([] p: string -> string -> bool) l1 l2 = forall2Direct p l1 l2 let test (env: int) (a: string list) (b: string list) = forall2Chained (eqf env) a b +""" + + // For this direct-apply shape, an instance `member inline` preserves InlineIfLambda through `<|`; + // the instance receiver does not cause closure allocation. (Forwarding to a non-inline callee is + // covered above - and that is the real cause of the closure a member/`<|` call site is sometimes + // blamed for: the lambda escaping into the callee, not the member or the pipe.) + + [] + let ``direct-apply inline instance member, back-piped with <| -> no closure`` () = + allocatesNoClosure + """ +type H() = + member inline _.M ([] f: unit -> int) = f () +let test (h: H) (env: int) = h.M <| (fun () -> env) """ From c4361c6b127d4717a5cb2cf94a7801fe67e01911 Mon Sep 17 00:00:00 2001 From: perf-bundle Date: Thu, 27 Aug 2026 10:05:03 +0200 Subject: [PATCH 07/10] Correct the member/<| comment: the <| penalty is real under escape Verified against the sibling StackGuard fix: with an escaping InlineIfLambda param, <| materialises the closure unconditionally every call while a method-call keeps it in the cold escape branch - a per-call placement difference a newobj-presence check cannot see. The non-escaping member+<| cell stays (still 0), with an honest caveat. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../EmittedIL/Inlining/InlineIfLambdaClosureForms.fs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/FSharp.Compiler.ComponentTests/EmittedIL/Inlining/InlineIfLambdaClosureForms.fs b/tests/FSharp.Compiler.ComponentTests/EmittedIL/Inlining/InlineIfLambdaClosureForms.fs index be7637f962e..01737e10a00 100644 --- a/tests/FSharp.Compiler.ComponentTests/EmittedIL/Inlining/InlineIfLambdaClosureForms.fs +++ b/tests/FSharp.Compiler.ComponentTests/EmittedIL/Inlining/InlineIfLambdaClosureForms.fs @@ -140,10 +140,12 @@ let test (env: int) (a: string list) (b: string list) = forall2Chained (eqf env) a b """ - // For this direct-apply shape, an instance `member inline` preserves InlineIfLambda through `<|`; - // the instance receiver does not cause closure allocation. (Forwarding to a non-inline callee is - // covered above - and that is the real cause of the closure a member/`<|` call site is sometimes - // blamed for: the lambda escaping into the callee, not the member or the pipe.) + // A direct-apply instance `member inline` whose lambda does NOT escape keeps InlineIfLambda through + // `<|` - no closure. This does not generalise: once the lambda escapes (e.g. captured by a slow-path + // closure, as in StackGuard.Guard), `<|` defeats InlineIfLambda and materialises it UNCONDITIONALLY + // every call, whereas a method-call `Guard(fun ..)` keeps InlineIfLambda firing so the closure stays + // in the cold escape branch. That is a per-call placement/byte difference a newobj-presence check + // cannot see, so it is characterised by allocation measurement, not asserted here. [] let ``direct-apply inline instance member, back-piped with <| -> no closure`` () = From e78de97c070e7c4b299d4f2ee60c2021582886cb Mon Sep 17 00:00:00 2001 From: perf-bundle Date: Thu, 27 Aug 2026 10:21:25 +0200 Subject: [PATCH 08/10] ListInline.forall2: flatten with a struct-tuple match A reference-tuple `match r1, r2` allocates a heap Tuple2 per iteration on this hot path; a struct tuple keeps the single flat match while `newobj valuetype ValueTuple` stays on the stack. Verified: 0 closures, no ValueTuple/Tuple in the gc-verbose trace, driven closures still collapse to 0, and struct-tuple forall2 == List.forall2 over 2,000,000 trials (raise-on-mismatch included). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Compiler/Utilities/illib.fs | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/src/Compiler/Utilities/illib.fs b/src/Compiler/Utilities/illib.fs index 2fd5f69e940..91a11eb0e68 100644 --- a/src/Compiler/Utilities/illib.fs +++ b/src/Compiler/Utilities/illib.fs @@ -463,21 +463,17 @@ module ListInline = let mutable go = true while go do - match r1 with - | h1 :: t1 -> - match r2 with - | h2 :: t2 -> - if predicate h1 h2 then - r1 <- t1 - r2 <- t2 - else - result <- false - go <- false - | [] -> invalidArg "list2" "The lists had different lengths." - | [] -> - match r2 with - | [] -> go <- false - | _ -> invalidArg "list2" "The lists had different lengths." + // A struct tuple keeps the match flat without the per-iteration heap allocation a reference tuple would add. + match struct (r1, r2) with + | struct (h1 :: t1, h2 :: t2) -> + if predicate h1 h2 then + r1 <- t1 + r2 <- t2 + else + result <- false + go <- false + | struct ([], []) -> go <- false + | _ -> invalidArg (nameof list2) "The lists had different lengths." result From e1854fc41148e8bd48c10e97e3995872762aac79 Mon Sep 17 00:00:00 2001 From: perf-bundle Date: Thu, 27 Aug 2026 10:25:56 +0200 Subject: [PATCH 09/10] ListInline.forall2: drop redundant struct keyword from the case patterns The scrutinee `match struct (r1, r2)` already fixes the struct-tuple type, so the cases need not repeat it. Verified the IL is unchanged - still `newobj valuetype ValueTuple` (stack, no heap tuple). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Compiler/Utilities/illib.fs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Compiler/Utilities/illib.fs b/src/Compiler/Utilities/illib.fs index 91a11eb0e68..d8632de9c1d 100644 --- a/src/Compiler/Utilities/illib.fs +++ b/src/Compiler/Utilities/illib.fs @@ -465,14 +465,14 @@ module ListInline = while go do // A struct tuple keeps the match flat without the per-iteration heap allocation a reference tuple would add. match struct (r1, r2) with - | struct (h1 :: t1, h2 :: t2) -> + | h1 :: t1, h2 :: t2 -> if predicate h1 h2 then r1 <- t1 r2 <- t2 else result <- false go <- false - | struct ([], []) -> go <- false + | [], [] -> go <- false | _ -> invalidArg (nameof list2) "The lists had different lengths." result From 756d6e97e001fb30fed22adf4e3604641c684dc5 Mon Sep 17 00:00:00 2001 From: perf-bundle Date: Thu, 27 Aug 2026 10:37:41 +0200 Subject: [PATCH 10/10] Test: split closure-form cases into DoesNotAllocate / AllocatesClosure; add top-level vs local partial-application Adds the verified distinction: a partial application of a TOP-LEVEL function into an inline InlineIfLambda HOF is beta-reduced to a saturated call (no closure), whereas a partial application of a LOCAL function that closes over a local is materialised (closure) - even though the HOF applies it directly. Regroups all cells by outcome into two sub-modules. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Inlining/InlineIfLambdaClosureForms.fs | 181 ++++++++++-------- 1 file changed, 98 insertions(+), 83 deletions(-) diff --git a/tests/FSharp.Compiler.ComponentTests/EmittedIL/Inlining/InlineIfLambdaClosureForms.fs b/tests/FSharp.Compiler.ComponentTests/EmittedIL/Inlining/InlineIfLambdaClosureForms.fs index 01737e10a00..a652f11c720 100644 --- a/tests/FSharp.Compiler.ComponentTests/EmittedIL/Inlining/InlineIfLambdaClosureForms.fs +++ b/tests/FSharp.Compiler.ComponentTests/EmittedIL/Inlining/InlineIfLambdaClosureForms.fs @@ -9,7 +9,7 @@ open FSharp.Test.Compiler /// heap closure for its function argument (a `newobj` of a closure). Each test compiles the shared /// `prelude` plus one `test` function; the argument captures `env`, so any closure it needs is a real /// per-call allocation, and the probe HOFs return `bool` (no list building) so the only `newobj` a -/// caller could show is the function closure itself. +/// caller could show is the function closure itself. The two sub-modules split the cases by outcome. module InlineIfLambdaClosureForms = let private prelude = @@ -41,117 +41,132 @@ let inline applyDirect ([] f: unit -> int) = f () let private allocatesNoClosure body = FSharp(prelude + body) |> withOptimize |> compile |> shouldSucceed |> verifyILNotPresent [ "newobj" ] - // Vanilla List.map is not inline, so the mapping function is always materialised as a value: - // a closure is allocated whatever the syntactic form. + module DoesNotAllocate = - [] - let ``vanilla List.map, lambda literal -> closure`` () = - allocatesClosure - """ -let test (env: int) (xs: string list) = - List.map (fun (s: string) -> string (s.Length + env)) xs -""" + // An inline + InlineIfLambda HOF that applies the function directly allocates nothing - for a + // lambda literal, a forward pipe, or a partial application of a top-level function alike; the + // optimizer beta-reduces it into a direct call, so no call-site eta-expansion is needed. - [] - let ``vanilla List.map, partial application -> closure`` () = - allocatesClosure - """ -let g (env: int) (s: string) = string (s.Length + env) -let test (env: int) (xs: string list) = - List.map (g env) xs -""" - - // An inline + InlineIfLambda HOF that forwards the function to a non-inline callee still allocates, - // and eta-expanding the call site does not change that. - - [] - let ``forwarding inline HOF, partial application -> closure`` () = - allocatesClosure - """ -let test (env: int) (a: string list) (b: string list) = - forall2Forward (eqf env) a b -""" - - [] - let ``forwarding inline HOF, eta-expanded lambda -> closure`` () = - allocatesClosure - """ -let test (env: int) (a: string list) (b: string list) = - forall2Forward (fun x y -> eqf env x y) a b -""" - - // An inline + InlineIfLambda HOF that applies the function directly allocates nothing - for a lambda - // literal, a partial application, or a piped call alike. The optimizer beta-reduces the partial - // application into a direct call, so no call-site eta-expansion is needed. - - [] - let ``direct-apply inline HOF, lambda literal -> no closure`` () = - allocatesNoClosure - """ + [] + let ``direct-apply inline HOF, lambda literal`` () = + allocatesNoClosure + """ let test (env: int) (a: string list) (b: string list) = forall2Direct (fun x y -> eqf env x y) a b """ - [] - let ``direct-apply inline HOF, partial application -> no closure`` () = - allocatesNoClosure - """ + // Partial application of a TOP-LEVEL function: the optimizer knows its arity and forms the + // saturated call, so no closure. (Contrast with the local-function case in AllocatesClosure.) + [] + let ``direct-apply inline HOF, partial application of a top-level function`` () = + allocatesNoClosure + """ let test (env: int) (a: string list) (b: string list) = forall2Direct (eqf env) a b """ - [] - let ``direct-apply inline HOF, piped -> no closure`` () = - allocatesNoClosure - """ + [] + let ``direct-apply inline HOF, forward-piped`` () = + allocatesNoClosure + """ let test (env: int) (a: string list) (b: string list) = (a, b) ||> forall2Direct (eqf env) """ - // `<|` does not defeat InlineIfLambda for a module-level `let inline`: the optimizer recovers the - // saturated call, so both the direct and back-piped forms are closure-free. + // `<|` does not defeat InlineIfLambda for a module-level `let inline` whose param does not escape: + // the optimizer recovers the saturated call, so both the direct and back-piped forms are clean. - [] - let ``direct-apply inline HOF, direct call -> no closure`` () = - allocatesNoClosure - """ + [] + let ``direct-apply inline HOF, direct call`` () = + allocatesNoClosure + """ let test (env: int) = applyDirect (fun () -> eqf env "a" "b" |> System.Convert.ToInt32) """ - [] - let ``direct-apply inline HOF, back-piped with <| -> no closure`` () = - allocatesNoClosure - """ + [] + let ``direct-apply inline HOF, back-piped with <|`` () = + allocatesNoClosure + """ let test (env: int) = applyDirect <| (fun () -> eqf env "a" "b" |> System.Convert.ToInt32) """ - // InlineIfLambda chains: an inline HOF that delegates to another inline + InlineIfLambda - // combinator is still closure-free. This is what lets List.mapq / lengthsEqAndForall2 keep - // their elegant bodies and call the ListInline combinators without allocating. - - [] - let ``inline HOF delegating to another inline combinator -> no closure`` () = - allocatesNoClosure - """ + // InlineIfLambda chains: an inline HOF that delegates to another inline + InlineIfLambda + // combinator is still closure-free. This is what lets List.mapq / lengthsEqAndForall2 keep their + // elegant bodies and call the ListInline combinators without allocating. + [] + let ``inline HOF delegating to another inline combinator`` () = + allocatesNoClosure + """ let inline forall2Chained ([] p: string -> string -> bool) l1 l2 = forall2Direct p l1 l2 let test (env: int) (a: string list) (b: string list) = forall2Chained (eqf env) a b """ - // A direct-apply instance `member inline` whose lambda does NOT escape keeps InlineIfLambda through - // `<|` - no closure. This does not generalise: once the lambda escapes (e.g. captured by a slow-path - // closure, as in StackGuard.Guard), `<|` defeats InlineIfLambda and materialises it UNCONDITIONALLY - // every call, whereas a method-call `Guard(fun ..)` keeps InlineIfLambda firing so the closure stays - // in the cold escape branch. That is a per-call placement/byte difference a newobj-presence check - // cannot see, so it is characterised by allocation measurement, not asserted here. - - [] - let ``direct-apply inline instance member, back-piped with <| -> no closure`` () = - allocatesNoClosure - """ + // A direct-apply instance `member inline` whose lambda does NOT escape keeps InlineIfLambda through + // `<|`. This does not generalise: once the lambda escapes (e.g. captured by a slow-path closure, as + // in StackGuard.Guard), `<|` defeats InlineIfLambda and materialises it UNCONDITIONALLY every call, + // whereas a method-call `Guard(fun ..)` keeps InlineIfLambda firing so the closure stays in the cold + // escape branch. That is a per-call placement/byte difference a newobj-presence check cannot see. + [] + let ``direct-apply inline instance member, back-piped with <|`` () = + allocatesNoClosure + """ type H() = member inline _.M ([] f: unit -> int) = f () let test (h: H) (env: int) = h.M <| (fun () -> env) +""" + + module AllocatesClosure = + + // Vanilla List.map is not inline, so the mapping function is always materialised as a value - + // a closure is allocated whatever the syntactic form. + + [] + let ``vanilla List.map, lambda literal`` () = + allocatesClosure + """ +let test (env: int) (xs: string list) = + List.map (fun (s: string) -> string (s.Length + env)) xs +""" + + [] + let ``vanilla List.map, partial application`` () = + allocatesClosure + """ +let g (env: int) (s: string) = string (s.Length + env) +let test (env: int) (xs: string list) = + List.map (g env) xs +""" + + // An inline + InlineIfLambda HOF that FORWARDS the function to a non-inline callee still allocates, + // and eta-expanding the call site does not change that. + + [] + let ``forwarding inline HOF, partial application`` () = + allocatesClosure + """ +let test (env: int) (a: string list) (b: string list) = + forall2Forward (eqf env) a b +""" + + [] + let ``forwarding inline HOF, eta-expanded lambda`` () = + allocatesClosure + """ +let test (env: int) (a: string list) (b: string list) = + forall2Forward (fun x y -> eqf env x y) a b +""" + + // Partial application of a LOCAL function that closes over a local: unlike a top-level function + // (see DoesNotAllocate), the local is itself a closure value the optimizer cannot reduce, so it is + // materialised even though the HOF applies it directly. + [] + let ``direct-apply inline HOF, partial application of a local closure`` () = + allocatesClosure + """ +let test (env: int) (a: string list) (b: string list) = + let local (cap: int) (x: string) (y: string) = x.Length = y.Length + cap + env + forall2Direct (local 5) a b """