blob: 14e7418376d3830562e691adb24265fc701ace0c [file]
/*
* 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 <cerrno>
#include <climits>
#include <cstring>
#include <fstream>
#include <vector>
// nnSdk exports no signal API at all, so consoles get the no-op handler.
#if !defined(_WIN32) && !defined(RIVE_NX)
#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();
}
#ifdef EXTERN_TOOLS
// libnnSdk owns main on the consoles; nx_main_wasm_gc_bench.cpp bridges in.
#define main rive_main
#endif
int main(int argc, char** argv)
{
installFaultHandler();
if (argc < 2)
{
fprintf(stderr, "usage: wasm_gc_bench <file.riv> [frames]\n");
return 1;
}
// atoi folds malformed input to 0, which is a meaningful collector
// setting; a validation lane must reject what it cannot parse.
auto parseInt = [](const char* text, int* out) {
char* end = nullptr;
errno = 0;
long value = strtol(text, &end, 10);
if (errno != 0 || end == text || *end != '\0' || value < INT_MIN ||
value > INT_MAX)
{
return false;
}
*out = (int)value;
return true;
};
// The frame count is optional, so flags may start at argv[2].
int frames = 600;
int flagStart = 2;
if (argc > 2 && argv[2][0] != '-')
{
flagStart = 3;
if (!parseInt(argv[2], &frames) || frames <= 0)
{
fprintf(stderr, "frame count must be a positive integer\n");
return 1;
}
}
// Consoles have no environment, so the debug knobs ride the command
// line: --verify runs the heap verifier after every boundary,
// --major-budget / --collect-mode feed the collector's tuning exports.
bool verify = false;
int majorBudget = -1;
int collectMode = -1;
for (int i = flagStart; i < argc; i++)
{
if (strcmp(argv[i], "--verify") == 0)
{
verify = true;
}
else if (strncmp(argv[i], "--major-budget=", 15) == 0)
{
if (!parseInt(argv[i] + 15, &majorBudget) || majorBudget < 0)
{
fprintf(stderr, "invalid --major-budget value '%s'\n", argv[i] + 15);
return 1;
}
}
else if (strncmp(argv[i], "--collect-mode=", 15) == 0)
{
// Modes: 0 automatic, 1 copying, 2 sliced; anything else would
// silently run automatic.
if (!parseInt(argv[i] + 15, &collectMode) || collectMode < 0 ||
collectMode > 2)
{
fprintf(stderr, "invalid --collect-mode value '%s'\n", argv[i] + 15);
return 1;
}
}
else
{
// A typoed flag silently skipping verification would defeat
// the lane's purpose.
fprintf(stderr, "unknown argument '%s'\n", argv[i]);
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().live();
}
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");
using CallOutcome = rive::WasmScriptingVM::CallOutcome;
// Knobs and the verifier apply to every module vm in the file.
auto applyKnob = [&](const char* name, int value) {
uint32_t args[1] = {(uint32_t)value};
// Individual modules may omit the export (a stub-runtime module
// beside a frame one), but a flag nothing accepted would report a
// configuration that never applied.
int applied = 0;
for (auto& moduleVm : file->wasmVMs())
{
uint32_t ignored = 0;
switch (moduleVm->callModuleChecked(name, 1, args, &ignored))
{
case CallOutcome::ok:
printf("gcbench %s: %d\n", name, value);
applied++;
break;
case CallOutcome::missing:
printf("gcbench %s: export missing in one module\n", name);
break;
case CallOutcome::trapped:
fprintf(stderr, "gcbench %s trapped\n", name);
return false;
}
}
if (applied == 0)
{
fprintf(
stderr,
"gcbench %s: no module exports it; flag not applied\n",
name);
return false;
}
return true;
};
if (majorBudget >= 0 && !applyKnob("__riveSetMajorBudget", majorBudget))
{
return 1;
}
if (collectMode >= 0 && !applyKnob("__riveSetCollectMode", collectMode))
{
return 1;
}
// A verify run that cannot verify must fail, not report success.
auto runVerifier = [&]() {
for (auto& moduleVm : file->wasmVMs())
{
uint32_t ignored = 0;
switch (
moduleVm
->callModuleChecked("__riveFrameVerify", 0, nullptr, &ignored))
{
case CallOutcome::ok:
break;
case CallOutcome::missing:
fprintf(
stderr,
"gcbench verify FAILED: __riveFrameVerify export "
"missing\n");
return false;
case CallOutcome::trapped:
fprintf(stderr, "gcbench verify FAILED: heap verifier trapped\n");
return false;
}
}
return true;
};
// 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));
if (verify && !runVerifier())
{
return 1;
}
std::vector<double> frameMs;
frameMs.reserve(frames);
std::vector<double> boundaryMs;
boundaryMs.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);
auto boundaryStart = Clock::now();
if (const char* notice = file->frameBoundary())
{
printf("gcbench notice: %s\n", notice);
}
boundaryMs.push_back(msSince(boundaryStart));
if (verify && !runVerifier())
{
return 1;
}
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());
std::sort(boundaryMs.begin(), boundaryMs.end());
printf(
"gcbench boundary: p50 %.1fus p95 %.1fus max %.1fus\n",
boundaryMs[frames / 2] * 1000.0,
boundaryMs[(size_t)(frames * 0.95)] * 1000.0,
boundaryMs.back() * 1000.0);
if (verify)
{
printf("gcbench verify: ran after every boundary\n");
}
printf("gcbench memory: %u -> %u wasm pages\n", pagesBefore, pages());
printf("gcbench handles: %u live, %u peak\n", live(), livePeak);
return 0;
}