From 73ec34d3e7f244616905dbb6b89353d3f7b99cca Mon Sep 17 00:00:00 2001 From: Tim Cassell Date: Mon, 27 Jul 2026 06:50:56 -0400 Subject: [PATCH] fix: teach x64 disassembler to chase call/jmp through MOV reg, imm64 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The x64 JIT sometimes emits an indirect call as `mov reg, imm64 ; call qword ptr [reg]` instead of the shorter RIP-relative `call qword ptr [rip+disp32]` — typically when the target slot is out of ±2 GB range from the call site. The RIP-relative form is handled by IntelDisassembler today; the 2-instruction form is not, because TryGetReferencedAddress only inspects the current instruction (the CALL) and its memory displacement is `0` (`[reg]` with no disp). Symptom: on the InProcessEmit toolchain, when the auto-generated `__ForDisassemblyDiagnoser__` wrapper's call site landed on the 2-instruction form, DisassemblyDiagnoser recovered only the wrapper and never followed the call to the benchmark method. Surfaces as a rare flake in `CanDisassembleInlinableBenchmarks(RyuJit, X64, InProcessEmit)` in the full test suite context (memory fragmentation makes the wider-encoding call site more likely). Add a per-Decode `Dictionary` that tracks `MOV reg, imm64` loads and gets invalidated on any subsequent write to the same register. When a `CALL/JMP [reg]` (or `[reg+disp]`) is seen and the base register has a tracked value, use `regValue + disp` as the referenced slot address and let the existing indirect-resolution path handle the pointer dereference and precode chase. Verified by reproducing the flake on AMD EPYC 9V74 Windows-latest runner (~1-in-15 dispatch attempts pre-fix) and observing that the same repro passes cleanly with the fix: JustReturn appears as its own entry in the disassembly results, matching the .NET 8 and RIP-relative-form behavior. --- .../Disassemblers/IntelDisassembler.cs | 62 ++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/src/BenchmarkDotNet/Disassemblers/IntelDisassembler.cs b/src/BenchmarkDotNet/Disassemblers/IntelDisassembler.cs index 930eea4edb..94c874e57e 100644 --- a/src/BenchmarkDotNet/Disassemblers/IntelDisassembler.cs +++ b/src/BenchmarkDotNet/Disassemblers/IntelDisassembler.cs @@ -12,15 +12,29 @@ protected override IEnumerable Decode(byte[] code, ulong startAddress, Stat var decoder = Decoder.Create(state.Runtime.DataTarget.DataReader.PointerSize * 8, reader); decoder.IP = startAddress; + // Track values loaded by MOV reg, imm64 so we can resolve subsequent CALL/JMP through + // the register (e.g. `mov rax, imm64; call qword ptr [rax]`). The JIT emits this + // 2-instruction form instead of a RIP-relative `call [rip+disp32]` when the target + // slot is out of ±2 GB range from the call site — common under fragmented layouts. + var trackedRegs = new System.Collections.Generic.Dictionary(); + while (reader.CanReadByte) { decoder.Decode(out var instruction); + UpdateRegisterTracking(instruction, trackedRegs); + bool isIndirect = instruction.IsCallFarIndirect || instruction.IsCallNearIndirect || instruction.IsJmpFarIndirect || instruction.IsJmpNearIndirect; bool isPrestubMD = false; ulong address = 0; - if (TryGetReferencedAddress(instruction, (uint)state.Runtime.DataTarget.DataReader.PointerSize, out address)) + bool haveAddress = TryGetReferencedAddress(instruction, (uint)state.Runtime.DataTarget.DataReader.PointerSize, out address); + if (!haveAddress && isIndirect + && TryResolveIndirectThroughTrackedReg(instruction, trackedRegs, out address)) + { + haveAddress = true; + } + if (haveAddress) { if (isIndirect) { @@ -132,6 +146,52 @@ private static bool TryResolvePrecode(IDataReader reader, ref ulong address, out return false; } + // Update the register-value tracker as we walk the instruction stream. We only track + // constants loaded by `MOV reg, imm64` (and its 32-bit-immediate sign-extended forms). + // Any other write to a register invalidates that entry. This is intentionally simple: + // it handles the common JIT-emitted `MOV reg, imm64 ; CALL/JMP [reg]` pattern without + // trying to model general dataflow. + private static void UpdateRegisterTracking(Instruction instruction, System.Collections.Generic.Dictionary trackedRegs) + { + if (instruction.Mnemonic == Mnemonic.Mov + && instruction.OpCount == 2 + && instruction.Op0Kind == OpKind.Register + && (instruction.Op1Kind == OpKind.Immediate64 + || instruction.Op1Kind == OpKind.Immediate32to64 + || instruction.Op1Kind == OpKind.Immediate32)) + { + trackedRegs[instruction.Op0Register] = instruction.GetImmediate(1); + return; + } + + // Conservative invalidation: any instruction whose first operand is a register + // clobbers our tracked value for that register. + if (instruction.OpCount > 0 && instruction.Op0Kind == OpKind.Register) + { + trackedRegs.Remove(instruction.Op0Register); + } + } + + // Handle `CALL/JMP qword ptr [reg]` (or `[reg+disp]`) where we've previously tracked + // `reg` via a `MOV reg, imm64`. In that case the referenced slot lives at `reg + disp`, + // which the base-line `TryGetReferencedAddress` can't compute since MemoryDisplacement64 + // there is just `disp`, not `reg + disp`. + private static bool TryResolveIndirectThroughTrackedReg(Instruction instruction, System.Collections.Generic.Dictionary trackedRegs, out ulong slotAddress) + { + slotAddress = 0; + if (instruction.OpCount == 0 || instruction.Op0Kind != OpKind.Memory) + return false; + if (instruction.IsIPRelativeMemoryOperand) + return false; + Register memBase = instruction.MemoryBase; + if (memBase == Register.None || instruction.MemoryIndex != Register.None) + return false; + if (!trackedRegs.TryGetValue(memBase, out ulong baseValue)) + return false; + slotAddress = baseValue + instruction.MemoryDisplacement64; + return slotAddress > ushort.MaxValue; + } + private static bool TryGetReferencedAddress(Instruction instruction, uint pointerSize, out ulong referencedAddress) { for (int i = 0; i < instruction.OpCount; i++)