16.5 Memory Allocators

Phase 16 · Engine Programming (deep C++, optional) · Study time: 20–35 h

The custom allocators engines rely on — arena, pool, stack and free-list — with per-frame scratch memory and allocation tracking.

In the C memory chapter you learned that every value lives somewhere: on the stack (fast, automatic, gone the instant a function returns) or on the heap (lives as long as you want, but you had to ask the operating system for it with something like malloc, and you have to give it back yourself). You also learned that a pointer is just a number — the address of a byte in memory. This chapter takes those two ideas and puts them to work on a very practical problem: calling malloc and new directly, thousands of times a second, for the entire length of a play session, is exactly the kind of thing a shipped game cannot get away with. You're going to build the tools real engines use instead: allocators (small pieces of code that manage a block of memory for you) that ask the OS for memory once, then hand it out themselves, fast and predictably.

1. Why Not Just malloc/new Everywhere?

malloc and new are general-purpose tools. They have to work for any size, called from any thread, freed in any order, for the entire life of the program. That generality is expensive, and it costs you in three separate ways:

Let's see the speed cost directly. Here's a small, runnable program that just allocates and frees, over and over, and times how long that takes:

#include <chrono>
#include <cstdio>

int main() {
    const int COUNT = 2000000;

    auto start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < COUNT; i++) {
        int* p = new int[16];   // 64 bytes each
        p[0] = i;                // touch it, so the compiler can't optimize the alloc away
        delete[] p;
    }
    auto end = std::chrono::high_resolution_clock::now();

    double ms = std::chrono::duration<double, std::milli>(end - start).count();
    printf("2,000,000 new/delete pairs: %.2f ms\n", ms);
    return 0;
}

A typical run on an ordinary laptop prints something like:

2,000,000 new/delete pairs: 58.31 ms

Your exact number depends on your CPU, your OS's allocator, and whatever else is running — that's the point, it's unpredictable. But the shape holds everywhere: a few tens of nanoseconds per call adds up fast, and under real load — multiple threads fighting over the same global heap lock, and fragmentation making the search for a free chunk slower — that number gets worse, and worse in a way you can't easily predict ahead of time. That unpredictability is the real enemy for code that has to hit a fixed frame budget every single frame.

Fragmentation: Free Bytes That Aren't Free

Here's the part that gets worse the longer your game runs, not the more allocations you do per frame. Imagine a heap after a busy few minutes of gameplay: objects of many different sizes have been allocated and freed, in an order nothing like the order they were requested in.

Heap after minutes of gameplay (blocks shown in the order they sit in memory) [ A: used 40 ][ gap: free 20 ][ B: used 30 ][ gap: free 50 ][ C: used 25 ][ gap: free 15 ][ D: used 10 ][ gap: free 10 ] total heap size = 200 bytes total FREE bytes = 20 + 50 + 15 + 10 = 95 bytes largest SINGLE free gap = 50 bytes (right after B) New request: "give me 64 contiguous bytes for a mesh buffer" 95 bytes are free somewhere in the heap ... but every single gap is smaller than 64. The allocator has to say: FAILED: out of memory even though the heap is less than half full. This is fragmentation.

"Free" does not mean "usable." An allocator can only hand out a single, unbroken (contiguous) run of bytes — it can't stitch together three separate 20-byte gaps to satisfy a 60-byte request. Every time your game allocates and frees objects of different sizes in a different order than they came in, it leaves gaps like this. Over a two-hour play session, with thousands of temporary objects of every size passing through the same heap, those gaps accumulate until even a small allocation can fail — not because you're out of memory, but because you're out of contiguous memory.

Common mistake Trusting a "bytes free" counter (like a task manager or a simple heap statistic) as proof that an allocation will succeed. That number is the sum of every free gap. What actually decides success or failure is the size of the largest single gap, which can be far smaller than the sum. A heap can report "10 MB free" and still fail to satisfy a 1 MB request.

2. The Big Idea: One Big Block, Managed by You

The fix used by essentially every game engine is simple to state: stop calling malloc/new during gameplay at all. Instead, at startup, ask the OS for one big block of memory in a single call — way more than you need for any one thing, but a fixed, known amount. From then on, every system in the game (physics, audio, rendering, AI) asks your own code for memory, and your own code just does simple pointer arithmetic to hand out pieces of that one block. No more OS calls, no more locks, no more surprises, for the rest of the program's life.

#include <cstdlib>
#include <cstdio>

class MemoryArena {
public:
    void Init(size_t sizeBytes) {
        base = static_cast<unsigned char*>(std::malloc(sizeBytes));
        capacity = sizeBytes;
        used = 0;
    }

    void Shutdown() {
        std::free(base);
        base = nullptr;
    }

    unsigned char* base = nullptr;
    size_t capacity = 0;
    size_t used = 0;
};

