Summary
A small nonrecursive local function written with fun can allocate a fresh
closure on every execution even though its only call is eventually inlined. The
semantically equivalent val f = fn ... spelling is inlined earlier and does
not allocate.
In the example below, the late BVL inliner removes the call to bump but leaves
a dead 24-byte closure allocation inside the loop. Over 100 million iterations,
that is 2.4 GB (about 2.24 GiB) of avoidable allocation. The named form was
1.76× slower in paired x64-64 measurements.
Reproducer
Named local function:
fun loop n acc =
if n = 0 then acc
else
let fun bump x = x + 1
in loop (n - 1) (acc + bump n) end;
fun main () =
let val n = Option.valOf (Int.fromString (List.hd (CommandLine.arguments ())))
in print_int (loop n 0); print "\n" end;
main ();
The control changes only the local binding:
- let fun bump x = x + 1
+ let val bump = fn x => x + 1
in loop (n - 1) (acc + bump n) end;
From an x64-64 build directory containing cake and basis_ffi.c, save the
variants as /tmp/named.cml and /tmp/anonymous.cml, then run:
./cake < /tmp/named.cml > /tmp/named.S
./cake < /tmp/anonymous.cml > /tmp/anonymous.S
cc -O2 /tmp/named.S basis_ffi.c -lm -o /tmp/named
cc -O2 /tmp/anonymous.S basis_ffi.c -lm -o /tmp/anonymous
env CML_HEAP_SIZE=32 CML_STACK_SIZE=16 taskset -c 2 \
/tmp/named 100000000 > /tmp/named.out
env CML_HEAP_SIZE=32 CML_STACK_SIZE=16 taskset -c 2 \
/tmp/anonymous 100000000 > /tmp/anonymous.out
cmp /tmp/named.out /tmp/anonymous.out
/usr/bin/time -f 'named: %e s' \
env CML_HEAP_SIZE=32 CML_STACK_SIZE=16 taskset -c 2 \
/tmp/named 100000000 > /dev/null
/usr/bin/time -f 'anonymous: %e s' \
env CML_HEAP_SIZE=32 CML_STACK_SIZE=16 taskset -c 2 \
/tmp/anonymous 100000000 > /dev/null
Both variants print 5000000150000000.
Measurements
| Form |
Median |
Interquartile range |
Relative |
local fun bump |
0.640 s |
0.620–0.670 s |
1.76× |
local val bump = fn |
0.365 s |
0.350–0.385 s |
1.00× |
Generated-code evidence
After clos_known, the named form still contains a Letrec and an annotated
application of bump. The anonymous form has already substituted the body at
the call site.
After bvl_inline, the named loop has this shape:
(let
(c <- (Cons closure_tag 0 (Label loop_bump_clos)))
(call loop
(Add (let (d <- n) (Add 1 d)) acc)
(Sub 1 n)))
The Add 1 d is the inlined body of bump, while c is never read. The closure
allocation is therefore dead, but it survives through final code generation.
The x64 loop checks for 24 bytes of heap and stores the closure header, code
pointer, and empty environment on every iteration. The anonymous loop has no
corresponding heap check or allocation.
The named executable also retains separate loop_bump worker and closure-wrapper
symbols; the anonymous executable does not retain a bump symbol.
Cause
clos_known treats anonymous Fn and Letrec differently:
- an anonymous
Fn can receive a body-carrying Clos approximation
(clos_knownScript.sml);
- every
Letrec, including this single function with no recursive occurrence,
receives ClosNoInline
(clos_knownScript.sml).
decide_inline can body-inline only Clos; ClosNoInline merely enables a
direct-call annotation
(clos_knownScript.sml).
Closure conversion consequently materializes the local closure before the BVL
pass gets an opportunity to inline its call; the single-function Letrec
lowering constructs a closure block
(clos_to_bvlScript.sml).
The later inliner substitutes the worker body but does not perform the
higher-level use/escape cleanup needed to remove the already-materialized
closure, wrapper, and worker.
This is a source-spelling performance cliff, not a correctness problem.
Expected behavior
For a small local function that is provably nonrecursive and nonescaping,
equivalent fun f x = e and val f = fn x => e forms should produce equivalent
optimized code. If all uses have been inlined or converted to direct worker
calls, no closure should be allocated.
Possible implementation direction
- Analyze dependencies within
Letrec groups and identify singleton or
otherwise nonrecursive SCCs.
- Give small nonrecursive members body-carrying approximations, subject to the
existing inlining and growth limits, instead of assigning every member
ClosNoInline.
- Split recursive groups into dependency SCCs where possible; retain the
conservative treatment for genuinely recursive members.
- Add post-inlining closure use/escape cleanup so a closure allocation and its
wrapper/worker can be removed when no value use remains.
The first two steps should fix this minimal case by allowing the inlining to
happen before closure conversion.
Regression coverage
- Compare equivalent nonrecursive local
fun and val ... = fn ... forms.
- Check optimized IR or emitted code to ensure the hot loop contains no closure
construction or heap-space check.
- Check that an unused wrapper and worker are removed in sealed compilation.
- Cover self recursion and mutually recursive
and groups to ensure SCC and
growth handling remain bounded.
- Include a nonescaping direct-call case too large to inline, which should use a
worker call without allocating a closure if all uses are known.
Related issues
Written by Codex (OpenAI).
Summary
A small nonrecursive local function written with
funcan allocate a freshclosure on every execution even though its only call is eventually inlined. The
semantically equivalent
val f = fn ...spelling is inlined earlier and doesnot allocate.
In the example below, the late BVL inliner removes the call to
bumpbut leavesa dead 24-byte closure allocation inside the loop. Over 100 million iterations,
that is 2.4 GB (about 2.24 GiB) of avoidable allocation. The named form was
1.76× slower in paired x64-64 measurements.
Reproducer
Named local function:
The control changes only the local binding:
From an x64-64 build directory containing
cakeandbasis_ffi.c, save thevariants as
/tmp/named.cmland/tmp/anonymous.cml, then run:Both variants print
5000000150000000.Measurements
fun bumpval bump = fnGenerated-code evidence
After
clos_known, the named form still contains aLetrecand an annotatedapplication of
bump. The anonymous form has already substituted the body atthe call site.
After
bvl_inline, the named loop has this shape:The
Add 1 dis the inlined body ofbump, whilecis never read. The closureallocation is therefore dead, but it survives through final code generation.
The x64 loop checks for 24 bytes of heap and stores the closure header, code
pointer, and empty environment on every iteration. The anonymous loop has no
corresponding heap check or allocation.
The named executable also retains separate
loop_bumpworker and closure-wrappersymbols; the anonymous executable does not retain a
bumpsymbol.Cause
clos_knowntreats anonymousFnandLetrecdifferently:Fncan receive a body-carryingClosapproximation(
clos_knownScript.sml);Letrec, including this single function with no recursive occurrence,receives
ClosNoInline(
clos_knownScript.sml).decide_inlinecan body-inline onlyClos;ClosNoInlinemerely enables adirect-call annotation
(
clos_knownScript.sml).Closure conversion consequently materializes the local closure before the BVL
pass gets an opportunity to inline its call; the single-function
Letreclowering constructs a closure block
(
clos_to_bvlScript.sml).The later inliner substitutes the worker body but does not perform the
higher-level use/escape cleanup needed to remove the already-materialized
closure, wrapper, and worker.
This is a source-spelling performance cliff, not a correctness problem.
Expected behavior
For a small local function that is provably nonrecursive and nonescaping,
equivalent
fun f x = eandval f = fn x => eforms should produce equivalentoptimized code. If all uses have been inlined or converted to direct worker
calls, no closure should be allocated.
Possible implementation direction
Letrecgroups and identify singleton orotherwise nonrecursive SCCs.
existing inlining and growth limits, instead of assigning every member
ClosNoInline.conservative treatment for genuinely recursive members.
wrapper/worker can be removed when no value use remains.
The first two steps should fix this minimal case by allowing the inlining to
happen before closure conversion.
Regression coverage
funandval ... = fn ...forms.construction or heap-space check.
andgroups to ensure SCC andgrowth handling remain bounded.
worker call without allocating a closure if all uses are known.
Related issues
Letrecbindings plus post-inline cleanup.Written by Codex (OpenAI).