Inlining and tail calls
The three terms, from scratch
Calling a function has a cost the arithmetic inside it doesn’t :
the CPU pushes the arguments and a return address, jumps away,
runs the body, then jumps back. For a one-line accessor like fn size(self) { return self.len; } that overhead can dwarf the
actual work.
Inlining removes the call entirely by pasting the function’s body into the caller at the call site.
x = size(p)becomesx = p.len— no jump, and the surrounding optimiser can now see through the body. The trade-off is code size: paste a big body into a thousand call sites and the binary balloons, so the compiler inlines small bodies eagerly and leaves large ones to a cost model.fn size(self) : i32 { return self.len; } x = size(p) BEFORE (a real call) AFTER (body pasted in) push p x = load p.len call size ─┐ jump away ▲ ... │ run body one load, no jump, and the ret ◄┘ jump back optimiser now sees x's provenance x = result straight through to the fieldThe pasted
load p.lenisn’t just cheaper by one jump — it lets every later pass (constant-folding, dead-code elimination, the slot classifier) reason aboutxas the field itself, which a call boundary would have hidden.A tail call is a call that is the very last thing a function does —
return f(args), with nothing left to compute afterfreturns. Its result becomes the caller’s result directly. Because the caller has no more work, its stack frame is dead the moment the tail call starts.Tail-call elimination / tail-recursion elimination (TRE) exploits that dead frame: instead of stacking a new frame for the call, reuse the current one. When a function tail-calls itself (self-recursion), this turns the recursion into a plain loop that runs at constant stack depth — the key reason a deep recursion doesn’t overflow the stack.
Three adjacent decisions the compiler makes on every function
follow from these : should the body be inlined, can direct
self-recursion be rewritten into a loop outright, and should the
LLVM tail call qualifier be stamped on the matching return f(args) ?
Inline decision
Every function is classified as one of four inline hints :
None — emit no inline attribute; the LLVM cost model decides
Hint — emit `inlinehint`; the optimiser should prefer inlining
Always — emit `alwaysinline`; the optimiser must inline
Never — emit `noinline`; the optimiser must not inline The classifier applies a four-tier rule :
inline fn, or a small body →Always. The keyword is the author’s own answer to “is this worth folding”, and the only spelling the language offers for it, so it takes the tier whatever the body measures. A body within a small statement budget takes it too, without being asked. At-O0the only inliner that runs isalways-inline, so at that level this is the decision — no cost model sits behind it to weigh code growth against the call it removes.- A body within a wider expanded budget →
Hint. The count is the body’s size after itsAlwayscallees have landed in it, which is what LLVM’s cost model will see. Aninline fntheAlwaysgates below refused lands here too. - A body split out of its caller, or
@heat(cold)→Never. A body the compiler itself carved out of a caller is not one the inliner should fold back, and a function declared cold is sized for the binary rather than for the call site.Neveremitsnoinlineand outranks every tier above. - Anything else →
None. Larger bodies get no attribute and let the LLVM cost model decide.extern "C"andasyncfunctions are alwaysNone— they have no inlinable Axle body.
Two gates refuse Always whatever the body measures : a function
that calls itself, directly or through a cycle (alwaysinline on one
is an LLVM hard error), and a body that uses the arena (folding it
merges two arena scopes and defeats the callee’s per-call reset). The
keyword cannot override either one.
The decision is stamped on every function at compile time. The code emitter reads it and attaches the matching LLVM attribute — no shape inspection at emit time.
Self-tail-recursion becomes a loop
Before any tail qualifier is stamped, the compiler rewrites a
function’s direct self-recursive tail calls into an in-place
loop — O(depth) stack frames become O(1) by construction, not by
optimiser goodwill :
fn fact(n : i64, acc : i64) : i64 {
if (n <= 1) { return acc; }
return fact(n - 1, acc * n);
} is rewritten (conceptually) into :
fn fact(n : i64, acc : i64) : i64 {
while (true) {
if (n <= 1) { return acc; }
let next_n = n - 1; // new arguments evaluated first …
let next_acc = acc * n;
n = next_n; // … then committed together
acc = next_acc;
}
} The two-phase update is the soundness crux : fact(n - 1, acc * n) reads the old n in both arguments, so the new values are
computed into temporaries before either parameter is overwritten.
This is not a conceptual approximation — it is the shape the
front-end actually stamps. axle build fact.axle --emit=hir prints
the rewritten body, temporaries and all :
149: fact {
params: [n: i64, acc: i64]
returns: i64
body:
let tre.n : i64 = param#0;
let tre.acc : i64 = param#1;
while (true) {
if ((local#0 <= 1)) { return local#1; }
let tre.tmp : i64 = (local#0 - 1); // new n, from the old n
let tre.tmp : i64 = (local#1 * local#0); // new acc, also from old n
local#0 = local#2; // … committed only now
local#1 = local#3;
continue;
}
} By the time LLVM sees the function there is no recursion left to
optimise — --emit=llvm shows a single block loop whose induction
values are plain φ nodes (named tre.n / tre.acc), a compare, and a
back-edge :
while.header:
%tre.n = phi i64 [ %n0, %entry ], [ %sub, %if.merge ]
%tre.acc = phi i64 [ %acc0, %entry ], [ %mul, %if.merge ]
%done = icmp sle i64 %tre.n, 1
br i1 %done, label %fact.exit, label %if.merge
if.merge:
%sub = sub i64 %tre.n, 1
%mul = mul i64 %tre.acc, %tre.n
br label %while.header ; no `call`, no `ret` per turn Why it matters is the stack. The recursive form stacks one frame per call ; the loop form reuses one frame forever :
recursion (O(depth) frames) loop rewrite (O(1) frame)
fact(5, 1) ┌─────────────────┐
fact(4, 5) │ n, acc │ ← one frame,
fact(3, 20) │ updated in place│ overwritten
fact(2, 60) └─────────────────┘ each turn
fact(1, 120) → 120
deep input → stack overflow deep input → still fine The rewrite fires only when it is provably equivalent : the function takes
no implicit receiver, every parameter is a by-value scalar or one the
callee does not retain, the body returns on every path, and the rewritten return fact(…) sits at the function’s top level or inside plain if branches — never inside an inner loop (the inserted back-jump
would target the wrong loop) or inside try / synchronized / defer (jumping back would skip finalisation). Self-calls that
don’t qualify stay ordinary calls, and the tail qualifier below
is all the help LLVM gets for them — that also covers mutual
recursion, which the rewrite never touches.
Tail call detection
A dedicated analysis collects every return f(args) whose
arguments are all by-value scalars (no pointer-shaped arguments
that could alias into the caller’s frame). At every such call
site the compiler stamps the LLVM tail qualifier on the
emitted call instruction. The matching return is rewritten so
the call result flows directly into the function’s return slot
without an intermediate alloca.
By-value-scalar means: i8 / i16 / i32 / i64 / u8 / u16 / u32 / u64 / f32 / f64 / bool / char. Pointers, arrays, Shared<T>, and
class types are all rejected — any of them could carry a stale
address into the freed caller frame.
LLVM treats tail as a permission, not a requirement. The
optimiser is free to reuse the caller’s frame for the tail jump,
which collapses iterative recursion into a single-frame loop.
The strict musttail variant — which requires LLVM to
unconditionally tail-call — is not stamped : it requires
matching ABIs and stack-frame discipline that the front-end
doesn’t yet verify, and an incorrect musttail is a
verification failure that aborts compilation.
Why arg safety matters
A pointer to a caller stack slot passed through a tail call
would let LLVM deallocate the slot before the jump. The callee’s
first stack push then clobbers the freed bytes. Refusing the
qualifier whenever any argument is pointer-shaped keeps the
contract sound ; the by-value-scalar predicate is the single
gate.
What you’d notice
- A recursive function whose recursive call is the last act of
every branch (
fn loop_step(state) : T { ... return loop_step(next); }) runs at constant stack depth. When every parameter is a scalar, the recursion is gone before LLVM even sees it — the emitted body is a plain loop. When the rewrite doesn’t apply, thetailqualifier still lets the disassembly show ajmpto the function entry instead of acall+retpair, and theRSPregister doesn’t grow across iterations. - A method body that’s a one-line forwarder
(
fn size(self) : i32 { return self.len; }) doesn’t show up as a call in the disassembly — LLVM inlines it everywhere. The generated IR contains the field load directly at every call site. Compiling a program whosemainends inreturn b.size();and running--emit=llvmbears this out : the module defines only@main, with no@…sizefunction and nocallto one — the accessor has been folded into its single field load at the use site. - Marking a function
inline fnfolds it into every caller whatever its size — the call disappears from the disassembly, and the body’s arithmetic is compiled against the arguments each site actually passes. That is the point of the keyword: a callee that took astrideparameter is specialised on the literal its caller holds, and a sum whose terms are constants folds away entirely. Anextern "C" fn(an FFI declaration with no Axle body) is alwaysNone— there is nothing to inline — and a function that calls itself or uses the arena stays atHint, because folding is illegal or harmful there.
See also
- Inlining, tail calls, and whole-program merge — the user-facing view of this page plus the stdlib/runtime merge, in the optimisations census.
- Escape analysis and promotion — inlining sometimes exposes a fresh-then-discarded allocation that the promoter can move to the stack.
- Loop and idiom rewrites — trivial inlining runs after the safety walkers, then later passes (dead-code elimination, slot classification) clean up the post-inline shape.
- Compiler internals overview — index of all internals pages.
- Concept index — every compiler-internal concept on one page.