int main() {
    MemoryArena arena;
    arena.Init(64 * 1024 * 1024); // 64 MB, ONE malloc call for the whole game

    printf("Reserved %zu bytes from the OS in a single call.\n", arena.capacity);
    printf("From here on, every game system asks THIS object for memory,\n");
    printf("never malloc/new directly.\n");

    arena.Shutdown();
    return 0;
}
Reserved 67108864 bytes from the OS in a single call.
From here on, every game system asks THIS object for memory,
never malloc/new directly.
Startup (once, at the very start of the program): OS heap ------------------------------------------------ malloc(64 MB) -- ONE call, ONE trip to the OS | v Engine-owned block: [ 64 MB ] base base+64MB During gameplay (every frame, thousands of times): No more calls to malloc/new/the OS at all. The engine hands pieces of THIS block to whoever asks, using plain pointer math (an offset and a size), and takes them back the same way -- entirely under YOUR control.

Everything in the rest of this chapter is a different strategy for slicing up that one block: a way of deciding which bytes to hand out next, and how to know when they can be reused. Different game data has a different lifetime (per-frame, per-level, per-object, forever), and each strategy below is shaped around one of those lifetimes.

3. Alignment: Why Bytes Need to Line Up

Before building any allocator, there's one piece of pointer arithmetic every one of them needs: alignment. Alignment means an address is a multiple of some number, usually a power of two (4, 8, 16...). CPUs don't read memory one byte at a time; they read in fixed-size chunks (words), and hardware instructions (especially SIMD instructions, which operate on 16 bytes at once) often flatly require their data to start on an aligned address.

#include <cstdint>
#include <cstdio>

uintptr_t AlignUp(uintptr_t address, size_t align) {
    // align MUST be a power of two (4, 8, 16, ...)
    return (address + align - 1) & ~(align - 1);
}

int main() {
    printf("alignof(int)    = %zu\n", alignof(int));
    printf("alignof(double) = %zu\n", alignof(double));

    uintptr_t addr = 1001; // pretend this is a raw byte address
    printf("1001 aligned to 4  -> %llu\n", (unsigned long long)AlignUp(addr, 4));
    printf("1001 aligned to 16 -> %llu\n", (unsigned long long)AlignUp(addr, 16));
    return 0;
}
alignof(int)    = 4
alignof(double) = 8
1001 aligned to 4  -> 1004
1001 aligned to 16 -> 1008

