Runtime Specialization

An inline cache remembers an answer. Specialization goes further: it rewrites the instruction based on what it has seen.

This is the technique CPython adopted in 3.11 (PEP 659) and it is an unusually good return on complexity — no machine-code generation at all, just self-modifying bytecode with guards.


The Problem

#![allow(unused)]
fn main() {
Op::Add => {
    let b = self.pop(); let a = self.pop();
    self.push(match (a, b) {
        (Integer(x), Integer(y)) => Integer(x.wrapping_add(y)),
        (Float(x),   Float(y))   => Float(x + y),
        (Integer(x), Float(y))   => Float(x as f64 + y),
        (Float(x),   Integer(y)) => Float(x + y as f64),
        _ => self.add_slow(a, b)?,          // strings, metamethods, errors
    });
}
}

Five branches, on every addition, forever — even in a loop where both operands have been integers ten million times running.

The JVM does not have this problem: javac knows the types and emits iadd. A dynamic language cannot know at compile time.

But it can know at run time.


The Idea

The interpreter watches. "This ADD has received two integers 10,000 times in a row." So rewrite that instruction, in place, to ADD_INT — which checks the types cheaply and falls back if wrong.

   0042  ADD                    ← generic: 5-way match, every time

   after 10,000 integer-integer executions:

   0042  ADD_INT                ← if both are Integer: add. Else: DE-SPECIALIZE and retry.

The guard is the type check, and it is cheaper than the full match because it tests one possibility rather than dispatching over five.


Adaptive, Not Just Specialized

The important refinement, and the one PEP 659 is built around: the specialized instruction can change back.

   ADD ──warms up──▶ ADD_INT ──guard fails──▶ ADD ──warms up──▶ ADD_FLOAT
                                    │
                                    └── or, after N failures: ADD_GENERIC (terminal)

Three states, and the third one matters for the same reason megamorphic must be terminal: a site whose types genuinely oscillate must stop paying the re-specialization cost.

#![allow(unused)]
fn main() {
struct AdaptiveCounter { warmup: u8, backoff: u8 }
// Specialize after `warmup` consistent observations.
// On a guard failure: de-specialize and DOUBLE the warmup threshold.
// After `backoff` failures: mark the site polymorphic and stop trying.
}

Exponential backoff on re-specialization is what keeps a genuinely polymorphic site from thrashing. Without it, a loop alternating integer and float additions rewrites the instruction on every iteration and is slower than the generic version.


Where the Feedback Lives

Same fork as inline caches:

OptionNotes
A. A side array on the Proto, indexed by instruction offset (ours)Chunk stays immutable and shareable; one indirection to reach the counter
B. Mutate the instruction streamCPython: the opcode itself changes. No indirection; Chunk becomes mutable and per-instance
C. A separate specialized ChunkCompile a second version; switch wholesale. Simple; doubles memory; no partial specialization

Decision: A. Chunk is shared by every closure over a Proto, and Lab 22's bytecode cache may share it across engines. Keeping it immutable means a specialization decision made by one closure does not silently apply to another with different behavior — and, more importantly, it keeps Chunk serializable.

B is what CPython does and it is faster (no indirection). It is available to CPython because a code object is not shared across interpreters in the way Ember's Proto is. Note the reason; "CPython does it" is not one.


What Is Worth Specializing

Driven by the profile, not by the list below. But when the profile points here, these are the candidates in rough order of value:

GenericSpecializedGuardTypical win
ADDADD_INT, ADD_FLOATboth operands' tagsone branch instead of five
LT/LELT_INT, LT_FLOATsamesame, and comparisons are common in policies
GET_FIELDGET_FIELD_CACHEDshapeinline caches — the same idea
CALLCALL_SCRIPT / CALL_NATIVEcallee kindskips the callee dispatch
GET_LOCAL; GET_LOCALGET_LOCAL2none — a superinstruction, not a specializationone dispatch instead of two

That last row is a different technique and worth distinguishing: a superinstruction fuses a sequence and needs no guard, because it does not assume anything about types. It is pure dispatch reduction, it is decided at compile time from a static analysis of common pairs, and it is strictly simpler than specialization. If your profile is dominated by GET_LOCAL, build superinstructions and skip the type feedback entirely.


