Skip to content

Memory Management

Jean Philippe edited this page Sep 3, 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 · 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 budgeting error to fix at design time, not a runtime failure to handle.
  3. 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.
  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.
  5. Zero hot-path touches. Alloc/free on the render thread or in inner simulation loops is off the table.

Before writing new or std::vector, identify the lifetime. Pick the cheapest allocator that matches it. If nothing fits, the lifetime is unclear — clarify it first.


Lifetime Model

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

Allocation Decision Framework

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.

CPU Memory — ArenaAllocator

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.

Key invariants

  • ArenaAllocator is 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.
  • ZReleaseScratch pairs must be released in strict LIFO order — see Scratch Arenas.
  • Never copy an ArenaAllocator by 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.

CPU Memory — PoolAllocator

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.

Safety invariants

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)

When NOT to use PoolAllocator

  • 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.

CPU Memory — TLSFSlab

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.

Phase 1 (shipped) — texture upload pipeline

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.

Phase 2 (shipped) — typed allocator for containers

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.

Phase 3 — blocked

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.

Roadmap

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)

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
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.


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.
  • ZGetScratch / ZReleaseScratch must 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.

Thread Safety

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.

Memory Budget

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
Loading
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

Performance Comparison

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, AssetManager containers — that used to leak through to the system heap.


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.

Array<T>(const Array&)             = delete;
Array<T>& operator=(const Array&)  = delete;
Array<T>(Array&& other) noexcept;
Array<T>& operator=(Array&& other) noexcept;

Passing conventions

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> for non-owning slices

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

Growing containers leak dead arena blocks

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.

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 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.


GPU Memory Domains

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"]
Loading

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


Arena-Allocated Vulkan Objects

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
Loading
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:

  1. Add an explicit destroy call in Shutdown() or Deinitialize().
  2. Decide: direct (GPU-idle guaranteed) or deferred.
  3. Set the pointer to nullptr after destruction.
  4. Never call delete on an arena-allocated pointer.

Platform Notes

macOS Apple Silicon — 16 KB pages

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.

macOS Apple Silicon — Unified Memory Architecture

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 weak memory ordering

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.

Linux — Transparent Huge Pages

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.

Windows — Commit semantics, and two real bugs found the hard way

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.


Memory Profiler

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).

Clone this wiki locally