AlignUp is a trick worth understanding, not just memorizing: align - 1 is a run of 1-bits (for align = 16, that's 0b1111). Adding it to address pushes the value past the next boundary, and ANDing with ~(align - 1) (all those low bits flipped and inverted) clears the low bits back down, snapping the result down to the nearest multiple of align at or above the original address. Every allocator in this chapter uses exactly this formula.

A 4-byte int, and a CPU that reads memory in 4-byte words Aligned (int placed at address 8, a multiple of 4): word0: [ . . . . ] addr 0-3 word1: [ . . . . ] addr 4-7 word2: [ X X X X ] addr 8-11 <- the whole int in ONE word, ONE read Misaligned (int placed at address 6, NOT a multiple of 4): word1: [ . . X X ] addr 4-7 <- half the int lives here word2: [ X X . . ] addr 8-11 <- the other half lives here The CPU must read word1 AND word2, then shift and stitch the bytes together. Two reads instead of one -- slower on most CPUs, and on some hardware (older ARM chips, some SIMD instructions, atomics on many platforms) it is not "slower," it is a hardware fault: the program crashes.
Tip Aligning to a bigger number than you need (say, aligning every allocation to 64 bytes "just in case") isn't free either — it wastes padding bytes on every single allocation, and over thousands of small objects that adds up. Align to what the data actually needs: alignof(T) for a type T, or 16 for SIMD vector data, and no more.

4. The Linear (Arena) Allocator: Bump a Pointer

The simplest possible allocator keeps one number: an offset into the block, starting at 0. Alloc(size) aligns the offset, checks there's enough room, hands back a pointer at the current offset, then moves the offset forward by size. That's it — no searching, no bookkeeping per allocation. This is called a linear allocator or arena, and the operation is often called "bumping the pointer."

#include <cstdint>
#include <cstddef>

class LinearAllocator {
public:
    void Init(void* memory, size_t sizeBytes) {
        base = static_cast<unsigned char*>(memory);
        capacity = sizeBytes;
        offset = 0;
    }

    void* Alloc(size_t sizeBytes, size_t align = 8) {
        uintptr_t current = reinterpret_cast<uintptr_t>(base) + offset;
        uintptr_t aligned = (current + align - 1) & ~(align - 1);
        size_t padding = aligned - current;

        if (offset + padding + sizeBytes > capacity) {
            return nullptr; // out of space in this arena
        }

        offset += padding + sizeBytes;
        return reinterpret_cast<void*>(aligned);
    }

    void Reset() {
        offset = 0; // "free" EVERYTHING in one step -- no per-object work at all
    }

private:
    unsigned char* base = nullptr;
    size_t capacity = 0;
    size_t offset = 0;
};

malloc always hands back memory aligned to at least alignof(std::max_align_t), which is 16 bytes on a typical 64-bit machine — so if we back this arena with malloc, offset 0 already lines up with an 8-byte alignment. That lets us trace the exact offsets a sequence of calls returns:

#include <cstdlib>
#include <cstdio>

int main() {
    unsigned char* memory = static_cast<unsigned char*>(std::malloc(256));
    LinearAllocator arena;
    arena.Init(memory, 256);

    void* a = arena.Alloc(40);
    void* b = arena.Alloc(30);
    void* c = arena.Alloc(60);

    printf("a offset = %td\n", (unsigned char*)a - memory);
    printf("b offset = %td\n", (unsigned char*)b - memory);
    printf("c offset = %td\n", (unsigned char*)c - memory);

    arena.Reset();
    void* d = arena.Alloc(10);
    printf("d offset after Reset() = %td\n", (unsigned char*)d - memory);

    std::free(memory);
    return 0;
}
a offset = 0
b offset = 40
c offset = 72
d offset after Reset() = 0

Trace it by hand: a starts at offset 0 (already aligned), uses 40 bytes, so the offset becomes 40. b starts at 40 (40 is already a multiple of 8, no padding needed), uses 30 bytes, offset becomes 70. c starts at 70 — but 70 isn't a multiple of 8, so 2 padding bytes are inserted to reach 72, then 60 bytes are used, offset becomes 132. Calling Reset() snaps the offset straight back to 0, so d starts right back where a did.

LinearAllocator over one frame (256-byte arena, bump pointer) base base+256 | | v v [--- a: 40 ---][--- b: 30 ---][pd][----- c: 60 -----][ ... free ... ] 0 40 70 72 132 256 ^ offset (next Alloc starts here) Alloc() never searches anything -- it checks "does offset + size fit inside capacity?" and moves offset forward. That IS the entire cost of one allocation: an add and a compare. Reset() sets offset back to 0. It does NOT walk the block calling destructors or freeing individual objects -- everything allocated since the last Reset() is gone at once. That is exactly what you want for memory that only needs to live for one frame.

This is the allocator to reach for whenever data only needs to survive one frame: scratch transforms for a render pass, a temporary buffer for a pathfinding search, string formatting for a debug overlay. Ask for a block once at startup, Alloc() from it all frame long, Reset() at the top of the next frame. Fragmentation is not just unlikely here — it's impossible, because nothing is ever freed individually. It's all-or-nothing, which is also exactly its limitation.

Tip Notice there's no Free(void* ptr) method at all — that's on purpose, not an oversight. If you find yourself wanting to free just one allocation out of a linear allocator, you want a different tool: a stack allocator or a pool allocator, both coming up next.

5. The Stack Allocator: LIFO with Markers

A stack allocator is a linear allocator with one extra trick: you can save the current offset as a marker, keep allocating, and later roll the offset back to that marker — freeing everything allocated after it, in one step, without touching anything allocated before it. The catch is the name: it only works LIFO (Last In, First Out, the same order rule a call stack itself follows). You must free markers in the reverse order you took them.

#include <cstdint>
#include <cstddef>

class StackAllocator {
public:
    using Marker = size_t;

    void Init(void* memory, size_t sizeBytes) {
        base = static_cast<unsigned char*>(memory);
        capacity = sizeBytes;
        offset = 0;
    }

    void* Alloc(size_t sizeBytes, size_t align = 8) {
        uintptr_t current = reinterpret_cast<uintptr_t>(base) + offset;
        uintptr_t aligned = (current + align - 1) & ~(align - 1);
        size_t padding = aligned - current;

        if (offset + padding + sizeBytes > capacity) {
            return nullptr;
        }

        offset += padding + sizeBytes;
        return reinterpret_cast<void*>(aligned);
    }

    Marker GetMarker() const {
        return offset;
    }

    void FreeToMarker(Marker marker) {
        offset = marker; // everything allocated AFTER this marker is gone
    }

private:
    unsigned char* base = nullptr;
    size_t capacity = 0;
    size_t offset = 0;
};
#include <cstdlib>
#include <cstdio>

int main() {
    unsigned char* memory = static_cast<unsigned char*>(std::malloc(256));
    StackAllocator stack;
    stack.Init(memory, 256);

    void* levelData = stack.Alloc(64);              // lives for the whole level
    StackAllocator::Marker frameMark = stack.GetMarker();

    void* pathBuffer = stack.Alloc(48);              // this frame's scratch work
    printf("offset with pathBuffer alive: %zu\n", stack.GetMarker());

    stack.FreeToMarker(frameMark);                   // done with this frame's scratch
    printf("offset after FreeToMarker:    %zu\n", stack.GetMarker());

    void* nextFrameBuffer = stack.Alloc(20);         // reuses the bytes pathBuffer used
    printf("nextFrameBuffer offset:       %td\n", (unsigned char*)nextFrameBuffer - memory);

    std::free(memory);
    return 0;
}
offset with pathBuffer alive: 112
offset after FreeToMarker:    64
nextFrameBuffer offset:       64

levelData takes the first 64 bytes, so the marker is saved at offset 64. pathBuffer takes 48 more bytes, pushing the offset to 112. Calling FreeToMarker(frameMark) snaps the offset straight back to 64 — levelData is untouched, but the 48 bytes pathBuffer used are now free again. Sure enough, the very next allocation, nextFrameBuffer, starts at offset 64: the exact same bytes pathBuffer was just using.

StackAllocator: LIFO with markers Alloc(levelData, 64) ------------------> offset = 64 marker = GetMarker() ------------------> marker = 64 (saved!) Alloc(pathBuffer, 48) ------------------> offset = 112 [---- levelData: 64 ----][---- pathBuffer: 48 ----][ ... free ... ] 0 64 112 256 ^ marker FreeToMarker(marker) --> offset snaps back to 64 [---- levelData: 64 ----][ ......... free (reclaimed) ......... ] 0 64 256 ^ next Alloc() starts here again Rule: only roll back to a marker once EVERYTHING allocated after it is also considered dead. Free markers in the reverse order you took them -- that is what LIFO means.
Common mistake Taking marker A, then allocating more and taking marker B, then calling FreeToMarker(A) while some code elsewhere still holds a pointer into the region between A and B, expecting it to stay valid. It won't — rolling back past A frees everything after it, including everything B was pointing into, whether or not you meant to keep it. If you need to free things out of order, a stack allocator is the wrong tool; reach for the pool allocator in the next section instead.

6. The Pool Allocator: Fixed-Size Blocks and a Free List

Neither of the two allocators above handles this common case well: lots of same-sized objects — bullets, particles, enemies — that get created and destroyed constantly, in an order that has nothing to do with when they were created. A pool allocator is built exactly for this. It slices the block into N fixed-size slots up front, and keeps a free list: a chain of the currently-unused slots, linked together. Alloc() pops the front of that list; Free() pushes a slot back onto the front. Both are O(1) — no searching, ever.

#include <cstddef>

class PoolAllocator {
public:
    void Init(void* memory, size_t blockSize, size_t blockCount) {
        // Each FREE block's first few bytes store a pointer to the NEXT
        // free block. That's the whole trick: the free list lives INSIDE
        // the free memory itself, at zero extra storage cost.
        unsigned char* p = static_cast<unsigned char*>(memory);
        this->blockSize = blockSize;
        freeList = reinterpret_cast<FreeBlock*>(p);

        for (size_t i = 0; i < blockCount - 1; i++) {
            FreeBlock* block = reinterpret_cast<FreeBlock*>(p + i * blockSize);
            block->next = reinterpret_cast<FreeBlock*>(p + (i + 1) * blockSize);
        }
        FreeBlock* last = reinterpret_cast<FreeBlock*>(p + (blockCount - 1) * blockSize);
        last->next = nullptr;
    }

    void* Alloc() {
        if (!freeList) {
            return nullptr; // pool is full
        }
        FreeBlock* block = freeList;
        freeList = freeList->next;   // pop the head
        return block;
    }

    void Free(void* ptr) {
        FreeBlock* block = static_cast<FreeBlock*>(ptr);
        block->next = freeList;      // push back onto the head
        freeList = block;
    }

private:
    struct FreeBlock {
        FreeBlock* next;
    };
    FreeBlock* freeList = nullptr;
    size_t blockSize = 0;
};
#include <cstdio>

struct Bullet {
    float x, y, z;
    float speed;
};

int main() {
    unsigned char memory[sizeof(Bullet) * 4];
    PoolAllocator bullets;
    bullets.Init(memory, sizeof(Bullet), 4);

    void* b0 = bullets.Alloc();
    void* b1 = bullets.Alloc();
    void* b2 = bullets.Alloc();
    printf("allocated 3 of 4 slots\n");

    bullets.Free(b1);              // b1 dies, e.g. it hit something
    printf("freed the middle slot (b1)\n");

    void* b3 = bullets.Alloc();    // reuses b1's slot immediately
    printf("b3 == b1? %s\n", (b3 == b1) ? "yes" : "no");

    void* b4 = bullets.Alloc();    // the last remaining free slot
    void* b5 = bullets.Alloc();    // pool is now full
    printf("b5 == nullptr? %s\n", (b5 == nullptr) ? "yes" : "no");

    return 0;
}
allocated 3 of 4 slots
freed the middle slot (b1)
b3 == b1? yes
b5 == nullptr? yes
PoolAllocator: 4 fixed-size slots, singly linked free list Right after Init() -- every slot is free, chained in memory order: freeList --> [slot0] --> [slot1] --> [slot2] --> [slot3] --> null After Alloc() x3 (slot0, slot1, slot2 handed out as b0, b1, b2): freeList --> [slot3] --> null [slot0: USED] [slot1: USED] [slot2: USED] [slot3: free] After Free(b1) -- slot1 is pushed back onto the FRONT of the list: freeList --> [slot1] --> [slot3] --> null [slot0: USED] [slot1: free] [slot2: USED] [slot3: free] The next Alloc() pops [slot1] again -- reused instantly, no search, no fragmentation possible, because every slot is exactly the same size.

The comment in Init() is worth re-reading: a free slot's own bytes are reused to store the "next" pointer of the free list. Nobody is treating that memory as a real Bullet while it's free, so it's safe to write a pointer there — and it means the free list costs zero extra memory beyond the slots themselves. This is exactly why pool allocators are the standard tool for bullets, particles, and enemies: create and destroy them in any order, as fast as a linked-list push/pop, with no fragmentation ever, because a freed slot is always exactly the right size for the next object of the same type.

7. The General-Purpose Free-List Allocator, Briefly

Sometimes you need the opposite of a pool: objects of many different sizes, freed in an unpredictable order, that aren't just scratch data for one frame — loading and unloading assets as the player moves between levels, for example. For this you need a general-purpose free-list allocator, which keeps a list of free blocks, each tagged with its own size:

struct FreeBlockHeader {
    size_t size;
    FreeBlockHeader* next;
};

Alloc(size) walks this list looking for a block big enough (the simplest rule, first-fit, just takes the first one that fits; best-fit takes the smallest one that still fits, trading search time for less wasted space). If the chosen block is much bigger than requested, it's split in two, with the leftover put back on the free list. Free(ptr) puts the block back on the list — and, critically, checks whether its immediate neighbors in memory are also free, merging them into one bigger block if so. This merging step is called coalescing, and it's the main defense a general-purpose allocator has against fragmentation.

Coalescing on Free(): merge adjacent free neighbors into one block Before freeing B (B sits between free A and used C in memory): [free A: 32][used B: 16][used C: 40][free D: 24] Free(B) -- check neighbors in MEMORY order (not allocation order): left neighbor (A) is free --> merge with it right neighbor (C) is USED --> cannot merge After: [ free A+B: 48 ][used C: 40][free D: 24] One 48-byte free run instead of two separate 32- and 16-byte runs. Coalescing REDUCES fragmentation, it does not eliminate it: D is still a separate free block from A+B (C sits between them), so a request for 60 contiguous bytes still fails here, even though 48 + 24 = 72 bytes are free overall.

If this sounds a lot like a description of how malloc itself works internally — it is. A general-purpose free-list allocator is genuinely general-purpose, which is exactly why section 1's speed and fragmentation problems apply to it too, just somewhat mitigated by coalescing. Engines do use this pattern, but they keep it out of the hot, every-frame path, reserving it for things that change relatively rarely: loading a level's worth of assets, not spawning a bullet.

8. Double-Buffered Frame Allocators

Modern engines are pipelined: while the GPU is still drawing frame N (reading vertex buffers, constants, and other per-frame data you handed it), the CPU has often already moved on to building frame N+1. If you reset a single arena at the start of every frame, you'd be overwriting memory the GPU hasn't finished reading yet — a race condition that shows up as flickering geometry or garbage on screen. The fix: keep two arenas, and swap which one is "live" every frame.

#include <cstdlib>

class FrameAllocator {
public:
    void Init(size_t sizeBytes) {
        bufferA.Init(std::malloc(sizeBytes), sizeBytes);
        bufferB.Init(std::malloc(sizeBytes), sizeBytes);
    }

    void BeginFrame() {
        usingA = !usingA;     // swap which buffer is "live"
        Current().Reset();    // safe: the OTHER buffer is what the GPU
                               // might still be reading from
    }

    void* Alloc(size_t sizeBytes, size_t align = 8) {
        return Current().Alloc(sizeBytes, align);
    }

    int CurrentIndex() const { return usingA ? 0 : 1; } // for debugging only

private:
    LinearAllocator& Current() { return usingA ? bufferA : bufferB; }

    LinearAllocator bufferA;
    LinearAllocator bufferB;
    bool usingA = false;
};
#include <cstdio>

int main() {
    FrameAllocator frameAlloc;
    frameAlloc.Init(1024);

    for (int frame = 0; frame < 4; frame++) {
        frameAlloc.BeginFrame();
        frameAlloc.Alloc(64); // e.g. this frame's transform matrices
        printf("frame %d writes into buffer %d\n", frame, frameAlloc.CurrentIndex());
    }
    return 0;
}
frame 0 writes into buffer 0
frame 1 writes into buffer 1
frame 2 writes into buffer 0
frame 3 writes into buffer 1
Double-buffered frame allocator across 4 frames Frame 0: CPU writes into buffer 0 | GPU: (nothing to read yet) Frame 1: CPU writes into buffer 1 | GPU: reads buffer 0 (frame 0's data) Frame 2: CPU writes into buffer 0 | GPU: reads buffer 1 (frame 1's data) ^ | buffer 0 is safe to Reset() and reuse here -- the GPU finished reading frame 0's copy of it a whole frame earlier, in "Frame 1" Frame 3: CPU writes into buffer 1 | GPU: reads buffer 0 (frame 2's data) With only ONE buffer, "Frame 1: CPU writes" would be overwriting the exact bytes the GPU is still reading from "Frame 0" -- a race that shows up as flickering or garbage geometry on screen.