Correctness

The specialized path must produce a bit-identical result to the generic one, or fall back.

That sentence is the whole correctness argument, and it is testable:

#![allow(unused)]
fn main() {
#[test]
fn every_specialized_opcode_agrees_with_its_generic_form() {
    for (generic, specialized) in SPECIALIZATION_PAIRS {
        for (a, b) in interesting_value_pairs() {          // including NaN, ±0.0, i64::MIN
            match execute_one(specialized, a, b) {
                Ok(v) => assert_eq!(v, execute_one(generic, a, b).unwrap(),
                                    "{specialized:?} differs from {generic:?} on ({a:?}, {b:?})"),
                Err(Guard) => {}     // falling back is ALWAYS allowed
                Err(e) => panic!("{specialized:?} errored where generic did not: {e:?}"),
            }
        }
    }
}
}

Falling back is always allowed; producing a different answer never is. That asymmetry is what makes speculation safe, and it is the same rule as an inline cache's.

And the differential tests remain the backstop: the tree walker has no specialization, so any divergence introduced here shows up immediately.


Production Concerns

  • i64::MIN, NaN, and ±0.0 belong in the specialized path's test set. ADD_INT using + instead of wrapping_add panics in debug on overflow — the exact bug Lab 3 warned about, reintroduced by an optimization.
  • Specialization must not be observable. Same results, same instruction counts — or if counts change, the deterministic regression gate must be regenerated deliberately, and the diff read. A silent count change means something else moved too.
  • A warm-up counter is per site, not global. A global counter specializes cold sites based on hot ones.
  • Do not specialize in the tree walker. It is the oracle.
  • Instrument it. --trace-specialize printing each site's transitions is how you find out that a site is thrashing rather than warming.

Relation to Real Systems

SystemTechniqueNotes
CPython 3.11+Adaptive specializing interpreter (PEP 659)Quickening, families of specialized instructions, exponential backoff. The direct ancestor of this chapter
V8 IgnitionBytecode handlers plus feedback vectors consumed by TurboFanThe feedback's real consumer is the JIT
HotSpotProfile counters in the template interpreter, consumed by C1/C2Same shape: interpreter observes, compiler acts
LuaJITTrace recording specializes a whole path at onceNo per-instruction specialization; the trace is the specialization
Forth systemsSuperinstructions and threaded codeThe non-speculative half of this chapter, and much older

The pattern across all of them: the interpreter observes and something acts on the observation. In CPython the interpreter acts on it directly; in V8 and HotSpot the observation feeds a compiler. Ember does the first, and the JIT chapter is about the second.


Things to Notice

  • The JVM's iadd exists because Java knows types at compile time. Everything in this chapter is a dynamic language buying that knowledge at run time.
  • Adaptive, not just specialized. De-specialization and exponential backoff are what stop a polymorphic site from thrashing.
  • Superinstructions are a different technique — no guard, no speculation, pure dispatch reduction. Distinguish them.
  • Falling back is always allowed; a different answer never is. The asymmetry that makes speculation safe.
  • i64::MIN and NaN belong in every specialized path's test set, or an optimization reintroduces a bug you fixed in Lab 3.
  • Keeping Chunk immutable costs an indirection and buys shareability and serializability. CPython chose differently for a reason that does not apply here.
  • The interpreter observes; something acts. Which "something" is the difference between CPython and V8.

Validation / Self-check

  1. Why can a dynamic language not emit ADD_INT at compile time, and what does the JVM do instead?
  2. What is the guard for ADD_INT, and why is it cheaper than the generic path?
  3. Why must specialization be adaptive? What happens without exponential backoff?
  4. Give the three places specialization feedback can live, and Ember's reason for its choice.
  5. How does a superinstruction differ from a specialization? Which needs no guard, and why?
  6. State the correctness rule in one sentence, and give the test that enforces it.
  7. Which three values must be in every specialized arithmetic path's test set, and why?
  8. What is the common pattern across CPython, V8, and HotSpot here, and where do they differ?

Next: JIT Architecture.