Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Reading the deopt log (--features deopt)

What a record looks like

<-- deopt occurs in <Array#each> FuncId(2251).
      [:00019] %1 = %1 + %2   [Integer][Integer]   exit: deopt (chained)
      guard: monoruby/src/codegen/jitgen/asmir/compile_shared.rs:541:34
      exit emitted by: monoruby/src/codegen/jitgen/compile/method_call.rs:973:24
      cause: class version
fieldmeans
first linethe frame that deoptimized
[:NNNNN]its bytecode index, and the TraceIR at that index
exit:what the handler is — deopt, evict, or recompile[reason], plus (chained)
guard:the lowering site of the guard that actually branched
exit emitted by:the front-end site that decided a deopt was needed here
cause:the operand the guard was looking at, or a name when there is none

guard: is the field to read first. It identifies the branch, not the handler — see below for why those differ.

Why it is built the way it is

The cause column used to be “whatever sat in rdi when the side exit ran”. That was wrong in two independent ways, and the two together produced four consecutive misdiagnoses during the activerecord deopt investigation:

The value was stale. The handler read rdi after the deopt write-back, which calls into C (f64_to_val, create_array, …) and clobbers the register file. Most records printed UNDEFINED — how a zero word renders — so the column was noise dressed as data.

The guard was unidentifiable. Deopt exits are deduplicated by (pc, write_back, chain), and a single AsmDeopt is handed to several guards even before that. Neither the handler nor the AsmDeopt index names the branch that was taken. Two guards at one pc are one handler, and the log could not tell them apart.

So identity is recorded where the branch is. Every lowering site that asks for a deopt label gets a trampoline of its own:

site_NNN:
    movq [rbx + EXECUTOR_DEOPT_CAUSE], <cause register>
    movl [rbx + EXECUTOR_DEOPT_SITE],  <site id>
    jmp  <deduplicated handler>

rbx is &mut Executor for the whole body, so this costs no scratch register, no stack traffic, and — at a point where a guard has just done its compare — no flag-clobbering instruction. Recording the operand before the write-back is what makes the value trustworthy, which in turn lets the log call stay where it always was: after the write-back, with nothing live in registers.

Handlers stay deduplicated. Only the trampolines multiply, at ~20 cold-page bytes each.

The invariant

The only way to reach a deopt handler from a JIT body is Codegen::deopt_label, and it demands a DeoptCause.

SideExitLabels has no Index<AsmDeopt> impl, and deopt_label has no default for cause. A new guard that skips the question does not compile.

Choosing a cause: for the label this call returns, is a meaningful operand present on every path that jumps to it? If some edge leaves the register undefined, the honest answer is DeoptCause::Static("…").

variantwhen
Value(r)a Ruby Value is in r on every edge
ValueVsBaked(r, v)…and the guard compared it against v, baked at compile time
Raw(r)non-Value bits (pointers, byte counts); printed as hex, never decoded
Static(s)no operand: global state (version word, BOP flag, counter), an unconditional deopt, or an unboxed float

Floats are deliberately Static. An FPReg is virtual — resolving one needs the frame’s base_stack_offset, since a spilled float lives on the stack rather than in an xmm — and threading a frame through every guard’s lowering to recover an operand for two call sites is not worth it.

What the log will not do

It will not decode a word it cannot vouch for. A Value cause is checked (Value::debug_check) before it is decoded, and the raw bits are always printed alongside. The write-back between capture and log can run a GC, so an object reachable only from the guard’s register may be gone by then; such a word reads as <not a Value> rather than as a plausible lie.

It will not attribute a branch it did not see. A handler entered without a trampoline — an evict resuming through a patched return address, say — reports guard: unknown (handler entered without a trampoline). The site id is taken and cleared on every read, so a later entry cannot inherit an earlier one’s identity.

It will flag its own contradictions. A ValueVsBaked guard that reports a miss on bits equal to what it compares against prints !!! guard reported a miss on equal bits. That exact contradiction — a guard appearing to fail against the value it was testing for — is what cost four hypotheses before the log could state it.

Scope

Everything here is cfg(feature = "deopt"). Normal builds emit byte-identical code: deopt_label compiles to the bare handler label and the cause argument is discarded. profile-only builds keep their original call site.

aarch64 records every deopt but not the branch that took it. a64_gen_deopt calls log_deoptimize at the x86 position (after the write-back, before the chain walk), so deopt builds print each record with its bytecode index and exit kind, and profile builds fill the deoptimization table. There are no trampolines, so every aarch64 record reads guard: unknown (handler entered without a trampoline). Until the call existed, an aarch64 guard that failed on every execution was invisible to both builds.

The trampoline cannot be copied from x86 as is. x86 parks it on the cold page; aarch64 guards reach their handlers with tbz/tbnz (±32 KiB), which is why the handlers are emitted as islands within reach (a64_thunk_side_exits), and a cold-page trampoline would be out of range. A port has to place the trampolines in the same islands as the handlers.