Skip to content

Memory retention: ~6 MB/cycle heap growth when creating/destroying artboards with data binding #88

@mfazekas

Description

@mfazekas

Summary

When repeatedly creating and destroying ArtboardInstance + ViewModelInstance with data binding on a .riv file that has nested ViewModel properties, heap memory grows ~6 MB per cycle and is never reclaimed — even after all rcp smart pointers go out of scope.

This is reproducible in pure C++ with no platform wrapper involved. Originally reported via rive-ios (rive-app/rive-ios#427) where it manifests as ~150 MB growth over 10 mount/unmount cycles.

Reproduction

The .riv file used is blinko.riv from the rive-ios example assets. It has 10 ViewModels (7 of 8 default instance properties are nested ViewModel types), 3 nested artboards, and 38 data binds on the default state machine.

C++ reproducer (Catch2 test, runs in rive-runtime test harness)
#include <rive/file.hpp>
#include <rive/artboard.hpp>
#include <rive/animation/state_machine_instance.hpp>
#include <rive/viewmodel/viewmodel_instance.hpp>
#include "rive_file_reader.hpp"
#include <catch.hpp>
#include <cstdio>
#ifdef __APPLE__
#include <malloc/malloc.h>
#include <mach/mach.h>
#else
#include <malloc.h>
#endif

using namespace rive;

static size_t getHeapUsage()
{
#ifdef __APPLE__
    malloc_statistics_t stats;
    size_t total = 0;
    unsigned int count = 0;
    malloc_zone_t** zones = nullptr;
    kern_return_t err = malloc_get_all_zones(
        mach_task_self(), nullptr, (vm_address_t**)&zones, &count);
    if (err == KERN_SUCCESS)
    {
        for (unsigned int i = 0; i < count; i++)
        {
            malloc_zone_statistics(zones[i], &stats);
            total += stats.size_in_use;
        }
    }
    return total;
#else
    struct mallinfo mi = mallinfo();
    return mi.uordblks;
#endif
}

TEST_CASE("heap growth with data binding", "[data binding]")
{
    auto file = ReadRiveFile("assets/blinko.riv");
    REQUIRE(file != nullptr);

    constexpr int NUM_CYCLES = 10;
    constexpr int FRAMES_PER_CYCLE = 30;

    // Warm up — first cycle allocates caches, font data, etc.
    {
        auto artboard = file->artboardDefault()->instance();
        auto vmi = file->createDefaultViewModelInstance(artboard.get());
        if (vmi)
        {
            auto machine = artboard->defaultStateMachine();
            if (machine)
            {
                machine->bindViewModelInstance(vmi);
                for (int f = 0; f < FRAMES_PER_CYCLE; f++)
                    machine->advanceAndApply(0.016f);
            }
        }
    }

    size_t baselineHeap = getHeapUsage();

    for (int i = 0; i < NUM_CYCLES; i++)
    {
        {
            auto artboard = file->artboardDefault()->instance();
            auto vmi = file->createDefaultViewModelInstance(artboard.get());
            auto machine = artboard->defaultStateMachine();
            machine->bindViewModelInstance(vmi);
            machine->advanceAndApply(0.0f);
            for (int f = 0; f < FRAMES_PER_CYCLE; f++)
                machine->advanceAndApply(0.016f);
            // artboard, machine, vmi all go out of scope here
        }

        size_t heap = getHeapUsage();
        printf("Cycle %2d: growth_from_baseline=%.1f MB\n",
               i, (heap - baselineHeap) / (1024.0 * 1024.0));
    }

    size_t finalHeap = getHeapUsage();
    double totalGrowth = (double)(finalHeap - baselineHeap) / (1024.0 * 1024.0);
    printf("Total growth: %.1f MB (%.2f MB/cycle)\n", totalGrowth, totalGrowth / NUM_CYCLES);

    // After all objects are destroyed, heap should return near baseline
    REQUIRE(totalGrowth < 5.0);  // Currently fails: ~63 MB growth
}

Run with:

cd tests/unit_tests
./test.sh -m "[data binding]"

Observed output

Baseline heap after warmup: 15.2 MB

Cycle  0: growth_from_baseline=6.3 MB
Cycle  1: growth_from_baseline=12.5 MB
Cycle  2: growth_from_baseline=18.8 MB
Cycle  3: growth_from_baseline=25.1 MB
Cycle  4: growth_from_baseline=31.4 MB
Cycle  5: growth_from_baseline=37.7 MB
Cycle  6: growth_from_baseline=44.0 MB
Cycle  7: growth_from_baseline=50.3 MB
Cycle  8: growth_from_baseline=56.6 MB
Cycle  9: growth_from_baseline=62.9 MB

Total growth: 62.9 MB (6.29 MB/cycle)

Heap grows linearly at ~6.3 MB per cycle and never returns to baseline despite all rcp<ArtboardInstance>, rcp<ViewModelInstance>, and unique_ptr<StateMachineInstance> going out of scope.

Expected behavior

Heap should return near baseline after each cycle since all objects are destroyed.

Environment

  • macOS 15.7.3, Apple M2 Max
  • rive-runtime at commit a8887748 (main as of 2026-03-24)
  • .riv file: blinko.riv from rive-ios Example-iOS/Assets/

Notes

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions