Skip to content

[Request For Feedback] Inline List/Array higher-order functions so lambda arguments allocate no closure - #20422

Draft
T-Gro wants to merge 1 commit into
mainfrom
t-gro-fsharpcore-inlineiflambda-spike
Draft

[Request For Feedback] Inline List/Array higher-order functions so lambda arguments allocate no closure#20422
T-Gro wants to merge 1 commit into
mainfrom
t-gro-fsharpcore-inlineiflambda-spike

Conversation

@T-Gro

@T-Gro T-Gro commented Sep 2, 2026

Copy link
Copy Markdown
Member

This makes 32 allocation-free List/Array higher-order functions inline + [<InlineIfLambda>]. For the common case — a lambda passed directly — it is a clear win: the per-call FSharpFunc closure disappears (24 B → 0), small-collection calls get faster, large ones are at parity.

The catch, and the reason this is a Request For Feedback: when the callback is not inlinable — an opaque function value such as a curried/partially-applied function held in a field, returned from a non-inlined function, or a delegate — the closure can't be removed, and dropping OptimizedClosures.Adapt regresses that path 1.6×–3×. The non-inline library methods use Adapt to hoist the per-element arity type-tests out of the loop; the inline path can't keep it, because applying Adapt to an inlined lambda would re-materialise the very closure we set out to remove. So the allocation win and Adapt are mutually exclusive.

let sumWith k xs = List.fold (fun acc x -> acc + x + k) 0 xs
// shipped: new closure@(k) on heap, then ListModule.Fold(closure, 0, xs)  -> 24 B/call
// inline : acc <- acc + x + k   spliced into fold's own loop              ->  0 B/call

Recursion is moved behind an inner let rec loop (a binding cannot be both rec and inline, FS3890); error paths go through hidden non-inline helpers so inline bodies can still raise the localized SR messages. Design: RFC FS-1115.

List (14): exists exists2 find findIndex fold fold2 forall forall2 iter2 iteri2 pick reduce skipWhile tryPick
Array (18): exists2 find findBack findIndex findIndexBack fold fold2 foldBack foldBack2 forall forall2 iter2 iteri iteri2 pick reduce reduceBack tryPick

Measured on .NET 11 / Arm64: exact allocation via GC.GetAllocatedBytesForCurrentThread, wall-clock via BenchmarkDotNet out-of-process, shipped FSharp.Core 10.0.101 vs this branch (same netstandard2.1 target).


✅ Clearly better

Allocation per call (exact probe; identical across the 25 probed functions):

argument form shipped inline
capturing lambda (fun a x -> a+x+k) 24 B 0 B
curried top-level partial-app (addBy k) 24 B 0 B

Wall-clock, small collections, capturing lambda:

N=5, ns/op shipped inline
List.fold (fun a x -> a+x+k) 4.48 1.33
Array.fold (fun a x -> a+x+k) 4.51 1.34

Inlined lambda reaches the hand-written-loop floor and stays alloc-free (N=1M):

lambda hand loop inline alloc
Array.find 252 us 252 us 24 B → 0
Array.iteri2 1.76 ms 1.26 ms 25 B → 0

⚪ Neutral

Wall-clock at large N (per-element work dominates), capturing lambda:

N=10000, ns/op shipped inline
List.fold 11384 11402
Array.fold 5027 5012
N=1M shipped inline alloc
Array.fold2 lambda 1.00 ms 1.01 ms 25 B → 0

Argument forms that were already free stay free:

form shipped inline
top-level fn by name / operator (+) 0 B 0 B

🔴 Risk of regression

Non-inlinable function values (N=1M):

opaque value shipped inline
Array.find (1-arg) 998 us 1563 us 1.57× slower
Array.iteri2 (3-arg) 1.39 ms 3.34 ms 2.40× slower
Array.fold2 (3-arg) 1.00 ms 3.04 ms 3.03× slower
// inlinable -> 0 closure, hits the hand-loop floor
Array.fold2 (fun s x y -> s + x*y + k) 0 xs ys

// opaque value the compiler can't inline -> regresses 2.4–3x
let f : int -> int -> int -> int = getFolder ()   // from a field / list / non-inlined fn / delegate
Array.fold2 f 0 xs ys

Shipped's non-inline fold2 runs OptimizedClosures.Adapt once, then a direct multi-arg invoke — as fast as a hand loop. The inline path does per-element arity dispatch instead. find (1-arg, no Adapt) still regresses 1.57× from the inlined-at-call-site loop vs the library's single loop; the benchmark shows the slowdown but does not isolate its cause.


Cost (not runtime): inline copies the loop to every recompiled call site — 10 representative sites: 308 → 707 IL bytes (~40 B/site; fold2 +91, iteri2 +116). Six public throw-helpers added to the SurfaceArea baseline (inline bodies can't reference internal SR).

Open question for feedback: keep all 32 and accept the opaque-value regression, or restrict to the shapes that don't shed Adapt? These benchmarks don't measure how common opaque-value call sites are in the wild.

… no closure

Add `inline` + `[<InlineIfLambda>]` to 32 allocation-free List/Array traversal
combinators, so a captured lambda is spliced into the loop and its FSharpFunc
closure (24 B) is never created. Recursion is moved behind inner `let rec loop`
bindings (a binding cannot be both `rec` and `inline`, FS3890). Error paths are
routed through hidden non-inline helpers so inline bodies can raise the localized
SR exception messages without exposing internal resources.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f15ee4ba-0309-440a-b0d2-c2bd7d1bffa1
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

❗ Release notes required

You can open this PR in browser to add release notes: open in github.dev


✅ Found changes and release notes in following paths:

Warning

No PR link found in some release notes, please consider adding it.

Change path Release notes path Description
`src/FSharp.Core` docs/release-notes/.FSharp.Core/11.0.100.md No current pull request URL (#20422) found, please consider adding it

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: New

Development

Successfully merging this pull request may close these issues.

1 participant