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/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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user