blob: 7d6d07031486ee421f1a396357485990e6fae208 [file]
/*
* Copyright 2026 Rive
*/
// Measurement harness for the draw-time serialization work. Deliberately
// source identical between the pre-refactor and post-refactor trees so the
// two sides can be compared opcode for opcode and microsecond for
// microsecond. Emits machine readable rows; nothing here asserts on a
// threshold.
//
// Hidden tag; run explicitly with test.sh -m "[deferred_measure]".
//
// Environment:
// RIVE_MEASURE_FRAMES total frames per riv (default 3000)
// RIVE_MEASURE_WARMUP frames treated as transient (default 300)
// RIVE_MEASURE_RIVS comma separated riv names, or "all" for the
// whole zzzgold corpus (default: a
// 6 riv short list)
// RIVE_MEASURE_SESSIONS concurrent sessions for the resident table
// sizing case (default 8)
#include "rive/renderer/cmd/deferred_render_factory.hpp"
#include "rive/renderer/cmd/deferred_render_resource.hpp"
#include "rive/renderer/cmd/deferred_replayer.hpp"
#include "rive/renderer/cmd/deferred_session.hpp"
#include "rive/renderer/cmd/render_commands.hpp"
#include "rive/renderer/cmd/render_replay.hpp"
#include "rive_file_reader.hpp"
#include "rive/scene.hpp"
#include "utils/factory_utils.hpp"
#include "utils/no_op_renderer.hpp"
#include <catch.hpp>
#include <algorithm>
#include <chrono>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <string>
#include <type_traits>
#include <vector>
using namespace rive;
using namespace rive::cmd;
namespace
{
// ---- configuration ----
int envInt(const char* name, int fallback)
{
const char* v = getenv(name);
return v != nullptr && *v != '\0' ? atoi(v) : fallback;
}
const char* kRivDir = "../../../../zzzgold/rivs/";
std::vector<std::string> corpus()
{
const char* v = getenv("RIVE_MEASURE_RIVS");
std::string spec = v != nullptr && *v != '\0' ? v : "";
std::vector<std::string> names;
if (spec == "all")
{
std::error_code ec;
for (const auto& e : std::filesystem::directory_iterator(kRivDir, ec))
{
std::string n = e.path().filename().string();
if (n.size() > 4 && n.compare(n.size() - 4, 4, ".riv") == 0)
{
names.push_back(n);
}
}
std::sort(names.begin(), names.end());
return names;
}
if (spec.empty())
{
return {"Halloween_v3.riv",
"UI_Swipe_left_to_delete.riv",
"Tom_Morello.riv",
"Knight_square_2.riv",
"falling.riv",
"popsicle_loader.riv"};
}
size_t start = 0;
while (start <= spec.size())
{
size_t comma = spec.find(',', start);
if (comma == std::string::npos)
{
comma = spec.size();
}
if (comma > start)
{
names.push_back(spec.substr(start, comma - start));
}
start = comma + 1;
}
return names;
}
// ---- sink ----
// No-op resources: replay mutates them freely and the factory counts
// creations, so consumer side object churn per frame is observable.
class MPath : public RenderPath
{
public:
void rewind() override {}
void fillRule(FillRule) override {}
void addPath(CommandPath*, const Mat2D&) override {}
void addRenderPath(const RenderPath*, const Mat2D&) override {}
void addRawPath(const RawPath&) override {}
void moveTo(float, float) override {}
void lineTo(float, float) override {}
void cubicTo(float, float, float, float, float, float) override {}
void close() override {}
};
class MPaint : public RenderPaint
{
public:
void color(unsigned int) override {}
void style(RenderPaintStyle) override {}
void thickness(float) override {}
void join(StrokeJoin) override {}
void cap(StrokeCap) override {}
void blendMode(BlendMode) override {}
void shader(rcp<RenderShader>) override {}
void invalidateStroke() override {}
void feather(float) override {}
};
class MShader : public RenderShader
{};
class MImage : public RenderImage
{};
class MFactory : public Factory
{
public:
int paths = 0, paints = 0, shaders = 0, buffers = 0, images = 0;
rcp<RenderPath> makeRenderPath(RawPath&, FillRule) override
{
paths++;
return make_rcp<MPath>();
}
rcp<RenderPath> makeEmptyRenderPath() override
{
paths++;
return make_rcp<MPath>();
}
rcp<RenderPaint> makeRenderPaint() override
{
paints++;
return make_rcp<MPaint>();
}
rcp<RenderShader> makeLinearGradient(float,
float,
float,
float,
const ColorInt[],
const float[],
size_t) override
{
shaders++;
return make_rcp<MShader>();
}
rcp<RenderShader> makeRadialGradient(float,
float,
float,
const ColorInt[],
const float[],
size_t) override
{
shaders++;
return make_rcp<MShader>();
}
rcp<RenderBuffer> makeRenderBuffer(RenderBufferType t,
RenderBufferFlags f,
size_t s) override
{
buffers++;
return make_rcp<DataRenderBuffer>(t, f, s);
}
rcp<RenderImage> decodeImage(Span<const uint8_t>) override
{
images++;
return make_rcp<MImage>();
}
};
class MSink : public DeferredFrameSink
{
public:
MFactory f;
Factory* factory() override { return &f; }
ore::Context* oreContext() override { return nullptr; }
// The harness measures one session against one screen, and a no-op
// renderer has nothing to dispatch per target anyway.
Renderer* beginScreenFrame(uint64_t target) override
{
REQUIRE(target == 0);
return &m_renderer;
}
private:
NoOpRenderer m_renderer;
};
// ---- retained geometry, present only on the post-refactor tree ----
template <typename T, typename = void> struct HasRetained : std::false_type
{};
template <typename T>
struct HasRetained<T, decltype(void(T::retainedGeometryBytes()))>
: std::true_type
{};
// Templated so the branch the tree does not have is never looked up: an
// if constexpr in a plain function still requires both arms to name real
// members.
template <typename Path = DeferredRenderPath> int64_t retainedGeometry()
{
if constexpr (HasRetained<Path>::value)
{
return Path::retainedGeometryBytes();
}
else
{
// The pre-refactor path serializes each mutation straight into the
// stream and holds no authoritative geometry, so there is nothing to
// report and no counter to read.
return -2;
}
}
// ---- stream census ----
constexpr size_t kNumCmds = static_cast<size_t>(RenderCmd::lastRenderCmd) + 1;
const char* cmdName(size_t i)
{
switch (static_cast<RenderCmd>(i))
{
#define RIVE_MEASURE_CMD_NAME(cmd, POD) \
case RenderCmd::cmd: \
return #cmd;
RIVE_RENDER_CMD_TABLE(RIVE_MEASURE_CMD_NAME)
#undef RIVE_MEASURE_CMD_NAME
}
return "?";
}
struct Census
{
uint64_t count[kNumCmds] = {};
uint64_t geomBytes = 0;
uint64_t commandBytes = 0, blobBytes = 0;
uint64_t frames = 0;
bool overrun = false;
void add(const RenderCommandBuffer& buf)
{
frames++;
commandBytes += buf.commandBytes().size();
blobBytes += buf.blobBytes().size();
CommandReader<uint8_t> r(buf.commandBytes(), buf.blobBytes());
uint8_t type;
while (r.next(type))
{
if (type >= kNumCmds)
{
overrun = true;
break;
}
RenderCmd cmd = static_cast<RenderCmd>(type);
count[type]++;
switch (cmd)
{
case RenderCmd::makePath:
{
auto c = r.read<MakePathPOD>();
geomBytes += c.verbCount * sizeof(PathVerb) +
c.pointCount * sizeof(Vec2D);
break;
}
case RenderCmd::pathAddRawPath:
{
auto c = r.read<PathRawPOD>();
geomBytes += c.verbCount * sizeof(PathVerb) +
c.pointCount * sizeof(Vec2D);
break;
}
default:
r.skip(payloadSizeOf(cmd));
break;
}
}
overrun |= r.overrun();
}
};
// ---- one riv ----
struct Phase
{
double advanceUs = 0, recordUs = 0, snapshotUs = 0, replayUs = 0;
uint64_t frames = 0;
Census census;
int64_t sinkPaths = 0, sinkPaints = 0, sinkShaders = 0, sinkBuffers = 0,
sinkImages = 0;
};
void row(const char* riv, const char* phase, const char* metric, double value)
{
printf("MEASURE,%s,%s,%s,%.6f\n", riv, phase, metric, value);
}
void emit(const char* riv, const char* name, const Phase& p)
{
if (p.frames == 0)
{
return;
}
double n = static_cast<double>(p.frames);
row(riv, name, "frames", n);
row(riv, name, "advance_us", p.advanceUs / n);
row(riv, name, "record_us", p.recordUs / n);
row(riv, name, "snapshot_us", p.snapshotUs / n);
row(riv, name, "replay_us", p.replayUs / n);
row(riv, name, "cmd_bytes", p.census.commandBytes / n);
row(riv, name, "blob_bytes", p.census.blobBytes / n);
row(riv,
name,
"stream_bytes",
(p.census.commandBytes + p.census.blobBytes) / n);
row(riv, name, "geom_bytes", p.census.geomBytes / n);
row(riv, name, "sink_paths", p.sinkPaths / n);
row(riv, name, "sink_paints", p.sinkPaints / n);
row(riv, name, "sink_shaders", p.sinkShaders / n);
row(riv, name, "sink_buffers", p.sinkBuffers / n);
row(riv, name, "sink_images", p.sinkImages / n);
for (size_t c = 0; c < kNumCmds; c++)
{
if (p.census.count[c] != 0)
{
std::string m = std::string("op_") + cmdName(c);
row(riv, name, m.c_str(), p.census.count[c] / n);
}
}
}
void measureRiv(const std::string& name, int frames, int warmup)
{
std::string path = std::string(kRivDir) + name;
FILE* fp = fopen(path.c_str(), "rb");
if (fp == nullptr)
{
printf("MEASURE_SKIP,%s,missing\n", name.c_str());
return;
}
fclose(fp);
DeferredSession session(nullptr);
auto file = ReadRiveFile(path.c_str(), &session);
if (file == nullptr)
{
printf("MEASURE_SKIP,%s,undecodable\n", name.c_str());
return;
}
auto artboard = file->artboardDefault();
if (artboard == nullptr)
{
printf("MEASURE_SKIP,%s,no_artboard\n", name.c_str());
return;
}
auto scene = artboard->defaultScene();
MSink sink;
DeferredReplayer replayer;
Phase first, transient, steady;
int64_t retainedAtSteady = -3;
uint32_t dropped = 0;
for (int frame = 0; frame < frames; frame++)
{
Phase* p = frame == 0 ? &first : frame < warmup ? &transient : &steady;
float seconds = frame == 0 ? 0.f : 1.f / 60.f;
auto t0 = std::chrono::steady_clock::now();
if (scene != nullptr)
{
scene->advanceAndApply(seconds);
}
else
{
artboard->advance(seconds);
}
auto t1 = std::chrono::steady_clock::now();
Renderer* renderer = session.screenRenderer();
renderer->save();
if (scene != nullptr)
{
scene->draw(renderer);
}
else
{
artboard->draw(renderer);
}
renderer->restore();
auto t2 = std::chrono::steady_clock::now();
p->census.add(session.commandBuffer());
DeferredFrame snapshot = snapshotFrame(session);
session.resetFrame();
auto t3 = std::chrono::steady_clock::now();
MFactory& f = sink.f;
int paths = f.paths, paints = f.paints, shaders = f.shaders,
buffers = f.buffers, images = f.images;
replayer.replayFrame(snapshot, sink);
auto t4 = std::chrono::steady_clock::now();
dropped += replayer.droppedDraws();
auto us = [](auto a, auto b) {
return std::chrono::duration<double, std::micro>(b - a).count();
};
p->advanceUs += us(t0, t1);
p->recordUs += us(t1, t2);
p->snapshotUs += us(t2, t3);
p->replayUs += us(t3, t4);
p->frames++;
p->sinkPaths += f.paths - paths;
p->sinkPaints += f.paints - paints;
p->sinkShaders += f.shaders - shaders;
p->sinkBuffers += f.buffers - buffers;
p->sinkImages += f.images - images;
if (frame == frames - 1)
{
retainedAtSteady = retainedGeometry();
}
}
emit(name.c_str(), "first", first);
emit(name.c_str(), "transient", transient);
emit(name.c_str(), "steady", steady);
row(name.c_str(),
"run",
"retained_geometry_bytes",
static_cast<double>(retainedAtSteady));
row(name.c_str(), "run", "dropped_draws", static_cast<double>(dropped));
row(name.c_str(),
"run",
"stream_overrun",
first.census.overrun || transient.census.overrun ||
steady.census.overrun
? 1
: 0);
// Consumer resident tables after the run: how far the dense vectors had
// to grow, which is what process wide ids trade against.
const ResourceTable& t = replayer.table();
auto live = [](const auto& r) {
size_t n = 0;
for (const auto& o : r.objects)
{
n += o != nullptr ? 1 : 0;
}
return static_cast<double>(n);
};
row(name.c_str(),
"resident",
"path_slots",
static_cast<double>(t.paths.objects.size()));
row(name.c_str(), "resident", "path_live", live(t.paths));
row(name.c_str(),
"resident",
"paint_slots",
static_cast<double>(t.paints.objects.size()));
row(name.c_str(), "resident", "paint_live", live(t.paints));
row(name.c_str(),
"resident",
"shader_slots",
static_cast<double>(t.shaders.objects.size()));
row(name.c_str(), "resident", "shader_live", live(t.shaders));
row(name.c_str(),
"resident",
"image_slots",
static_cast<double>(t.images.objects.size()));
row(name.c_str(),
"resident",
"buffer_slots",
static_cast<double>(t.buffers.objects.size()));
row(name.c_str(), "resident", "buffer_live", live(t.buffers));
// Bytes the dense vectors themselves occupy, ignoring the objects.
double slotBytes = static_cast<double>(t.paths.objects.size()) *
(sizeof(rcp<RenderPath>) + 2 * sizeof(uint32_t)) +
static_cast<double>(t.paints.objects.size()) *
(sizeof(rcp<RenderPaint>) + 2 * sizeof(uint32_t)) +
static_cast<double>(t.shaders.objects.size()) *
(sizeof(rcp<RenderShader>) + 2 * sizeof(uint32_t)) +
static_cast<double>(t.images.objects.size()) *
(sizeof(rcp<RenderImage>) + 2 * sizeof(uint32_t)) +
static_cast<double>(t.buffers.objects.size()) *
(sizeof(rcp<RenderBuffer>) + 2 * sizeof(uint32_t));
row(name.c_str(), "resident", "slot_vector_bytes", slotBytes);
}
} // namespace
TEST_CASE("deferred measure", "[.][deferred_measure]")
{
int frames = envInt("RIVE_MEASURE_FRAMES", 3000);
int warmup = envInt("RIVE_MEASURE_WARMUP", 300);
printf("MEASURE_CONFIG,frames,%d,warmup,%d\n", frames, warmup);
printf("MEASURE_CONFIG,retained_instrumented,%d\n",
HasRetained<DeferredRenderPath>::value ? 1 : 0);
for (const std::string& name : corpus())
{
measureRiv(name, frames, warmup);
}
}
// Several sessions live at once, each drawing its own riv, so the consumer
// resident vectors size to the process wide id high water rather than to any
// one session's own resources.
TEST_CASE("deferred measure concurrent sessions", "[.][deferred_measure]")
{
int sessions = envInt("RIVE_MEASURE_SESSIONS", 8);
int frames = std::max(4, envInt("RIVE_MEASURE_FRAMES", 3000) / 100);
std::vector<std::string> names = corpus();
if (names.empty())
{
return;
}
struct Live
{
std::unique_ptr<DeferredSession> session;
rcp<File> file;
std::unique_ptr<ArtboardInstance> artboard;
std::unique_ptr<Scene> scene;
MSink sink;
DeferredReplayer replayer;
};
std::vector<std::unique_ptr<Live>> live;
for (int i = 0; i < sessions; i++)
{
const std::string& name = names[i % names.size()];
std::string path = std::string(kRivDir) + name;
FILE* fp = fopen(path.c_str(), "rb");
if (fp == nullptr)
{
continue;
}
fclose(fp);
auto l = std::make_unique<Live>();
l->session = std::make_unique<DeferredSession>(nullptr);
l->file = ReadRiveFile(path.c_str(), l->session.get());
if (l->file == nullptr)
{
continue;
}
l->artboard = l->file->artboardDefault();
if (l->artboard == nullptr)
{
continue;
}
l->scene = l->artboard->defaultScene();
live.push_back(std::move(l));
}
printf("MEASURE_CONFIG,concurrent_sessions,%d,frames,%d\n",
static_cast<int>(live.size()),
frames);
for (int frame = 0; frame < frames; frame++)
{
for (auto& l : live)
{
float seconds = frame == 0 ? 0.f : 1.f / 60.f;
if (l->scene != nullptr)
{
l->scene->advanceAndApply(seconds);
}
else
{
l->artboard->advance(seconds);
}
Renderer* r = l->session->screenRenderer();
r->save();
if (l->scene != nullptr)
{
l->scene->draw(r);
}
else
{
l->artboard->draw(r);
}
r->restore();
DeferredFrame snapshot = snapshotFrame(*l->session);
l->session->resetFrame();
l->replayer.replayFrame(snapshot, l->sink);
}
}
double totalSlots = 0, totalLive = 0, totalBytes = 0;
for (size_t i = 0; i < live.size(); i++)
{
const ResourceTable& t = live[i]->replayer.table();
auto liveCount = [](const auto& r) {
size_t n = 0;
for (const auto& o : r.objects)
{
n += o != nullptr ? 1 : 0;
}
return static_cast<double>(n);
};
double slots = static_cast<double>(
t.paths.objects.size() + t.paints.objects.size() +
t.shaders.objects.size() + t.images.objects.size() +
t.buffers.objects.size());
double used = liveCount(t.paths) + liveCount(t.paints) +
liveCount(t.shaders) + liveCount(t.images) +
liveCount(t.buffers);
double bytes = slots * (sizeof(rcp<RenderPath>) + 2 * sizeof(uint32_t));
printf("MEASURE,session_%d,resident,slots,%.0f\n",
static_cast<int>(i),
slots);
printf("MEASURE,session_%d,resident,live,%.0f\n",
static_cast<int>(i),
used);
totalSlots += slots;
totalLive += used;
totalBytes += bytes;
}
printf("MEASURE,all_sessions,resident,slots,%.0f\n", totalSlots);
printf("MEASURE,all_sessions,resident,live,%.0f\n", totalLive);
printf("MEASURE,all_sessions,resident,slot_vector_bytes,%.0f\n",
totalBytes);
}