How Architecture Influences Memory Management: A Benchmark Comparison of Two C++ DSP Architectures

May 19, 2026 · Luciano Muratore

Previously, I have introduced the Equalizer.

The Equalizer, https://github.com/Dextromethorpan/Equalizer, is a C++20 and Qt6 desktop application that lets you load a WAV file, select a region on a spectrogram display, and modify its magnitude using gain and brightness sliders. It uses FFTW3 for Short-Time Fourier Transform processing, PortAudio for audio playback, and libsndfile for WAV file loading. You can hear the difference between the original and modified audio in real time.

The application works well. But while going through a post (https://www.linkedin.com/posts/luciano-muratore-3ab1a998_factory-design-pattern-i-like-to-explain-share-7387468520760115200-c9CC/) that I have made on LinkedIn on which I explained in a very simple way what the Factory Design Pattern is, I met up again with the comments from the post.

There was one comment from Marc Gallo (https://www.linkedin.com/in/marc-gallo-a68a263/) suggesting the implementation of the Factory Pattern inside a System Class.

I have to say that back then I was busy with other stuffs. And, I didn’t pay the attention needed to what Marc was proposing. Now, I wanted to put those ideas into practice. This article is about that. Now, let’s go back to the Equalizer.

In the Equalizer, as in most straightforward Qt audio applications, object creation and object use are handled by the same piece of code. When the spectrogram processing needs a working buffer, it allocates one. When the audio callback runs, it creates the data structures it needs in the moment it needs them. There is no separation between the decision to create an object and the act of using it, the code that uses something is also the code that decided to create it and is responsible for cleaning it up.

This is not wrong. But this fusion of creation and use has consequences that only become visible when you measure memory behaviour at runtime particularly in the audio thread, where timing is everything.

Having said that, what happens when there are two architectureal elements that the Equalizer currently lacks: a System Class that owns all resources, and a Factory embedded inside it as a service (as Marc suggested). Together they dissociate object creation from object use. The factory decides how and when objects are created. The objects themselves simply do their work with the resources they were given.

This article benchmarks that difference. It takes the Equalizer’s direct allocation style as Version A,implements the proposed architecture as Version B, and measures both across four memory concepts that matter in real-time audio: timing unpredictability, memory churn, fragmentation, and concurrency safety.


Table of Contents

  1. Introduction
  2. Purpose of this Article
  3. The Two Architectures
  4. AudioSystem, MemoryPool, and the Factory Pattern in Version B
  5. The Four Memory Concepts
  6. Measurement Variables
  7. Results
  8. BenchmarkViewer
  9. Conclusion

1. Introduction

1.1 The Factory Design Pattern

The Factory Design Pattern is a creational pattern that separates the decision of what object to create from the act of using it. Instead of constructing objects directly with new, client code asks a factory to produce an object through a well-defined interface. The factory is the only place that knows the concrete type being instantiated.

In audio software this is particularly valuable because DSP effect types — gain, band-pass, reverb, compression — share a common processing interface but differ in their internal state and resource requirements. A factory lets the effect chain grow without the caller ever changing.

The pattern has three parts:

  • Abstract Product (IEffect) — the interface all effects must implement, typically a process(buffer, numSamples) method.
  • Abstract Creator (IEffectFactory) — declares a pure virtual createEffect() method that returns an IEffect.
  • Concrete Creator (GainEffectFactory, BandPassEffectFactory) — overrides createEffect() to construct and return a specific effect type.
// Abstract Product
class IEffect {
public:
    virtual ~IEffect() = default;
    virtual void process(float* buffer, std::size_t numSamples) = 0;
};
 
// Abstract Creator
class IEffectFactory {
public:
    virtual ~IEffectFactory() = default;
    virtual std::unique_ptr<IEffect> createEffect(MemoryPool& pool) const = 0;
};
 
// Concrete Creator
class GainEffectFactory final : public IEffectFactory {
public:
    explicit GainEffectFactory(float gainDb) : mGainDb(gainDb) {}
 
    std::unique_ptr<IEffect> createEffect(MemoryPool& pool) const override {
        float* scratch = pool.acquire();          // uses system pool — no malloc
        return std::make_unique<GainEffect>(mGainDb, scratch, pool.blockSize());
    }
private:
    float mGainDb;
};

1.2 The System Class

A System Class is an architectural concept rather than a formal design pattern. It is a class that acts as the central owner and coordinator of a subsystem. It owns resources, has a defined lifetime, and is passed explicitly by pointer to whoever needs it.

The System Class stands in contrast to two common alternatives:

  • Global variables — accessible from anywhere implicitly, with no control over who touches them or when. Multiple plugin instances share the same global state invisibly.
  • Singletons — enforce a single instance globally. Dangerous in plugin environments where a DAW may load multiple instances of the same plugin simultaneously.
// DANGEROUS — singleton pattern in a plugin context
class AudioEngine {
public:
    static AudioEngine& getInstance() {
        static AudioEngine instance;   // shared across ALL plugin instances
        return instance;
    }
};
 
// SAFE — system class passed by pointer
class AudioSystem {
public:
    explicit AudioSystem(float sampleRate, std::size_t blockSize, std::size_t numBlocks)
        : mSampleRate(sampleRate)
        , mPool(std::make_unique<MemoryPool>(blockSize, numBlocks))
        , mChain(std::make_unique<EffectChain>())
    {}
    // No static instance. No global access. Each plugin instance owns its own.
};

The System Class is instantiated once per plugin instance and passed by pointer to all components that need it. No global access. No enforced uniqueness. Full isolation between instances.

1.3 How They Work Together

The Factory and the System Class become particularly powerful when combined. The factory lives inside the system class and is exposed as a service. When something needs a new effect, it asks the system for it. The system’s factory creates the effect using the system’s own memory pool — without calling malloc.

// Usage — factory as a service inside AudioSystem
AudioSystem system(44100.f, 4096, 16);   // created in main(), passed by pointer
 
system.registerFactory(std::make_unique<GainEffectFactory>(6.0f));
system.registerFactory(std::make_unique<BandPassEffectFactory>(1000.f, 44100.f));
 
system.addEffect("gain");       // factory uses system's pool — no malloc
system.addEffect("bandpass");   // factory uses system's pool — no malloc

2. Purpose of this Article

The central question this article answers is: does architectural design directly influence memory behaviour at runtime?

To answer it concretely, two C++ architectures (Version A and Version B) are benchmarked against each other across four memory concepts. The results are measured values from a running program, collected by a headless benchmark tool and displayed in a terminal-style Qt desktop viewer.

The four memory concepts measured are:

  • Timing Unpredictability — does the audio callback allocate heap memory during processing?
  • Memory Churn — how many objects are created and destroyed during steady-state?
  • Memory Fragmentation — does the heap degrade over the course of a long session?
  • Concurrency Safety — do two simultaneous instances share state they should not?

3. The Two Architectures

The fundamental difference between Version A and Version B is not what they compute: both apply the same gain and band-pass transformations to the same audio signal and produce the same output. The difference is how they handle the relationship between the creation of an object and the object itself.

In Version A, creation and use are fused. The effect is responsible for both its processing logic and its memory lifecycle. Every time it runs, it decides how much memory it needs and acquires it from the heap.

In Version B, creation and use are dissociated. The factory decides how the effect is created and what memory it receives. The effect itself has no knowledge of where its memory came from. It simply uses what was prepared for it at startup.

This dissociation is the architectural decision. Everything else (the pool, the factory, the system class) is the infrastructure that makes that dissociation possible and safe.

3.1 Version A — Direct Allocation Style

Version A represents the naive approach: the effect object owns its entire lifecycle. It allocates scratch memory when it needs it, uses it, and frees it: all inside process(). There is no factory, no system class, and no shared memory pool. Effects are constructed directly wherever they are needed using raw new.

// Version A — the effect is responsible for BOTH its logic AND its memory
class GainEffect final : public IEffect {
public:
    explicit GainEffect(float gainDb)
        : mGain(std::pow(10.f, gainDb / 20.f)) {}
 
    void process(float* buffer, std::size_t n) override {
        // Creation and use are FUSED — the object decides when to allocate
        float* temp = new float[n];              // ← ALLOC: happens every block
        std::memcpy(temp, buffer, n * sizeof(float));
        for (std::size_t i = 0; i < n; ++i)
            buffer[i] = temp[i] * mGain;
        delete[] temp;                            // ← FREE: happens every block
        // The object created its memory and destroyed it — no external control
    }
private:
    float mGain;
    // No scratch buffer member — memory is created fresh on every call
};

The effect chain also reflects this style. Effects are constructed directly with new and managed manually with raw pointers. There is no abstraction over object creation: the caller decides what type to create and constructs it inline:

// Version A — no factory, no abstraction over creation
class EffectChain {
public:
    void addGain(float gainDb) {
        // The caller knows the concrete type and constructs it directly
        mEffects.push_back(new GainEffect(gainDb));   // ← direct new
    }
 
    void addBandPass(float centre, float sr) {
        mEffects.push_back(new BandPassEffect(centre, sr)); // ← direct new
    }
 
    void process(float* buffer, std::size_t n) {
        for (auto* fx : mEffects)
            fx->process(buffer, n);   // each effect allocates internally
    }
 
    ~EffectChain() {
        for (auto* fx : mEffects) delete fx;  // ← manual cleanup
    }
 
private:
    std::vector<IEffect*> mEffects;           // raw pointers — no ownership semantics
};
 
// Top-level object — no system class, just a direct owner
class Equalizer {
public:
    explicit Equalizer(float sampleRate) : mSampleRate(sampleRate) {
        // Effects created directly — no factory involved
        mChain.addGain(6.0f);
        mChain.addBandPass(1000.f, mSampleRate);
    }
 
    void processBlock(float* buffer, std::size_t n) {
        mChain.process(buffer, n);  // ← triggers heap allocation inside each effect
    }
 
private:
    float       mSampleRate;
    EffectChain mChain;
};

What this means at runtime: every call to processBlock() causes new and delete to be called inside each effect. The heap is touched on every audio block. The object is its own creator.

This style is representative of the Equalizer.

3.2 Version B — System Class Architecture

Version B separates the two concerns that Version A conflates. The factory decides how an effect is created and what memory it receives. The effect only decides what to do with that memory. By the time processBlock() is called, all memory decisions have already been made.

The key structural difference starts at the effect level. The effect no longer allocates — it receives its scratch buffer at construction time:

// Version B — creation and use are DISSOCIATED
class GainEffect final : public IEffect {
public:
    // The factory passes scratch memory in at construction — the effect did not allocate it
    GainEffect(float gainDb, float* scratchBuffer, std::size_t bufSize)
        : mGain(std::pow(10.f, gainDb / 20.f))
        , mScratch(scratchBuffer)   // ← received from outside, not created here
        , mBufSize(bufSize) {}
 
    void process(float* buffer, std::size_t n) override {
        std::size_t count = std::min(n, mBufSize);
        // Use the pre-allocated scratch — the effect has no idea where it came from
        std::memcpy(mScratch, buffer, count * sizeof(float));
        for (std::size_t i = 0; i < count; ++i)
            buffer[i] = mScratch[i] * mGain;
        // ← ZERO allocation — mScratch was decided by the factory at startup
    }
 
private:
    float       mGain;
    float*      mScratch;   // points into pool — this object does not own it
    std::size_t mBufSize;
    // The effect only knows HOW to process — not WHERE its memory came from
};

The factory is what bridges the effect and the pool. It acquires memory from the pool and passes it into the effect’s constructor. This is where the dissociation is implemented: the factory is the only place that knows both the concrete effect type and the memory source:

// Version B — the factory owns the creation decision
class GainEffectFactory final : public IEffectFactory {
public:
    explicit GainEffectFactory(float gainDb) : mGainDb(gainDb) {}
 
    std::unique_ptr<IEffect> createEffect(MemoryPool& pool) const override {
        // Factory decides: acquire from pool, pass to effect
        float* scratch = pool.acquire();          // ← memory decided HERE, once, at startup
        return std::make_unique<GainEffect>(mGainDb, scratch, pool.blockSize());
        // After this call: effect exists, memory is assigned, factory's job is done
    }
 
private:
    float mGainDb;
    // Factory stores configuration — not memory
};

The factory lives inside AudioSystem and is registered by name. When an effect is added, AudioSystem finds the factory, calls it with the pool, and adds the resulting effect to the chain. The caller never touches the factory or the pool directly:

// Version B — object creation fully mediated by AudioSystem
class AudioSystem {
public:
    void registerFactory(std::unique_ptr<IEffectFactory> factory) {
        // Factory stored inside AudioSystem — alongside the pool it will use
        mFactories[factory->effectName()] = std::move(factory);
    }
 
    void addEffect(const std::string& name) {
        auto& factory = mFactories.at(name);
        // Factory creates effect using system's pool — no external malloc
        mChain->addEffect(factory->createEffect(*mPool));
    }
 
    void processBlock(float* buffer, std::size_t n) {
        mChain->process(buffer, n);
        // ← no allocation here — all memory was decided during addEffect()
    }
 
private:
    std::unique_ptr<MemoryPool>   mPool;      // owns all scratch memory
    std::unique_ptr<EffectChain>  mChain;     // owns all effects
    std::unordered_map<std::string,
        std::unique_ptr<IEffectFactory>> mFactories;  // owns all factories
};

What this means at runtime: processBlock() does not touch the heap. The effect uses memory that was assigned to it once, at startup, by the factory. The object is not its own creator — it is a consumer of resources that were prepared for it in advance.

The table below summarises the structural difference between the two versions at each layer of the architecture:

LayerVersion AVersion B
Effect process()Allocates and frees every callUses pre-assigned pool memory
Effect constructionDirect new by the callercreateEffect() called by factory
Memory sourceHeap (malloc) at runtimePool (acquire()) at startup
Who owns scratch memoryThe effect itselfThe pool, via AudioSystem
FactoryDoes not existLives inside AudioSystem
System classDoes not existAudioSystem — passed by pointer
Creation and useFused in the same objectDissociated across factory and effect

4. AudioSystem, MemoryPool, and the Factory Pattern in Version B

4.1 AudioSystem — The System Class

AudioSystem is the single point of truth for everything audio-related. It is not a singleton. It is created in main() and its address is passed to the GUI thread and the audio callback thread as a plain pointer.

class AudioSystem {
public:
    explicit AudioSystem(float sampleRate,
                         std::size_t blockSize = 4096,
                         std::size_t numBlocks = 16)
        : mSampleRate(sampleRate)
        , mPool(std::make_unique<MemoryPool>(blockSize, numBlocks))
        , mChain(std::make_unique<EffectChain>())
    {}
 
    // Factory service — exposed to all components via this object
    void registerFactory(std::unique_ptr<IEffectFactory> factory) {
        mFactories[factory->effectName()] = std::move(factory);
    }
 
    void addEffect(const std::string& name) {
        auto& factory = mFactories.at(name);
        mChain->addEffect(factory->createEffect(*mPool)); // pool passed in
    }
 
    void processBlock(float* buffer, std::size_t n) {
        mChain->process(buffer, n);  // zero allocations during processing
    }
 
private:
    float       mSampleRate;
    std::unique_ptr<MemoryPool>   mPool;
    std::unique_ptr<EffectChain>  mChain;
    std::unordered_map<std::string, std::unique_ptr<IEffectFactory>> mFactories;
};
 
// In main() — one instance per plugin, passed by pointer
int main(int argc, char* argv[]) {
    AudioSystem system(44100.f, 4096, 16);  // lives here
    MainWindow window(&system);             // GUI receives pointer
    // audio callback also receives &system
    window.show();
    return app.exec();
}

4.2 MemoryPool — Eliminating Runtime Allocation

MemoryPool allocates one contiguous block of memory at construction: one malloc call, never repeated.

class MemoryPool {
public:
    explicit MemoryPool(std::size_t blockSize, std::size_t numBlocks)
        : mBlockSize(blockSize)
    {
        // ONE allocation at startup — never again during processing
        mStorage.resize(blockSize * numBlocks);
        for (std::size_t i = 0; i < numBlocks; ++i)
            mFreeList.push_back(mStorage.data() + i * blockSize);
    }
 
    float* acquire() {
        if (mFreeList.empty())
            throw std::runtime_error("MemoryPool exhausted");
        float* p = mFreeList.back();
        mFreeList.pop_back();
        return p;                    // ← NO malloc
    }
 
    void release(float* p) {
        mFreeList.push_back(p);      // ← NO free
    }
 
    std::size_t blockSize() const { return mBlockSize; }
 
private:
    std::size_t          mBlockSize;
    std::vector<float>   mStorage;   // single allocation — never changes
    std::vector<float*>  mFreeList;
};

During steady-state processing acquire() and release() are the only memory operations: both are simple pointer list operations with deterministic execution time.

4.3 The Factory as a Service

Adding a new effect type requires writing one new class pair. No existing code changes.

// New effect — e.g. a simple low-pass filter
class LowPassEffect final : public IEffect {
public:
    LowPassEffect(float cutoffHz, float sr, float* scratch, std::size_t sz)
        : mCutoff(cutoffHz), mSampleRate(sr), mScratch(scratch), mBufSize(sz) {}
 
    void process(float* buffer, std::size_t n) override {
        // biquad implementation using mScratch — zero allocation
    }
    const char* name() const override { return "LowPassEffect"; }
private:
    float mCutoff, mSampleRate;
    float* mScratch;
    std::size_t mBufSize;
};
 
class LowPassEffectFactory final : public IEffectFactory {
public:
    LowPassEffectFactory(float cutoffHz, float sr) : mCutoff(cutoffHz), mSr(sr) {}
 
    std::unique_ptr<IEffect> createEffect(MemoryPool& pool) const override {
        float* scratch = pool.acquire();
        return std::make_unique<LowPassEffect>(mCutoff, mSr, scratch, pool.blockSize());
    }
    const char* effectName() const override { return "lowpass"; }
private:
    float mCutoff, mSr;
};
 
// Registration — one line, no existing code touched
system.registerFactory(std::make_unique<LowPassEffectFactory>(800.f, 44100.f));
system.addEffect("lowpass");

5. The Four Memory Concepts

5.1 Timing Unpredictability

Timing unpredictability refers to not knowing how long a memory operation will take. In real-time audio, the audio callback fires on a hard deadline, for example every 5ms at 44100 Hz with a 256-sample buffer. If malloc is called during that callback, the OS scheduler may pause the thread for an unpredictable duration. The result is an audio dropout.

This is independent of correctness. The program produces the right output: it just takes too long at random intervals.

// How to test timing unpredictability
// Override global new/delete to count allocations
 
#include <atomic>
namespace AllocCounter {
    inline std::atomic<long long> totalAllocs{0};
    inline std::atomic<long long> totalFrees{0};
    inline void reset() { totalAllocs = 0; totalFrees = 0; }
}
 
void* operator new(std::size_t size) {
    ++AllocCounter::totalAllocs;
    void* p = std::malloc(size);
    if (!p) throw std::bad_alloc{};
    return p;
}
 
void operator delete(void* ptr) noexcept {
    if (ptr) { ++AllocCounter::totalFrees; std::free(ptr); }
}
 
// Measurement loop
for (int i = 0; i < MEASURED_BLOCKS; ++i) {
    fillTestBuffer(buffer.data(), BLOCK_SIZE, SAMPLE_RATE);
 
    long long before = AllocCounter::totalAllocs.load();
 
    auto t0 = std::chrono::high_resolution_clock::now();
    system.processBlock(buffer.data(), BLOCK_SIZE);
    auto t1 = std::chrono::high_resolution_clock::now();
 
    long long after = AllocCounter::totalAllocs.load();
 
    long long allocsThisBlock = after - before;   // should be 0 for Version B
    long long latencyNs = std::chrono::duration_cast<
        std::chrono::nanoseconds>(t1 - t0).count();
}

Target: allocsThisBlock == 0 for every block during steady-state.

5.2 Memory Churn

Memory churn is the instability of the memory landscape during runtime: how many objects are being created and destroyed while the program is doing its main work. High churn means the heap is constantly being carved up and re-formed.

// How to test memory churn
// Reset counters after warm-up, measure only steady-state
 
for (int i = 0; i < WARMUP_BLOCKS; ++i)
    system.processBlock(buffer.data(), BLOCK_SIZE);
 
// Reset — start measuring steady-state only
AllocCounter::reset();
 
for (int i = 0; i < MEASURED_BLOCKS; ++i) {
    fillTestBuffer(buffer.data(), BLOCK_SIZE, SAMPLE_RATE);
    system.processBlock(buffer.data(), BLOCK_SIZE);
}
 
long long totalAllocs = AllocCounter::totalAllocs.load();
long long totalFrees  = AllocCounter::totalFrees.load();
 
// Version A: totalAllocs = 200, totalFrees = 200 (over 100 blocks)
// Version B: totalAllocs = 0,   totalFrees = 0

Target: totalAllocs == 0 and totalFrees == 0 across all measured blocks.

5.3 Memory Fragmentation

Fragmentation occurs when the heap has been carved into many small non-contiguous pieces. The total free memory may be sufficient, but no single contiguous block of the required size exists. Fragmentation grows over time and in long sessions can cause progressively slower allocations and eventual allocation failure.

// How to test memory fragmentation
// Probe largest contiguous free block via binary search
 
std::size_t largestContiguousBlock() {
    std::size_t size = 128ULL * 1024 * 1024;  // start at 128 MB
    while (size >= 4096) {
        void* p = std::malloc(size);
        if (p) { std::free(p); return size; }  // found largest block
        size /= 2;
    }
    return 0;
}
 
// Measure before and after the run
std::size_t fragBefore = largestContiguousBlock();
 
for (int i = 0; i < MEASURED_BLOCKS; ++i)
    system.processBlock(buffer.data(), BLOCK_SIZE);
 
std::size_t fragAfter = largestContiguousBlock();
 
long long growth = static_cast<long long>(fragBefore)
                 - static_cast<long long>(fragAfter);
 
// growth > 0 means fragmentation is accumulating
// Version B: growth should be 0 — pool never fragments the heap

Target: fragAfter >= fragBefore (no shrinkage of the largest available block).

5.4 Concurrency Safety (Memory Safety)

Concurrency safety refers to whether two threads can access the same memory address simultaneously without corruption. It is important to distinguish this from the other three concerns: timing unpredictability, churn, and fragmentation are single-thread allocation problems. Concurrency safety is a two-thread access problem. They require different solutions.

// How to test concurrency safety
// Two independent instances, identical input, simultaneous threads
// Checksums must match every round
 
uint32_t checksum(const float* buf, std::size_t n) {
    uint32_t h = 0x12345678u;
    for (std::size_t i = 0; i < n; ++i) {
        uint32_t bits;
        std::memcpy(&bits, &buf[i], sizeof(bits));
        h ^= bits;
        h = (h << 7) | (h >> 25);  // rotate left 7
    }
    return h;
}
 
int mismatches = 0;
for (int round = 0; round < 50; ++round) {
    // Two completely independent instances
    auto instanceA = makeAudioSystem();
    auto instanceB = makeAudioSystem();
 
    std::vector<float> bufA(BLOCK_SIZE), bufB(BLOCK_SIZE);
    fillTestBuffer(bufA.data(), BLOCK_SIZE, SAMPLE_RATE);
    fillTestBuffer(bufB.data(), BLOCK_SIZE, SAMPLE_RATE);  // identical input
 
    // Run simultaneously in separate threads
    std::thread tA([&]{ instanceA->processBlock(bufA.data(), BLOCK_SIZE); });
    std::thread tB([&]{ instanceB->processBlock(bufB.data(), BLOCK_SIZE); });
    tA.join();
    tB.join();
 
    // Same input + same architecture = same output, always
    if (checksum(bufA.data(), BLOCK_SIZE) != checksum(bufB.data(), BLOCK_SIZE))
        ++mismatches;
}
 
// mismatches > 0 means shared state is leaking between instances

Target: mismatches == 0 across all 50 rounds.


6. Measurement Variables

Each memory concept is measured by one or more concrete variables collected by overriding new/delete, probing the heap directly, and running concurrent threads.

6.1 Timing Unpredictability

VariableHow MeasuredTarget
Avg allocations per audio blockAtomic counter delta before/after each block0
Avg block latency (µs)std::chrono::high_resolution_clock around processBlock()Minimal and consistent

6.2 Memory Churn

VariableHow MeasuredTarget
Total new() calls (steady-state)Global override counter, reset after warm-up0 over 100 blocks
Total delete() calls (steady-state)Global override counter, reset after warm-up0 over 100 blocks

6.3 Memory Fragmentation

VariableHow MeasuredTarget
Largest block before runBinary search malloc probeBaseline reference
Largest block after runSame probe after 100 blocksEqual to or larger than before
Fragmentation growthDifference before − after0 MB

6.4 Concurrency Safety

VariableHow MeasuredTarget
Checksum mismatchesXOR-rotate checksum comparison, 50 rounds of simultaneous threads0
Instances independentDerived from checksum resultYES

7. Results

ConceptVariableVersion AVersion B
Timing UnpredictabilityAvg allocs per block20
Timing UnpredictabilityAvg block latency~107 µs~102 µs
Memory ChurnTotal new() calls (100 blocks)2000
Memory ChurnTotal delete() calls (100 blocks)2000
FragmentationLargest block before128.0 MB128.0 MB
FragmentationLargest block after128.0 MB128.0 MB
FragmentationFragmentation growth0.0 MB0.0 MB
Concurrency SafetyChecksum mismatches00
Concurrency SafetyInstances independentYESYES
SUMMARYConcepts passed2/44/4

Version A fails the two most critical real-time audio metrics: it allocates 2 objects per block and makes 200 heap operations during 100 steady-state blocks. Version B scores 0 on both. Both pass fragmentation and concurrency safety at this scale; fragmentation differences become visible in long sessions, and concurrency violations would appear if global state were introduced.


8. BenchmarkViewer

The benchmark results are not confined to the console. The headless MemoryBenchmark executable writes a structured results.json file after every run:

{
  "benchmark": "Memory Architecture Comparison",
  "blocks_measured": 100,
  "block_size": 4096,
  "sample_rate": 44100,
  "results": [
    {
      "name": "Version A (Direct)",
      "timing": { "allocs_per_block": 2, "avg_latency_us": 107.75, "passed": false },
      "churn":  { "total_allocs": 200, "total_frees": 200, "passed": false },
      "fragmentation": { "before_mb": 128.0, "after_mb": 128.0, "growth_mb": 0.0, "passed": true },
      "concurrency": { "checksum_mismatches": 0, "instances_independent": true, "passed": true }
    },
    {
      "name": "Version B (System Class)",
      "timing": { "allocs_per_block": 0, "avg_latency_us": 102.52, "passed": true },
      "churn":  { "total_allocs": 0, "total_frees": 0, "passed": true },
      "fragmentation": { "before_mb": 128.0, "after_mb": 128.0, "growth_mb": 0.0, "passed": true },
      "concurrency": { "checksum_mismatches": 0, "instances_independent": true, "passed": true }
    }
  ]
}

This file is consumed by BenchmarkViewer — a Qt6 desktop application with a terminal-style dashboard inspired by htop. The viewer renders:

  • Score bars at the top, one per architecture, showing concepts passed out of 4 in [ ||| ] bracket style
  • Four sections — one per memory concept — with metrics, measured values, and [PASS] / [FAIL] badges in green or red
  • A summary line at the bottom: real-time safe or needs attention The two projects are deliberately decoupled. The viewer knows only the JSON schema — it has no knowledge of the benchmark code. Any future benchmark that writes the same JSON structure will display correctly in the viewer without modification.

Source code:


9. Conclusion

The benchmark confirms what this architectural exploration implied: the way you design ownership and object creation directly controls what happens to memory at runtime.

Version A’s direct allocation style is not wrong, it even produces correct results. But it fails two of the four real-time safety criteria because its architecture never enforced any discipline around when and how memory is used.

Version B’s architecture AudioSystem as system class, MemoryPool for pre-allocated buffers, Factory as a managed service that passes all four criteria. Zero allocations per block. Zero churn during steady-state. No fragmentation growth. No shared state between instances.

In the end, memory behaviour is an emergent property of architectural decisions made long before any profiling tool is ever run.