-
-
Notifications
You must be signed in to change notification settings - Fork 32
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
- Lifetime Model
- Allocation Decision Framework
- CPU Memory — ArenaAllocator
- CPU Memory — PoolAllocator
- CPU Memory — TLSFSlab (Roadmap)
- Allocation Macros
- Scratch Arenas
- Thread Safety
- Memory Budget
- Performance Comparison
- Container Ownership Rules
- GPU Memory — VMA Allocator
- GPU Memory Domains
- Arena-Allocated Vulkan Objects
- Platform Notes
- 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 budgeting error to fix at design time, not a runtime failure to handle.
-
Lifetime = scope. Objects allocated from an arena are freed by
ArenaAllocator::Clear()(cursor reset). There is no per-object free. Pick the allocator whose lifetime matches the object's lifetime. -
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. - Zero hot-path touches. Alloc/free on the render thread or in inner simulation loops is off the table.
Before writing
neworstd::vector, identify the lifetime. Pick the cheapest allocator that matches it. If nothing fits, the lifetime is unclear — clarify it first.
Each allocation belongs to exactly one lifetime tier:
| Tier | When freed | Allocator | Examples |
|---|---|---|---|
| Engine | Shutdown only | ArenaAllocator |
VulkanDevice, ECSScene, AssetManager arenas |
| Scene | Scene load/unload | ArenaAllocator |
EditorScene::LocalArena (200 MB) — instance arrays, scene graph, strings |
| Per-task | After task completes | ArenaAllocator |
ImportPipeline (1 GB) — GltfImporter, Assimp decode scratch |
| Per-frame | End of frame |
ArenaTemp (scratch) |
Draw lists, barrier batches, camera UBO staging |
| Per-object | Individual free needed | PoolAllocator |
Entity slots, command buffer handles, mesh instance slots |
| Variable | Individual free, variable size | TLSFSlab |
Texture decode buffers, closure captures, growing AssetManager containers |
New allocation needed
│
▼
Group lifetime? (reset all at once after frame / import / scene)
YES ──► Stable after init? (no grows once setup is done)
YES ──► ArenaAllocator (~3 cyc + memset)
NO ──► TLSFSlab (~30 cyc, O(1))
NO
│
▼
Fixed size? (same N bytes every time)
YES ──► PoolAllocator (~5 cyc + memset(chunk), O(1))
NO
│
▼
Variable size + individual lifetime?
YES ──► TLSFSlab (~30 cyc, O(1))
NO ──► Re-examine the lifetime. Do NOT use std::vector / new.
File: ZEngine/ZEngine/Core/Memory/Allocator.h
struct ArenaAllocator
{
ArenaAllocator(const ArenaAllocator&) = delete;
ArenaAllocator& operator=(const ArenaAllocator&) = delete;
ArenaAllocator(ArenaAllocator&&) noexcept;
ArenaAllocator& operator=(ArenaAllocator&&) noexcept;
void Initialize(size_t size, size_t page_size);
void* Allocate(size_t size, size_t alignment = DEFAULT_ALIGNMENT); // zeroed
void* AllocateNoZero(size_t size, size_t alignment = DEFAULT_ALIGNMENT); // skips secure_memset
void* Resize(void* ptr, size_t old_size, size_t new_size, size_t 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. alignment must be a power of two — asserted on every call (#680). Virtual address space is reserved up-front; physical pages are committed on first write (RSS much lower than virtual reservation). Every Allocate calls secure_memset(ptr, 0, n) — negligible for small objects, dominant for large buffers (e.g. ~0.4 ms for a 16 MB decode buffer at 40 GB/s). AllocateNoZero skips that zeroing for callers that will fully overwrite the memory before reading it (decode buffers, staging allocations) — do not use it for structs that rely on zero-initialized fields (#683).
Resize extends in-place if the pointer is the most recent allocation; otherwise allocates a new block forward and copies, leaving the old block permanently dead. Safe for scratch arenas; a slow memory leak for long-lived growing containers — see TLSFSlab for the fix on AssetManager containers specifically.
CreateSubArena advances the parent cursor by size and page-aligns the sub-arena's start on every platform. On Windows this is a correctness requirement, not just tidiness — see Platform Notes.
Copy is deleted, move is supported. A shallow copy would alias m_memory — both instances would then call VirtualFree/munmap on the same pointer at destruction (double-free). Move construction/assignment null the source so only one instance ever owns the backing memory.
-
ArenaAllocatoris not thread-safe — all arenas are carved on the main thread before any worker starts. - Arena release does not call destructors. Call
ptr->~T()explicitly on objects owning OS/GPU handles. -
ZReleaseScratchpairs must be released in strict LIFO order — see Scratch Arenas. - Never copy an
ArenaAllocatorby value (e.g.auto arena = manager.MainArena;) — take a reference or pointer instead. The copy constructor is deleted specifically to catch this at compile time.
File: ZEngine/ZEngine/Core/Memory/Allocator.h
Fixed-size free list backed by a single arena carve at init. Suited for objects of a single known size (entity slots, component handles) with individual lifetimes.
struct PoolAllocator
{
void Initialize(ArenaAllocator* arena, size_t total_size,
size_t chunk_size, size_t alignment = DEFAULT_ALIGNMENT);
void* Allocate(); // O(1) — pop free-list head, zero chunk
void Free(void*); // O(1) — push free-list head; asserts range + alignment
void Clear(); // O(capacity) — zero all chunks, rebuild free list
};Free list links are stored inside free chunks — zero separate metadata. After Clear() or across alloc/free cycles, allocation order is LIFO-scrambled; sequential layout is only guaranteed at init.
| Check | Enforced? |
|---|---|
Free ptr in range |
Always-on assert |
Free ptr chunk-aligned |
Always-on assert |
| Double-free | Debug-only scan of the free list — asserts before corrupting it (fixed in #697) |
| Exhaustion |
Allocate now asserts instead of silently returning nullptr (fixed in #681) |
- Multiple object sizes — requires multiple pools or wasteful over-sizing to largest.
- Capacity unknown at init — no in-place growth; growing requires a new arena carve.
- Per-frame
Clear()— O(capacity) traversal is too expensive.
Status: Phase 1 and Phase 2 shipped (merged to develop). Phase 3 blocked.
Design doc: tlsf-allocator-integration.md
TLSFSlab wraps mattconte/tlsf (vendored via FetchContent) with a backing buffer carved from a parent ArenaAllocator. Fills the gap for variable-size, individually-freed allocations that neither Arena nor Pool can handle: texture decode buffers, closure captures, asset metadata containers that grow unpredictably.
struct TLSFSlab {
void Init(ArenaAllocator* arena, size_t bytes);
void* Alloc(size_t n); // O(1) worst-case — asserts on exhaustion
void* Realloc(void* ptr, size_t n); // O(1) if in-place, O(n) copy otherwise
void Free(void* ptr); // O(1) — coalesces with adjacent free blocks
void Shutdown(); // tlsf_destroy; does NOT free backing
size_t Overhead() const; // tlsf internal metadata bytes
private:
mutable std::atomic_flag m_lock; // guards Alloc/Realloc/Free for cross-thread Free
};The backing buffer is carved from the parent arena once at Init. Subsequent Alloc/Free never touch the arena. Internal fragmentation is bounded at ≤ 1.0625× requested size. Adjacent frees always coalesce — no fragmentation cliff over time. An atomic_flag spinlock protects all three operations — the typical case (one worker Allocs, the render thread Frees after upload) is contention-free, so uncontended overhead is ~5 ns.
Each worker thread owns an exclusive 128 MB TLSFSlab (RenderResourceManager::m_upload_slabs[MAX_WORKERS]), assigned via thread_local TLSFSlab* t_worker_slab and a ThreadPool::RegisterWorkerInit callback that runs before a worker's first task — no submit-vs-init race. STBI_MALLOC/STBI_REALLOC/STBI_FREE route through GetWorkerSlab(), falling back to malloc/free on the main thread or when no slab is assigned. TextureDeferral carries Pixels + ByteSize + Slab* instead of the old std::variant; CompleteDeferrals on the render thread calls Slab->Free after the GPU upload completes — the spinlock exists specifically to make that cross-thread free safe.
A 512 KB closure slab (ThreadPool::InitClosureSlab) also backs ThreadPoolHelper::Submit<T>'s lambda captures, replacing new/delete per submitted task.
Array<T> and UnorderedHashMap<K,V> both accept an optional TLSFSlab* via init(slab, capacity). When a slab is set, reserve()/rehash() call slab->Realloc instead of ZResize on the arena — TLSF extends in-place when the physically adjacent block is free, so growing containers no longer abandon dead blocks. AssetManager::ContainerSlab (256 MB) backs five long-lived growing containers: NodeHierarchies, Meshes, Materials, UUIDToTextureHandle, UUIDToMaterialSlot.
Per-archetype TLSFSlab for variable-payload ECS component types (physics bodies, animation rigs, scripting blobs). Blocked on those systems not existing yet — nothing to size the archetype tables against.
| Phase | Status | Scope |
|---|---|---|
| 1 | Shipped | Per-worker upload slabs, TextureDeferral refactor, STBI_MALLOC override, closure slab |
| 2 | Shipped |
AssetManager containers — typed allocator for Array<T> / UnorderedHashMap
|
| 3 | Blocked | Per-archetype ECS slab for variable-payload component types (needs physics/animation/scripting first) |
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 |
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/ZPushArray— POD structs, trivial types. -
ZPushStructCtor— objects with non-trivial default constructor (CommandPool,Semaphore, …). -
ZPushStructCtorArgs— objects requiring constructor arguments (GameWindow,VulkanDevice, …).
Calling delete on an arena-allocated pointer is undefined behavior. Call ptr->~T() explicitly, then set the pointer to nullptr.
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. -
ZGetScratch/ZReleaseScratchmust be released in strict LIFO order. Releasing an outer scratch while an inner scratch is still live leaves the inner's save point stale — a subtle corruption that manifests later. - Each thread has its own arena pair — scratch arenas are not shared across threads.
| Allocator | Thread-safe? | Notes |
|---|---|---|
ArenaAllocator |
No | All arenas carved on main thread before workers start. Workers never call ArenaAllocator::Allocate after init. |
PoolAllocator |
No | All current pools are single-threaded (main thread or one render thread). CAS / spinlock needed if shared. |
TLSFSlab |
atomic_flag spinlock |
Each worker owns its slab exclusively via thread_local for Alloc; the render thread calls Free on a worker's slab after GPU upload completes — the spinlock makes that specific cross-thread free safe. Uncontended cost ~5 ns. |
GpuAllocator (VMA) |
Yes | VMA handles its own synchronization internally. |
MemoryBudgetConfig in ZEngine/ZEngine/Core/Memory/MemoryManager.h.
graph TD
root["MainArena · 8 GB virtual\nmmap / VirtualAlloc — demand-paged\nRSS much lower than reservation"]
vkd["VulkanDevice · 1 GB\nVMA, descriptor pools, command pools,\nswapchain, TLSFSlab × N workers (Phase 1)"]
asset["AssetManager · 512 MB\nMesh / material / texture / hierarchy arrays\nUUID maps, AssetRegistry"]
ecs["ECSScene · 512 MB\nComponentStorage dense arrays\nEntityRegistry, ActorManager"]
imp["ImportPipeline · 1 GB\nGltf + Assimp decode scratch\nCleared after each import session"]
ser["Serializer · 256 MB\nScene save/load temporaries"]
anim["AnimationManager · 256 MB\nSkeleton data, clip arrays, blend trees"]
ui["UIContext · 128 MB\nZUI system — FrameArena, PersistentArena,\nfont atlases, panel state"]
vfs["VirtualFS · 64 MB\nMount table, scanner cache, watcher events"]
shader["ShaderCache · 64 MB\nSPIR-V bytecode, reflection data"]
swap["Swapchain · 8 MB"]
log["Logging · 8 MB\nRing buffer, category filter"]
input["Input · 4 MB"]
root --> vkd & asset & ecs & imp & ser & anim
root --> ui & vfs & shader & swap & log & input
| Subsystem | Budget | What lives there |
|---|---|---|
| VulkanDevice | 1 GB | VMA, descriptor pools, command buffers, swapchain, upload slabs (Phase 1) |
| ImportPipeline | 1 GB | GltfImporter (64 MB) + AssimpImporter (128 MB) × 2 instances |
| AssetManager | 512 MB |
Meshes[], Materials[], Textures[], UUID hash maps |
| ECSScene | 512 MB |
ComponentStorage dense arrays, EntityRegistry
|
| Serializer | 256 MB | EditorSceneSerializer scratch (150 MB sub-arena) |
| AnimationManager | 256 MB | Animation clips, blend tree nodes, state machines |
| UIContext | 128 MB | ZUI FrameArena, PersistentArena, font atlases, panel state |
| ShaderCache | 64 MB | SPIR-V, reflection data |
| VirtualFS | 64 MB | Mount table, scanner cache, file watcher events |
Total committed: ~3.8 GB. Headroom: ~4.2 GB reserved for future systems:
| Planned system | Budget |
|---|---|
| StreamingManager | 2 GB |
| PhysicsEngine | 512 MB |
| NavigationEngine | 256 MB |
Approximate cycle counts on a cache-warm allocation path (bookkeeping only — does not include memset(n) zeroing which scales linearly with size):
| Allocator | Alloc cost | Free cost | Fragmentation | Best for |
|---|---|---|---|---|
| ArenaAllocator | ~3–5 cyc + memset(n)
|
N/A | Zero | Scratch, import, per-frame |
| PoolAllocator | ~5–8 cyc + memset(chunk)
|
~5–8 cyc | Zero | Entity slots, fixed-size objects |
| TLSFSlab | ~20–40 cyc + spinlock | ~20–40 cyc + spinlock | ≤ 1.0625× | Upload buffers, closures, growing containers |
| System heap (jemalloc) | ~50–300 cyc | ~50–300 cyc | Accumulates | Nothing on the hot path |
| System heap (ptmalloc) | ~100–500 cyc | ~100–500 cyc | Accumulates | Nothing on the hot path |
Arena and Pool cover the majority of engine allocations. TLSFSlab now covers the remaining variable-size, individually-freed case — texture decode, closures,
AssetManagercontainers — that used to leak through to the system heap.
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.
Array<T>(const Array&) = delete;
Array<T>& operator=(const Array&) = delete;
Array<T>(Array&& other) noexcept;
Array<T>& operator=(Array&& other) noexcept;void Inspect(const Array<uint32_t>& arr); // read-only
void Mutate(Array<uint32_t>& arr); // in-place mutation
void Consume(Array<uint32_t> arr); // ownership transfer — caller std::move()ArrayView<T> is a plain {T*, size_t} — freely copyable, no ownership semantics.
Every Array<T>::grow() that reallocates directly on an ArenaAllocator abandons the old block — it becomes permanently dead for the lifetime of the arena. Mitigation: pre-size containers via init(arena, expected_capacity), or back the container with a TLSFSlab instead (init(slab, capacity)) — reserve()/rehash() then call slab->Realloc, which extends in-place when the physically adjacent block is free. AssetManager's five long-lived growing containers (NodeHierarchies, Meshes, Materials, UUIDToTextureHandle, UUIDToMaterialSlot) do this via ContainerSlab — shipped in #695. Containers still backed directly by an ArenaAllocator (most of them) retain the dead-block behavior; migrate to a slab if a container both grows unpredictably and lives long enough for the waste to matter.
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 Vulkan Memory Allocator (VMA). GpuAllocator wraps VmaAllocator and exposes typed helpers:
BufferView AllocateBuffer(VkDeviceSize, VkBufferUsageFlags, GpuMemoryDomain, const char* debug_name);
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 before the device is destroyed.
graph LR
DG["DeviceGeometry\nVMA_MEMORY_USAGE_AUTO\ndevice-local preferred → VRAM\nGlobal VB / IB, render targets"]
DT["DeviceTexture\nVMA_MEMORY_USAGE_AUTO\ndevice-local preferred → VRAM\nTexture images"]
HU["HostUniform\nVMA_MEMORY_USAGE_AUTO\nhost-visible required → BAR / shared\nTransformSB, DrawDataSB"]
HS["HostStaging\nVMA_MEMORY_USAGE_AUTO\nhost-visible required → RAM\nUpload staging — alloc + free per call"]
Rule: HostUniform buffers are written with vmaCopyMemoryToAllocation. DeviceGeometry and DeviceTexture require a staging copy via VkCommandBuffer.
Arena Clear() does not call destructors. Objects holding VkCommandPool, VkSemaphore, etc. must have their destructor called explicitly before the device is destroyed.
flowchart TD
A["Arena-allocated object owns VkHandle"]
B["Subsystem Shutdown() / Deinitialize()"]
C{"GPU-idle\nguaranteed?"}
D["Direct: ptr→~T() → vkDestroy*\nat QueueWaitAll point"]
E["Deferred: Device→DeferFree(entry)\ndrained when timeline value ≥ stamp"]
F["ptr = nullptr"]
A --> B --> C
C -->|Yes| D --> F
C -->|No| E --> F
| Class | Strategy | Reason |
|---|---|---|
CommandPool |
Direct | Always freed at GPU-idle |
FramebufferVNext |
Direct | Called after QueueWaitAll
|
GraphicPipeline |
Direct | Same |
Semaphore |
Deferred | Can be signalled; deferred prevents in-flight use |
Fence |
Deferred | Same |
DeferredFreeQueue is a 2048-slot circular buffer, drained in Deinitialize() and Dispose().
Checklist for a new arena-allocated class holding a Vulkan handle:
- Add an explicit destroy call in
Shutdown()orDeinitialize(). - Decide: direct (GPU-idle guaranteed) or deferred.
- Set the pointer to
nullptrafter destruction. - Never call
deleteon an arena-allocated pointer.
mprotect rounds to 16 KB boundaries. The arena uses sysconf(_SC_PAGE_SIZE) → 16384 on arm64. A single 1-byte first-allocation commits 16 KB of physical RAM (vs 4 KB on Linux/Windows). Creating many small arenas at startup is 4× more expensive in physical pages than on Linux.
CPU and GPU share the same physical memory pool. With MoltenVK, a TLSFSlab-backed decode buffer (Phase 1) could be passed directly to Metal as MTLBuffer { storageMode = .shared }, eliminating the GPU staging copy entirely on Apple Silicon. Not yet implemented — TextureDeferral still stages through VMA today.
ARM64 (Apple Silicon, Linux ARM) uses a weakly-ordered memory model. Stores require explicit barriers (dmb/stlr) to guarantee visibility across cores. The render thread's d.Slab->Free(d.Pixels) on a worker's TLSF slab is a data race on all platforms but was more reliably observable on ARM64 under TSAN during development. Resolved in #690 with an atomic_flag spinlock guarding TLSFSlab::Alloc/Realloc/Free — see TLSFSlab.
On Linux with THP = madvise, calling madvise(ptr, size, MADV_HUGEPAGE) on hot arenas promotes pages to 2 MB huge pages. TLB coverage improves from 4 KB × 512 entries = 2 MB to 2 MB × 512 = 1 GB per miss. Measurable win for dense ECS archetype iteration. No code change required beyond one madvise call in ArenaAllocator::Initialize for arenas larger than 2 MB.
On Windows, VirtualAlloc(MEM_COMMIT) reserves pagefile space immediately (not demand-paged like POSIX mprotect). The engine only commits as the cursor advances — this part was always correct. Two separate, more subtle bugs sat underneath it and both shipped fixes:
Bug 1 — CreateSubArena used to eagerly commit. It originally called Allocate(size) on the parent, which committed the entire sub-arena's pages immediately. With ~5.19 GB of sub-arena budgets ahead of UIContext in Engine::Initialize, this exhausted pagefile quota before UIContext was even reached, and its VirtualAlloc returned nullptr — a startup crash. Fixed in #728: CreateSubArena now only bumps the parent's cursor; each sub-arena commits its own pages lazily as its own cursor advances. macOS/Linux were never affected — mmap(PROT_READ|PROT_WRITE) with overcommit backs pages on first write, so there's no equivalent eager-commit step to get wrong.
Bug 2 — m_mem_page_size was unsigned long, which is 32-bit on Windows. This is the one that actually mattered: Windows uses the LLP64 data model, where unsigned long is 32-bit (vs 64-bit on macOS/Linux's LP64). The page-align commit mask
(offset + size + m_mem_page_size - 1) & ~(m_mem_page_size - 1)computed ~(page_size - 1) in 32-bit arithmetic, then zero-extended that 32-bit bit pattern to 64-bit for the &. Once offset + size crossed 4 GB — which it does, given the sub-arena budget total — the zero-extended mask cleared bit 32, collapsing commit_size to a value smaller than what was already committed. The subsequent commit_size - m_committed_size underflowed, and the resulting garbage size handed to VirtualAlloc(MEM_COMMIT) failed, returning nullptr — which the caller then wrote through, producing an access violation. Fixed in #731: m_mem_page_size is size_t everywhere now, so every commit-mask computation is pure 64-bit arithmetic on every platform.
The lesson: a type that's 64-bit on the two platforms you test on every day (macOS, Linux) and 32-bit on the one you don't (Windows) is invisible until you cross a size threshold specific to that platform's data model. Prefer size_t/uint64_t over unsigned long for anything that participates in bitwise masking against a 64-bit value — the compiler will not warn you.
Auditing memory pressure on Windows requires checking pagefile reservation, not RSS, because the commit semantics differ from Linux's demand-paged model.
File: ZEngine/ZEngine/Profiling/MemoryProfiler.h
Profiling::MemoryProfiler::TrackArena("MainArena", &MainArena);ZENGINE_PROFILING must be defined (set by default in Debug builds). Records per-arena peak usage; reported in the in-editor memory overlay (MemoryProfilerPanel).