Skip to content

Memory Management

Jean Philippe edited this page Aug 18, 2026 · 5 revisions

Memory Management

ZEngine uses a custom arena-based memory model with no new/delete in hot paths. This page documents all memory primitives, allocation patterns, GPU memory domains, and the rules for objects that own Vulkan handles.

See also: Engine Architecture for how arenas are sized and where they live.


Table of Contents


Philosophy

  1. One up-front allocation. MemoryManager reserves a 3 GB virtual address space at startup as MainArena. Individual objects never call malloc/new outside of third-party libraries.
  2. Sub-arenas carve fixed budgets. Each subsystem gets a dedicated sub-arena sized to its worst-case working set. Running out of a sub-arena is a compile-time budgeting error, not a runtime allocation failure.
  3. Lifetime = scope. Objects allocated from an arena are freed by calling ArenaAllocator::Clear() (resets the cursor to zero) or by the sub-arena going out of scope. There is no per-object free.
  4. No destructor guarantee. ZPushStructCtor places objects via placement-new, but arena release does not call destructors. Any object that owns an OS or GPU resource must have its destructor called explicitly before the arena is cleared. See Arena-Allocated Vulkan Objects.

CPU Memory — Arena Allocator

File: ZEngine/ZEngine/Core/Memory/Allocator.h

struct ArenaAllocator
{
    void  Initialize(size_t size);              // reserves virtual pages
    void* Allocate(size_t size, size_t alignment = DEFAULT_ALIGNMENT);
    void  CreateSubArena(size_t size, ArenaAllocator* out);
    void  Clear();                              // reset cursor to 0, keep pages
    void  Shutdown();                           // unmap pages
};

Allocate bumps a cursor — O(1), thread-safe with an atomic cursor, no locks needed for single-threaded sub-arenas. Memory is demand-paged: virtual address space is reserved up-front but physical pages are only committed on first write.

CreateSubArena(size, out) carves a fixed block from the parent arena and hands it to out to manage independently. The parent cursor advances by size; the sub-arena has its own cursor starting at 0 within its block.

Clear() resets the cursor to zero without unmapping pages — the physical pages remain hot. Subsequent allocations reuse the same physical memory. This is used for per-frame scratch arenas and importer scratch buffers.


Allocation Macros

File: ZEngine/ZEngine/ZEngineDef.h

Macro Equivalent Notes
ZKilo(n) n * 1024 —
ZMega(n) n * 1024 * 1024 —
ZGiga(n) n * 1024³ —
ZPushArray(arena, T, count) arena->Allocate(count * sizeof(T), alignof(T)) Returns T*, no constructor
ZPushStruct(arena, T) ZPushArray(arena, T, 1) Returns T*, no constructor
ZPushStructCtor(arena, T) new (ZPushStruct(arena, T)) T() Placement-new, default constructor
ZPushStructCtorArgs(arena, T, ...) new (ZPushStruct(arena, T)) T(...) Placement-new with args

When to use each:

  • ZPushStruct — POD structs, arrays of trivial types, anything that doesn't need construction.
  • ZPushStructCtor — objects with a non-trivial default constructor (most engine objects: CommandPool, Semaphore, VFSScanner, …).
  • ZPushStructCtorArgs — objects that require constructor arguments (GameWindow, VulkanDevice, …).

Calling destructors explicitly is required for objects that own OS or GPU handles. Call ptr->~T() before the arena is cleared. Placement-new memory belongs to the arena — never call delete on it.


Scratch Arenas

Short-lived per-call temporary allocations use a thread-local scratch arena pair to avoid polluting long-lived arenas.

// Acquire a scratch arena that does NOT conflict with the given arena.
auto scratch = ZGetScratch(&my_long_lived_arena);

// Use scratch.Arena for temporaries.
Array<uint32_t> tmp;
tmp.init(scratch.Arena, 64);

// Release — resets scratch.Arena cursor to the checkpoint captured at acquire.
ZReleaseScratch(scratch);

ZGetScratch picks one of two alternating thread-local arenas, choosing the one that is not my_long_lived_arena (to prevent aliasing). ZReleaseScratch restores the cursor to the state at acquire time, so nested scratch pairs compose safely as long as the arena argument differs.

Rules:

  • Never store a pointer into a scratch arena past ZReleaseScratch.
  • Always pair ZGetScratch / ZReleaseScratch — no exceptions, no early returns between them.
  • Scratch arenas are not thread-safe across threads; each thread has its own pair.

Container Ownership Rules

File: ZEngine/ZEngine/Core/Containers/Array.h

Array<T> is move-only

