| /* |
| * 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); |
| } |