Viscosity
Review
Part VI · The compiler middle-end · Day 21

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.

About 16 minutes, including the exercises and quiz
By the end you will be able to
  • 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

Intuition

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:

Step throughInlining area(s, 5)
The callee at the top, the caller being rewritten belowCALLEEfunction area(w, h) {  const s = w * h;  return s;}CALLERconst s = 2;const r = area(s, 5) + 1;console.log(r);
The call area(s, 5). Notice the caller has its own variable called s, and so does area. Two different variables, one name.
1/6
Definition

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 fact into itself produces another call to fact, forever. A compiler can unroll a few levels at most.
  • Not knowing the callee. In JavaScript, f(x) calls whatever f holds right now. xs.map(g) calls g from inside map. o.m() calls whatever m the 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
Definition

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].

Under the hood

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.

PlaygroundInline move by hand

Your turn

ExerciseInline calls in an expression tree

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));
Extra practiceWhich functions are recursive?

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, return turned 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. 1.

    You inline twice(n), whose body is const t = n + n; return t;, at the call const t = 1; const u = twice(t);. Which result is correct?

  2. 2.

    Why is inlining often called an “enabling” optimization?

  3. 3.

    In a call graph, what does a cycle f → g → f tell an inliner?

  4. 4.

    What did the V8 team report about inlining and the Octane benchmark?

  5. 5.

    Which use of total would stop Viscosity treating it as a known function?

  6. 6.

    total(xs) is called as total(a) and total(b), with two different arrays. What does Viscosity do?

References

  1. [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. [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. [3] The V8 team (2017). Retiring Octane. V8 blog (v8.dev), 12 April 2017. v8.dev/blog/retiring-octane (checked 2026-10-31)
  4. [4] The LLVM Project. LLVM’s Analysis and Transform Passes. LLVM documentation. llvm.org/docs/Passes.html (checked 2026-10-31)