Array<T> has its copy constructor and copy assignment deleted. This is a direct consequence of arena ownership: the arena owns the backing memory block; Array<T> holds only a raw pointer into it. A default copy would produce a shallow alias — two Array objects sharing the same arena buffer. The moment either one grew (triggering reserve) or the arena cleared, the other would be a dangling pointer.

Array<T>(const Array&)             = delete;  // shallow copy → dangling alias
Array<T>& operator=(const Array&)  = delete;  // same

Move is correct because it transfers the pointer and nulls the source — single logical owner, arena retains the backing memory for its own lifetime:

Array<T>(Array&& other) noexcept;             // steals m_data/m_size/m_capacity
Array<T>& operator=(Array&& other) noexcept;  // same

Returning Array<T> from a function

C++17 mandatory RVO applies to prvalue returns (no copy or move fired at all). For named local returns (NRVO), the compiler elides in practice; if it can't, it falls back to the move constructor — which is correct. Before the copy was deleted, NRVO failure silently produced a wrong shallow copy.

// Correct — NRVO applies; if not, move fires
Array<uint32_t> BuildIndexList(ArenaAllocator* arena)
{
    Array<uint32_t> result;
    result.init(arena, 64);
    // ...
    return result;  // NRVO or move — both correct
}

Passing Array<T> into functions

Never pass by value unless the callee needs to own it. Pass by const-ref for read-only access; pass by non-const ref for in-place mutation; pass by value + caller uses std::move when transferring ownership:

// Read-only — pass by const ref (no copy, no move)
void Inspect(const Array<uint32_t>& arr);

// Ownership transfer — caller moves
void Consume(Array<uint32_t> arr);     // declaration
Consume(std::move(my_arr));            // call site

// Function that fills an Array — take by ref or return by value
void Fill(Array<uint32_t>& out);

ArrayView<T> for non-owning views

When you need to pass or store a non-owning slice of an Array without taking ownership, use ArrayView<T>:

struct FrameBufferSpecificationVNext
{
    Core::Containers::ArrayView<uint32_t> RenderTargets = {};  // view, not owner
    ...
};

// Bind the view to the owning array:
spec.RenderTargets = ArrayView<uint32_t>(pass.RenderTargets);
// or implicitly via the Array& constructor:
spec.RenderTargets = pass.RenderTargets;  // ArrayView(Array<T>&)

ArrayView is a plain {T*, size_t} — freely copyable, no ownership semantics.

HashMap / UnorderedHashMap with move-only values

Both maps support move-only value types. Use the rvalue insert overload and, where the map is iterated, the iterator dereferences to std::pair<const K&, V&> — references, not copies:

// Insert a move-only value:
map.insert(key, std::move(my_array));

// Range-for — value is a reference, not a copy:
for (auto& [k, v] : my_map)
    v.push(42u);  // direct mutation of the stored Array

The insert(const K&, const V&) overload is gated with requires std::is_copy_assignable_v<V> — attempting to use it with a move-only V is a compile error.


Memory Budget

MemoryBudgetConfig in ZEngine/ZEngine/Core/Memory/MemoryManager.h defines the sub-arena sizes for the current session. MemoryManager::Default() returns the baseline config; override fields before calling Engine::Initialize().

Subsystem Default Notes
VulkanDevice 512 MB VMA metadata, descriptor pool backing, command pools
AssetManager 100 MB UUID maps, mesh/material/texture handle arrays
Importer 350 MB Assimp import scratch, cleared after each job
ECS::Scene 128 MB ComponentStorage dense arrays, EntityRegistry
Animation::AnimationManager 64 MB Skeleton data, clip arrays, pose pools
Physics::PhysicsWorld 64 MB Jolt body data, broad-phase grid, constraints
Audio::AudioEngine 32 MB miniaudio state, decoded clip pool
VFS 32 MB Path cache, mount table, watcher event queue
Shader cache 16 MB SPIR-V bytecode
UI::UIContext 8 MB Per-frame widget tree and draw list
MainThreadScheduler < 1 MB 512 MPSC slots
Network 32 MB Peer state, rollback ring buffers
Serializer scratch 150 MB Scene save/load temporaries
Swapchain 3 MB Swapchain-specific allocations
Logging 4 MB Logger ring buffer, category filter
Scratch / general 48 MB Engine-internal temporaries
Total ~1,500 MB ~500 MB headroom in 2 GB block

The 3 GB MainArena reserves virtual space; pages are committed on demand, so RSS at runtime is much lower than the virtual reservation.


GPU Memory — VMA Allocator

File: ZEngine/ZEngine/Core/Memory/GpuAllocator.h

GPU memory is managed by the Vulkan Memory Allocator (VMA). GpuAllocator wraps VmaAllocator and exposes typed allocation helpers:

