When I trace a deoptimization event through the lens of Orinoco’s parallel scavenger, I don’t see a single abrupt switch—I see a precisely choreographed handshake between two deeply cooperative systems. The story begins the moment a speculative guard fails inside a TurboFan‑compiled function. That failure kicks off a bailout, but while the runtime rewinds optimization, the scavenger might be simultaneously evacuating young objects just a few memory pages away. The weak list of deoptimized functions is the quiet mediator that ensures neither side steps on the other.
The bailout itself is neither a panic nor a full teardown. When the guard fails, the compiled code branches to a Deoptimize builtin that halts at a known safepoint—a point where the mutator thread can be paused without corrupting heap invariants. This is the same safepoint mechanism that Orinoco uses to coordinate its parallel worker threads. The builtin captures the current frame’s state, calling on a FrameDescription to map compiled machine registers and stack slots back into source‑level virtual registers, and begins materializing any values that were elided during optimization. While that happens, the function’s optimized code object is silently placed onto a weak list—the deoptimized code list. This is not an immediate unlink; it is a deferred promise. The weak list acts as a soft note to the garbage collector: “This code may still be referenced by live closures, but if it becomes unreachable, you can sweep it away later.” The entry sits there, inert, held by a weak reference that the mutator thread doesn’t need to touch again.
Meanwhile, Orinoco’s parallel scavenger is running its own cycle. Scavenge is a minor GC that copies live objects out of the young generation into a fresh semispace, then reclaims the entire previous semispace wholesale. For the scavenger to do its job safely, it must know every root that points into the young generation. Those roots include stack frames (the current executing frames), global handles, and the remembered sets—the write‑barrier‑fed cards that record old‑to‑young pointers. The deoptimized code list is not directly a young‑generation root because the code objects it references live in old space. But the list itself is a heap object that must be kept alive, and if that list object happens to have been allocated in the young generation (unlikely for such long‑lived metadata, but not impossible), the scavenger will see it as part of the root set and evacuate it, updating any pointers. More importantly, the deoptimized frame being translated is a stack root: its local variables might hold references to young objects, and those references are legitimate roots that the scavenger must trace. The safepoint handshake guarantees that the scavenger does not walk the stack while the Deoptimize builtin is rewriting the frame. When the thread reaches a safepoint for the bailout, it is also a safe moment for the scavenger to inspect that thread’s stack. The handshake ensures that either the scavenger sees the fully consistent pre‑bailout frame (still optimized) or the fully reconstructed interpreter frame—never a half‑translated intermediate state.
After frame translation is complete, the builtin installs a trampoline that redirects the return address to the interpreter’s bytecode dispatch. The optimized code object is now orphaned from the call stack, but it persists in memory with a weak entry in the deoptimized list. This is the essence of lazy unlinking: no eager sweep is performed, so the deoptimization path stays fast. The scavenger, having finished its copy phase, continues its own cleanup. It does not walk the weak list to decide liveness of old code objects—that is the job of a full mark‑compact cycle. Instead, the scavenger merely treats the weak list as a reachable object (to keep the list metadata alive) and moves on. Any young objects that were referenced only through the now‑deoptimized frame will have already been traced via the stack root during the scavenge, so no live young objects are lost.
The interplay extends further into the remembered set and the write barrier. During the scavenge, the write barrier—a small snippet of code inserted at every store—captures any pointer written into an old object that references a young one. This is the store buffer, later processed as new remembered entries. The deoptimization process itself may perform stores as it rematerializes values into the interpreter frame; those stores will go through the same write barrier if they write an old‑to‑young reference. Thus, even as the runtime deoptimizes, it feeds the scavenger’s bookkeeping, ensuring that no pointer to a freshly evacuated young object is missed.
When the scavenge cycle concludes, all young objects that survived have been copied to a new semispace. The deoptimized frame’s weak list entry remains, a quiet marker that will only be acted upon by a future major GC. Lazy unlinking means that the optimized code object—still cached and still sharing its Code object with other closures—stays allocated until the next full cycle. At that point, if no live closure references the function anymore, the weak reference is cleared and the memory can be freed. The trampoline prevents any future call to the optimized code from re‑entering the compiled body; instead, new calls go through the interpreter and, later, may re‑optimize with the feedback accumulated during interpreted execution.
What looks from the outside like a single catastrophic guard failure is, inside the engine, a finely tuned cooperation: the bailout routine and the parallel scavenger pause, inspect, and update without colliding, all because they agree on a shared vocabulary—safepoints, weak lists, stack roots, remembered sets, and write barriers. The deoptimized code list is not a cost centre nor a complication; it is a gentle nudge to the GC that says, “when you have a quiet moment, this may be no longer needed.” And the scavenger, in its own parallel rush, never needs to slow down for it.
Comments