The pattern generalizes: some engines use three or more buffers ("triple buffering") if the CPU can get further ahead of the GPU than one frame. The core idea stays the same — never reset a buffer the reader (GPU, render thread, network thread, whatever) might not be finished with yet.

9. Tracking Allocations Per Subsystem

Once you have several custom allocators running — a physics arena, an audio pool, a rendering frame allocator, an AI scratch buffer — it gets hard to answer a simple question: when total memory use creeps up over a two-hour play session, who is using it? The fix is to tag every allocation with an owning subsystem, and keep a small running total per tag.

#include <cstdio>

enum class MemoryTag {
    Physics,
    Audio,
    Rendering,
    AI,
    Count // not a real tag -- just gives the array a size
};

struct MemoryTracker {
    size_t usedBytes[(int)MemoryTag::Count] = {};

    void Track(MemoryTag tag, size_t bytes) {
        usedBytes[(int)tag] += bytes;
    }

    void Untrack(MemoryTag tag, size_t bytes) {
        usedBytes[(int)tag] -= bytes;
    }

    void PrintReport() const {
        const char* names[] = { "Physics", "Audio", "Rendering", "AI" };
        for (int i = 0; i < (int)MemoryTag::Count; i++) {
            printf("%s: %zu bytes\n", names[i], usedBytes[i]);
        }
    }
};

