Survey dynamic_cast call shapes + native rate counter: 84% need no hierarchy walk
Task #58 asked whether the engine induces the __dynamic_cast flood or the game does. A native LD_PRELOAD interposer added to trace_agent answers it: native ARM32 on the A9 calls it up to 824,200/sec, against this engine's 284,986/sec. The game is simply that RTTI-heavy, and native absorbs it because a real call costs tens of nanoseconds. Our rate is not demand, it is supply - the shim boundary is throttling the guest. (Interposing __dynamic_cast collides with libc++abi.a, which the NDK links statically into the agent; resolved with --allow-multiple-definition scoped to that target, since our object precedes the archive and wins.) That makes a guest-side implementation interesting: Shim_dynamic_cast is already a pure guest-memory walk - it reads the object's vtable, vtable[-1] (dynamic type) and vtable[-2] (offset-to-top), then compares type_info records. Nothing comes from the host, so the same algorithm could run as emulated ARM32 with NO boundary crossing at all. Whether that is worth doing depends on how big it would have to be, so this adds a temporary shape survey. Measured over a prologue load, 2.7M calls: exact (depth 0) 84.2% one base 0.4% deeper 0.0% (max depth reached all run: 3) not found 15.4% 84% of calls need NO hierarchy walk - the object's dynamic type already IS the target. A guest fast path of roughly six instructions (load vtable, compare vtable[-1] to the target, return ptr + vtable[-2]) with a fallback to this shim would eliminate 84% of dynamic_cast crossings, which is ~41% of ALL shim crossings given dynamic_cast is 49% of them. Also confirms there is no implementation to reuse inside libapp.so: not only __dynamic_cast but the type_info vtables themselves (__class_type_info, __si_class_type_info, __vmi_class_type_info, __pointer_type_info) are all UND - this engine supplies them from rtti_shims.cpp. Survey left in the tree behind kSurveyDynamicCast, default off. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
#include <chrono>
|
||||
#include <atomic>
|
||||
#include "rtti_shims.h"
|
||||
#include "../util/util.h"
|
||||
#include <cstring>
|
||||
@@ -64,9 +66,12 @@ struct GuestVmiClassTypeInfoHeader {
|
||||
// __vmi_class_type_info (multiple bases, recurse each, fail on ambiguity).
|
||||
// Virtual-base offsets (need the vbase table, not just a static offset)
|
||||
// are a documented gap - skipped, not silently wrong.
|
||||
thread_local int g_searchDepthReached = 0;
|
||||
|
||||
bool SearchBase(GuestEngine& eng, GuestAddr typeInfoAddr, GuestAddr dstTypeInfoAddr,
|
||||
int32_t curOffset, int32_t* outOffset, int depth) {
|
||||
if (!typeInfoAddr || depth > 32) return false;
|
||||
if (depth > g_searchDepthReached) g_searchDepthReached = depth;
|
||||
if (typeInfoAddr == dstTypeInfoAddr) {
|
||||
*outOffset = curOffset;
|
||||
return true;
|
||||
@@ -111,9 +116,44 @@ bool SearchBase(GuestEngine& eng, GuestAddr typeInfoAddr, GuestAddr dstTypeInfoA
|
||||
return false;
|
||||
}
|
||||
|
||||
// Task #59 shape survey (2026-09-19, temporary). Native calls __dynamic_cast
|
||||
// up to 824,000 times/sec and this engine can only serve ~285,000 - the
|
||||
// boundary, not the algorithm, is the bottleneck (task #58). A guest-side
|
||||
// ARM32 implementation would avoid the crossing entirely, but only pays off
|
||||
// if it can be SMALL. So: which cases actually occur? If the overwhelming
|
||||
// majority are "the object already is the target type" or one single-
|
||||
// inheritance step, a short guest fast path with a fallback to this shim
|
||||
// captures nearly everything. Counted, not assumed.
|
||||
namespace {
|
||||
constexpr bool kSurveyDynamicCast = false;
|
||||
std::atomic<uint64_t> g_dcTotal{0}, g_dcNullArg{0}, g_dcExact{0}, g_dcDepth1{0},
|
||||
g_dcDeeper{0}, g_dcMiss{0}, g_dcMaxDepth{0};
|
||||
void SurveyReport() {
|
||||
static std::atomic<uint64_t> lastNs{0};
|
||||
uint64_t now = (uint64_t)std::chrono::duration_cast<std::chrono::nanoseconds>(
|
||||
std::chrono::steady_clock::now().time_since_epoch()).count();
|
||||
uint64_t last = lastNs.load(std::memory_order_relaxed);
|
||||
if (now - last < 2000000000ull) return;
|
||||
if (!lastNs.compare_exchange_strong(last, now, std::memory_order_relaxed) || last == 0) return;
|
||||
uint64_t t = g_dcTotal.load(std::memory_order_relaxed);
|
||||
if (!t) return;
|
||||
auto pct = [t](std::atomic<uint64_t>& c) {
|
||||
return 100.0 * (double)c.load(std::memory_order_relaxed) / (double)t;
|
||||
};
|
||||
Log("rtti_shims: DCSHAPE total=%llu | exact(depth0)=%.1f%% oneBase(depth1)=%.1f%% "
|
||||
"deeper=%.1f%% notFound=%.1f%% nullArg=%.1f%% | maxDepth=%llu",
|
||||
(unsigned long long)t, pct(g_dcExact), pct(g_dcDepth1), pct(g_dcDeeper), pct(g_dcMiss),
|
||||
pct(g_dcNullArg), (unsigned long long)g_dcMaxDepth.load(std::memory_order_relaxed));
|
||||
}
|
||||
} // namespace
|
||||
|
||||
uint32_t Shim_dynamic_cast(GuestEngine& eng, uint32_t srcPtr, uint32_t /*srcTypeInfo*/,
|
||||
uint32_t dstTypeInfo, uint32_t /*src2dstHint*/, uint32_t) {
|
||||
if (!srcPtr || !dstTypeInfo) return 0;
|
||||
if (kSurveyDynamicCast) { g_dcTotal.fetch_add(1, std::memory_order_relaxed); SurveyReport(); }
|
||||
if (!srcPtr || !dstTypeInfo) {
|
||||
if (kSurveyDynamicCast) g_dcNullArg.fetch_add(1, std::memory_order_relaxed);
|
||||
return 0;
|
||||
}
|
||||
uint32_t objVtablePtr = 0;
|
||||
memcpy(&objVtablePtr, eng.G2H(srcPtr), 4);
|
||||
if (!objVtablePtr) return 0;
|
||||
@@ -130,7 +170,20 @@ uint32_t Shim_dynamic_cast(GuestEngine& eng, uint32_t srcPtr, uint32_t /*srcType
|
||||
GuestAddr mostDerivedPtr = (GuestAddr)((int32_t)srcPtr + (int32_t)offsetToTopRaw);
|
||||
|
||||
int32_t foundOffset = 0;
|
||||
if (!SearchBase(eng, dynTypeInfo, dstTypeInfo, 0, &foundOffset, 0)) return 0;
|
||||
if (kSurveyDynamicCast && dynTypeInfo == dstTypeInfo) {
|
||||
g_dcExact.fetch_add(1, std::memory_order_relaxed);
|
||||
return (uint32_t)mostDerivedPtr;
|
||||
}
|
||||
g_searchDepthReached = 0;
|
||||
if (!SearchBase(eng, dynTypeInfo, dstTypeInfo, 0, &foundOffset, 0)) {
|
||||
if (kSurveyDynamicCast) g_dcMiss.fetch_add(1, std::memory_order_relaxed);
|
||||
return 0;
|
||||
}
|
||||
if (kSurveyDynamicCast) {
|
||||
(g_searchDepthReached <= 1 ? g_dcDepth1 : g_dcDeeper).fetch_add(1, std::memory_order_relaxed);
|
||||
uint64_t d = (uint64_t)g_searchDepthReached, prev = g_dcMaxDepth.load(std::memory_order_relaxed);
|
||||
while (d > prev && !g_dcMaxDepth.compare_exchange_weak(prev, d, std::memory_order_relaxed)) {}
|
||||
}
|
||||
return (uint32_t)((int32_t)mostDerivedPtr + foundOffset);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,3 +31,11 @@ target_link_libraries(trace_agent
|
||||
)
|
||||
|
||||
target_compile_options(trace_agent PRIVATE -Wall -Wno-unused-parameter)
|
||||
|
||||
# __dynamic_cast is also defined in libc++abi.a, which the NDK links
|
||||
# statically into this agent. Interposing it (task #58 - counting the real
|
||||
# call rate on native hardware) therefore produces a duplicate-symbol link
|
||||
# error. Our definition comes from this target's own objects, which the
|
||||
# linker sees before the archive, so it wins; the archive copy is simply
|
||||
# left unused. Scoped to this target rather than set globally.
|
||||
target_link_options(trace_agent PRIVATE "-Wl,--allow-multiple-definition")
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include <fcntl.h>
|
||||
#include <atomic>
|
||||
#include <cstdarg>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
@@ -564,4 +565,40 @@ void glLinkProgram(GLuint program) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---- __dynamic_cast rate (2026-09-19, task #58) ----
|
||||
// The emulated engine's own per-shim counter found __dynamic_cast making up
|
||||
// 49% of ALL shim crossings during a prologue load - 284,986 calls/sec. The
|
||||
// obvious question that number does NOT answer: is that the game's own
|
||||
// behaviour, or something this engine induces? libapp.so imports
|
||||
// __dynamic_cast as an undefined symbol, so LD_PRELOAD can count the real
|
||||
// thing on native ARM32 hardware and settle it.
|
||||
//
|
||||
// Reports a rate once a second rather than logging each call: at a quarter
|
||||
// million per second, per-call logging would dominate the measurement (and
|
||||
// this agent writes to a file, see InitFileLog). Deliberately mirrors the
|
||||
// engine's TOPSHIMS accounting so the two numbers are directly comparable.
|
||||
void* __dynamic_cast(const void* sub, const void* src, const void* dst, ptrdiff_t src2dst) {
|
||||
static auto real = RealSym<void* (*)(const void*, const void*, const void*, ptrdiff_t)>(
|
||||
"__dynamic_cast");
|
||||
static std::atomic<uint64_t> calls{0};
|
||||
static std::atomic<uint64_t> lastNs{0};
|
||||
static uint64_t prevCalls = 0;
|
||||
|
||||
uint64_t n = calls.fetch_add(1, std::memory_order_relaxed) + 1;
|
||||
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||
uint64_t now = (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec;
|
||||
uint64_t last = lastNs.load(std::memory_order_relaxed);
|
||||
if (now - last >= 1000000000ull &&
|
||||
lastNs.compare_exchange_strong(last, now, std::memory_order_relaxed) && last != 0) {
|
||||
double dt = (now - last) / 1e9;
|
||||
LOGI("DYNCAST native rate=%.0f/s (total %llu)", (double)(n - prevCalls) / dt,
|
||||
(unsigned long long)n);
|
||||
prevCalls = n;
|
||||
}
|
||||
return real(sub, src, dst, src2dst);
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
|
||||
Reference in New Issue
Block a user