82. Inlining
Calling a function is cheap. Not being able to see inside it is expensive. Inlining pastes the function into its caller so the optimizer can see everything at once, and Viscosity gets the same view a different way.
- Inline a call by hand, renaming the callee's locals and substituting arguments for parameters all at once.
- Explain why inlining helps other optimizations more than it saves call overhead, and what it costs.
- Draw a call graph and say why recursion and unknown callees limit inlining.
- Explain how Viscosity follows a collection into “known functions” instead of inlining them.
One small function
Here is a particle update, written the way most people would write it, with the per-particle work in its own function:
const ps = [];
for (let i = 0; i < 100; i++) {
ps.push({ x: i, vx: 1 });
}
function move(p) {
p.x += p.vx;
}
for (let i = 0; i < ps.length; i++) {
move(ps[i]);
}
console.log(ps[9].x);
Ask Viscosity what it makes of this, and it refuses to change the layout (explain output, wrapped to fit):
ps (line 1) {x, vx}
→ kept as array of objects
· unsafe: line 9: an element is passed to
a function call
Now paste the body of move into the loop yourself:
for (let i = 0; i < ps.length; i++) {
const p = ps[i];
p.x += p.vx;
}
Same program, same output. This time:
ps (line 1) {x, vx} → struct of arrays
· loop at line 5 in <module>() touches
2 of 2 fields (x, vx)
· typed Float64Array columns for all 2
fields
· about 100 elements
· 1 of 2 element accesses proven in bounds
Nothing about the work changed. What changed is what the compiler could see. Inside one function, every use of the element is visible. Across a call, the element disappears into code the analysis doesn’t follow.
Pasting a function in
A recipe says “make the sauce (see page 40)”. You can flip to page 40 every time, or you can copy page 40 into the recipe. Copying saves the flipping, but the real win is that now you can read the whole dish in one place, and notice that half the sauce steps are pointless for this dish.
Doing it by hand looks easy. Doing it mechanically, for every call, has two traps, both about names. Step through it:
Inlining replaces a call with a copy of the callee’s body. The copy
binds each parameter to its argument, gives each callee local a fresh
name so it can’t collide with the caller’s variables, and turns
return v into “the value of the call is v”. (A body with several
returns becomes several blocks that jump to one join point, with a phi
for the result.)
The renaming really must happen all at once. Inline sub(b, a), where
sub(a, b) returns a - b, by replacing one parameter at a time:
first a → b gives b - b, then b → a gives a - a. Wrong. Done
simultaneously, every parameter is replaced in the original body, and
you get the correct b - a.
The price of a copy
If inlining is so useful, why not inline every call?
- Size. Every inlined call duplicates the body. Inline a 50-line function at 20 call sites and you have compiled 1,000 lines. A JIT pays for that in compile time and memory, while the program is running.
- Recursion. Inlining
factinto itself produces another call tofact, forever. A compiler can unroll a few levels at most. - Not knowing the callee. In JavaScript,
f(x)calls whateverfholds right now.xs.map(g)callsgfrom insidemap.o.m()calls whatevermthe object’s prototype chain finds. A compiler can only paste in code it is sure about.
A JIT has one advantage here: it can guess the callee from what it has seen at run time, speculate, and deoptimize (chapter 66) if the guess ever turns out wrong. An ahead-of-time tool like Viscosity has to prove it instead.
The call graph
To decide what to inline, a compiler needs a map of who calls whom:
main ──► step ──► move ──► clamp
│
└────► clamp
A call graph has one node per function and an edge f → g when f
contains a call that may reach g. A cycle in the call graph is
recursion. A call whose target can’t be pinned down gets an edge to
“unknown”, and everything it might reach must be assumed reachable.
Inliners often work bottom-up: first inline the leaves (clamp
into move), then the now-bigger move into step, so each function is
already optimized when it is copied into its callers. LLVM describes its
inline pass this way[4].
V8’s optimizing compiler inlines. Its release notes for V8 6.1 say
TurboFan now inlines Array.prototype.forEach[1],
and when V8’s team rewrote Array.prototype.sort, they pointed out
that the old version, written in JavaScript, let the optimizing compiler
inline the comparison function you passed in[2].
They have also written about the cost: the Octane benchmark rewarded
aggressive inlining, and inlining heuristics tuned to it caused
regressions in real code through higher compile time and memory
use[3].
You have seen inlining in this book’s own measurements. In chapter 23,
the get-objects case handed each SoA element to a small function that
wanted an object. SoA did not lose there, because V8 inlined the
function and then removed the temporary object altogether.
Viscosity follows calls instead
Viscosity does not inline. It is a source-to-source tool, and pasting function bodies all over your program would rewrite far more than the collection code it means to touch. Instead, it gets the view that matters by following a collection into the function it is passed to.
First it finds known functions: functions declared once, whose name
is never used for anything but a direct call. Not passed as a callback,
not called as a method, not reassigned. For those, it knows every call
site, which is a small, exact piece of the call graph. Simplified from
analysis.ts:
for (const l of loads.get(f.declaredAs)) {
for (const u of usesOf(l.id)) {
const i = u.inst;
if (i && i.op === 'call' &&
i.args[0] === l.id &&
i.note !== 'method') sites.push(i);
else ok = false;
}
}
if (ok) callSites.set(f, sites);
Then, when the collection is passed to a known function, the parameter counts as another name for the collection, but only if every call site passes that same collection in that position:
const allSame = sites.every((site) => {
const a = site.args[k + 1]; // 0 = callee
return collectionOfLoad.get(a) === c;
});
If so, the analysis keeps going inside the callee as if the code were
inline. That is why drift(bodies, 0.5) in the test corpus works: the
loop in drift shows up in the explain output as a loop over bodies.
If not, it stops. In the corpus program interproc_mixed.js, total
is called with two different arrays:
a (line 2) {x} → kept as array of objects
· unsafe: line 10: passed to total(),
which is also called with other values
Viscosity doesn’t clone total into a copy for a and a copy for b,
which is what inlining would effectively do. And it follows only whole
collections into calls, not single elements, which is why move(ps[i])
failed. Try it: inline move by hand below and watch the decision flip.
Your turn
Expressions are trees: { n: 3 } is a number, { v: 'x' } a variable, { op, l, r } a binary operation and { call, args } a call. fns maps a function name to { params, body }. Write inline(e, fns) that returns a new tree with every call to a function in fns replaced by its body, with the (inlined) arguments put in place of the parameters simultaneously. Calls to other names stay, but their arguments are inlined. Bodies may call other functions in fns; there is no recursion.
function inline(e, fns) {
// your code here
return e;
}
const fns = {
sq: {
params: ['a'],
body: { op: '*', l: { v: 'a' },
r: { v: 'a' } },
},
};
const e = { call: 'sq',
args: [{ v: 'x' }] };
const out = inline(e, fns);
console.log(JSON.stringify(out));Using the same trees, build the call graph of fns and return the names (sorted) of functions that can reach themselves through one or more calls. Those are the ones an inliner can’t expand completely. Calls to names not in fns lead nowhere.
function recursive(fns) {
// your code here
return [];
}
const fns = {
f: { params: ['a'],
body: { call: 'f',
args: [{ v: 'a' }] } },
g: { params: [], body: { n: 1 } },
};
console.log(recursive(fns)); // ['f']What you learned
- Inlining replaces a call with the callee’s body: parameters bound
to arguments simultaneously, locals renamed to fresh names,
returnturned into the call’s value. - Its main benefit is visibility: constant propagation, DCE and layout decisions can now see caller and callee together.
- Its costs are code size and compile time, and it is limited by recursion and by not knowing the callee.
- The call graph records who may call whom. JavaScript makes it hard to build, because callees are values.
- Viscosity doesn’t inline. It follows a collection into known functions whose every call site passes that same collection, and bails out when a function sees two different arrays, or when a single element is passed to a call.
Check your understanding
0/6- 1.
You inline
twice(n), whose body isconst t = n + n; return t;, at the callconst t = 1; const u = twice(t);. Which result is correct? - 2.
Why is inlining often called an “enabling” optimization?
- 3.
In a call graph, what does a cycle
f → g → ftell an inliner? - 4.
What did the V8 team report about inlining and the Octane benchmark?
- 5.
Which use of
totalwould stop Viscosity treating it as a known function? - 6.
total(xs)is called astotal(a)andtotal(b), with two different arrays. What does Viscosity do?
References
- [1] The V8 team (2017). V8 release v6.1. V8 blog (v8.dev), 3 August 2017. v8.dev/blog/v8-release-61 (checked 2026-10-31)
- [2] Simon Zünd (2018). Getting things sorted in V8. V8 blog (v8.dev), 28 September 2018. v8.dev/blog/array-sort (checked 2026-10-31)
- [3] The V8 team (2017). Retiring Octane. V8 blog (v8.dev), 12 April 2017. v8.dev/blog/retiring-octane (checked 2026-10-31)
- [4] The LLVM Project. LLVM’s Analysis and Transform Passes. LLVM documentation. llvm.org/docs/Passes.html (checked 2026-10-31)