int main() {
    MemoryTracker tracker;

    tracker.Track(MemoryTag::Physics, 4096);      // a rigid body pool
    tracker.Track(MemoryTag::Audio, 65536);        // a decoded music buffer
    tracker.Track(MemoryTag::Rendering, 1048576);  // this frame's arena
    tracker.Track(MemoryTag::AI, 2048);            // pathfinding scratch

    tracker.PrintReport();
    return 0;
}
Physics: 4096 bytes
Audio: 65536 bytes
Rendering: 1048576 bytes
AI: 2048 bytes
Memory report, taken one moment into a play session Physics [#### ] 4,096 B Audio [###### ] 65,536 B Rendering [####################] 1,048,576 B AI [# ] 2,048 B A per-subsystem report like this is how you catch "wait, why is Audio using 40 MB now?" BEFORE a two-hour session runs out of memory -- without it, all you can see from outside is one growing number for the entire heap, with no way to tell which system is responsible.

In a real engine, this isn't a separate manual step — each allocator instance (arena, pool, frame allocator) carries its own debug name and tag, reports its own high-water mark and current usage, and a memory profiler (an in-editor or in-game tool for browsing this data, which you'll see a concrete example of in section 11) reads that data live while the game runs. The version above is the same idea in miniature: know who owns every byte, not just how many bytes exist.

