feat(scripting): frame generational collector for wasm scripts (#13869) 0613f5b262 * add rive --bench for headless frame timing and a rasc runtime env override * map the guest sampler compare sentinel to none instead of casting it raw * stage tier ladder wamrc input before the in-place module load corrupts it * compile ladder artifacts with sw bounds checks so non guard page runtimes can run them * carry the execution tier through the shared module cache and let bench wait for the o3 swap * add an allocation churn kernel to the wamr bench * recurring leak warnings with a time-to-trap estimate, arena modules watched too * allocation-free Mat4 Into builders and multiply, reusable render pass wrapper, cached canvas view * deliver decode completions at the frame boundary and re-take the mark around them and resize * build the canvas view wrapper eagerly so it cannot land above a frame arena mark * rive.yaml wasmRuntime choice and finalizer-based handle release under the collecting runtime * pregrow aot memory to the declared ceiling and cap ladder artifacts at opt level 1 * let tlsf claim pregrown memory before growing so aot pregrow does not read as oom * per runtime leak wording via a collected marker export and trap context after warnings * hw bounds artifact species on the tier ladder for guard page builds * cap hw artifacts at opt level 0, the miscompile bites earlier without inline checks * bind ASC_RUNTIME so runtime-gated std branches stop compiling as stub * register the handle finalizer through the runtime, pin its closure, zero renew slack * finalizer, soak and aot differential coverage plus a no-ceiling aot notice * frame collector milestone 0: checked arena runtime, design doc and per-visitor generation fix * frame collector milestone 1: cheney scavenger, moving visitors, remembered set, exact handle reap * record frame collector m1 status in the design doc * pass multivalue through bake postOptimize so value struct modules survive wasm-opt * add RIVE_RASC_UNSAFE bake hatch and inert arena hooks on collecting runtimes * record box2d and fields comparison results in the frame gc design doc * frame collector milestone 2: copying major, large objects born old, pin list, debug heap verifier * spill start function managed locals to the shadow stack so mid-init itcms steps see them * count majors for telemetry and pin the threshold trigger with a fixture * design doc: record milestone 2 and the copying major decision * android armv7 wamr lane and a headless on-device gc bench tool * calibrate the major threshold at the first boundary instead of collecting init retention * declare THUMBV7 to the aot loader and print fault addresses in the device bench * design doc: console validation results and the arm32 aot lane status * fix wamrc strict fp miscompile: strictfp on generated functions, codegen cap below ir opt, ladder lifts sw to optimized ir * frame collector milestone 3: sliced major marks and sweeps across boundaries for big heaps * design doc: record milestone 3 * address review: reentrant decode cancel, bump-based leak watch under pregrow, pin chunk refcounts, stale view wrapper on resize, windows bench build * check in the churn soak project used for on-device collector validation * address review round two: carve capacity includes the block gap, unpin marks during majors, gate the bench target, hw species in the ladder test * address review round three: reject bad bench counts, version the artifact cache, cover late visitor stubs, clear remembered bits at the copying major * pin the leak warning firing, unpin-during-sweep, and stale remembered bit with tests * match the pin contract: trap on double pin and foreign unpin * twin the function env visitor and cover closure survival under the frame collector * export the major tuning knobs from frame bakes and stop callModule folding traps and missing exports into zero * consolidate on the frame collector: delete tcms and the arena lane, deprecate incremental, default wasmRuntime to frame * remove the incremental runtime: itcms, tlsf, and the shadow stack live on archive/rasc-gc-experiments with tcms and the arena * sweep retained chunks in sliced majors, relocate DataView dataStart, atomic ladder cache writes, review fixes * gc bench covers every module vm and the tier bench fails when the O3 swap never lands * objects born in the old region during a sweep only carry a mark while the sweep still covers their chunk * switch the sweep to the retained list even when the budget dies on the last old chunk * reject non-positive --bench frame counts * renumber our wamrc patches past master's 0013 windows patch Co-authored-by: Luigi Rosso <luigi-rosso@users.noreply.github.com>
diff --git a/.rive_head b/.rive_head index 14789ee..17d18e7 100644 --- a/.rive_head +++ b/.rive_head
@@ -1 +1 @@ -6066a2edc0d6500e26ea83cc7d849ee095f6c790 +0613f5b2624c3aa03e8158a9daf4996864305240
diff --git a/include/rive/wasm/module_tier_ladder.hpp b/include/rive/wasm/module_tier_ladder.hpp index 7c62567..d353edb 100644 --- a/include/rive/wasm/module_tier_ladder.hpp +++ b/include/rive/wasm/module_tier_ladder.hpp
@@ -22,7 +22,10 @@ enum class TierSpecies : uint8_t { o0 = 0, // wamrc -O0: ~2x interp, sub-second for typical modules - o3 = 1, // wamrc -O3: native parity, the bits that ship + o3 = 1, // wamrc top opt level with sw bounds, runs on every build + // No inline bounds checks; needs the guard-page trap handler, so only + // RIVE_WASM_HW_BOUNDS builds produce or load these. + hw = 2, }; // Drives wamrc subprocesses that turn wasm modules into AOT artifacts and @@ -60,6 +63,11 @@ uint64_t moduleKey, Span<const uint8_t> moduleBytes); + // Write the module's wamrc input before wasm_runtime_load touches the + // buffer: the fast-interp loader rewrites it in place, so bytes staged + // at schedule time are no longer valid wasm. + void stagePristine(uint64_t moduleKey, Span<const uint8_t> moduleBytes); + // Ready artifact path for a module at the given species, empty if none. std::string artifactPath(uint64_t moduleKey, TierSpecies species);
diff --git a/include/rive/wasm/wasm_scripting_vm.hpp b/include/rive/wasm/wasm_scripting_vm.hpp index 58504fb..6ef7b54 100644 --- a/include/rive/wasm/wasm_scripting_vm.hpp +++ b/include/rive/wasm/wasm_scripting_vm.hpp
@@ -269,31 +269,30 @@ /// no wasm frames on the stack. Returns true when a swap happened. bool maybeUpgradeTier(); - /// Frame-arena contract for stub-runtime modules. A module opts in by - /// exporting __riveFrameArena and __riveFrameRewind (rt/frame in rasc's - /// std). The host takes the arena mark at the first frame boundary after - /// the first advance completes, re-takes it after any later script init - /// so init allocations persist, and rewinds to it at every other frame - /// boundary. The contract: state that must survive a frame lives in - /// allocations made before the mark; module-static caches over per-frame - /// data register an __onReset hook and drop them on rewind. - /// - /// Stub-runtime modules without the opt-in get a leak watch instead: - /// once linear memory grows well past its post-first-advance baseline - /// the call returns a one-time warning for the host's log channel - /// (RIVE_WASM_LEAK_WARN=0 silences it, e.g. for benches). + /// The per-frame collection point: frame collector modules scavenge + /// here (call once during load after init so init promotion lands in + /// load time). All rasc modules also get the leak watch: once the heap + /// grows well past its post-first-advance baseline the call returns a + /// warning for the host's log channel, re-armed per 8MB of further + /// growth and carrying a time-to-trap estimate (RIVE_WASM_LEAK_WARN=0 + /// silences it, e.g. for benches). /// /// Call once per host frame with no wasm frames live; returns null when /// there is nothing to report. const char* frameBoundary(); const char* handleLeakWarning(); + const char* heapGrowthWarning(); + + /// Current size of the module's linear memory in 64KB wasm pages. + uint32_t memoryPages() const; /// Swap execution onto a compiled artifact of this module, carrying /// memory, globals, and tables. No wasm frames may be live. On failure /// the current instance keeps running. bool applyTierArtifact(Span<const uint8_t> artifactBytes, ExecutionTier tier, - std::string& error); + std::string& error, + bool hwBounds = false); /// Backend seams: host code reaches module memory and module functions /// only through these, so a browser backend can substitute staged @@ -311,6 +310,19 @@ uint32_t argc, uint32_t* argv); + /// The honest variant: distinguishes a missing export from a trap from + /// a real zero result, for callers where the difference is a failure. + enum class CallOutcome + { + ok, + missing, + trapped, + }; + CallOutcome callModuleChecked(const char* name, + uint32_t argc, + uint32_t* argv, + uint32_t* result); + /// Ends module execution like a trap once the current native returns. virtual void raiseModuleError(const char* message); @@ -370,17 +382,34 @@ /// execution, completed or cancelled entries erase themselves. std::unordered_map<uint32_t, rcp<WorkTask>> m_pendingDecodes; uint64_t m_decodeOwnerId = 0; - // Frame-arena contract state; see frameBoundary(). - bool m_frameArenaOptIn = false; - bool m_frameArenaPending = false; - bool m_frameArenaAnnounced = false; - uint32_t m_frameArenaMark = 0; + // Delivery is synchronous, so the pixels borrow the decoder's buffer. + struct DecodeResult + { + bool ok = false; + uint32_t token = 0; + uint32_t width = 0; + uint32_t height = 0; + Span<const uint8_t> pixels; + std::string error; + }; + void deliverDecodeResult(const DecodeResult& result); bool m_advancedOnce = false; + bool m_frameMinor = false; + bool m_frameMinorAnnounced = false; + /// Module exports __riveHeapUsed; the leak watch reads bump bytes + /// instead of page counts, which pregrown aot memory freezes. + bool m_heapUsedProbe = false; + /// Collected runtimes warn only on a second consecutive growth window. + bool m_leakArmedCollected = false; bool m_leakWatch = false; - uint32_t m_leakBaselinePages = 0; - uint32_t m_leakFrames = 0; std::string m_leakWarning; - bool m_reapHandles = false; + uint32_t m_leakBaselinePages = 0; + uint32_t m_leakFirstBaselinePages = 0; + uint32_t m_leakFrames = 0; + uint32_t m_leakTotalFrames = 0; + uint32_t m_leakWarningCount = 0; + bool m_leakTrapContextPrinted = false; + bool m_collectedRuntime = false; bool m_handleWatch = false; uint32_t m_handleBaselineLive = 0; uint32_t m_handleFrames = 0;
diff --git a/scripting/premake5_wamr.lua b/scripting/premake5_wamr.lua index ae2e020..8aa357f 100644 --- a/scripting/premake5_wamr.lua +++ b/scripting/premake5_wamr.lua
@@ -49,7 +49,11 @@ table.insert(wamrConfigDefines, 'WASM_API_EXTERN=') end -local platformDir = os.target() == 'linux' and 'linux' +-- os.target() stays the host under --for_android; the option is the +-- truth for the platform dir. +local forAndroid = _OPTIONS['for_android'] ~= nil +local platformDir = forAndroid and 'android' + or os.target() == 'linux' and 'linux' or os.target() == 'windows' and 'windows' or 'darwin' @@ -72,13 +76,15 @@ buildoptions({ '-fno-lto' }) -- The tail-dup knobs need llvm 19; older clangs build correct but -- slower dispatch, still caught by the dispatch-site guard where it runs. + -- The probe runs host clang; cross toolchains (the NDK's) may reject + -- the knobs the host accepts, so cross builds keep default dispatch. local devNull = os.ishost('windows') and 'NUL' or '/dev/null' local _, tailDupProbe = os.outputof( 'clang -fsyntax-only -x c ' .. devNull .. ' -mllvm -tail-dup-pred-size=5000' .. ' -mllvm -tail-dup-succ-size=5000 2>&1' ) - if tailDupProbe == 0 then + if tailDupProbe == 0 and _OPTIONS['for_android'] == nil then buildoptions({ '-mllvm -tail-dup-pred-size=5000', '-mllvm -tail-dup-succ-size=5000', @@ -89,6 +95,8 @@ defines({ 'BH_PLATFORM_DARWIN' }) filter({ 'system:linux' }) defines({ 'BH_PLATFORM_LINUX' }) + filter({ 'system:android' }) + defines({ 'BH_PLATFORM_ANDROID' }) filter({ 'system:windows' }) defines({ 'BH_PLATFORM_WINDOWS', @@ -107,9 +115,20 @@ else isArm64 = machine == 'arm64' or machine == 'aarch64' end + local isArm32 = forAndroid and archOption == 'arm' -- The quoted string define goes through buildoptions pre-escaped so both -- the gmake and ninja generators deliver the quotes to the compiler. - if isArm64 then + if isArm32 then + -- NDK armv7 compiles thumb2 with NEON/VFP by default. Upstream's + -- thumb reloc does pointer arithmetic clang 18 makes a hard error. + -- The THUMBV7 string matters: the aot loader matches it against + -- wamrc's thumbv7 artifacts (bare THUMB defaults to thumbv4t). + defines({ 'BUILD_TARGET_THUMB_VFP' }) + buildoptions({ + '-DBUILD_TARGET=\\"THUMBV7\\"', + '-Wno-int-conversion', + }) + elseif isArm64 then defines({ 'BUILD_TARGET_AARCH64' }) buildoptions({ '-DBUILD_TARGET=\\"AARCH64\\"' }) else @@ -118,13 +137,17 @@ end -- em64 is WAMR's x86-64 SysV invoke shim. With WASM_ENABLE_SIMD the -- invoke marshaling widens float slots to v128, so the shim must be the - -- _simd variant or int registers load from the wrong offsets. Windows - -- takes the mingw shim: same Win64 ABI as MSVC targets, and GAS syntax - -- that clang's integrated assembler handles without ml64. - local invokeNative = isArm64 and 'invokeNative_aarch64_simd.s' + -- _simd variant or int registers load from the wrong offsets. arm32 has + -- no simd invoke variant; its marshaling never widens. Windows takes + -- the mingw shim: same Win64 ABI as MSVC targets, and GAS syntax that + -- clang's integrated assembler handles without ml64. + local invokeNative = isArm32 and 'invokeNative_thumb_vfp.s' + or isArm64 and 'invokeNative_aarch64_simd.s' or os.target() == 'windows' and 'invokeNative_mingw_x64_simd.s' or 'invokeNative_em64_simd.s' - local aotReloc = isArm64 and 'aot_reloc_aarch64.c' or 'aot_reloc_x86_64.c' + local aotReloc = isArm32 and 'aot_reloc_thumb.c' + or isArm64 and 'aot_reloc_aarch64.c' + or 'aot_reloc_x86_64.c' includedirs({ wamr .. '/core/iwasm/include', wamr .. '/core/iwasm/common',
diff --git a/scripting/wamr_patches/0014-strictfp-on-generated-functions.patch b/scripting/wamr_patches/0014-strictfp-on-generated-functions.patch new file mode 100644 index 0000000..1548a4b --- /dev/null +++ b/scripting/wamr_patches/0014-strictfp-on-generated-functions.patch
@@ -0,0 +1,24 @@ +--- a/core/iwasm/compilation/aot_llvm.c ++++ b/core/iwasm/compilation/aot_llvm.c +@@ -95,6 +95,21 @@ + return NULL; + } + ++ /* Hard-float lowering emits llvm.experimental.constrained.* calls; ++ LangRef requires strictfp on any function containing them, or the ++ optimizer and ISel are free to assume default FP semantics and ++ miscompile (observed at opt-level >= 1 once mem2reg/sroa expose the ++ dataflow). */ ++ { ++ unsigned kind = LLVMGetEnumAttributeKindForName("strictfp", 8); ++ if (kind != 0) { ++ LLVMAddAttributeAtIndex( ++ func, (LLVMAttributeIndex)LLVMAttributeFunctionIndex, ++ LLVMCreateEnumAttribute(LLVMGetModuleContext(module), kind, ++ 0)); ++ } ++ } ++ + j = 0; + local_value = LLVMGetParam(func, j++); + LLVMSetValueName(local_value, "exec_env");
diff --git a/scripting/wamr_patches/0015-codegen-opt-level-cap.patch b/scripting/wamr_patches/0015-codegen-opt-level-cap.patch new file mode 100644 index 0000000..89da6e0 --- /dev/null +++ b/scripting/wamr_patches/0015-codegen-opt-level-cap.patch
@@ -0,0 +1,61 @@ +--- a/core/iwasm/compilation/aot_llvm.c ++++ b/core/iwasm/compilation/aot_llvm.c +@@ -3238,9 +3253,17 @@ + else + code_model = LLVMCodeModelSmall; + +- /* Create the target machine */ ++ /* Create the target machine. The backend level may be pinned below ++ the IR level: LLVM's optimizing AArch64 codegen miscompiles the ++ strict (constrained) FP this compiler emits, so a build can keep ++ IR optimization while selecting instructions conservatively. */ ++ uint32 tm_opt_level = opt_level; ++ if (option->codegen_opt_level_plus_one != 0 ++ && option->codegen_opt_level_plus_one - 1 < tm_opt_level) { ++ tm_opt_level = option->codegen_opt_level_plus_one - 1; ++ } + if (!(comp_ctx->target_machine = LLVMCreateTargetMachineWithOpts( +- target, triple_norm, cpu, features, opt_level, ++ target, triple_norm, cpu, features, tm_opt_level, + LLVMRelocStatic, code_model, false, + comp_ctx->stack_usage_file))) { + aot_set_last_error("create LLVM target machine failed."); +--- a/core/iwasm/include/aot_comp_option.h ++++ b/core/iwasm/include/aot_comp_option.h +@@ -83,6 +83,10 @@ + bool enable_shared_chain; + char *use_prof_file; + uint32_t opt_level; ++ /* Backend codegen level override, stored as level + 1 so a zeroed ++ struct follows opt_level. Lets a build keep IR optimization while ++ pinning conservative instruction selection. */ ++ uint32_t codegen_opt_level_plus_one; + uint32_t size_level; + uint32_t output_format; + uint32_t bounds_checks; +--- a/wamr-compiler/main.c ++++ b/wamr-compiler/main.c +@@ -130,6 +130,7 @@ + printf(" For example, --cpu-features=+feature1,-feature2\n"); + printf(" Use --cpu-features=+help to list all the features supported\n"); + printf(" --opt-level=n Set the optimization level (0 to 3, default is 3)\n"); ++ printf(" --codegen-opt-level=n Cap the backend codegen level below the IR opt level\n"); + printf(" --size-level=n Set the code size level (0 to 3, default is 3)\n"); + printf(" 0 - Large code model\n"); + printf(" 1 - Medium code model\n"); +@@ -472,6 +473,15 @@ + option.opt_level = (uint32)atoi(argv[0] + 12); + if (option.opt_level > 3) + option.opt_level = 3; ++ } ++ else if (!strncmp(argv[0], "--codegen-opt-level=", 20)) { ++ uint32 level; ++ if (argv[0][20] == '\0') ++ PRINT_HELP_AND_EXIT(); ++ level = (uint32)atoi(argv[0] + 20); ++ if (level > 3) ++ level = 3; ++ option.codegen_opt_level_plus_one = level + 1; + } + else if (!strncmp(argv[0], "--size-level=", 13)) { + if (argv[0][13] == '\0')
diff --git a/src/wasm/module_tier_ladder.cpp b/src/wasm/module_tier_ladder.cpp index da0b28c..5318d0f 100644 --- a/src/wasm/module_tier_ladder.cpp +++ b/src/wasm/module_tier_ladder.cpp
@@ -7,6 +7,7 @@ #include <algorithm> #include <cerrno> #include <cstdio> +#include <filesystem> #include <fcntl.h> #include <cstdlib> #include <cstring> @@ -71,6 +72,17 @@ { m_wamrcPath = getenv("RIVE_WAMRC"); } + // Self-configure from env so the first module load, which happens + // before any host configure call, can stage its pristine bytes. + if (m_cacheDir.empty() && !m_wamrcPath.empty()) + { + const char* dirEnv = getenv("RIVE_AOT_CACHE_DIR"); + m_cacheDir = + dirEnv != nullptr + ? dirEnv + : (std::filesystem::temp_directory_path() / "rive_aot_cache") + .string(); + } return !m_wamrcPath.empty() && !m_cacheDir.empty(); } @@ -119,7 +131,10 @@ std::string ModuleTierLadder::keyedCacheDir() { - std::string dir = m_cacheDir + "/" + wamrcVersion(); + // The revision folds our wamrc flag choices into the cache key; bump + // it whenever species flags change or stale artifacts (like the + // pre-codegen-cap -O1 ones) get served on a cache hit. + std::string dir = m_cacheDir + "/" + wamrcVersion() + "-r3"; mkdir(m_cacheDir.c_str(), 0755); mkdir(dir.c_str(), 0755); return dir; @@ -128,11 +143,17 @@ std::string ModuleTierLadder::artifactName(uint64_t moduleKey, TierSpecies species) { + const char* pattern = "%016llx.aot"; + if (species == TierSpecies::o0) + { + pattern = "%016llx.o0.aot"; + } + else if (species == TierSpecies::hw) + { + pattern = "%016llx.hw.aot"; + } char name[64]; - snprintf(name, - sizeof(name), - species == TierSpecies::o0 ? "%016llx.o0.aot" : "%016llx.aot", - (unsigned long long)moduleKey); + snprintf(name, sizeof(name), pattern, (unsigned long long)moduleKey); return name; } @@ -153,6 +174,52 @@ return std::string(); } +// The cache is shared across processes and a name must never be visible +// half-written: write a process-unique temp, then atomically rename. +static bool writeFileAtomic(const std::string& path, Span<const uint8_t> bytes) +{ + std::string tmpPath = path + "." + std::to_string(getpid()) + ".tmp"; + FILE* f = fopen(tmpPath.c_str(), "wb"); + if (f == nullptr) + { + return false; + } + size_t written = fwrite(bytes.data(), 1, bytes.size(), f); + if (fclose(f) != 0 || written != bytes.size()) + { + unlink(tmpPath.c_str()); + return false; + } + if (rename(tmpPath.c_str(), path.c_str()) != 0) + { + unlink(tmpPath.c_str()); + return false; + } + return true; +} + +void ModuleTierLadder::stagePristine(uint64_t moduleKey, + Span<const uint8_t> moduleBytes) +{ + if (!enabled()) + { + return; + } + std::unique_lock<std::mutex> lock(m_mutex); + char wasmName[64]; + snprintf(wasmName, + sizeof(wasmName), + "%016llx.wasm", + (unsigned long long)moduleKey); + std::string wasmPath = keyedCacheDir() + "/" + wasmName; + struct stat st; + if (stat(wasmPath.c_str(), &st) == 0) + { + return; + } + writeFileAtomic(wasmPath, moduleBytes); +} + void ModuleTierLadder::schedule(const std::string& laneId, uint64_t moduleKey, Span<const uint8_t> moduleBytes) @@ -184,14 +251,21 @@ (unsigned long long)moduleKey); std::string wasmPath = dir + "/" + wasmName; + // On guard-page builds the top rung is the hw species: no inline + // bounds checks and growth never moves the memory base. +#ifdef RIVE_WASM_HW_BOUNDS + constexpr TierSpecies kTopSpecies = TierSpecies::hw; +#else + constexpr TierSpecies kTopSpecies = TierSpecies::o3; +#endif std::vector<TierSpecies> wanted; if (moduleBytes.size() <= kStraightToO3Bytes) { - wanted = {TierSpecies::o3}; + wanted = {kTopSpecies}; } else { - wanted = {TierSpecies::o0, TierSpecies::o3}; + wanted = {TierSpecies::o0, kTopSpecies}; } bool queued = false; @@ -212,13 +286,14 @@ } if (!queued) { - FILE* f = fopen(wasmPath.c_str(), "wb"); - if (f == nullptr) + // A pristine stage from before the in-place load wins; the bytes + // passed here may already be loader-rewritten. + struct stat wasmStat; + if (stat(wasmPath.c_str(), &wasmStat) != 0 && + !writeFileAtomic(wasmPath, moduleBytes)) { return; } - fwrite(moduleBytes.data(), 1, moduleBytes.size(), f); - fclose(f); queued = true; } m_queue.push_back( @@ -300,10 +375,33 @@ std::string tmpPath = finalPath + ".tmp"; std::vector<std::string> args = {wamrc}; - if (job.species == TierSpecies::o0) + if (job.species == TierSpecies::hw) + { + // Guard-page bounds; native stack checks stay sw (the runtime + // builds with WASM_DISABLE_STACK_HW_BOUND_CHECK). + args.push_back("--bounds-checks=0"); + args.push_back("--stack-bounds-checks=1"); + } + else + { + // sw bounds run on every build; wamrc's default hw-bounds output + // segfaults on runtimes without the guard-page trap handler. + args.push_back("--bounds-checks=1"); + } + if (job.species == TierSpecies::o0 || job.species == TierSpecies::hw) { args.push_back("--opt-level=0"); } + else + { + // LLVM's optimizing backend miscompiles the strict (constrained) + // FP wamrc emits, probabilistically corrupting float-heavy modules + // (box2d was the repro; the town's draco trap was the same class). + // Our vendored wamrc pins codegen conservative while the IR still + // optimizes; lift only against the box2d strict-probe soak. + args.push_back("--opt-level=1"); + args.push_back("--codegen-opt-level=0"); + } args.push_back("-o"); args.push_back(tmpPath); args.push_back(job.wasmPath); @@ -314,6 +412,11 @@ } argv.push_back(nullptr); +#ifdef RIVE_ANDROID + // No wamrc on device, and posix_spawn needs API 28; the ladder never + // schedules compiles here. + return false; +#else pid_t pid = -1; posix_spawn_file_actions_t actions; posix_spawn_file_actions_init(&actions); @@ -358,6 +461,7 @@ } // Atomic arrival: a partial artifact can never carry the final name. return rename(tmpPath.c_str(), finalPath.c_str()) == 0; +#endif } void ModuleTierLadder::drain() @@ -387,6 +491,7 @@ uint64_t, Span<const uint8_t>) {} +void ModuleTierLadder::stagePristine(uint64_t, Span<const uint8_t>) {} std::string ModuleTierLadder::artifactPath(uint64_t, TierSpecies) { return std::string();
diff --git a/src/wasm/wasm_scripting_vm.cpp b/src/wasm/wasm_scripting_vm.cpp index 4d65756..171a917 100644 --- a/src/wasm/wasm_scripting_vm.cpp +++ b/src/wasm/wasm_scripting_vm.cpp
@@ -122,10 +122,16 @@ // instance of the same content reloads identical bytes. Entries live for // the process; the byte buffer must outlive the module (wasm_runtime_load // keeps referencing it). +static void pregrowAotMemory(wasm_module_inst_t instance); + struct SharedWasmModule { std::vector<uint8_t> bytes; wasm_module_t module = nullptr; + // Artifact-backed entries hand every later VM their real tier; without + // this a cache hit reports interp while running compiled code. + WasmScriptingVM::ExecutionTier tier = + WasmScriptingVM::ExecutionTier::interp; }; static std::unordered_map<uint64_t, SharedWasmModule>& sharedModuleCache() @@ -165,11 +171,24 @@ uint32_t argc, uint32_t* argv) { + uint32_t result = 0; + callModuleChecked(name, argc, argv, &result); + return result; +} + +WasmScriptingVM::CallOutcome WasmScriptingVM::callModuleChecked( + const char* name, + uint32_t argc, + uint32_t* argv, + uint32_t* result) +{ wasm_module_inst_t inst = m_state->instance; wasm_function_inst_t f = wasm_runtime_lookup_function(inst, name); if (f == nullptr) { - return 0; + // Callers probing optional exports read the outcome; a plain + // callModule folds this to zero, so probe before relying on it. + return CallOutcome::missing; } uint32_t buf[8] = {0}; for (uint32_t i = 0; i < argc; i++) @@ -183,12 +202,25 @@ const char* exception = wasm_runtime_get_exception(inst); if (exception != nullptr) { - fprintf(stderr, "wasm call trapped: %s\n", exception); + fprintf(stderr, "wasm call trapped in %s: %s\n", name, exception); + if (m_leakWarningCount > 0 && !m_leakTrapContextPrinted) + { + // A bare trap after leak warnings is almost always the + // memory ceiling; say so once for hosts that dropped the + // warning strings. + m_leakTrapContextPrinted = true; + fprintf(stderr, + "wasm call trapped after %u script heap leak " + "warnings; the module likely hit its wasmMaxPages " + "ceiling\n", + m_leakWarningCount); + } wasm_runtime_clear_exception(inst); } - return 0; + return CallOutcome::trapped; } - return buf[0]; + *result = buf[0]; + return CallOutcome::ok; } void* WasmScriptingVM::resolveModulePtr(uint32_t appAddr, uint32_t size) @@ -1502,7 +1534,10 @@ desc.wrapU = (ore::WrapMode)podDesc->wrapU; desc.wrapV = (ore::WrapMode)podDesc->wrapV; desc.wrapW = (ore::WrapMode)podDesc->wrapW; - desc.compare = (ore::CompareFunction)podDesc->compare; + // The guest sends ~0 for "no comparison sampler". + desc.compare = podDesc->compare == 0xFFFFFFFFu + ? ore::CompareFunction::none + : (ore::CompareFunction)podDesc->compare; desc.minLod = podDesc->minLod; desc.maxLod = podDesc->maxLod; desc.maxAnisotropy = podDesc->maxAnisotropy; @@ -4736,6 +4771,44 @@ } } +void WasmScriptingVM::deliverDecodeResult(const DecodeResult& result) +{ + if (result.ok) + { + uint32_t byteCount = (uint32_t)result.pixels.size(); + uint32_t sizeArgs[1] = {byteCount}; + uint32_t pixelsPtr = callModule("malloc", 1, sizeArgs); + if (pixelsPtr == 0) + { + DecodeResult failure; + failure.token = result.token; + failure.error = "failed to allocate decoded pixels"; + deliverDecodeResult(failure); + return; + } + memcpy(resolveModulePtr(pixelsPtr, byteCount), + result.pixels.data(), + byteCount); + uint32_t args[6] = {m_L, + result.token, + result.width, + result.height, + pixelsPtr, + byteCount}; + callModule("host_image_decoded", 6, args); + guestFree(pixelsPtr); + return; + } + uint32_t messagePtr = guestString(result.error.c_str()); + if (messagePtr == 0) + { + return; + } + uint32_t args[3] = {m_L, result.token, messagePtr}; + callModule("host_image_decode_failed", 3, args); + guestFree(messagePtr); +} + void WasmScriptingVM::resolveImageDecode(uint32_t token, uint32_t width, uint32_t height, @@ -4747,17 +4820,13 @@ { return; } - uint32_t sizeArgs[1] = {byteCount}; - uint32_t pixelsPtr = callModule("malloc", 1, sizeArgs); - if (pixelsPtr == 0) - { - rejectImageDecode(token, "failed to allocate decoded pixels"); - return; - } - memcpy(resolveModulePtr(pixelsPtr, byteCount), pixels, byteCount); - uint32_t args[6] = {m_L, token, width, height, pixelsPtr, byteCount}; - callModule("host_image_decoded", 6, args); - guestFree(pixelsPtr); + DecodeResult result; + result.ok = true; + result.token = token; + result.width = width; + result.height = height; + result.pixels = Span<const uint8_t>(pixels, byteCount); + deliverDecodeResult(result); } void WasmScriptingVM::rejectImageDecode(uint32_t token, const char* message) @@ -4767,14 +4836,10 @@ { return; } - uint32_t messagePtr = guestString(message); - if (messagePtr == 0) - { - return; - } - uint32_t args[3] = {m_L, token, messagePtr}; - callModule("host_image_decode_failed", 3, args); - guestFree(messagePtr); + DecodeResult result; + result.token = token; + result.error = message; + deliverDecodeResult(result); } void WasmScriptingVM::setTimeoutMs(int ms) @@ -4895,11 +4960,24 @@ auto& ladder = ModuleTierLadder::instance(); if (ladder.enabled()) { - std::string path = ladder.artifactPath(moduleKey, TierSpecies::o3); - if (!path.empty() && path.size() < sizeof(aotPath)) +#ifdef RIVE_WASM_HW_BOUNDS + std::string hwPath = + ladder.artifactPath(moduleKey, TierSpecies::hw); + if (!hwPath.empty() && hwPath.size() < sizeof(aotPath)) { - memcpy(aotPath, path.c_str(), path.size() + 1); - haveAot = true; + memcpy(aotPath, hwPath.c_str(), hwPath.size() + 1); + haveHwAot = true; + } +#endif + if (!haveHwAot) + { + std::string path = + ladder.artifactPath(moduleKey, TierSpecies::o3); + if (!path.empty() && path.size() < sizeof(aotPath)) + { + memcpy(aotPath, path.c_str(), path.size() + 1); + haveAot = true; + } } } } @@ -4919,6 +4997,7 @@ { m_state->module = cached->second.module; m_state->ownsModule = false; + m_tier = cached->second.tier; // The VM's own copy is redundant against the cache entry, but the // tier ladder still needs the bytes; entries live for the process. m_scheduleBytes = Span<const uint8_t>(cached->second.bytes.data(), @@ -4944,6 +5023,15 @@ m_tier = ExecutionTier::aotO3; } } + if (!haveAot && !haveHwAot) + { + // The load below rewrites the buffer in place; wamrc needs the + // module as it is now. + ModuleTierLadder::instance().stagePristine( + m_moduleKey, + Span<const uint8_t>(m_moduleBytes.data(), + m_moduleBytes.size())); + } m_state->module = wasm_runtime_load(m_moduleBytes.data(), (uint32_t)m_moduleBytes.size(), error, @@ -4957,6 +5045,7 @@ SharedWasmModule entry; entry.bytes = std::move(m_moduleBytes); entry.module = m_state->module; + entry.tier = m_tier; auto inserted = cache.emplace(moduleKey, std::move(entry)); m_state->ownsModule = false; m_scheduleBytes = @@ -4995,6 +5084,10 @@ m_lastError = std::string("module instantiate failed: ") + error; return false; } + if (m_tier != ExecutionTier::interp && !haveHwAot) + { + pregrowAotMemory(m_state->instance); + } m_state->execEnv = wasm_runtime_create_exec_env(m_state->instance, 512 * 1024); if (m_state->execEnv == nullptr) @@ -5004,31 +5097,27 @@ } wasm_runtime_set_user_data(m_state->execEnv, this); - // Frame-arena opt-in, else the leak watch for rasc-linked modules - // (riveRegister marks one); their stub allocator never frees, so - // unbounded linear-memory growth is content leaking per frame. - m_frameArenaOptIn = - wasm_runtime_lookup_function(m_state->instance, "__riveFrameArena") != - nullptr && - wasm_runtime_lookup_function(m_state->instance, "__riveFrameRewind") != - nullptr; + // The leak watch for rasc-linked modules (riveRegister marks one): the + // stub baseline never frees, and sustained growth on a collecting + // module is references accumulating. const char* leakEnv = getenv("RIVE_WASM_LEAK_WARN"); - m_leakWatch = !m_frameArenaOptIn && - wasm_runtime_lookup_function(m_state->instance, + m_leakWatch = wasm_runtime_lookup_function(m_state->instance, "riveRegister") != nullptr && (leakEnv == nullptr || strcmp(leakEnv, "0") != 0); - // Frame-scoped handle reaping only composes with the arena: the rewind - // is the proof that unreleased per-frame wrappers are unreachable. Both - // hooks or neither, else a missing rebase would reap mark-scoped mints. - m_reapHandles = - m_frameArenaOptIn && - wasm_runtime_lookup_function(m_state->instance, "__riveReapHandles") != - nullptr && - wasm_runtime_lookup_function(m_state->instance, - "__riveHandlesRebase") != nullptr; + m_collectedRuntime = + wasm_runtime_lookup_function(m_state->instance, "__riveCollected") != + nullptr; m_handleWatch = wasm_runtime_lookup_function(m_state->instance, "riveRegister") != nullptr && (leakEnv == nullptr || strcmp(leakEnv, "0") != 0); + // Frame collector modules scavenge at every boundary instead of + // rewinding or finalizing. + m_frameMinor = wasm_runtime_lookup_function(m_state->instance, + "__riveFrameMinor") != nullptr; + // Stub modules report their bump position; page counts go blind once + // the aot lanes pregrow to wasmMaxPages. + m_heapUsedProbe = wasm_runtime_lookup_function(m_state->instance, + "__riveHeapUsed") != nullptr; callModule("__wasm_call_ctors", 0, nullptr); m_L = callModule("host_newstate", 0, nullptr); @@ -5082,7 +5171,16 @@ return false; } ExecutionTier target = ExecutionTier::aotO3; - std::string path = ladder.artifactPath(m_moduleKey, TierSpecies::o3); + bool hwBounds = false; + std::string path; +#ifdef RIVE_WASM_HW_BOUNDS + path = ladder.artifactPath(m_moduleKey, TierSpecies::hw); + hwBounds = !path.empty(); +#endif + if (path.empty()) + { + path = ladder.artifactPath(m_moduleKey, TierSpecies::o3); + } if (path.empty() && m_tier < ExecutionTier::aotO0) { target = ExecutionTier::aotO0; @@ -5111,7 +5209,8 @@ if (!applyTierArtifact( Span<const uint8_t>(artifact.data(), artifact.size()), target, - error)) + error, + hwBounds)) { fprintf(stderr, "wasm tier swap failed: %s\n", error.c_str()); return false; @@ -5125,41 +5224,34 @@ { return nullptr; } - if (m_frameArenaOptIn) + if (m_frameMinor) { - if (m_frameArenaPending) + uint32_t promoted = callModule("__riveFrameMinor", 0, nullptr); + if (getenv("RIVE_FRAME_GC_DEBUG") != nullptr) { - m_frameArenaPending = false; - if (m_reapHandles) - { - // Everything minted so far becomes mark-scoped state; drop - // it from the reap log without releasing. - callModule("__riveHandlesRebase", 0, nullptr); - } - m_frameArenaMark = callModule("__riveFrameArena", 0, nullptr); - if (m_frameArenaMark != 0 && !m_frameArenaAnnounced) - { - m_frameArenaAnnounced = true; - return "script frame arena: mark taken, rewinding per frame"; - } + fprintf(stderr, "frameMinor promoted=%u\n", promoted); + callModule("__riveFrameVerify", 0, nullptr); } - else if (m_frameArenaMark != 0) + if (!m_frameMinorAnnounced) { - if (m_reapHandles) - { - // Before the rewind erases this frame's wrappers: any handle - // they minted and never released is provably garbage. - callModule("__riveReapHandles", 0, nullptr); - } - uint32_t args[1] = {m_frameArenaMark}; - callModule("__riveFrameRewind", 1, args); + m_frameMinorAnnounced = true; + return "script frame gc: scavenging per frame"; } - return handleLeakWarning(); + if (const char* warning = handleLeakWarning()) + { + return warning; + } + return heapGrowthWarning(); } if (const char* warning = handleLeakWarning()) { return warning; } + return heapGrowthWarning(); +} + +const char* WasmScriptingVM::heapGrowthWarning() +{ if (!m_leakWatch || !m_advancedOnce) { return nullptr; @@ -5170,16 +5262,25 @@ { return nullptr; } - uint32_t pages = (uint32_t)wasm_memory_get_cur_page_count(memory); + // The stub bump position beats page counts when available: pregrown + // aot memory never grows. + uint32_t pages = + m_heapUsedProbe + ? std::max(1u, callModule("__riveHeapUsed", 0, nullptr) >> 16) + : (uint32_t)wasm_memory_get_cur_page_count(memory); if (m_leakBaselinePages == 0) { m_leakBaselinePages = pages; + m_leakFirstBaselinePages = pages; return nullptr; } m_leakFrames++; + m_leakTotalFrames++; // 8MB past baseline over at least two seconds of frames: far beyond any // one-time warmup we have measured, reached in seconds by a per-frame - // leak (box2d hand-optimized leaked ~2.1MB/s). + // leak (box2d hand-optimized leaked ~2.1MB/s). The watch re-arms after + // each warning, so a leaking session keeps hearing about it every 8MB + // instead of dying hours after a single line scrolled away. constexpr uint32_t kLeakWarnPages = 128; constexpr uint32_t kLeakWarnMinFrames = 120; if (m_leakFrames < kLeakWarnMinFrames || @@ -5187,16 +5288,61 @@ { return nullptr; } - m_leakWatch = false; - char buffer[256]; - snprintf(buffer, - sizeof(buffer), - "script heap grew %uMB over %u frames; the stub runtime never " - "frees, so per-frame allocations leak. Opt into the frame arena " - "(export const __riveFrameArena = true) or use a collecting " - "runtime. RIVE_WASM_LEAK_WARN=0 silences this.", - (pages - m_leakBaselinePages) / 16, - m_leakFrames); + if (m_collectedRuntime && !m_leakArmedCollected) + { + // Linear memory never shrinks, so a collected runtime's one-time + // spike would read as growth forever. Demand a second growing + // window before the first warning. + m_leakArmedCollected = true; + m_leakBaselinePages = pages; + m_leakFrames = 0; + return nullptr; + } + uint32_t grownMB = (pages - m_leakBaselinePages) / 16; + uint32_t frames = m_leakFrames; + m_leakBaselinePages = pages; + m_leakFrames = 0; + m_leakWarningCount++; + + // Time to the wasmMaxPages trap from the average growth rate at 60fps. + // Growth arrives in page-doubling steps, so this is an estimate. + char projection[96] = {0}; + uint32_t maxPages = (uint32_t)wasm_memory_get_max_page_count(memory); + double pagesPerFrame = + (double)(pages - m_leakFirstBaselinePages) / (double)m_leakTotalFrames; + if (maxPages > pages && pagesPerFrame > 0.0) + { + double minutes = + (double)(maxPages - pages) / pagesPerFrame / (60.0 * 60.0); + snprintf(projection, + sizeof(projection), + "; at this rate every frame traps in roughly %.0f minutes", + minutes < 1.0 ? 1.0 : minutes); + } + char buffer[384]; + if (m_collectedRuntime) + { + snprintf(buffer, + sizeof(buffer), + "script heap grew %uMB over %u frames%s; the collector is " + "running, so something is accumulating references (a " + "growing array, map, or cache). RIVE_WASM_LEAK_WARN=0 " + "silences this.", + grownMB, + frames, + projection); + } + else + { + snprintf(buffer, + sizeof(buffer), + "script heap grew %uMB over %u frames%s; the stub runtime " + "never frees, so per-frame allocations leak. Set " + "wasmRuntime: frame. RIVE_WASM_LEAK_WARN=0 silences this.", + grownMB, + frames, + projection); + } m_leakWarning = buffer; return m_leakWarning.c_str(); } @@ -5260,6 +5406,52 @@ return "unknown"; } +// Growing linear memory reallocs it, which in-flight AOT frames do not +// tolerate (fields root cause #3): on an artifact, take the module's whole +// declared ceiling up front, while no frames are live, so it never grows +// again. Modules without a declared ceiling keep growth-on-demand. +static void pregrowAotMemory(wasm_module_inst_t instance) +{ + wasm_memory_inst_t memory = wasm_runtime_get_default_memory(instance); + if (memory == nullptr) + { + return; + } + uint32_t pages = (uint32_t)wasm_memory_get_cur_page_count(memory); + uint32_t maxPages = (uint32_t)wasm_memory_get_max_page_count(memory); + constexpr uint32_t kUnboundedPages = 65536; + if (maxPages >= kUnboundedPages) + { + fprintf(stderr, + "wasm aot: module declares no wasmMaxPages; memory cannot be " + "reserved up front, so growth during frames may trap\n"); + return; + } + if (maxPages <= pages) + { + return; + } + if (!wasm_runtime_enlarge_memory(instance, maxPages - pages)) + { + fprintf(stderr, + "wasm aot: pregrow to %u pages failed; growth during frames " + "may trap\n", + maxPages); + } +} + +uint32_t WasmScriptingVM::memoryPages() const +{ + if (m_state == nullptr || m_state->instance == nullptr) + { + return 0; + } + wasm_memory_inst_t memory = + wasm_runtime_get_default_memory(m_state->instance); + return memory == nullptr ? 0 + : (uint32_t)wasm_memory_get_cur_page_count(memory); +} + const char* WasmScriptingVM::handleLeakWarning() { if (!m_handleWatch || !m_advancedOnce) @@ -5284,7 +5476,11 @@ { return nullptr; } - m_handleWatch = false; + // Re-arm so a leaking session keeps warning every 512 handles. + uint32_t grown = live + 1 - m_handleBaselineLive; + uint32_t frames = m_handleFrames; + m_handleBaselineLive = live + 1; + m_handleFrames = 0; uint32_t counts[(size_t)HandleTable::Tag::node + 1] = {0}; for (const HandleTable::Slot& slot : m_handles.slots) { @@ -5307,8 +5503,8 @@ "script leaked %u host handles over %u frames (most: %u %s); " "resources created per frame need release() or finish(). " "RIVE_WASM_LEAK_WARN=0 silences this.", - live + 1 - m_handleBaselineLive, - m_handleFrames, + grown, + frames, counts[top], handleTagName((HandleTable::Tag)top)); m_leakWarning = buffer; @@ -5317,7 +5513,8 @@ bool WasmScriptingVM::applyTierArtifact(Span<const uint8_t> artifactBytes, ExecutionTier tier, - std::string& error) + std::string& error, + bool hwBounds) { auto next = std::make_unique<WamrState>(); next->artifactBytes.assign(artifactBytes.begin(), artifactBytes.end()); @@ -5331,6 +5528,10 @@ error = std::string("artifact load failed: ") + loadError; return false; } + if (hwBounds) + { + wasm_runtime_set_module_hw_bounds(next->module, true); + } next->instance = wasm_runtime_instantiate(next->module, 512 * 1024, 0, @@ -5354,6 +5555,12 @@ return false; } wasm_runtime_set_user_data(next->execEnv, this); + if (!hwBounds) + { + // Guard-page memory never moves on growth; only the sw lane needs + // the ceiling reserved up front. + pregrowAotMemory(next->instance); + } m_state = std::move(next); m_tier = tier; return true; @@ -5432,11 +5639,24 @@ name.size() + 1); uint32_t requireArgs[2] = {m_L, namePtr}; - uint32_t status = callModule("host_require", 2, requireArgs); + uint32_t status = 0; + CallOutcome outcome = + callModuleChecked("host_require", 2, requireArgs, &status); uint32_t freeArgs[1] = {namePtr}; callModule("free", 1, freeArgs); + if (outcome != CallOutcome::ok) + { + // Folding a trap into "status 0" once read as a successful require + // with no generator; fail the require instead. + m_lastError = outcome == CallOutcome::trapped + ? "module require trapped" + : "module has no host_require export"; + uint32_t topArgs[2] = {m_L, 0}; + callModule("host_settop", 2, topArgs); + return false; + } if (status != 0) { uint32_t strArgs[2] = {m_L, (uint32_t)-1}; @@ -5540,12 +5760,6 @@ { status = buf[0]; } - if (m_frameArenaMark != 0) - { - // A later init's allocations must persist: re-take the mark at the - // next boundary instead of rewinding them away. - m_frameArenaPending = true; - } switch (status) { case 0: @@ -5582,10 +5796,7 @@ } if (!m_advancedOnce) { - // The mark waits for the first full frame so lazy statics and - // steady-state warmup growth land below it. m_advancedOnce = true; - m_frameArenaPending = m_frameArenaOptIn; } return results[0].of.i32 != 0; }
diff --git a/tests/premake5.lua b/tests/premake5.lua index 0b96e41..c20f6d5 100644 --- a/tests/premake5.lua +++ b/tests/premake5.lua
@@ -115,6 +115,18 @@ end end +-- Headless collector validation on device targets; a plain executable so it +-- runs from adb shell without the APK harness. Wasm scripting only: the +-- source names WasmScriptingVM, which other configurations never declare. +if _OPTIONS['with_rive_scripting'] + and (_OPTIONS['scripting_vm'] == 'wasm' or _OPTIONS['scripting_vm'] == 'both') +then + rive_tools_project('wasm_gc_bench', 'ConsoleApp') + do + files({ 'wasm_gc_bench/wasm_gc_bench.cpp' }) + end +end + rive_tools_project('player', 'RiveTool') do files({ 'player/player.cpp' })
diff --git a/tests/unit_tests/runtime/module_tier_ladder_test.cpp b/tests/unit_tests/runtime/module_tier_ladder_test.cpp index e2f8a01..807aed1 100644 --- a/tests/unit_tests/runtime/module_tier_ladder_test.cpp +++ b/tests/unit_tests/runtime/module_tier_ladder_test.cpp
@@ -54,7 +54,13 @@ // Under the straight-to-O3 cutoff, so exactly the one artifact. CHECK(arrivals.load() == 1); - std::string path = ladder.artifactPath(key, TierSpecies::o3); + // Guard-page builds ride the hw species as their top rung. +#ifdef RIVE_WASM_HW_BOUNDS + constexpr TierSpecies kTopSpecies = TierSpecies::hw; +#else + constexpr TierSpecies kTopSpecies = TierSpecies::o3; +#endif + std::string path = ladder.artifactPath(key, kTopSpecies); REQUIRE(!path.empty()); CHECK(ladder.artifactPath(key, TierSpecies::o0).empty());
diff --git a/tests/wasm_gc_bench/wasm_gc_bench.cpp b/tests/wasm_gc_bench/wasm_gc_bench.cpp new file mode 100644 index 0000000..466499b --- /dev/null +++ b/tests/wasm_gc_bench/wasm_gc_bench.cpp
@@ -0,0 +1,183 @@ +/* + * Copyright 2026 Rive + */ + +// Headless on-device harness for the wasm scripting collector lanes: loads a +// baked .riv, advances frames through the host frame boundary, and reports +// timing, wasm memory, and live handle counts. No GPU; recording goes into a +// deferred session that is never replayed. + +#include "rive/animation/state_machine_instance.hpp" +#include "rive/file.hpp" +#include "rive/renderer/cmd/deferred_session.hpp" +#include "rive/wasm/wasm_scripting_vm.hpp" +#include "utils/no_op_renderer.hpp" + +#include <algorithm> +#include <chrono> +#include <cstdio> +#include <cstdlib> +#include <fstream> +#include <vector> + +#ifndef _WIN32 +#include <csignal> + +// SIGILL diagnostics for the device AOT lane: adb shell has no tombstone +// access, so print the fault address before dying. +static void onFault(int sig, siginfo_t* info, void*) +{ + fprintf(stderr, "gcbench fault: signal %d at %p\n", sig, info->si_addr); + fflush(stderr); + signal(sig, SIG_DFL); + raise(sig); +} + +static void installFaultHandler() +{ + struct sigaction action = {}; + action.sa_sigaction = onFault; + action.sa_flags = SA_SIGINFO; + sigaction(SIGILL, &action, nullptr); + sigaction(SIGSEGV, &action, nullptr); + sigaction(SIGBUS, &action, nullptr); +} +#else +static void installFaultHandler() {} +#endif + +using Clock = std::chrono::steady_clock; + +static double msSince(Clock::time_point start) +{ + return std::chrono::duration<double, std::milli>(Clock::now() - start) + .count(); +} + +int main(int argc, char** argv) +{ + installFaultHandler(); + if (argc < 2) + { + fprintf(stderr, "usage: wasm_gc_bench <file.riv> [frames]\n"); + return 1; + } + int frames = argc > 2 ? atoi(argv[2]) : 600; + if (frames <= 0) + { + fprintf(stderr, "frame count must be positive\n"); + return 1; + } + + std::ifstream in(argv[1], std::ios::binary); + std::vector<uint8_t> bytes((std::istreambuf_iterator<char>(in)), + std::istreambuf_iterator<char>()); + if (bytes.empty()) + { + fprintf(stderr, "cannot read %s\n", argv[1]); + return 1; + } + + rive::cmd::DeferredSession session(rive::ore::ReplayCaps{}); + rive::ImportResult result; + auto importStart = Clock::now(); + auto file = rive::File::import( + rive::Span<const uint8_t>(bytes.data(), (size_t)bytes.size()), + &session, + &result); + double importMs = msSince(importStart); + if (result != rive::ImportResult::success || file == nullptr) + { + fprintf(stderr, "import failed\n"); + return 1; + } + rive::WasmScriptingVM* vm = file->wasmScriptingVM(); + if (vm == nullptr) + { + fprintf(stderr, "no wasm scripting vm in this file\n"); + return 1; + } + // Multi-module files run one VM per module; boundaries and metrics + // must cover them all. + auto live = [&]() { + uint32_t count = 0; + for (auto& moduleVm : file->wasmVMs()) + { + count += (uint32_t)(moduleVm->handles().slots.size() - + moduleVm->handles().freeSlots.size()); + } + return count; + }; + auto pages = [&]() { + uint32_t count = 0; + for (auto& moduleVm : file->wasmVMs()) + { + count += moduleVm->memoryPages(); + } + return count; + }; + printf("gcbench import: %.1fms, %u wasm pages\n", importMs, pages()); + + auto instance = file->artboardDefault(); + if (instance == nullptr) + { + fprintf(stderr, "no default artboard\n"); + return 1; + } + // Scripted content runs through the state machine when one exists. + auto machine = instance->defaultStateMachine(); + printf("gcbench driving: %s\n", + machine != nullptr ? "state machine" : "artboard"); + + // Load-time boundary: init's survivors promote here, during load, so + // the first presented frame never pays for them. + auto loadCollect = Clock::now(); + if (const char* notice = file->frameBoundary()) + { + printf("gcbench notice: %s\n", notice); + } + printf("gcbench post-init collect: %.1fms\n", msSince(loadCollect)); + + std::vector<double> frameMs; + frameMs.reserve(frames); + uint32_t pagesBefore = pages(); + uint32_t livePeak = 0; + for (int i = 0; i < frames; i++) + { + auto start = Clock::now(); + if (machine != nullptr) + { + machine->advanceAndApply(1.0f / 60); + } + else + { + instance->advance(1.0f / 60); + } + // The draw pass runs the scripted render callbacks; recording goes + // nowhere but the script-side work is real. + rive::NoOpRenderer renderer; + instance->draw(&renderer); + if (const char* notice = file->frameBoundary()) + { + printf("gcbench notice: %s\n", notice); + } + frameMs.push_back(msSince(start)); + livePeak = std::max(livePeak, live()); + } + std::sort(frameMs.begin(), frameMs.end()); + double total = 0; + for (double ms : frameMs) + { + total += ms; + } + printf("gcbench frames: %d, advance+boundary mean %.3fms p50 %.3fms p95 " + "%.3fms max %.3fms\n", + frames, + total / frames, + frameMs[frames / 2], + frameMs[(size_t)(frames * 0.95)], + frameMs.back()); + printf("gcbench memory: %u -> %u wasm pages\n", pagesBefore, pages()); + printf("gcbench handles: %u live, %u peak\n", live(), livePeak); + return 0; +}