Repository navigation
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.
- Philosophy
- CPU Memory — Arena Allocator
- Allocation Macros
- Scratch Arenas
- Container Ownership Rules
- Memory Budget
- GPU Memory — VMA Allocator
- GPU Memory Domains
- Arena-Allocated Vulkan Objects
- Memory Profiler
-
One up-front allocation.
MemoryManagerreserves a 3 GB virtual address space at startup asMainArena. Individual objects never callmalloc/newoutside of third-party libraries. - 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.
-
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. -
No destructor guarantee.
ZPushStructCtorplaces 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.
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.
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.
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.
File: ZEngine/ZEngine/Core/Containers/Array.h
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; // sameMove 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; // sameC++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
}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);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.
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 ArrayThe 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.
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.
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.
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).
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:
- Add an explicit destroy call in the subsystem's
Shutdown()orDeinitialize()method. - Decide: direct destroy (if always freed at GPU-idle) or deferred (if freed mid-frame).
- Set the pointer to
nullptrafter destruction to prevent double-free. - Do NOT call
deleteon an arena-allocated pointer.
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).