Skip to content

Memory Management

Jean Philippe edited this page Aug 20, 2026 · 3 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 · Asset Manager


Table of Contents


Philosophy

  1. One up-front allocation. MemoryManager reserves 8 GB of 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). 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 (mmap / VirtualAlloc)
    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), no locks needed for single-threaded sub-arenas. Memory is demand-paged: virtual address space is reserved up-front but physical pages are committed on first write, so RSS is much lower than the virtual reservation.

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.

Clear() resets the cursor without unmapping pages — physical pages remain hot, subsequent allocations reuse the same memory. Used for per-frame scratch arenas and importer scratch buffers.


Allocation Macros

File: ZEngine/ZEngine/ZEngineDef.h

Macro Equivalent Notes
ZKilo(n) uint64_t(n) * 1024 Always 64-bit — no overflow
ZMega(n) uint64_t(n) * 1024² Always 64-bit
ZGiga(n) uint64_t(n) * 1024³ Always 64-bit — ZGiga(8u) overflowed before this was fixed
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.
  • ZPushStructCtor — objects with a non-trivial default constructor (CommandPool, Semaphore, VFSScanner, …).
  • ZPushStructCtorArgs — objects requiring constructor arguments (GameWindow, VulkanDevice, …).

Calling destructors explicitly is required for objects owning OS or GPU handles. Call ptr->~T() before the arena is cleared. Never call delete on an arena-allocated pointer.


Scratch Arenas

Short-lived per-call temporaries use a scratch arena to avoid polluting long-lived arenas.

sequenceDiagram
    participant Code as Caller
    participant SA as ZGetScratch / ZReleaseScratch
    participant TA as Thread-local arena pair [A, B]

    Code->>SA: ZGetScratch(&my_arena)
    SA->>TA: pick arena that is NOT &my_arena
    SA-->>Code: ScratchArena { .Arena = chosen, .checkpoint }
    Code->>Code: allocate temporaries from scratch.Arena
    Code->>SA: ZReleaseScratch(scratch)
    SA->>TA: reset chosen arena cursor to checkpoint
Loading

Rules:

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

Memory Budget

MemoryBudgetConfig in ZEngine/ZEngine/Core/Memory/MemoryManager.h. Two profiles:

  • Default() — game runtime (audio + network enabled)
  • Editor() — editor build (no audio, no network; larger UIContext)
graph TD
    root["MainArena · ZGiga(8ULL) = 8 GB\nmmap / VirtualAlloc — virtual reservation\nphysical pages committed on demand"]

    vkd["VulkanDevice · 1 GB\nVMA metadata, descriptor pools,\ncommand pools, render targets"]
    asset["AssetManager · 512 MB\nmesh / material / texture / hierarchy arrays\nUUID maps, AssetRegistry"]
    ecs["ECSScene · 512 MB\nComponentStorage dense arrays\nEntityRegistry"]
    imp["Importer · 512 MB\nImportCoordinator overhead\nGltf/Assimp scratch (each owns sub-arenas)"]
    ser["Serializer · 256 MB\nscene save/load temporaries"]
    anim["AnimationManager · 256 MB\nskeleton data, clip arrays, pose pools"]
    audio["AudioEngine · 128 MB\nDefault only — 0 in Editor"]
    ui["UIContext · 64 MB Default\n128 MB Editor\nImguiLayer + all UI components"]
    vfs["VirtualFS · 64 MB\npath cache, mount table, watcher events"]
    shader["ShaderCache · 64 MB\nSPIR-V bytecode"]
    net["Network · 64 MB\nDefault only — 0 in Editor"]
    scp["Serializer · 256 MB"]
    swap["Swapchain · 8 MB"]
    log["Logging · 8 MB\nLogger ring buffer, category filter"]
    input["Input · 4 MB"]

    root --> vkd & asset & ecs & imp & ser & anim
    root --> audio & ui & vfs & shader & net & swap & log & input
Loading
Profile Total committed Headroom in 8 GB
Default ~3.4 GB ~4.6 GB
Editor ~3.3 GB ~4.7 GB

Headroom is reserved for future systems: StreamingManager (2 GB), PhysicsEngine (512 MB), NavigationEngine (256 MB) — see issue #635.


Container Ownership Rules

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

Array<T> is move-only

Array<T> copy constructor and copy assignment are deleted. The arena owns the backing memory; a shallow copy would alias the same buffer. Moving transfers the pointer and nulls the source — single logical owner.