10. Debug Tricks: Guard Bytes and Poison Patterns

Custom allocators give you a chance to catch two classic memory bugs cheaply — bugs the C chapters already introduced you to via AddressSanitizer, but which you can now catch with a few extra bytes of your own bookkeeping, inside your own allocator, without any external tool at all.

Guard Bytes: Catching Buffer Overruns

A buffer overrun is code writing past the end of the memory it was given, corrupting whatever comes right after it. The fix: place a small fixed pattern of extra bytes (a guard, sometimes called a canary) immediately before and after every allocation's usable region. If code ever writes past its own allocation, it corrupts a guard first — and checking the guards tells you exactly that happened.

#include <cstring>

const unsigned char GUARD_PATTERN = 0xFE;
const size_t GUARD_SIZE = 4;

void* GuardedAlloc(LinearAllocator& arena, size_t sizeBytes) {
    unsigned char* mem = static_cast<unsigned char*>(
        arena.Alloc(GUARD_SIZE + sizeBytes + GUARD_SIZE));
    if (!mem) return nullptr;

    std::memset(mem, GUARD_PATTERN, GUARD_SIZE);                          // front guard
    std::memset(mem + GUARD_SIZE + sizeBytes, GUARD_PATTERN, GUARD_SIZE); // back guard

    return mem + GUARD_SIZE; // hand back a pointer to the USABLE region only
}

bool CheckGuards(void* userPtr, size_t sizeBytes) {
    unsigned char* mem = static_cast<unsigned char*>(userPtr) - GUARD_SIZE;
    for (size_t i = 0; i < GUARD_SIZE; i++) {
        if (mem[i] != GUARD_PATTERN || mem[GUARD_SIZE + sizeBytes + i] != GUARD_PATTERN) {
            return false; // something wrote past the edge of its allocation
        }
    }
    return true;
}
#include <cstdio>

int main() {
    unsigned char memory[256];
    LinearAllocator arena;
    arena.Init(memory, sizeof(memory));

    int* healthArray = static_cast<int*>(GuardedAlloc(arena, 3 * sizeof(int)));
    healthArray[0] = 100;
    healthArray[1] = 80;
    healthArray[2] = 50;

    printf("guards OK before overrun? %s\n", CheckGuards(healthArray, 3 * sizeof(int)) ? "yes" : "no");

    healthArray[3] = 999; // BUG: one element past the end -- into the back guard

    printf("guards OK after overrun?  %s\n", CheckGuards(healthArray, 3 * sizeof(int)) ? "yes" : "no");
    return 0;
}
guards OK before overrun? yes
guards OK after overrun?  no

healthArray is only 3 ints, so healthArray[3] writes 4 bytes right into the start of the back guard, overwriting part of its 0xFE 0xFE 0xFE 0xFE pattern with bytes from 999. CheckGuards notices the mismatch and returns false — catching an overrun that, without guard bytes, would have silently corrupted whatever the allocator handed out next.

