Skip to content

Memory

Every allocation in Lumina goes through Lumina::Memory. Underneath it is rpmalloc, a lock-free thread-caching allocator. On top of it sit a few bump allocators for per-frame and scratch work, plus an opt-in tracker.

Memory::GMalloc is an FMalloc wrapping rpmalloc. The free functions are the public surface:

void* Memory::Malloc(size_t Size, size_t Alignment = DEFAULT_ALIGNMENT); // 16
void* Memory::Realloc(void* Memory, size_t NewSize, size_t Alignment);
void Memory::Free(void*& Memory); // takes a reference and nulls it
template<typename T, typename... Args> T* Memory::New(Args&&...);
template<typename T> void Memory::Delete(T*&);

Memory::Free takes the pointer by reference and nulls it, which removes a whole class of double-free bug. Memory::Delete does the same for typed deletion.

Statistics are available without enabling tracking, straight from rpmalloc: GetCurrentMappedMemory, GetPeakMappedMemory, GetCachedMemory, GetCurrentHugeAllocMemory, GetTotalMappedMemory, GetTotalUnmappedMemory. The editor’s Memory tool displays these.

rpmalloc keeps per-thread heaps. Two rules follow:

  • Every module must route its operator new / delete through the engine allocator. DECLARE_MODULE_ALLOCATOR_OVERRIDES() defines the full set of global operator overloads (sized, aligned, nothrow, array forms). The executable declares it in Launch.cpp; IMPLEMENT_MODULE emits it for every modular DLL. Skip it and memory allocated in one module and freed in another goes through two different CRT heaps.
  • Every thread that allocates needs a thread heap. Memory::InitializeThreadHeap() is called by the module init export, by Threading::Initialize, and by the job scheduler for its workers. IsThreadHeapInitialized() checks the state.

Vendored libraries that take an allocator hook are routed through a C-ABI shim:

void* LmThirdPartyMalloc(size_t Size, const char* Category);
void* LmThirdPartyRealloc(void* Ptr, size_t Size, const char* Category);
void* LmThirdPartyCalloc(size_t Count, size_t Size, const char* Category);
void LmThirdPartyFree(void* Ptr);

The shim is declared without an API macro (to avoid a linkage clash inside vendored translation units) and exported with /EXPORT pragmas from Memory.cpp. miniz, OpenFBX, MikkTSpace, and RmlUi use it, so their allocations show up in the engine’s tracking with their own category label.

Memory/Allocators/Allocator.h defines an IAllocator interface plus three implementations.

TypeBehavior
FDefaultAllocatorForwards to Memory::Malloc / Free.
FLinearAllocatorSingle fixed block, bump pointer, bulk reset.
FBlockLinearAllocatorChained blocks, bump pointer, mark and restore. The workhorse.

FBlockLinearAllocator never runs destructors. Freeing is rewinding a cursor. Blocks past a restored mark stay allocated and get reused by later requests, so a steady-state workload stops calling the OS entirely.

An oversized request that cannot fit any block fails loudly rather than handing back a pointer whose writes would overrun the block. If you see that assert, the allocation is larger than the arena’s block size and needs the heap instead.

Memory::GetThreadScratchAllocator() is a per-thread scratch stack. Do not use it directly; use FMemMark, an RAII scope that captures the cursor on construction and rewinds it on destruction.

{
FMemMark Mark;
auto* Scratch = Mark.Alloc<FThing>(Count);
auto Temp = TVector<int, ...>(Mark.Eastl()); // container backed by this scope
// ...
} // O(1) bulk free

Nested marks compose LIFO. No destructors run on scope exit, so only store trivially destructible data (or destroy it yourself). Marks are reclaimed only by their scope or by thread exit, never per allocation.

Memory::GetThreadFrameAllocator() is a different lifetime: a per-thread bump allocator that lives for the whole frame and is bulk-reset at the frame boundary by ResetThreadFrameAllocators(), not by an FMemMark scope.

Use it for per-thread scratch whose data must outlive a parallel-for and be consumed later in the same frame, the classic gather-then-merge shape:

GTaskSystem->ParallelFor(Num, [&](const Task::FParallelRange& Range)
{
auto& Arena = Memory::GetThreadFrameAllocator();
// gather into per-thread storage from Arena
});
// ... later this frame, on the game thread: merge the per-thread results

An arena registers itself globally on first touch so the boundary reset can find it. It grows to the per-thread high-water mark and is reused every frame, so a repeated parallel pass never reallocates its pool.

ResetThreadFrameAllocators() runs at the top of FEngine::Update, which is the one point in the frame that is genuinely quiescent: one game thread, and the previous frame’s parallel gathers already joined and consumed. Holding a frame-arena pointer across a frame boundary is a use-after-free.

FFrameArenaAllocator and the frame-arena-backed container aliases let EASTL containers allocate from an arena. The arena must outlive the container, and the container is bulk-reset with no per-item free.

MemoryTracking.h is an opt-in tracker layered over the allocator. It is category-based and callstack-capable.

{
LUMINA_MEMORY_SCOPE("Render Scene");
// every allocation on this thread inside the scope is attributed to that category
}

Categories are just strings registered on first use (RegisterCategory), so adding one is a new LUMINA_MEMORY_SCOPE string, not an edit to a central enum. The macro compiles to nothing when tracking is disabled at build time.

CallPurpose
SetTrackingEnabled(bool) / IsTrackingEnabled()Runtime toggle.
GetCategoryStats(Out, MaxOut)Per-category live bytes and counts.
GetTrackedLiveBytes() / GetTrackedLiveCount()Totals.
GetTrackingOverflowCount()Allocations the tracker could not record. Nonzero means the results are incomplete.
SetCaptureCallstacks(bool)Adds a callstack per allocation. Expensive.
GetTopCallSites(Out, MaxOut, Sort)Hot call sites, sorted by live bytes, count, or total.
ResolveSymbol(Address, Out, Size)Symbolizes a captured frame.

Tracing also feeds Tracy: LUMINA_PROFILE_ALLOC / LUMINA_PROFILE_FREE wrap TracyCAllocS / TracyCFreeS with a 12-frame callstack depth.

The editor exposes all of this through a single Memory tool, which shows rpmalloc totals, per-category breakdown, and top call sites.

  • Memory/SmartPtr.h provides TUniquePtr, TSharedPtr, and TWeakPtr aliases, plus MakeUnique / MakeShared, all allocator-aware.
  • Memory/RefCounted.h provides an intrusive reference-counted base for types that need it (render resources, for instance).
  • CObject instances are not managed by these. They have their own lifetime model; see The Object System.
SymptomCause
Crash freeing a pointer allocated in another moduleMissing DECLARE_MODULE_ALLOCATOR_OVERRIDES() in that module.
Crash on first allocation on a new threadThe thread heap was never initialized. Threads created outside the scheduler must call Memory::InitializeThreadHeap().
Assert about an oversized arena allocationThe request exceeds the arena’s block size. Use the heap.
Garbage data read from a gather bufferA frame-arena pointer survived past ResetThreadFrameAllocators().
Objects not destructed after a scratch scopeBump allocators never run destructors. Store trivially destructible data.
Tracking totals do not add upCheck GetTrackingOverflowCount(), and remember untracked paths (direct rpmalloc use inside third-party code without the shim).