-
-
Notifications
You must be signed in to change notification settings - Fork 30
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
- Philosophy
- CPU Memory — Arena Allocator
- Allocation Macros
- Scratch Arenas
- Memory Budget
- Container Ownership Rules
- GPU Memory — VMA Allocator
- GPU Memory Domains
- Arena-Allocated Vulkan Objects
- Memory Profiler
-
One up-front allocation.
MemoryManagerreserves 8 GB of 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). 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 (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.
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.
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
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.
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
| 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.
File: ZEngine/ZEngine/Core/Containers/Array.h
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;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> is a plain {T*, size_t} — freely copyable, no ownership semantics. Use it when passing or storing a non-owning slice.
map.insert(key, std::move(my_array)); // rvalue overload for move-only values
for (auto& [k, v] : my_map) { v.push(42); } // reference — no copyThe 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.
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.
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"]
Rule: HostUniform buffers are written with vmaCopyMemoryToAllocation. DeviceGeometry and DeviceTexture require a staging copy via a VkCommandBuffer.
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
| 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:
- Add an explicit destroy call in the subsystem's
Shutdown()orDeinitialize(). - Decide: direct destroy (GPU-idle guaranteed) or deferred (freed mid-frame).
- Set the pointer to
nullptrafter destruction. - Do NOT call
deleteon an arena-allocated pointer.
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.