Poison Patterns: Catching Use-After-Free

The other classic bug is use-after-free: reading or writing through a pointer after the memory it points to has already been freed (or, for an arena, after Reset() has handed that memory to something else). The fix: the instant memory is freed, immediately overwrite every byte of it with an obvious, unmistakable poison pattern — commonly 0xDD.

void PoisonFree(unsigned char* mem, size_t sizeBytes) {
    std::memset(mem, 0xDD, sizeBytes); // 0xDD 0xDD 0xDD ... everywhere
}

0xDD is chosen specifically because it never occurs naturally in real game data: as a 32-bit int, 0xDDDDDDDD is -573785174, as a float it's meaningless garbage, and as a pointer it's obviously not a valid address. The moment a debugger shows you 0xDDDDDDDD sitting in a variable that's supposed to be, say, a health value, you know instantly: this is freed memory, and something read it after it should have stopped.

Guard bytes around one allocation, and poison after Free() While ALIVE: [FE FE FE FE][ ... your 12 bytes of int[3] ... ][FE FE FE FE] front guard usable memory back guard If code writes healthArray[3] (one element past the end): [FE FE FE FE][ ... your 12 bytes ... ][E7 03 00 00] front guard usable memory ^ guard OVERWRITTEN CheckGuards() now returns false After Free() / PoisonFree(): [DD DD DD DD DD DD DD DD DD DD DD DD DD DD DD DD] every byte of the freed region -- unmistakably "dead" if read later
Tip Keep guard-byte and poison-pattern code behind a compile-time switch (#ifdef DEBUG_MEMORY) so it costs nothing in the final, optimized build. The whole point of these tricks is that they're a development-time safety net — extra bytes and extra checks you're happy to pay for while making the game, but that a shipped build has no reason to carry.

11. Connecting to Unity: Allocator.Temp, TempJob, and Persistent

You will very rarely write a class like LinearAllocator yourself in normal Unity gameplay code — the managed heap and garbage collector from the C# chapter handle ordinary objects for you, and that's exactly why C# feels easier day to day than C++. But there is one major place in modern Unity where the ideas from this whole chapter show up directly, by name: Unity.Collections (native containers like NativeArray) and the Job System. Every native container's constructor asks you to pick an Allocator, and the three common choices map straight onto allocators you just built.

using Unity.Collections;
using UnityEngine;

public class NativeArrayDemo : MonoBehaviour
{
    void Update()
    {
        // Allocator.Temp: behaves like a StackAllocator marker taken and
        // freed right here, in this one call -- it never crosses a frame.
        NativeArray<float> scratch = new NativeArray<float>(64, Allocator.Temp);
        for (int i = 0; i < scratch.Length; i++)
        {
            scratch[i] = i * 2f;
        }
        Debug.Log("scratch[10] = " + scratch[10]);
        scratch.Dispose(); // must happen before this frame ends
    }
}
scratch[10] = 20
using Unity.Collections;
using Unity.Jobs;
using UnityEngine;

public struct DoubleJob : IJob
{
    public NativeArray<float> data;

    public void Execute()
    {
        for (int i = 0; i < data.Length; i++)
        {
            data[i] *= 2f;
        }
    }
}

public class TempJobDemo : MonoBehaviour
{
    void Start()
    {
        // Allocator.TempJob: safe to hand to a job that might not finish
        // this exact frame -- Allocator.Temp is NOT allowed to cross into
        // a job at all.
        NativeArray<float> data = new NativeArray<float>(4, Allocator.TempJob);
        data[0] = 1f; data[1] = 2f; data[2] = 3f; data[3] = 4f;

        DoubleJob job = new DoubleJob { data = data };
        JobHandle handle = job.Schedule();
        handle.Complete();

        Debug.Log("data[2] = " + data[2]);
        data.Dispose(); // we allocated it, so we must dispose it
    }
}
data[2] = 6
What you built in C++ Unity's equivalent StackAllocator (section 5) --> Allocator.Temp bump-pointer, LIFO, one frame one call/frame, per-thread, MUST Dispose() before the frame ends, cannot cross into a scheduled Job Pool / short-lived arena --> Allocator.TempJob (sections 6 and 8) survives a few frames, safe to hand to a scheduled Job Free-list allocator (section 7) --> Allocator.Persistent general-purpose, long-lived general-purpose, long-lived, slowest of the three, YOU Dispose() it when truly done
Common mistake Allocating a NativeArray with Allocator.Temp and then handing it to Job.Schedule(). In the editor, Unity's safety system throws an exception immediately — Temp is never allowed to cross a job boundary, for exactly the same reason you'd never keep a pointer into a StackAllocator region after rolling a marker back past it: the memory can be reclaimed the instant the current function returns, and a scheduled job might not even start running until later.

None of this means you should reach for a hand-written arena inside an ordinary MonoBehaviour. The managed heap and GC are the right tool for regular gameplay objects, and fighting them with your own allocator underneath would help nothing. These ideas matter the moment you're calling Unity.Collections or Job System code directly — or if you ever end up writing engine code instead of game code, in C++.

12. Glossary

13. Exercises

Exercise 1 A 300-byte heap has these blocks laid out in memory order: used 50, free 40, used 70, free 90, used 20, free 30. Compute: (a) the total number of free bytes, (b) the size of the largest single contiguous free run, and (c) whether a request for 85 contiguous bytes would succeed, and why.
Show answer

(a) Total free bytes: 40 + 90 + 30 = 160 bytes.

(b) Largest single contiguous free run: 90 bytes (the gap between the second "used 70" block and the "used 20" block).

(c) A request for 85 contiguous bytes succeeds: 85 is less than or equal to the largest single free gap (90 bytes), so the allocator can carve those 85 bytes out of that gap, leaving 5 bytes of that particular gap still free afterward. It would have failed if the request had been for, say, 95 bytes, even though 160 bytes are free overall — because no single gap is that big.

Exercise 2 A LinearAllocator starts at offset 0 (assume the base pointer itself is address 0, already aligned to everything, to keep the numbers simple). Trace three calls in order: Alloc(13, align=4), Alloc(6, align=8), Alloc(20, align=4). For each call, give the offset it returns and the new value of the internal offset afterward. What is the total number of bytes used at the end, including any padding?
Show answer

Call 1: Alloc(13, align=4) — current offset is 0, which is already a multiple of 4, so padding is 0. Returns offset 0. New offset: 0 + 13 = 13.

Call 2: Alloc(6, align=8) — current offset is 13; the next multiple of 8 at or above 13 is 16, so padding is 16 - 13 = 3. Returns offset 16. New offset: 16 + 6 = 22.

Call 3: Alloc(20, align=4) — current offset is 22; the next multiple of 4 at or above 22 is 24, so padding is 24 - 22 = 2. Returns offset 24. New offset: 24 + 20 = 44.

Total bytes used at the end: 44 (13 + 3 padding + 6 + 2 padding + 20 = 44). Of those 44 bytes, 5 were pure padding wasted on alignment (3 + 2).

Exercise 3 Using the PoolAllocator from section 6 (4 slots, chained slot0 -> slot1 -> slot2 -> slot3 by Init()), trace this sequence:
void* a = bullets.Alloc();
void* b = bullets.Alloc();

bullets.Free(a);
bullets.Free(a);   // BUG: freeing the same pointer twice

void* c = bullets.Alloc();
void* d = bullets.Alloc();
void* e = bullets.Alloc();
What memory addresses do c, d, and e end up pointing to, and why? What specifically goes wrong with the free list because of the double Free(a) call, and why is that dangerous?
Show answer

Trace the free list step by step. After Init(): freeList = slot0 -> slot1 -> slot2 -> slot3 -> null.

a = Alloc()          // pops slot0.  freeList = slot1 -> slot2 -> slot3 -> null.  a = slot0
b = Alloc()          // pops slot1.  freeList = slot2 -> slot3 -> null.          b = slot1

Free(a)              // pushes slot0 onto the front:
                      // slot0->next = (old freeList) = slot2
                      // freeList = slot0 -> slot2 -> slot3 -> null

Free(a)  // BUG, again // pushes slot0 onto the front AGAIN:
                      // slot0->next = (old freeList) = slot0   <-- itself!
                      // freeList = slot0 -> slot0 -> slot0 -> ...  (a CYCLE)
                      // slot2 and slot3 are now UNREACHABLE -- lost

The second Free(a) overwrites slot0's next pointer a second time, and this time freeList was already slot0 — so slot0->next gets set to slot0 itself. The free list is now a one-node loop that points to itself forever. Worse, the previous link (slot0->next = slot2) is gone, so slot2 and slot3 — which were legitimately free and unused — are permanently unreachable: a memory leak of two whole slots.

c = Alloc() pops the head, slot0, and sets freeList = slot0->next, which is slot0 again — so freeList doesn't actually change. c == slot0. The same thing happens for d = Alloc() and e = Alloc(): every single call pops slot0 and the list loops right back to slot0. So c, d, and e are all the exact same address.

This is why double-free is so dangerous specifically for an intrusive singly-linked free list: the game's code thinks it just got three independent Bullet objects, but writing to c->x silently overwrites the same memory d and e are also using (three bullets aliasing one slot), while two perfectly good slots sit permanently orphaned and can never be allocated again for the rest of the program.

That's the full toolkit: why calling malloc/new everywhere is too slow and unpredictable for a real-time game, and how fragmentation silently eats a heap even when plenty of bytes are "free." From there, four concrete allocators, each shaped around one lifetime — the arena that frees everything at once for per-frame data, the stack allocator that adds LIFO rollback with markers, the pool allocator that gives O(1) alloc/free for same-sized objects like bullets and particles, and the general-purpose free-list allocator for everything else, used sparingly. Alignment, double buffering, per-subsystem tracking, and guard bytes/poison patterns round out the toolkit real engines actually ship with. And in Unity, you'll meet exactly these ideas again the moment you touch NativeArray and the Job System — Allocator.Temp, TempJob, and Persistent are not new concepts, just new names for the allocators you just built by hand.

← Back to all chapters