Array<T>(const Array&)             = delete;   // shallow copy → dangling alias
Array<T>& operator=(const Array&)  = delete;
Array<T>(Array&& other) noexcept;              // steals m_data/m_size/m_capacity
Array<T>& operator=(Array&& other) noexcept;

Passing conventions

void Inspect(const Array<uint32_t>& arr);    // read-only — const ref
void Mutate(Array<uint32_t>& arr);           // in-place mutation — ref
void Consume(Array<uint32_t> arr);           // ownership transfer — caller std::move()

ArrayView<T> for non-owning slices

ArrayView<T> is a plain {T*, size_t} — freely copyable, no ownership semantics. Use it when passing or storing a non-owning slice.

HashMap / UnorderedHashMap with move-only values

map.insert(key, std::move(my_array));         // rvalue overload for move-only values
for (auto& [k, v] : my_map) { v.push(42); }  // reference — no copy

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


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:

BufferView  AllocateBuffer(VkDeviceSize, VkBufferUsageFlags, GpuMemoryDomain, const char*);
void        FreeBuffer(BufferView&);

BufferImage AllocateImage(VkImageCreateInfo&, GpuMemoryDomain, VkDevice,
                           VkImageAspectFlagBits, VkImageViewType, uint32_t layers, const char*);
void        FreeImage(BufferImage&, VkDevice);

BufferView and BufferImage hold raw VkHandles + VmaAllocation. They are not arena-allocated and must be freed explicitly via FreeBuffer / FreeImage before the device is destroyed.


GPU Memory Domains

graph LR
    DG["DeviceGeometry\nVMA_MEMORY_USAGE_AUTO\ndevice-local preferred\n→ VRAM\nGlobal VB / IB, render targets"]
    DT["DeviceTexture\nVMA_MEMORY_USAGE_AUTO\ndevice-local preferred\n→ VRAM\nTexture images"]
    HU["HostUniform\nVMA_MEMORY_USAGE_AUTO\nhost-visible required\n→ BAR / shared\nTransformSB, DrawDataSB, ImGui VB/IB"]
    HS["HostStaging\nVMA_MEMORY_USAGE_AUTO\nhost-visible required\n→ RAM\nUpload staging — alloc + free per call"]
Loading

Rule: HostUniform buffers are written with vmaCopyMemoryToAllocation. DeviceGeometry and DeviceTexture require a staging copy via a VkCommandBuffer.


Arena-Allocated Vulkan Objects

The problem: ZPushStructCtor allocates via placement-new. Arena Clear() releases physical pages without calling any destructors. Objects holding VkCommandPool, VkSemaphore, VkFence, etc. will silently leak those handles.

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

flowchart TD
    A["Arena-allocated object owns VkHandle"]
    B["Subsystem::Shutdown() or Deinitialize()"]
    C{"Direct or\nDeferred destroy?"}
    D["Direct: ptr→~T() → vkDestroy*\ncalled at GPU-idle point (QueueWaitAll)"]
    E["Deferred: Device→DeferFree(entry)\nentry stamped with timeline value\ndrained when completed_value ≥ stamp"]
    F["ptr = nullptr"]

    A --> B --> C
    C -->|GPU-idle guaranteed| D --> F
    C -->|may be in-flight| E --> F
Loading
Class Strategy Reason
CommandPool Direct — vkDestroyCommandPool in ~CommandPool() Always freed at GPU-idle
FramebufferVNext Direct — vkDestroyFramebuffer in Dispose() Called after QueueWaitAll or vkDeviceWaitIdle
GraphicPipeline Direct — vkDestroyPipeline[Layout] in Dispose() Same
Semaphore Deferred — Device->DeferFree() in ~Semaphore() Can be signalled; deferred ensures no in-flight use
Fence Deferred — Device->DeferFree() in ~Fence() Same

DeferredFreeQueue is a 2048-slot circular buffer. Drained in Deinitialize() (twice: before and after swapchain disposal) and once more in Dispose() just before vkDestroyDevice.

Checklist for a new arena-allocated class holding a Vulkan handle:

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

Memory Profiler

Files: ZEngine/ZEngine/Profiling/MemoryProfiler.h

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

ZENGINE_PROFILING must be defined (set by default in Debug builds) for tracking to be active. Records per-arena peak usage; reported in the in-editor memory overlay when implemented.