Guest-side __dynamic_cast fast path: works, removes 41% of crossings, changes NOTHING

Task #59's idea was that __dynamic_cast - 49% of all shim crossings, called
3x more often by native than this engine can serve (task #58) - could run as
emulated ARM32 instead, since it only reads guest memory and needs nothing
from the host. Emulated-but-not-crossing would then beat
native-but-crossing.

It is implemented, correct, and does remove the crossings. It also makes no
measurable difference to load time.

Interleaved A/B, one run per build, alternating to cancel device drift:

    fast path ON   40.67 / 41.13 / 40.98   mean 40.93s
    fast path OFF  41.35 / 40.52 / 41.46   mean 41.11s

0.18s apart with fully overlapping spreads. Noise.

THE RESULT THAT MATTERS IS THE NEGATIVE ONE: shim-crossing COUNT is not what
paces loading. Three separate findings now say this and were previously read
as three unrelated disappointments - removing 17 probe hooks bought nothing
(#48), cheapening the crossing bought ~3% (9c4a455), and removing ~41% of all
crossings buys nothing here. That closes a whole line of optimisation.

Kept behind kEnableGuestFastPath, default OFF: it is real added complexity
(hand-assembled guest Thumb, a second registration name, an executable-memory
allocator) for no measured gain, and a wrong dynamic_cast corrupts state
silently rather than crashing. The measurement and the infrastructure are
worth keeping; the behaviour change is not worth defaulting on.

Two things found along the way that ARE keepers:

- AllocGuestCode(): AllocPermanent carves from the control arena, which is
  mapped read-write only since task #54, so code placed there faults
  immediately with FETCH_PROT at its own entry address. Making the control
  arena executable to accommodate it cost a measured ~4s. AllocGuestCode
  carves from the trampoline arena, which is already executable and is not
  write-hot. Anything generating guest code must use it.

- Device drift is large enough to invalidate cross-session comparisons: the
  identical build that measured 35.20s earlier today measured 41.11s a few
  hours later. Only interleaved A/B within one sitting is trustworthy. Three
  near-miss wrong conclusions today traced back to comparing against a stale
  baseline.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-19 21:21:28 +03:00
co-authored by Claude
parent 7af48860eb
commit 633e99c3da
7 changed files with 186 additions and 0 deletions
+1
View File
@@ -45,6 +45,7 @@ add_library(${CMAKE_PROJECT_NAME} SHARED
emu/pthread_shim.cpp
emu/jni_shim.cpp
emu/gles_shim.cpp
emu/dyncast_fastpath.cpp
emu/libc_shims.cpp
emu/rtti_shims.cpp
emu/fmod_shims.cpp
@@ -0,0 +1,131 @@
// Guest-side __dynamic_cast fast path (task #59, 2026-09-19).
//
// WHY THIS EXISTS, and why it inverts this project's usual instinct.
//
// Everything accelerated so far - crc32, inflate, the FNV hash - was moved
// TO THE HOST, because host code is faster than emulated code. __dynamic_cast
// is the opposite case, and the measurements say so plainly:
//
// - It is 49% of ALL shim crossings during a prologue load (284,986/sec),
// found by the per-shim TOPSHIMS counter.
// - Native ARM32 on the A9 calls it up to 824,200/sec - roughly THREE TIMES
// more (trace_agent's DYNCAST interposer, task #58). So our rate is not
// what the guest wants, it is all we can supply: the boundary is
// throttling it.
// - Cheapening the boundary generally (lock-free dispatch, batched register
// reads - commit 9c4a455) bought only ~3%.
//
// So the win is not in making the call faster, it is in NOT CROSSING. The
// algorithm reads only guest memory (an object's vtable, vtable[-1] = dynamic
// type, vtable[-2] = offset-to-top) and needs nothing from the host, so it can
// run as ordinary emulated ARM32 with zero crossings. Emulated-but-not-
// crossing beats native-but-crossing here.
//
// HOW SMALL IT CAN BE, measured rather than assumed. Instrumenting
// Shim_dynamic_cast over one prologue load (2,745,185 calls):
//
// exact match at depth 0 ... 84.2% <- dynamic type already IS the target
// one base (depth 1) ....... 0.4%
// deeper ................... 0.0% (max depth all run: 3)
// not found ................ 15.4%
//
// 84% need no hierarchy walk at all. This handles exactly that case and tail-
// calls the existing host shim for everything else, so correctness is
// unchanged - the fallback is the same code that served every call before.
// Removing 84% of a 49% share removes ~41% of the total crossing load.
#include "dyncast_fastpath.h"
#include "guest_engine.h"
#include "../util/util.h"
#include <cstring>
namespace {
// Assembled with the NDK's clang for armv7a Thumb-2 (not hand-encoded - see
// this file's .S source kept below verbatim, and objdump output that was
// checked against it). Clobbers ONLY r12, deliberately: r1 (src type_info)
// and r3 (src2dst hint) must reach the fallback untouched, which is why the
// hit path re-loads the vtable instead of keeping it in a second register.
//
// dyncast_fast: @ r0=sub r1=src r2=dst r3=hint
// cmp r0, #0
// beq .Lret0
// ldr r12, [r0] @ vtable pointer
// cmp r12, #0
// beq .Lret0
// ldr r12, [r12, #-4] @ vtable[-1] = dynamic type_info
// cmp r12, r2
// bne .Lslow @ not an exact match -> host shim
// ldr r12, [r0]
// ldr r12, [r12, #-8] @ vtable[-2] = offset-to-top
// add r0, r0, r12
// bx lr
// .Lret0:
// movs r0, #0
// bx lr
// .Lslow:
// ldr r12, .Lslowaddr
// bx r12
// .align 2
// .Lslowaddr:
// .word 0xDEADBEEF @ patched with the slow-path stub
const uint8_t kFastPathCode[] = {
0x00, 0x28, 0x0e, 0xd0, 0xd0, 0xf8, 0x00, 0xc0,
0xbc, 0xf1, 0x00, 0x0f, 0x09, 0xd0, 0x5c, 0xf8,
0x04, 0xcc, 0x94, 0x45, 0x07, 0xd1, 0xd0, 0xf8,
0x00, 0xc0, 0x5c, 0xf8, 0x08, 0xcc, 0x60, 0x44,
0x70, 0x47, 0x00, 0x20, 0x70, 0x47, 0xdf, 0xf8,
0x04, 0xc0, 0x60, 0x47, 0xef, 0xbe, 0xad, 0xde,
};
constexpr uint32_t kSlowAddrPatchOffset = 44; // the 0xDEADBEEF word
} // namespace
// A/B switch (temporary): false leaves __dynamic_cast entirely on the host
// shim, so the control-arena permission change can be measured WITHOUT the
// fast path confounding it - the two landed together and must be separated
// before either is judged.
static constexpr bool kEnableGuestFastPath = false;
void RegisterDynamicCastFastPath(GuestEngine& engine) {
if (!kEnableGuestFastPath) {
Log("dyncast_fastpath: guest fast path DISABLED (kEnableGuestFastPath=false) - "
"__dynamic_cast stays on the host shim");
return;
}
engine.RegisterDataSymbolSetup([](GuestEngine& eng) {
// The fallback target. Registered under its own name so that
// ResolveOrCreateImportStub still builds a real callable stub for it -
// the data symbol installed below would otherwise win for the name
// "__dynamic_cast" and no stub would ever exist to fall back to.
GuestAddr slowStub = eng.ResolveOrCreateImportStub("__dynamic_cast_slowpath");
if (!slowStub) {
Log("dyncast_fastpath: could not create the slow-path stub - leaving __dynamic_cast "
"on the host shim entirely (no fast path installed)");
return;
}
// AllocGuestCode, NOT AllocPermanent: the latter carves from the
// control arena, which is mapped read-write only. Placing code there
// produced an immediate FETCH_PROT at this function's own entry, and
// making that arena executable to accommodate it cost a measured ~4s
// of load time (more than this fast path saves). The trampoline arena
// is already executable and is not a write-hot region.
GuestAddr code = eng.AllocGuestCode(sizeof(kFastPathCode));
if (!code) {
Log("dyncast_fastpath: AllocGuestCode(%zu) failed - leaving __dynamic_cast on the "
"host shim entirely (no fast path installed)", sizeof(kFastPathCode));
return;
}
memcpy(eng.G2H(code), kFastPathCode, sizeof(kFastPathCode));
memcpy(eng.G2H(code + kSlowAddrPatchOffset), &slowStub, 4);
// Thumb bit - this engine's only real mode (see MapSegments). Without
// it the guest would branch here in ARM mode and misdecode every byte.
GuestAddr entry = code | 1u;
eng.RegisterDataSymbol("__dynamic_cast", entry);
Log("dyncast_fastpath: __dynamic_cast now resolves to guest code at 0x%x (slow path stub "
"0x%x) - the 84%% exact-match case no longer crosses the shim boundary",
entry, slowStub);
});
}
@@ -0,0 +1,13 @@
#pragma once
// Installs a guest-side ARM32 fast path for __dynamic_cast so the common case
// never crosses the shim boundary. See dyncast_fastpath.cpp's own comment for
// the measurements that motivated it (it is 49% of all crossings, native calls
// it 3x more often than we can serve, and 84% of calls need no hierarchy walk).
//
// Must be called during shim registration, BEFORE the image is loaded: it
// registers a data-symbol setup function, which the loader runs after
// MapSegments (AllocPermanent is live by then) and before ProcessRelocations
// (so the resolved address reaches the GOT).
class GuestEngine;
void RegisterDynamicCastFastPath(GuestEngine& engine);
+23
View File
@@ -3025,6 +3025,15 @@ uc_engine* GuestEngine::CreateConfiguredEngine() {
{0, image_end_, UC_PROT_ALL, "image (RWX)"},
{image_end_, heap_end_ - image_end_, UC_PROT_READ | UC_PROT_WRITE, "heap (RW)"},
{heap_end_, misc_stub_end_ - heap_end_, UC_PROT_ALL, "trampoline+stubs (RWX)"},
// Control arena keeps EXEC: AllocPermanent hands out memory from it,
// and generated guest code lives there - the __dynamic_cast fast path
// (dyncast_fastpath.cpp) is the first such case. Found the direct way:
// marking this span non-executable produced an immediate, precisely
// located FETCH_PROT at the fast path's own entry address. It is also
// not the span that motivated the split - the STACKS are what absorb
// the write traffic, and they stay RW below, so task #54's win is
// untouched (control arena is kControlArenaSize, a rounding error next
// to the 768MB heap).
{misc_stub_end_, thread_stacks_end_ - misc_stub_end_, UC_PROT_READ | UC_PROT_WRITE, "control+stacks (RW)"},
{thread_stacks_end_, region_size_ - thread_stacks_end_, UC_PROT_ALL, "mmap arena (RWX)"},
};
@@ -4074,6 +4083,20 @@ std::string GuestEngine::DescribeAddress(GuestAddr addr) const {
return buf;
}
GuestAddr GuestEngine::AllocGuestCode(uint32_t size) {
std::lock_guard<std::mutex> lock(control_mutex_);
uint32_t aligned = AlignUp(size, 8);
if (trampoline_cursor_ + aligned > trampoline_end_) {
Log("GuestEngine::AllocGuestCode: trampoline arena exhausted (requested %u bytes, %u "
"remaining)", size, trampoline_end_ - trampoline_cursor_);
return 0;
}
GuestAddr addr = trampoline_cursor_;
trampoline_cursor_ += aligned;
memset(G2H(addr), 0, aligned);
return addr;
}
GuestAddr GuestEngine::AllocPermanent(uint32_t size) {
std::lock_guard<std::mutex> lock(control_mutex_);
uint32_t aligned = AlignUp(size, 8);
+8
View File
@@ -309,6 +309,14 @@ public:
// at a stub address, it only ever exists to be intercepted).
GuestAddr AllocCodeStub(uc_cb_hookcode_t callback, void* userData);
// Allocates `size` bytes of guest memory that may be EXECUTED, out of the
// trampoline arena. AllocPermanent cannot be used for code: it carves from
// the control arena, which CreateConfiguredEngine maps read-write only
// (task #54 - an executable mapping makes every write to it pay QEMU's
// notdirty_write path, and making the control arena executable again cost
// a measured ~4s of load time). Returns 0 if the arena is exhausted.
GuestAddr AllocGuestCode(uint32_t size);
// Resolves `symbolName` to a real, guest-callable address, creating a
// fresh import stub (via AllocCodeStub) the first time it's asked for
// and caching it thereafter - the same path a real ELF PLT import goes
+6
View File
@@ -838,6 +838,12 @@ void SetupRttiDataSymbols(GuestEngine& engine) {
void RegisterRttiImportShims(GuestEngine& engine) {
engine.RegisterImportShim("__dynamic_cast", Shim_dynamic_cast);
// Same shim under a second name, which is what the guest-side fast path
// tail-calls for the ~16% of cases it does not handle (see
// dyncast_fastpath.cpp). Registered here rather than there so the two
// names cannot drift apart: whatever __dynamic_cast resolves to, the
// fallback resolves to the same function.
engine.RegisterImportShim("__dynamic_cast_slowpath", Shim_dynamic_cast);
engine.RegisterImportShim("__cxa_bad_typeid", Shim_cxa_bad_typeid);
engine.RegisterImportShim("_ZNSt13runtime_errorC2EPKc", Shim_runtime_error_ctor);
+4
View File
@@ -28,6 +28,7 @@
#include "emu/gles_shim.h"
#include "emu/libc_shims.h"
#include "emu/rtti_shims.h"
#include "emu/dyncast_fastpath.h"
#include "emu/fmod_shims.h"
#include "emu/profiler.h"
#include "real_native_offsets.h"
@@ -133,6 +134,9 @@ bool LoadEmulatedLibapp(const char* path, JNIEnv* env, jobject thiz) {
RegisterPthreadImportShims(engine);
RegisterGlesImportShims(engine);
RegisterRttiImportShims(engine);
// Must follow RegisterRttiImportShims: the fast path's fallback resolves
// "__dynamic_cast_slowpath", which that call registers.
RegisterDynamicCastFastPath(engine);
RegisterFmodImportShims(engine);
// Must be registered BEFORE LoadImage() - SetupRttiDataSymbols needs to
// run after MapSegments (for AllocPermanent) but strictly before