open atlas
↑ Back to track
JavaScript Engine internals JSE · 04 · 06

On-Stack Replacement: swapping the frame mid-loop

A function called once but containing a long hot loop never returns to be re-entered, so normal tier-up can't speed it up. OSR swaps the executing frame in flight

JSE Senior ◷ 14 min
Level
FoundationsJuniorMiddleSenior

You write a script with one giant for loop that crunches ten million rows. The function is called exactly once — main() — and never returns until the loop is done. By the tiering rules you have learned, this function can never speed up: tier-up swaps the optimised code in on the next call, and there is no next call. Yet you measure it and the loop does accelerate, partway through, while it is still running. The only way that can happen is if V8 replaced the code under the running loop’s feet. That trick is On-Stack Replacement.

The problem normal tier-up can’t solve

Recall how tier-up installs optimised code (lesson 01): when a function’s budget trips, V8 compiles a faster version and swaps it in for the next entry to the function. That model has a blind spot. Consider a function that is called once and spends all its time in a single long loop:

function crunch(rows) {
  let acc = 0;
  for (let i = 0; i < rows.length; i++) {   // 10,000,000 iterations
    acc += score(rows[i]);
  }
  return acc;
}
crunch(tenMillionRows); // called exactly once

The loop is blazingly hot — millions of back-edges — so its back-edge counter trips the budget almost immediately (lesson 01 introduced this counter precisely for this case). V8 wants to optimise. But the normal mechanism is useless here: it would install optimised code for the next call to crunch, and crunch is mid-flight on the only call it will ever get. By the time crunch returns, the work is done. Waiting for re-entry means never optimising the one loop that matters.

OSR: replacing the frame in flight

On-Stack Replacement is the mechanism that breaks this deadlock. Instead of waiting for the function to return and be re-called, V8 replaces the executing frame in place, mid-loop. The sequence:

  1. The loop’s back-edge counter trips the tier-up budget while the loop is running in the interpreter (or baseline tier).
  2. V8 compiles a special OSR-entry version of the function — optimised machine code whose entry point is not the function’s top, but the loop header. It is specialised to begin executing as if control had just arrived at the start of a loop iteration, with all the loop’s live variables already in place.
  3. V8 performs a live-state transfer: it reads the current values of every live variable from the interpreter frame (the loop index i, the accumulator acc, the rows reference) and writes them into the optimised frame’s registers and stack slots, in the representations the optimised code expects. This is the inverse of the deopt frame translation from lesson 05 — there we rebuilt an interpreter frame from an optimised one; here we build an optimised frame from an interpreter one.
  4. Control transfers into the OSR-entry optimised code at the loop header, and the same loop continues — but now in optimised machine code. The remaining nine-and-a-half million iterations run fast.
  5. After the loop finishes and crunch eventually returns, normal optimised code (a regular, top-entry compilation) takes over for any subsequent calls.

Why micro-benchmarks live or die by OSR

This is the mechanism behind a notorious benchmarking pitfall. If your benchmark fires up once and runs a single giant loop, ask yourself: has your function ever been called enough times to get a normal tier-up? The answer is almost certainly no — you are depending entirely on OSR. A micro-benchmark that wraps its work in a single huge loop — for (let i = 0; i < 1e9; i++) work() — does not warm up work through invocation-count tier-up in the usual way; it relies on OSR kicking in partway through to optimise the loop body live. That has two consequences for anyone measuring performance:

  • The first chunk of iterations runs unoptimised (interpreter/baseline), then there is a discontinuity where OSR fires and the rest run fast. If you time the whole loop as one number, you blend cold and hot execution and get a meaningless average.
  • OSR-entry code can be slightly worse than a normal top-entry optimisation, because it must accept the loop’s live state as it found it rather than from a clean function entry, which constrains some optimisations. So an OSR-warmed measurement can under-report the steady-state speed you would see if the function were called many times normally.

The fix is the standard one: warm up the function with many separate calls before timing (so it gets a normal, non-OSR optimisation), and measure steady-state iterations, not the warm-up transient. You can see OSR happen with --trace-osr; combined with --trace-opt it shows the OSR-entry compile distinct from the regular one.

OSR at a glance
Triggered by
loop back-edge counter
Entry point
loop header (not fn top)
State moved
interpreter frame -> optimised
Relation to deopt
inverse frame translation
OSR code vs normal opt
sometimes slightly worse
After the loop
normal opt for next calls
Trace flag
--trace-osr
Benchmark relevance
single-giant-loop benches
Quiz

Why can't normal tier-up optimise a function that is called once and spends all its time in one long loop?

Quiz

What does OSR have to do at the moment it swaps the frame, and how does it relate to deopt?

Order the steps

Order how OSR optimises a long-running loop in a once-called function.

  1. 1 The loop's back-edge counter trips the tier-up budget mid-execution
  2. 2 V8 compiles an OSR-entry version whose entry point is the loop header
  3. 3 V8 transfers live loop state from the interpreter frame into the optimised frame
  4. 4 Control resumes in the optimised code and the same loop continues fast
  5. 5 After the loop returns, normal optimised code serves any later calls
Why this works

Why does the OSR-entry version sometimes optimise slightly worse than a normal compilation? Because a normal top-entry optimisation gets to assume a clean function entry — fresh arguments, no live loop state mid-computation — and can lay out the whole function optimally. The OSR-entry version must instead accept the loop’s live values exactly as they exist at the trip point and begin from the loop header, which pins some representations and limits a few rearrangements the compiler would otherwise make. It is still vastly faster than the interpreter; it is just occasionally a hair behind the steady-state code you get from many normal calls — which is exactly why warming with separate calls gives cleaner benchmark numbers.

Recall before you leave
  1. 01
    What problem does OSR solve that normal tier-up cannot?
  2. 02
    Walk through the OSR mechanism step by step.
  3. 03
    Why must single-giant-loop micro-benchmarks be warmed with separate calls, and what does OSR have to do with it?
Recap

On-Stack Replacement is the mechanism that lets V8 optimise a loop that is already running, closing the one gap in normal tier-up. Normal tier-up installs optimised code for the next entry to a function, which is useless for a function called once that spends all its time in a single long loop: the loop is intensely hot via its back-edge counter, but there is no next call to install code for. OSR fixes this by replacing the executing frame in flight. When the back-edge budget trips, V8 compiles an OSR-entry version of the function whose entry point is the loop header, then performs a live-state transfer - reading every live loop variable out of the interpreter frame and writing it into the optimised frame in the representations the optimised code expects, the exact inverse of deopt’s frame translation. Control jumps into the optimised code at the loop header and the same in-flight loop continues fast, with normal top-entry optimised code taking over for any later calls once the loop returns. Because single-giant-loop micro-benchmarks rely on OSR rather than ordinary tier-up, and because OSR-entry code can be marginally worse than a clean optimisation, such benchmarks must be warmed with many separate calls and measured at steady state. Observe it all with —trace-osr alongside —trace-opt. Now when you write a benchmark and wonder why the first million iterations are slow and the rest fast, you know: that is the OSR boundary, and the fix is to call your function in a warm-up loop before you start the clock.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 8 done
Connected lessons
appears again in184

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.