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
ADDhas received two integers 10,000 times in a row." So rewrite that instruction, in place, toADD_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:
| Option | Notes |
|---|---|
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 stream | CPython: the opcode itself changes. No indirection; Chunk becomes mutable and per-instance |
C. A separate specialized Chunk | Compile a second version; switch wholesale. Simple; doubles memory; no partial specialization |
Decision: A.
Chunkis shared by every closure over aProto, 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 keepsChunkserializable.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
Protois. 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:
| Generic | Specialized | Guard | Typical win |
|---|---|---|---|
ADD | ADD_INT, ADD_FLOAT | both operands' tags | one branch instead of five |
LT/LE | LT_INT, LT_FLOAT | same | same, and comparisons are common in policies |
GET_FIELD | GET_FIELD_CACHED | shape | inline caches — the same idea |
CALL | CALL_SCRIPT / CALL_NATIVE | callee kind | skips the callee dispatch |
GET_LOCAL; GET_LOCAL | GET_LOCAL2 | none — a superinstruction, not a specialization | one 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.0belong in the specialized path's test set.ADD_INTusing+instead ofwrapping_addpanics 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-specializeprinting each site's transitions is how you find out that a site is thrashing rather than warming.
Relation to Real Systems
| System | Technique | Notes |
|---|---|---|
| CPython 3.11+ | Adaptive specializing interpreter (PEP 659) | Quickening, families of specialized instructions, exponential backoff. The direct ancestor of this chapter |
| V8 Ignition | Bytecode handlers plus feedback vectors consumed by TurboFan | The feedback's real consumer is the JIT |
| HotSpot | Profile counters in the template interpreter, consumed by C1/C2 | Same shape: interpreter observes, compiler acts |
| LuaJIT | Trace recording specializes a whole path at once | No per-instruction specialization; the trace is the specialization |
| Forth systems | Superinstructions and threaded code | The 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
iaddexists 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::MINandNaNbelong in every specialized path's test set, or an optimization reintroduces a bug you fixed in Lab 3.- Keeping
Chunkimmutable 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
- Why can a dynamic language not emit
ADD_INTat compile time, and what does the JVM do instead? - What is the guard for
ADD_INT, and why is it cheaper than the generic path? - Why must specialization be adaptive? What happens without exponential backoff?
- Give the three places specialization feedback can live, and Ember's reason for its choice.
- How does a superinstruction differ from a specialization? Which needs no guard, and why?
- State the correctness rule in one sentence, and give the test that enforces it.
- Which three values must be in every specialized arithmetic path's test set, and why?
- What is the common pattern across CPython, V8, and HotSpot here, and where do they differ?
Next: JIT Architecture.