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:
@@ -45,6 +45,7 @@ add_library(${CMAKE_PROJECT_NAME} SHARED
|
|||||||
emu/pthread_shim.cpp
|
emu/pthread_shim.cpp
|
||||||
emu/jni_shim.cpp
|
emu/jni_shim.cpp
|
||||||
emu/gles_shim.cpp
|
emu/gles_shim.cpp
|
||||||
|
emu/dyncast_fastpath.cpp
|
||||||
emu/libc_shims.cpp
|
emu/libc_shims.cpp
|
||||||
emu/rtti_shims.cpp
|
emu/rtti_shims.cpp
|
||||||
emu/fmod_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);
|
||||||
@@ -3025,6 +3025,15 @@ uc_engine* GuestEngine::CreateConfiguredEngine() {
|
|||||||
{0, image_end_, UC_PROT_ALL, "image (RWX)"},
|
{0, image_end_, UC_PROT_ALL, "image (RWX)"},
|
||||||
{image_end_, heap_end_ - image_end_, UC_PROT_READ | UC_PROT_WRITE, "heap (RW)"},
|
{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)"},
|
{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)"},
|
{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)"},
|
{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;
|
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) {
|
GuestAddr GuestEngine::AllocPermanent(uint32_t size) {
|
||||||
std::lock_guard<std::mutex> lock(control_mutex_);
|
std::lock_guard<std::mutex> lock(control_mutex_);
|
||||||
uint32_t aligned = AlignUp(size, 8);
|
uint32_t aligned = AlignUp(size, 8);
|
||||||
|
|||||||
@@ -309,6 +309,14 @@ public:
|
|||||||
// at a stub address, it only ever exists to be intercepted).
|
// at a stub address, it only ever exists to be intercepted).
|
||||||
GuestAddr AllocCodeStub(uc_cb_hookcode_t callback, void* userData);
|
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
|
// Resolves `symbolName` to a real, guest-callable address, creating a
|
||||||
// fresh import stub (via AllocCodeStub) the first time it's asked for
|
// 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
|
// and caching it thereafter - the same path a real ELF PLT import goes
|
||||||
|
|||||||
@@ -838,6 +838,12 @@ void SetupRttiDataSymbols(GuestEngine& engine) {
|
|||||||
|
|
||||||
void RegisterRttiImportShims(GuestEngine& engine) {
|
void RegisterRttiImportShims(GuestEngine& engine) {
|
||||||
engine.RegisterImportShim("__dynamic_cast", Shim_dynamic_cast);
|
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("__cxa_bad_typeid", Shim_cxa_bad_typeid);
|
||||||
|
|
||||||
engine.RegisterImportShim("_ZNSt13runtime_errorC2EPKc", Shim_runtime_error_ctor);
|
engine.RegisterImportShim("_ZNSt13runtime_errorC2EPKc", Shim_runtime_error_ctor);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
#include "emu/gles_shim.h"
|
#include "emu/gles_shim.h"
|
||||||
#include "emu/libc_shims.h"
|
#include "emu/libc_shims.h"
|
||||||
#include "emu/rtti_shims.h"
|
#include "emu/rtti_shims.h"
|
||||||
|
#include "emu/dyncast_fastpath.h"
|
||||||
#include "emu/fmod_shims.h"
|
#include "emu/fmod_shims.h"
|
||||||
#include "emu/profiler.h"
|
#include "emu/profiler.h"
|
||||||
#include "real_native_offsets.h"
|
#include "real_native_offsets.h"
|
||||||
@@ -133,6 +134,9 @@ bool LoadEmulatedLibapp(const char* path, JNIEnv* env, jobject thiz) {
|
|||||||
RegisterPthreadImportShims(engine);
|
RegisterPthreadImportShims(engine);
|
||||||
RegisterGlesImportShims(engine);
|
RegisterGlesImportShims(engine);
|
||||||
RegisterRttiImportShims(engine);
|
RegisterRttiImportShims(engine);
|
||||||
|
// Must follow RegisterRttiImportShims: the fast path's fallback resolves
|
||||||
|
// "__dynamic_cast_slowpath", which that call registers.
|
||||||
|
RegisterDynamicCastFastPath(engine);
|
||||||
RegisterFmodImportShims(engine);
|
RegisterFmodImportShims(engine);
|
||||||
// Must be registered BEFORE LoadImage() - SetupRttiDataSymbols needs to
|
// Must be registered BEFORE LoadImage() - SetupRttiDataSymbols needs to
|
||||||
// run after MapSegments (for AllocPermanent) but strictly before
|
// run after MapSegments (for AllocPermanent) but strictly before
|
||||||
|
|||||||
Reference in New Issue
Block a user