// Allocate a VkBuffer and return a BufferView (handle + allocation).
BufferView AllocateBuffer(VkDeviceSize size, VkBufferUsageFlags usage,
                          GpuMemoryDomain domain, const char* debug_name = nullptr);

// Free a VkBuffer.
void FreeBuffer(BufferView& view);

// Allocate a VkImage + VkImageView and return a BufferImage.
BufferImage AllocateImage(VkImageCreateInfo& info, GpuMemoryDomain domain,
                          VkDevice device, VkImageAspectFlagBits aspect,
                          VkImageViewType view_type, uint32_t layer_count,
                          const char* debug_name = nullptr);

// Free a VkImage + VkImageView.
void FreeImage(BufferImage& image, VkDevice device);

BufferView and BufferImage are plain structs holding the VkHandle + VmaAllocation. They are NOT arena-allocated — they hold raw Vulkan handles and must be freed explicitly via FreeBuffer / FreeImage before the device is destroyed.


GPU Memory Domains

GpuMemoryDomain selects the VMA memory pool for an allocation:

Domain VMA usage Physical memory Used for
DeviceGeometry VMA_MEMORY_USAGE_AUTO with device-local preferred VRAM Global vertex/index buffers, render targets
DeviceTexture VMA_MEMORY_USAGE_AUTO with device-local preferred VRAM Texture images
HostUniform VMA_MEMORY_USAGE_AUTO with host-visible required BAR / shared Per-frame transform and draw-data buffers, ImGui VB/IB
HostStaging VMA_MEMORY_USAGE_AUTO with host-visible required RAM Upload staging buffers — allocated and freed within a single upload call

Rule: HostUniform buffers are always host-visible and can be written with vmaCopyMemoryToAllocation. DeviceGeometry and DeviceTexture buffers require a staging copy via a VkCommandBuffer (AppendToGlobalBuffer / texture timeline).


Arena-Allocated Vulkan Objects

This is the most important memory rule in ZEngine.

The problem: ZPushStructCtor allocates objects in the arena via placement-new. When the arena is cleared or the process exits, the physical pages are released without calling any destructors. Objects that hold VkCommandPool, VkSemaphore, VkFence, or other Vulkan handles will silently leak those handles — the Vulkan validation layer reports VUID-vkDestroyDevice-device-05137 ("child object not destroyed before device").

The rule: Every arena-allocated object that owns a Vulkan handle must have its destructor called explicitly before the device is destroyed.

// CORRECT — explicit teardown before arena is cleared:
m_upload_pool->~CommandPool();   // calls vkDestroyCommandPool
m_upload_pool = nullptr;

// WRONG — arena cleared without destroying Vulkan handle:
arena.Clear();  // leaks VkCommandPool!

Direct vs deferred destroy:

Class Strategy Reason
CommandPool Direct — vkDestroyCommandPool in ~CommandPool() Freed at a known GPU-idle point (after QueueWaitAll)
FramebufferVNext Direct — vkDestroyFramebuffer in Dispose() Freed after resize vkDeviceWaitIdle or shutdown QueueWaitAll
GraphicPipeline Direct — vkDestroyPipeline[Layout] in Dispose() Same — only disposed at GPU-idle time
Semaphore Deferred — Device->DeferFree(e) in ~Semaphore() May be signalled; deferred ensures no in-flight use
Fence Deferred — Device->DeferFree(e) in ~Fence() Same

DeferredFreeQueue is a 2048-slot circular buffer. Entries are stamped with SwapchainPtr->RenderTimelineNextValue and drained in VulkanDevice::Deinitialize() (twice: before and after swapchain disposal) and once more in VulkanDevice::Dispose() just before vkDestroyDevice.

Checklist for a new arena-allocated class that holds a Vulkan handle:

  1. Add an explicit destroy call in the subsystem's Shutdown() or Deinitialize() method.
  2. Decide: direct destroy (if always freed at GPU-idle) or deferred (if freed mid-frame).
  3. Set the pointer to nullptr after destruction to prevent double-free.
  4. Do NOT call delete on an arena-allocated pointer.

Memory Profiler

Files: ZEngine/ZEngine/Profiling/MemoryProfiler.h

Arena allocators can be registered with the memory profiler to track watermarks:

Profiling::MemoryProfiler::TrackArena("MainArena", &MainArena);

ZENGINE_PROFILING must be defined (PUBLIC in CMake, set on by default in Debug builds via Engine::EntryPoint.cpp) for tracking to be active. When enabled, the profiler records per-arena peak usage and reports it in the in-editor memory overlay (when implemented).

Clone this wiki locally