From 633e99c3da6230c8da867f28e8bc47a8ba805e10 Mon Sep 17 00:00:00 2001 From: megboyzz Date: Sat, 19 Sep 2026 21:21:28 +0300 Subject: [PATCH] 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 --- mpcore/src/main/cpp/CMakeLists.txt | 1 + mpcore/src/main/cpp/emu/dyncast_fastpath.cpp | 131 +++++++++++++++++++ mpcore/src/main/cpp/emu/dyncast_fastpath.h | 13 ++ mpcore/src/main/cpp/emu/guest_engine.cpp | 23 ++++ mpcore/src/main/cpp/emu/guest_engine.h | 8 ++ mpcore/src/main/cpp/emu/rtti_shims.cpp | 6 + mpcore/src/main/cpp/main.cpp | 4 + 7 files changed, 186 insertions(+) create mode 100644 mpcore/src/main/cpp/emu/dyncast_fastpath.cpp create mode 100644 mpcore/src/main/cpp/emu/dyncast_fastpath.h diff --git a/mpcore/src/main/cpp/CMakeLists.txt b/mpcore/src/main/cpp/CMakeLists.txt index c99a344..1cbeb3f 100644 --- a/mpcore/src/main/cpp/CMakeLists.txt +++ b/mpcore/src/main/cpp/CMakeLists.txt @@ -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 diff --git a/mpcore/src/main/cpp/emu/dyncast_fastpath.cpp b/mpcore/src/main/cpp/emu/dyncast_fastpath.cpp new file mode 100644 index 0000000..ef1ef7d --- /dev/null +++ b/mpcore/src/main/cpp/emu/dyncast_fastpath.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 + +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); + }); +} diff --git a/mpcore/src/main/cpp/emu/dyncast_fastpath.h b/mpcore/src/main/cpp/emu/dyncast_fastpath.h new file mode 100644 index 0000000..112b517 --- /dev/null +++ b/mpcore/src/main/cpp/emu/dyncast_fastpath.h @@ -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); diff --git a/mpcore/src/main/cpp/emu/guest_engine.cpp b/mpcore/src/main/cpp/emu/guest_engine.cpp index e521545..95d0bae 100644 --- a/mpcore/src/main/cpp/emu/guest_engine.cpp +++ b/mpcore/src/main/cpp/emu/guest_engine.cpp @@ -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 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 lock(control_mutex_); uint32_t aligned = AlignUp(size, 8); diff --git a/mpcore/src/main/cpp/emu/guest_engine.h b/mpcore/src/main/cpp/emu/guest_engine.h index 4046d0d..ef75773 100644 --- a/mpcore/src/main/cpp/emu/guest_engine.h +++ b/mpcore/src/main/cpp/emu/guest_engine.h @@ -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 diff --git a/mpcore/src/main/cpp/emu/rtti_shims.cpp b/mpcore/src/main/cpp/emu/rtti_shims.cpp index 036c573..58b8094 100644 --- a/mpcore/src/main/cpp/emu/rtti_shims.cpp +++ b/mpcore/src/main/cpp/emu/rtti_shims.cpp @@ -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); diff --git a/mpcore/src/main/cpp/main.cpp b/mpcore/src/main/cpp/main.cpp index 353138d..eb5c04f 100644 --- a/mpcore/src/main/cpp/main.cpp +++ b/mpcore/src/main/cpp/main.cpp @@ -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